From 762edd8eb74a9abe08d8a8e573899dfa56faffe3 Mon Sep 17 00:00:00 2001 From: Jackson Roberts Date: Wed, 4 Mar 2026 18:44:29 -0600 Subject: [PATCH] feat(sales): update opportunity product and overview flows --- src/components/AddProductModal.svelte | 610 ++++++++++++------ src/lib/optima-api/modules/sales.ts | 123 +++- .../sales/opportunity/[id]/+page.svelte | 21 +- .../[id]/components/OpportunitySidebar.svelte | 20 +- .../[id]/components/OverviewTab.svelte | 53 +- .../[id]/components/ProductsTab.svelte | 490 +++++++++++++- 6 files changed, 1088 insertions(+), 229 deletions(-) diff --git a/src/components/AddProductModal.svelte b/src/components/AddProductModal.svelte index c9f90a7..056f7bc 100644 --- a/src/components/AddProductModal.svelte +++ b/src/components/AddProductModal.svelte @@ -7,9 +7,16 @@ type EcosystemEntry, type EcosystemManufacturer, } from "$lib/optima-api/modules/procurement"; + import { + sales, + type LaborCustomerType, + type LaborOptionsResponse, + type LaborStyle, + } from "$lib/optima-api/modules/sales"; export let isOpen = false; export let accessToken: string; + export let opportunityId = ""; export let onSelect: ( items: CatalogItem | CatalogItem[], ) => void | Promise = () => {}; @@ -57,8 +64,9 @@ customerDescription: string; }; + let laborCpuMultiplier = 0.5; const laborDefaultCpu = (rate: number): string => - String(Number((rate * 0.5).toFixed(2))); + String(Number((rate * laborCpuMultiplier).toFixed(2))); const initialLaborForm: LaborForm = { description: "Field Labor", @@ -72,14 +80,18 @@ }; let laborForm: LaborForm = { ...initialLaborForm }; - type LaborCustomerType = "corporate" | "residential"; - type LaborType = "field" | "tech"; - const laborRates: Record = { + type LaborType = LaborStyle; + let laborRates: Record = { corporate: 100, residential: 85, }; let laborCustomerType: LaborCustomerType = "corporate"; let laborType: LaborType = "field"; + let laborDefaultQuantity = 1; + let laborOptions: LaborOptionsResponse | null = null; + let laborError = ""; + let isLoadingLaborOptions = false; + let isSubmittingLabor = false; let laborShowAdvanced = false; let laborPpuOverridden = false; let laborCpuOverridden = false; @@ -190,6 +202,77 @@ ? laborQty * laborUnitCost : null; $: laborDefaultRate = laborRates[laborCustomerType]; + $: canSubmitLabor = + Number.isFinite(laborQty) && + laborQty > 0 && + Number.isFinite(laborUnitPrice) && + laborUnitPrice >= 0 && + Number.isFinite(laborUnitCost) && + laborUnitCost >= 0 && + !isLoadingLaborOptions; + + function laborOptionLabel(style: LaborStyle): string { + return ( + laborOptions?.options?.[style]?.name ?? + (style === "field" ? "Field Labor" : "Tech Labor") + ); + } + + function isDefaultLaborDescription(value: string): boolean { + const normalized = value.trim(); + if (!normalized) return true; + return [ + "Field Labor", + "Tech Labor", + laborOptionLabel("field"), + laborOptionLabel("tech"), + ].includes(normalized); + } + + function applyLaborDefaultsFromOptions() { + const defaults = laborOptions?.defaults; + const options = laborOptions?.options; + if (!defaults || !options) return; + + laborRates = { + corporate: defaults.rates.corporate, + residential: defaults.rates.residential, + }; + laborCpuMultiplier = defaults.cpuMultiplier; + laborDefaultQuantity = defaults.quantity; + laborCustomerType = defaults.customerType; + + const chosenOption = options[laborType]; + const chosenRate = laborRates[laborCustomerType]; + laborForm = { + ...laborForm, + description: chosenOption?.name ?? laborOptionLabel(laborType), + quantity: String(laborDefaultQuantity), + pricePerUnit: String(chosenRate), + costPerUnit: laborDefaultCpu(chosenRate), + taxable: chosenOption?.taxableFlag ?? laborForm.taxable, + }; + } + + async function loadLaborOptions() { + if (!accessToken || !opportunityId) return; + isLoadingLaborOptions = true; + laborError = ""; + try { + const response = await sales.fetchLaborOptions( + accessToken, + opportunityId, + ); + laborOptions = response?.data ?? null; + applyLaborDefaultsFromOptions(); + } catch (err) { + laborError = + err instanceof Error ? err.message : "Failed to load labor options."; + console.error("[AddProductModal] Failed to load labor options:", err); + } finally { + isLoadingLaborOptions = false; + } + } function buildBreadcrumb( _step: Step, @@ -525,6 +608,7 @@ } else if (step === "special-order" || step === "labor") { step = "browse"; specialOrderError = ""; + laborError = ""; } else if (step === "browse" && selectedCategory) { selectedCategory = null; browseMode = null; @@ -560,8 +644,15 @@ specialOrderForm = { ...initialSpecialOrderForm }; specialOrderError = ""; laborForm = { ...initialLaborForm }; + laborError = ""; + isLoadingLaborOptions = false; + isSubmittingLabor = false; + laborOptions = null; laborCustomerType = "corporate"; laborType = "field"; + laborDefaultQuantity = 1; + laborCpuMultiplier = 0.5; + laborRates = { corporate: 100, residential: 85 }; laborShowAdvanced = false; laborPpuOverridden = false; laborCpuOverridden = false; @@ -626,8 +717,15 @@ specialOrderError = ""; isSubmittingSpecialOrder = false; laborForm = { ...initialLaborForm }; + laborError = ""; + isLoadingLaborOptions = false; + isSubmittingLabor = false; + laborOptions = null; laborCustomerType = "corporate"; laborType = "field"; + laborDefaultQuantity = 1; + laborCpuMultiplier = 0.5; + laborRates = { corporate: 100, residential: 85 }; laborShowAdvanced = false; laborPpuOverridden = false; laborCpuOverridden = false; @@ -715,14 +813,20 @@ } } - function addLabor() { + async function addLabor() { laborForm = { ...initialLaborForm }; + laborError = ""; + isSubmittingLabor = false; laborCustomerType = "corporate"; laborType = "field"; + laborDefaultQuantity = 1; + laborCpuMultiplier = 0.5; + laborRates = { corporate: 100, residential: 85 }; laborShowAdvanced = false; laborPpuOverridden = false; laborCpuOverridden = false; step = "labor"; + await loadLaborOptions(); } function setLaborCustomerType(next: LaborCustomerType) { @@ -743,10 +847,12 @@ function setLaborType(next: LaborType) { laborType = next; - if (!laborForm.description.trim() || laborForm.description === "Field Labor" || laborForm.description === "Tech Labor") { + if (isDefaultLaborDescription(laborForm.description)) { laborForm = { ...laborForm, - description: next === "field" ? "Field Labor" : "Tech Labor", + description: laborOptionLabel(next), + taxable: + laborOptions?.options?.[next]?.taxableFlag ?? laborForm.taxable, }; } } @@ -771,6 +877,61 @@ }; } + async function submitLabor() { + if (isSubmittingLabor || isLoadingLaborOptions) return; + + const hours = Number(laborForm.quantity); + const ppu = Number(laborForm.pricePerUnit); + const cpu = Number(laborForm.costPerUnit); + + if (!Number.isFinite(hours) || hours <= 0) { + laborError = "Hours must be greater than 0."; + return; + } + + if (!Number.isFinite(ppu) || ppu < 0) { + laborError = "PPU must be 0 or greater."; + return; + } + + if (!Number.isFinite(cpu) || cpu < 0) { + laborError = "CPU must be 0 or greater."; + return; + } + + const selectedOption = laborOptions?.options?.[laborType]; + const laborItem: CatalogItem = { + id: `labor-${Date.now()}`, + identifier: "LABOR", + cwCatalogId: selectedOption?.cwCatalogId, + description: laborForm.description.trim() || laborOptionLabel(laborType), + quantity: hours, + price: ppu, + cost: cpu, + taxableFlag: laborForm.taxable, + procurementNotes: laborForm.procurementNotes.trim() || undefined, + productNarrative: laborForm.productNarrative.trim() || undefined, + customerDescription: laborForm.customerDescription.trim() || undefined, + laborStyle: laborType, + customerType: laborCustomerType, + hours, + rate: laborRates[laborCustomerType], + ppu, + cpu, + }; + + laborError = ""; + isSubmittingLabor = true; + try { + await Promise.resolve(onSelect(laborItem)); + handleClose(); + } catch (err) { + laborError = err instanceof Error ? err.message : "Failed to add labor."; + } finally { + isSubmittingLabor = false; + } + } + // ── Helpers ── function formatPrice(amount?: number | null): string { @@ -1737,201 +1898,286 @@ - - {:else if step === "labor"} -
-
-
- - - - + + {: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( + laborRates.corporate, + )}/hr, Residential {formatPrice( + laborRates.residential, + )}/hr. CPU defaults to {Math.round( + laborCpuMultiplier * 100, + )}% of selected rate. +
-
-

Labor

-

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

+ +
+
+
Customer Type
+
+ + +
+
+ +
+
Labor Style
+
+ + +
+
-
- + -
-
-
- + {#if laborShowAdvanced} +
+
+ - - -
- 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} +
+ + {#if isLoadingLaborOptions} +
+ + + + + Loading labor defaults...
+ {/if} + + {#if laborError} +
{laborError}
+ {/if} + +
+ +
+
{:else if step === "results"} diff --git a/src/lib/optima-api/modules/sales.ts b/src/lib/optima-api/modules/sales.ts index e33317c..c86ed8f 100644 --- a/src/lib/optima-api/modules/sales.ts +++ b/src/lib/optima-api/modules/sales.ts @@ -4,6 +4,7 @@ export interface SalesOpportunity { id: string; cwOpportunityId?: number; name: string; + description?: string | null; notes?: string | null; type?: { id?: number; name?: string } | null; stage?: { id?: number; name?: string } | null; @@ -75,7 +76,10 @@ export interface SalesOpportunity { dateBecameLead?: string | null; closedDate?: string | null; closedFlag?: boolean; - closedBy?: string | null; + closedBy?: + | string + | { id?: number | string; identifier?: string; name?: string } + | null; companyId?: string; productSequence?: number[] | null; cwLastUpdated?: string | null; @@ -132,6 +136,57 @@ export interface SpecialOrderBody { productNarrative?: string; } +export type LaborStyle = "field" | "tech"; +export type LaborCustomerType = "corporate" | "residential"; + +export interface LaborOptionsResponse { + defaults: { + customerType: LaborCustomerType; + rates: Record; + cpuMultiplier: number; + quantity: number; + }; + options: Record< + LaborStyle, + { + cwCatalogId: number; + identifier: string; + name: string; + taxableFlag?: boolean; + } + >; +} + +export interface AddLaborBody { + laborStyle: LaborStyle; + customerType?: LaborCustomerType; + hours?: number; + taxable?: boolean; + taxableFlag?: boolean; + rate?: number; + ppu?: number; + cpu?: number; + description?: string; + customerDescription?: string; + procurementNotes?: string; + productNarrative?: string; +} + +export interface EditOpportunityProductBody { + productDescription?: string; + quantity?: number; + unitPrice?: number; + unitCost?: number; + customerDescription?: string | null; + productNarrative?: string | null; + procurementNotes?: string | null; +} + +export interface CancelOpportunityProductBody { + quantityCancelled: number; + cancellationReason?: string | null; +} + export const sales = { async fetchMany( accessToken: string, @@ -329,6 +384,72 @@ export const sales = { return response.data; }, + async fetchLaborOptions(accessToken: string, identifier: string) { + const response = await api.get( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/labor/options`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data as { + status?: number; + message?: string; + data: LaborOptionsResponse; + successful?: boolean; + }; + }, + + async addLabor(accessToken: string, identifier: string, body: AddLaborBody) { + const response = await api.post( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/labor`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + + async editProduct( + accessToken: string, + identifier: string, + productId: number, + body: EditOpportunityProductBody, + ) { + const response = await api.patch( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/${productId}/edit`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + + async cancelProduct( + accessToken: string, + identifier: string, + productId: number, + body: CancelOpportunityProductBody, + ) { + const response = await api.patch( + `/v1/sales/opportunities/${encodeURIComponent(identifier)}/products/${productId}/cancel`, + body, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + return response.data; + }, + async refreshOpportunity(accessToken: string, identifier: string) { const response = await api.post( `/v1/sales/opportunities/${encodeURIComponent(identifier)}/refresh`, diff --git a/src/routes/sales/opportunity/[id]/+page.svelte b/src/routes/sales/opportunity/[id]/+page.svelte index 3af064c..d6b2d6d 100644 --- a/src/routes/sales/opportunity/[id]/+page.svelte +++ b/src/routes/sales/opportunity/[id]/+page.svelte @@ -34,6 +34,12 @@ } onMount(() => { checkMobile(); + console.log("[OpportunityLoad] Description values:", { + description: (opportunity as Record | null)?.description, + notes: opportunity?.notes ?? null, + name: opportunity?.name ?? null, + opportunityId, + }); window.addEventListener("resize", checkMobile); return () => window.removeEventListener("resize", checkMobile); }); @@ -56,7 +62,9 @@ function guardedSetTab(tab: Tab) { if (activeTab === tab) return; if (productsEditing) { - if (!confirm('You have unsaved product changes. Discard and switch tabs?')) { + if ( + !confirm("You have unsaved product changes. Discard and switch tabs?") + ) { return; } productsEditing = false; @@ -76,12 +84,18 @@ localProductSequence = e.detail; } + function handleProductsChanged(e: CustomEvent) { + products = e.detail; + } + // Mobile nav state let mobileActiveTab: Tab | null = null; function selectMobileTab(tab: Tab) { if (productsEditing) { - if (!confirm('You have unsaved product changes. Discard and switch tabs?')) { + if ( + !confirm("You have unsaved product changes. Discard and switch tabs?") + ) { return; } productsEditing = false; @@ -92,7 +106,7 @@ function mobileBack() { if (productsEditing) { - if (!confirm('You have unsaved product changes. Discard and go back?')) { + if (!confirm("You have unsaved product changes. Discard and go back?")) { return; } productsEditing = false; @@ -277,6 +291,7 @@ initialProductId={pendingProductId} bind:isEditing={productsEditing} on:sequenceSaved={handleSequenceSaved} + on:productsChanged={handleProductsChanged} /> {:else if activeTab === "Notes"}
- {#if opportunity.notes} + {#if opportunity.description}
Description
-

{opportunity.notes}

+

{opportunity.description}

{/if} @@ -296,8 +308,8 @@ {#if opportunity.campaign} {opportunity.campaign} {/if} - {#if opportunity.closedBy} - Closed by {opportunity.closedBy} + {#if closedByDisplay} + Closed by {closedByDisplay} {/if} {#if opportunity.cwLastUpdated} { + if (!opportunity) return false; + const statusText = `${opportunity.status?.name ?? ""} ${opportunity.type?.name ?? ""}`.toLowerCase(); + return ( + !!opportunity.closedFlag || + !!opportunity.closedDate || + statusText.includes("won") || + statusText.includes("lost") + ); + })(); + // Days until expected close $: daysUntilClose = (() => { - if (!opportunity?.expectedCloseDate || opportunity?.closedFlag) return null; + if (!opportunity?.expectedCloseDate || isClosedOpportunity) return null; const diff = Math.ceil( (new Date(opportunity.expectedCloseDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24), @@ -118,6 +129,16 @@ return diff; })(); + $: closeOutcomeLabel = (() => { + const opp = opportunity; + if (!isClosedOpportunity || !opp) return null; + const outcomeText = + `${opp.status?.name ?? ""} ${opp.type?.name ?? ""}`.toLowerCase(); + if (outcomeText.includes("won")) return "WON"; + if (outcomeText.includes("lost")) return "LOST"; + return "CLOSED"; + })(); + // Age in days $: ageDays = (() => { if (!opportunity?.createdAt) return null; @@ -230,18 +251,30 @@
{/if}
- {#if daysUntilClose !== null} + {#if closeOutcomeLabel || daysUntilClose !== null}
= 0 && daysUntilClose <= 14} + class:overdue={!closeOutcomeLabel && + daysUntilClose !== null && + daysUntilClose < 0} + class:soon={!closeOutcomeLabel && + daysUntilClose !== null && + daysUntilClose >= 0 && + daysUntilClose <= 14} > - {Math.abs(daysUntilClose)} - - {daysUntilClose < 0 - ? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue` - : `day${daysUntilClose !== 1 ? "s" : ""} to close`} - + {#if closeOutcomeLabel} + {closeOutcomeLabel} + {#if closeOutcomeLabel !== "WON" && closeOutcomeLabel !== "LOST"} + Closed opportunity + {/if} + {:else if daysUntilClose !== null} + {Math.abs(daysUntilClose)} + + {daysUntilClose < 0 + ? `day${Math.abs(daysUntilClose) !== 1 ? "s" : ""} overdue` + : `day${daysUntilClose !== 1 ? "s" : ""} to close`} + + {/if}
{/if}
diff --git a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte index 3daa62f..08a9659 100644 --- a/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte +++ b/src/routes/sales/opportunity/[id]/components/ProductsTab.svelte @@ -11,6 +11,10 @@ import type { CatalogItem } from "$lib/optima-api/modules/procurement"; import type { AddProductBody, + AddLaborBody, + CancelOpportunityProductBody, + LaborCustomerType, + LaborStyle, SpecialOrderBody, } from "$lib/optima-api/modules/sales"; @@ -20,7 +24,10 @@ export let productSequence: number[] | null = null; export let initialProductId: number | null = null; - const dispatch = createEventDispatcher<{ sequenceSaved: number[] }>(); + const dispatch = createEventDispatcher<{ + sequenceSaved: number[]; + productsChanged: OpportunityProduct[]; + }>(); let showAddProductModal = false; @@ -78,6 +85,7 @@ ); if (result?.data) { products = result.data; + dispatch("productsChanged", products); } } catch (err) { console.error("[Products] Failed to refresh:", err); @@ -89,17 +97,19 @@ const items = Array.isArray(incoming) ? incoming : [incoming]; if (items.length === 0) return; + const laborItems = items.filter((item) => item.identifier === "LABOR"); const specialOrderItems = items.filter( (item) => item.identifier === "SPECIAL-ORDER", ); const standardItems = items.filter( - (item) => item.identifier !== "SPECIAL-ORDER", + (item) => + item.identifier !== "SPECIAL-ORDER" && item.identifier !== "LABOR", ); isAddingProduct = true; addProductError = ""; try { - // Add standard catalog/labor items through generic endpoint + // Add standard catalog items through generic endpoint if (standardItems.length > 0) { await Promise.all( standardItems.map((item) => @@ -112,6 +122,43 @@ ); } + if (laborItems.length > 0) { + const payload: AddLaborBody[] = laborItems.map((item) => { + const laborStyle = (item.laborStyle ?? "field") as LaborStyle; + const customerType = (item.customerType ?? + "corporate") as LaborCustomerType; + const hours = + typeof item.hours === "number" + ? item.hours + : item.quantity && item.quantity > 0 + ? item.quantity + : 1; + const ppu = typeof item.ppu === "number" ? item.ppu : item.price; + const cpu = typeof item.cpu === "number" ? item.cpu : item.cost; + const rate = typeof item.rate === "number" ? item.rate : ppu; + + return { + laborStyle, + customerType, + hours, + taxable: item.taxableFlag, + rate, + ppu, + cpu, + description: item.description, + customerDescription: item.customerDescription, + procurementNotes: item.procurementNotes, + productNarrative: item.productNarrative, + }; + }); + + await Promise.all( + payload.map((body) => + optima.sales.addLabor(accessToken!, opportunityId, body), + ), + ); + } + // Add special-order items through dedicated endpoint if (specialOrderItems.length > 0) { const payload: SpecialOrderBody[] = specialOrderItems.map((item) => ({ @@ -154,6 +201,15 @@ // ── Edit mode state ── export let isEditing = false; let showActionMenu = false; + let isSavingEdit = false; + let editSaveError = ""; + let showCancelModal = false; + let isSavingCancellation = false; + let cancellationSaveError = ""; + let cancellationForm = { + quantityCancelled: "0", + cancellationReason: "", + }; let editForm: { unitPrice: string; unitCost: string; @@ -186,21 +242,109 @@ procurementNotes: selectedProduct.procurementNotes ?? "", }; isEditing = true; + editSaveError = ""; showActionMenu = false; } function cancelEdit() { isEditing = false; + editSaveError = ""; + } + + function hasCancellation(p: OpportunityProduct): boolean { + return !!( + p.cancelled || + p.cancellationType || + (p.quantityCancelled ?? 0) > 0 + ); + } + + function maxCancellationQty(p: OpportunityProduct): number { + const qty = Math.floor(p.quantity ?? 0); + return qty > 0 ? qty : 0; + } + + function openCancellationModal() { + if (!selectedProduct) return; + const maxQty = maxCancellationQty(selectedProduct); + const initialQty = Math.min( + Math.max( + selectedProduct.quantityCancelled ?? + (selectedProduct.cancellationType === "full" ? maxQty : 0), + 0, + ), + maxQty, + ); + + cancellationForm = { + quantityCancelled: String(initialQty), + cancellationReason: selectedProduct.cancelledReason ?? "", + }; + cancellationSaveError = ""; + showActionMenu = false; + showCancelModal = true; + } + + function closeCancellationModal() { + if (isSavingCancellation) return; + showCancelModal = false; + cancellationSaveError = ""; + } + + async function saveCancellation() { + if (!selectedProduct || !accessToken || isSavingCancellation) return; + + const maxQty = maxCancellationQty(selectedProduct); + const parsedQty = Number.parseInt(cancellationForm.quantityCancelled, 10); + const quantityCancelled = Number.isFinite(parsedQty) + ? Math.min(Math.max(parsedQty, 0), maxQty) + : 0; + const reason = cancellationForm.cancellationReason.trim(); + + const payload: CancelOpportunityProductBody = { + quantityCancelled, + cancellationReason: quantityCancelled > 0 ? reason || null : null, + }; + + isSavingCancellation = true; + cancellationSaveError = ""; + try { + await optima.sales.cancelProduct( + accessToken, + opportunityId, + selectedProduct.id, + payload, + ); + await refreshProducts(); + selectedProduct = + products.find((p) => p.id === selectedProduct?.id) ?? null; + showCancelModal = false; + cancellationSaveError = ""; + } catch (err) { + console.error("[CancelProduct] Failed:", err); + cancellationSaveError = + err instanceof Error ? err.message : "Failed to update cancellation"; + } finally { + isSavingCancellation = false; + } } async function saveEdit() { - if (!selectedProduct || !accessToken) return; + if (!selectedProduct || !accessToken || isSavingEdit) return; const qty = parseFloat(editForm.quantity) || selectedProduct.quantity || 1; const up = parseFloat(editForm.unitPrice); const uc = parseFloat(editForm.unitCost); - const updates: Record = { + const updates: { + quantity: number; + productDescription: string; + customerDescription: string | null; + productNarrative: string | null; + procurementNotes: string | null; + unitPrice?: number; + unitCost?: number; + } = { quantity: qty, productDescription: editForm.description, customerDescription: editForm.customerDescription || null, @@ -209,20 +353,38 @@ }; if (!isNaN(up)) { - updates.revenue = up * qty; + updates.unitPrice = up; } if (!isNaN(uc)) { - updates.cost = uc * qty; + updates.unitCost = uc; } - // TODO: Wire up the actual API call once the endpoint exists - // await optima.sales.updateProduct(accessToken, opportunityId, selectedProduct.id, updates); - console.log("[EditProduct] Would save:", { - productId: selectedProduct.id, - updates, - }); + console.log( + "[EditProduct] Description payload:", + updates.productDescription, + ); - isEditing = false; + isSavingEdit = true; + editSaveError = ""; + try { + await optima.sales.editProduct( + accessToken, + opportunityId, + selectedProduct.id, + updates, + ); + await refreshProducts(); + selectedProduct = + products.find((p) => p.id === selectedProduct?.id) ?? null; + isEditing = false; + editSaveError = ""; + } catch (err) { + console.error("[EditProduct] Failed:", err); + editSaveError = + err instanceof Error ? err.message : "Failed to update product"; + } finally { + isSavingEdit = false; + } } // ── Unsaved changes guard ── @@ -381,6 +543,19 @@ return "negative"; } + function marginBarWidthPct(revenue?: number, margin?: number): number { + const cost = (revenue ?? 0) - (margin ?? 0); + if (!cost || cost <= 0) return 0; + const pct = ((margin ?? 0) / cost) * 100; + return Math.min(Math.abs(pct), 100); + } + + function isNegativeMargin(revenue?: number, margin?: number): boolean { + const cost = (revenue ?? 0) - (margin ?? 0); + if (!cost || cost <= 0) return false; + return ((margin ?? 0) / cost) * 100 < 0; + } + function checkForChanges() { hasChanges = activeProducts.some((p, i) => p.id !== originalOrderIds[i]); } @@ -517,6 +692,8 @@ showActionMenu = false; } isClosing = false; + showCancelModal = false; + cancellationSaveError = ""; selectedProduct = p; showPanel = true; } @@ -529,6 +706,8 @@ } isEditing = false; showActionMenu = false; + showCancelModal = false; + cancellationSaveError = ""; isClosing = true; setTimeout(() => { selectedProduct = null; @@ -539,6 +718,10 @@ function handleKeydown(e: KeyboardEvent) { if (e.key === "Escape") { + if (showCancelModal) { + closeCancellationModal(); + return; + } if (showActionMenu) { showActionMenu = false; return; @@ -1024,12 +1207,13 @@ p.revenue, p.margin, )}" - style="width: {Math.min( - Math.max( - ((p.margin ?? 0) / ((p.cost ?? 0) || 1)) * 100, - 0, - ), - 100, + class:from-right={isNegativeMargin( + p.revenue, + p.margin, + )} + style="width: {marginBarWidthPct( + p.revenue, + p.margin, )}%" >
@@ -1280,6 +1464,7 @@ on:click={cancelEdit} type="button" title="Discard changes" + disabled={isSavingEdit} > Cancel @@ -1288,10 +1473,25 @@ on:click={saveEdit} type="button" title="Save changes" + disabled={isSavingEdit} > - Save + {isSavingEdit ? "Saving..." : "Save"} {:else} +
+ {#if isEditing && editSaveError} +
{editSaveError}
+ {/if} + {#if !isEditing && (selectedProduct.cancelled || selectedProduct.cancellationType || (selectedProduct.quantityCancelled != null && selectedProduct.quantityCancelled > 0))}
@@ -1910,9 +2113,101 @@ {/if}
+{#if showCancelModal && selectedProduct} + + +
+ + +
+
+

+ {hasCancellation(selectedProduct) + ? "Edit Cancellation" + : "Cancel Product"} +

+ +
+ +

+ Set cancelled quantity between 0 and {maxCancellationQty( + selectedProduct, + )}. +

+ + {#if cancellationSaveError} +
{cancellationSaveError}
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+{/if} + @@ -2573,6 +2868,10 @@ transition: width 0.4s ease; } + .card-margin-fill.from-right { + margin-left: auto; + } + .card-margin-fill.healthy { background: #22c55e; } @@ -2888,12 +3187,19 @@ border-radius: 6px; font-size: 12px; font-weight: 600; + white-space: nowrap; + flex-shrink: 0; cursor: pointer; transition: background 0.15s, color 0.15s; } + .detail-action-btn:disabled { + opacity: 0.65; + cursor: not-allowed; + } + .cancel-btn { background: transparent; color: var(--text-secondary); @@ -2904,6 +3210,26 @@ color: var(--text-primary); } + .cancel-state-btn { + background: transparent; + color: var(--text-secondary); + } + + .cancel-state-btn:hover { + background: rgba(239, 68, 68, 0.12); + color: #ef4444; + } + + .cancel-state-btn.active { + background: rgba(245, 158, 11, 0.14); + color: #d97706; + } + + .cancel-state-btn.active:hover { + background: rgba(245, 158, 11, 0.2); + color: #d97706; + } + .save-btn { background: var(--accent, #6366f1); color: #fff; @@ -2913,6 +3239,108 @@ background: color-mix(in srgb, var(--accent, #6366f1) 85%, #000); } + .detail-inline-error { + margin: 10px 18px 0; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(220, 38, 38, 0.25); + background: rgba(220, 38, 38, 0.08); + color: #ef4444; + font-size: 12px; + font-weight: 500; + } + + .cancel-modal-overlay { + position: fixed; + inset: 0; + z-index: 60; + background: rgba(0, 0, 0, 0.45); + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + } + + .cancel-modal { + width: min(480px, calc(100vw - 40px)); + border-radius: 12px; + border: 1px solid var(--border-subtle, rgba(255, 255, 255, 0.1)); + background: var(--bg-surface, #1c1c22); + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + } + + .cancel-modal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } + + .cancel-modal-header h4 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-primary); + } + + .cancel-modal-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + } + + .cancel-modal-close:hover { + background: var(--bg-hover, rgba(255, 255, 255, 0.06)); + color: var(--text-primary); + } + + .cancel-modal-copy { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; + } + + .cancel-modal-error { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid rgba(220, 38, 38, 0.25); + background: rgba(220, 38, 38, 0.08); + color: #ef4444; + font-size: 12px; + font-weight: 500; + } + + .cancel-modal-field { + display: flex; + flex-direction: column; + gap: 6px; + } + + .cancel-modal-field label { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + } + + .cancel-modal-actions { + margin-top: 4px; + display: flex; + justify-content: flex-end; + gap: 8px; + } + /* Edit inputs */ .edit-input { width: 100%; @@ -3209,6 +3637,10 @@ transition: width 0.4s ease; } + .detail-margin-fill.from-right { + margin-left: auto; + } + .detail-margin-fill.healthy { background: #22c55e; }