From e04a1ad746c0d9624f54e94a0a41b1585f28a230 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Wed, 4 Mar 2026 00:11:36 -0600 Subject: [PATCH] Add special-order product flow and improve opportunity product sequencing --- src/components/AddProductModal.svelte | 1160 +++++++++++++++-- src/lib/optima-api/modules/procurement.ts | 27 +- src/lib/optima-api/modules/sales.ts | 31 + .../sales/opportunity/[id]/+page.svelte | 13 +- .../[id]/components/NotesTab.svelte | 13 +- .../[id]/components/ProductsTab.svelte | 153 ++- 6 files changed, 1260 insertions(+), 137 deletions(-) diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte index 75bf26e..c9f90a7 100644 --- a/src/components/AddProductModal.svelte +++ b/src/components/AddProductModal.svelte @@ -10,14 +10,80 @@ export let isOpen = false; export let accessToken: string; - export let onSelect: (items: CatalogItem | CatalogItem[]) => void = () => {}; + export let onSelect: ( + items: CatalogItem | CatalogItem[], + ) => void | Promise = () => {}; // ── Step state ── // "browse" → pick category, ecosystem, or search // "results" → filtered catalog items list - type Step = "browse" | "results"; + type Step = "browse" | "results" | "special-order" | "labor"; let step: Step = "browse"; + type SpecialOrderForm = { + description: string; + quantity: string; + pricePerUnit: string; + costPerUnit: string; + taxable: boolean; + procurementNotes: string; + productNarrative: string; + customerDescription: string; + }; + + const initialSpecialOrderForm: SpecialOrderForm = { + description: "", + quantity: "1", + pricePerUnit: "", + costPerUnit: "", + taxable: true, + procurementNotes: "", + productNarrative: "", + customerDescription: "", + }; + + let specialOrderForm: SpecialOrderForm = { ...initialSpecialOrderForm }; + let specialOrderError = ""; + let isSubmittingSpecialOrder = false; + + type LaborForm = { + description: string; + quantity: string; + pricePerUnit: string; + costPerUnit: string; + taxable: boolean; + procurementNotes: string; + productNarrative: string; + customerDescription: string; + }; + + const laborDefaultCpu = (rate: number): string => + String(Number((rate * 0.5).toFixed(2))); + + const initialLaborForm: LaborForm = { + description: "Field Labor", + quantity: "1", + pricePerUnit: "100", + costPerUnit: laborDefaultCpu(100), + taxable: true, + procurementNotes: "", + productNarrative: "", + customerDescription: "", + }; + + let laborForm: LaborForm = { ...initialLaborForm }; + type LaborCustomerType = "corporate" | "residential"; + type LaborType = "field" | "tech"; + const laborRates: Record = { + corporate: 100, + residential: 85, + }; + let laborCustomerType: LaborCustomerType = "corporate"; + let laborType: LaborType = "field"; + let laborShowAdvanced = false; + let laborPpuOverridden = false; + let laborCpuOverridden = false; + // ── Category / ecosystem data ── let categories: CategoryTreeEntry[] = []; let ecosystems: EcosystemEntry[] = []; @@ -41,7 +107,7 @@ // ── Filters for results step ── let filters: CatalogItemFilters = {}; let searchQuery = ""; - let filterSubcategories: string[] = []; + let filterSubcategories: CategoryTreeEntry[] = []; let filterManufacturers: string[] = []; let isLoadingFilters = false; @@ -49,9 +115,14 @@ let results: CatalogItem[] = []; let isLoadingResults = false; let resultPage = 1; + let listedResults = 0; let totalResults = 0; let totalPages = 0; + let nextPage: number | null = null; + let previousPage: number | null = null; const rpp = 20; + $: resultsStart = totalResults > 0 ? (resultPage - 1) * rpp + 1 : 0; + $: resultsEnd = Math.min(resultsStart + listedResults - 1, totalResults); // ── Detail pane state ── let detailItem: CatalogItem | null = null; @@ -67,6 +138,17 @@ let errorMessage = ""; // ── Breadcrumb ── + $: categoryNameById = buildCategoryNameById(categories); + $: subcategoryNameById = buildSubcategoryNameById(categories); + $: categoryFilterLabel = resolveFilterLabel( + filters.category, + categoryNameById, + ); + $: subcategoryFilterLabel = resolveFilterLabel( + filters.subcategory, + subcategoryNameById, + ); + $: breadcrumb = buildBreadcrumb( step, browseMode, @@ -76,6 +158,39 @@ filters, ); + $: specialOrderQty = Number(specialOrderForm.quantity); + $: specialOrderUnitPrice = Number(specialOrderForm.pricePerUnit); + $: specialOrderUnitCost = Number(specialOrderForm.costPerUnit); + $: specialOrderRevenue = + Number.isFinite(specialOrderQty) && Number.isFinite(specialOrderUnitPrice) + ? specialOrderQty * specialOrderUnitPrice + : null; + $: specialOrderCostTotal = + Number.isFinite(specialOrderQty) && Number.isFinite(specialOrderUnitCost) + ? specialOrderQty * specialOrderUnitCost + : null; + $: canSubmitSpecialOrder = + specialOrderForm.description.trim().length > 0 && + Number.isFinite(specialOrderQty) && + specialOrderQty > 0 && + Number.isFinite(specialOrderUnitPrice) && + specialOrderUnitPrice >= 0 && + Number.isFinite(specialOrderUnitCost) && + specialOrderUnitCost >= 0; + + $: laborQty = Number(laborForm.quantity); + $: laborUnitPrice = Number(laborForm.pricePerUnit); + $: laborUnitCost = Number(laborForm.costPerUnit); + $: laborRevenue = + Number.isFinite(laborQty) && Number.isFinite(laborUnitPrice) + ? laborQty * laborUnitPrice + : null; + $: laborCostTotal = + Number.isFinite(laborQty) && Number.isFinite(laborUnitCost) + ? laborQty * laborUnitCost + : null; + $: laborDefaultRate = laborRates[laborCustomerType]; + function buildBreadcrumb( _step: Step, _browseMode: "category" | "ecosystem" | null, @@ -98,11 +213,76 @@ return crumbs; } + function buildCategoryNameById( + entries: CategoryTreeEntry[], + ): Map { + const map = new Map(); + for (const category of entries) { + if (category.cwId != null) map.set(category.cwId, category.name); + } + return map; + } + + function buildSubcategoryNameById( + entries: CategoryTreeEntry[], + ): Map { + const map = new Map(); + for (const category of entries) { + for (const group of category.entries ?? []) { + if ((group.subcategories?.length ?? 0) === 0 && group.cwId != null) { + map.set(group.cwId, group.name); + } + for (const sub of group.subcategories ?? []) { + if (sub.cwId != null) map.set(sub.cwId, sub.name); + } + } + } + return map; + } + + function resolveFilterLabel( + value: CatalogItemFilters["category"] | CatalogItemFilters["subcategory"], + nameMap: Map, + ): string { + if (value == null || value === "") return ""; + if (typeof value === "number") return nameMap.get(value) ?? String(value); + + const parsed = Number(value); + if (!Number.isNaN(parsed) && value.trim() !== "") { + return nameMap.get(parsed) ?? value; + } + + return value; + } + // ── Lifecycle ── $: if (isOpen && !treeLoaded) { loadCategoryTree(); } + $: filterSubcategories = collectSubcategories(selectedCategory); + + function collectSubcategories( + category: CategoryTreeEntry | null, + ): CategoryTreeEntry[] { + if (!category?.entries) return []; + + const seen = new Set(); + const options: CategoryTreeEntry[] = []; + + for (const group of category.entries) { + for (const sub of group.subcategories ?? []) { + if (sub.cwId == null) continue; + const key = `${sub.cwId}`; + if (seen.has(key)) continue; + seen.add(key); + options.push(sub); + } + } + + return options; + } + async function loadCategoryTree() { if (!accessToken || treeLoaded) return; isLoadingTree = true; @@ -121,7 +301,10 @@ } } - async function loadFilterValues(category?: string, subcategory?: string) { + async function loadFilterValues( + category?: string | number, + subcategory?: string | number, + ) { if (!accessToken) return; isLoadingFilters = true; try { @@ -129,7 +312,6 @@ category, subcategory, }); - filterSubcategories = data.subcategories ?? []; filterManufacturers = data.manufacturers ?? []; } catch (err) { console.error("Failed to load filter values:", err); @@ -142,7 +324,6 @@ if (!accessToken) return; isLoadingResults = true; errorMessage = ""; - resultPage = page; try { const response = await procurement.fetchMany( accessToken, @@ -150,9 +331,18 @@ filters, rpp, ); + console.log("[AddProductModal] Catalog fetch meta:", response?.meta); results = response?.data ?? []; - totalResults = response?.pagination?.totalRecords ?? 0; - totalPages = response?.pagination?.totalPages ?? 0; + + const pagination = response?.meta?.pagination ?? response?.pagination; + listedResults = pagination?.listedRecords ?? results.length; + resultPage = pagination?.currentPage ?? page; + previousPage = pagination?.previousPage ?? null; + nextPage = pagination?.nextPage ?? null; + totalResults = pagination?.totalRecords ?? results.length; + totalPages = + pagination?.totalPages ?? + (totalResults > 0 ? Math.max(1, Math.ceil(totalResults / rpp)) : 0); } catch (err) { errorMessage = err instanceof Error ? err.message : "Failed to search catalog"; @@ -173,8 +363,11 @@ function toggleGroup(group: CategoryTreeEntry) { const subs = group.subcategories ?? []; if (subs.length === 0) { - // No subcategories — go straight to results with group filter - filters = { category: selectedCategory?.name, group: group.name }; + // No subcategories — treat this row as the final subcategory when possible + filters = + group.cwId != null + ? { category: selectedCategory?.cwId, subcategory: group.cwId } + : { category: selectedCategory?.cwId }; goToResults(); } else { // Toggle expand/collapse @@ -182,13 +375,8 @@ } } - function browseGroup(group: CategoryTreeEntry) { - filters = { category: selectedCategory?.name, group: group.name }; - goToResults(); - } - function selectSubcategory(sub: CategoryTreeEntry) { - filters = { category: selectedCategory?.name, subcategory: sub.name }; + filters = { category: selectedCategory?.cwId, subcategory: sub.cwId }; goToResults(); } @@ -309,7 +497,12 @@ if (step === "results") { step = "browse"; results = []; + listedResults = 0; totalResults = 0; + totalPages = 0; + resultPage = 1; + previousPage = null; + nextPage = null; errorMessage = ""; detailPaneOpen = false; paneClosing = false; @@ -329,6 +522,9 @@ searchQuery = ""; filterSubcategories = []; filterManufacturers = []; + } else if (step === "special-order" || step === "labor") { + step = "browse"; + specialOrderError = ""; } else if (step === "browse" && selectedCategory) { selectedCategory = null; browseMode = null; @@ -342,7 +538,12 @@ function goHome() { step = "browse"; results = []; + listedResults = 0; totalResults = 0; + totalPages = 0; + resultPage = 1; + previousPage = null; + nextPage = null; errorMessage = ""; selectedCategory = null; selectedGroup = null; @@ -356,13 +557,21 @@ filterSubcategories = []; filterManufacturers = []; expandedGroupName = null; + specialOrderForm = { ...initialSpecialOrderForm }; + specialOrderError = ""; + laborForm = { ...initialLaborForm }; + laborCustomerType = "corporate"; + laborType = "field"; + laborShowAdvanced = false; + laborPpuOverridden = false; + laborCpuOverridden = false; } // ── Inline filters on results page ── - function applySubcategoryFilter(sub: string) { - filters = { ...filters, subcategory: sub }; - loadFilterValues(filters.category, sub); + function applySubcategoryFilter(subcategoryId: number) { + filters = { ...filters, subcategory: subcategoryId }; + loadFilterValues(filters.category, subcategoryId); fetchResults(1); } @@ -397,9 +606,12 @@ filters = {}; searchQuery = ""; results = []; + listedResults = 0; totalResults = 0; totalPages = 0; resultPage = 1; + previousPage = null; + nextPage = null; detailItem = null; detailPaneOpen = false; paneClosing = false; @@ -410,6 +622,15 @@ filterSubcategories = []; filterManufacturers = []; expandedGroupName = null; + specialOrderForm = { ...initialSpecialOrderForm }; + specialOrderError = ""; + isSubmittingSpecialOrder = false; + laborForm = { ...initialLaborForm }; + laborCustomerType = "corporate"; + laborType = "field"; + laborShowAdvanced = false; + laborPpuOverridden = false; + laborCpuOverridden = false; } function handleClose() { @@ -434,25 +655,120 @@ // ── Quick-add special items ── function addSpecialOrder() { + step = "special-order"; + specialOrderError = ""; + } + + async function submitSpecialOrder() { + if (isSubmittingSpecialOrder) return; + + const description = specialOrderForm.description.trim(); + const quantity = Number(specialOrderForm.quantity); + const pricePerUnit = Number(specialOrderForm.pricePerUnit); + const costPerUnit = Number(specialOrderForm.costPerUnit); + + if (!description) { + specialOrderError = "Description is required."; + return; + } + + if (!Number.isFinite(quantity) || quantity <= 0) { + specialOrderError = "QTY must be greater than 0."; + return; + } + + if (!Number.isFinite(pricePerUnit) || pricePerUnit < 0) { + specialOrderError = "Price per Unit must be 0 or greater."; + return; + } + + if (!Number.isFinite(costPerUnit) || costPerUnit < 0) { + specialOrderError = "Cost per Unit must be 0 or greater."; + return; + } + const specialItem: CatalogItem = { id: `special-order-${Date.now()}`, identifier: "SPECIAL-ORDER", - description: "Special Order", + description, category: "Special Order", + quantity, + price: pricePerUnit, + cost: costPerUnit, + taxableFlag: specialOrderForm.taxable, + procurementNotes: specialOrderForm.procurementNotes.trim() || undefined, + productNarrative: specialOrderForm.productNarrative.trim() || undefined, + customerDescription: + specialOrderForm.customerDescription.trim() || undefined, }; - onSelect(specialItem); - handleClose(); + + specialOrderError = ""; + isSubmittingSpecialOrder = true; + try { + await Promise.resolve(onSelect(specialItem)); + handleClose(); + } catch (err) { + specialOrderError = + err instanceof Error ? err.message : "Failed to add special order."; + } finally { + isSubmittingSpecialOrder = false; + } } function addLabor() { - const laborItem: CatalogItem = { - id: `labor-${Date.now()}`, - identifier: "LABOR", - description: "Labor", - category: "Labor", + laborForm = { ...initialLaborForm }; + laborCustomerType = "corporate"; + laborType = "field"; + laborShowAdvanced = false; + laborPpuOverridden = false; + laborCpuOverridden = false; + step = "labor"; + } + + function setLaborCustomerType(next: LaborCustomerType) { + laborCustomerType = next; + const nextPrice = laborPpuOverridden + ? laborForm.pricePerUnit + : String(laborRates[next]); + const nextCost = laborCpuOverridden + ? laborForm.costPerUnit + : laborDefaultCpu(laborRates[next]); + + laborForm = { + ...laborForm, + pricePerUnit: nextPrice, + costPerUnit: nextCost, + }; + } + + function setLaborType(next: LaborType) { + laborType = next; + if (!laborForm.description.trim() || laborForm.description === "Field Labor" || laborForm.description === "Tech Labor") { + laborForm = { + ...laborForm, + description: next === "field" ? "Field Labor" : "Tech Labor", + }; + } + } + + function handleLaborPpuInput(value: string) { + laborPpuOverridden = true; + laborForm = { ...laborForm, pricePerUnit: value }; + } + + function handleLaborCpuInput(value: string) { + laborCpuOverridden = true; + laborForm = { ...laborForm, costPerUnit: value }; + } + + function resetLaborRateDefaults() { + laborPpuOverridden = false; + laborCpuOverridden = false; + laborForm = { + ...laborForm, + pricePerUnit: String(laborRates[laborCustomerType]), + costPerUnit: laborDefaultCpu(laborRates[laborCustomerType]), }; - onSelect(laborItem); - handleClose(); } // ── Helpers ── @@ -860,7 +1176,7 @@ class="group-row browse-all-row" type="button" on:click={() => { - filters = { category: selectedCategory?.name }; + filters = { category: selectedCategory?.cwId }; goToResults(); }} > @@ -921,27 +1237,6 @@ {#if expandedGroupName === group.name && (group.subcategories?.length ?? 0) > 0}
- - {#each group.subcategories ?? [] as sub} +
+ + + + {:else if step === "labor"} +
+
+
+ + + + +
+
+

Labor

+

Choose customer + labor type, then hours. Advanced overrides are optional.

+
+
+ +
+ + +
+
+
+ + + + +
+ Rate + {formatPrice(Number.isFinite(laborUnitPrice) ? laborUnitPrice : laborDefaultRate)}/hr +
+
+ +
+ + Revenue: + {laborRevenue != null ? formatPrice(laborRevenue) : "—"} + + + Cost: + {laborCostTotal != null ? formatPrice(laborCostTotal) : "—"} + +
+ + + Default rates: Corporate {formatPrice(100)}/hr, Residential {formatPrice(85)}/hr. CPU defaults to 50% of selected rate. + +
+ +
+
+
Customer Type
+
+ + +
+
+ +
+
Labor Style
+
+ + +
+
+
+
+ + + + {#if laborShowAdvanced} +
+
+ + + +
+ +
+ + + +
+ + + + +
+ {/if} +
+ +
+ +
+
+ {:else if step === "results"} -
{#if filters.category} - {filters.category} + {categoryFilterLabel} - - {/if} {#if filters.subcategory} - {filters.subcategory} + {subcategoryFilterLabel} @@ -1541,7 +1636,9 @@ width="14" height="14" > - +