From c344fdc3b827751b62597dd0affa33b922e42682 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 10:45:39 -0500 Subject: [PATCH] Fix a few product editor issues, normalize prices on spreadsheet import --- inventory-server/src/routes/import.js | 16 ++-- .../components/ai/AiDescriptionCompare.tsx | 6 +- .../product-editor/ProductEditForm.tsx | 2 +- .../components/cells/MultilineInput.tsx | 7 +- .../ValidationStep/store/validationStore.ts | 24 +++-- .../steps/ValidationStep/utils/priceUtils.ts | 87 +++++++++++++++++-- .../ValidationStepOLD/utils/priceUtils.ts | 2 +- 7 files changed, 120 insertions(+), 24 deletions(-) diff --git a/inventory-server/src/routes/import.js b/inventory-server/src/routes/import.js index e26a23f..b298daa 100644 --- a/inventory-server/src/routes/import.js +++ b/inventory-server/src/routes/import.js @@ -1144,10 +1144,11 @@ router.get('/search-products', async (req, res) => { p.harmonized_tariff_code, pcp.price_each AS price, p.sellingprice AS regular_price, - CASE - WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) - THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) - ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) + CASE + WHEN sid.supplier_id = 92 THEN + CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END + ELSE + CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END END AS cost_price, s.companyname AS vendor, sid.supplier_itemnumber AS vendor_reference, @@ -1266,9 +1267,10 @@ const PRODUCT_SELECT = ` pcp.price_each AS price, p.sellingprice AS regular_price, CASE - WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0) - THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0) - ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1) + WHEN sid.supplier_id = 92 THEN + CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END + ELSE + CASE WHEN COALESCE(sid.supplier_cost_each, 0) > 0 THEN sid.supplier_cost_each ELSE sid.notions_cost_each END END AS cost_price, s.companyname AS vendor, sid.supplier_itemnumber AS vendor_reference, diff --git a/inventory/src/components/ai/AiDescriptionCompare.tsx b/inventory/src/components/ai/AiDescriptionCompare.tsx index fc97553..0553219 100644 --- a/inventory/src/components/ai/AiDescriptionCompare.tsx +++ b/inventory/src/components/ai/AiDescriptionCompare.tsx @@ -106,7 +106,7 @@ export function AiDescriptionCompare({ }, [currentValue, editedSuggestion, syncTextareaHeights]); return ( -
+
{/* Left: current description */}
@@ -149,7 +149,7 @@ export function AiDescriptionCompare({ onCurrentChange(e.target.value); syncTextareaHeights(); }} - className="overflow-y-auto overscroll-contain text-sm resize-y bg-white min-h-[120px]" + className="overflow-y-auto overscroll-contain text-sm resize-y bg-white min-h-[120px] max-h-[50vh]" /> {/* Footer spacer matching the action buttons height on the right */}
@@ -203,7 +203,7 @@ export function AiDescriptionCompare({ setEditedSuggestion(e.target.value); syncTextareaHeights(); }} - className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px]" + className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]" />
diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx index 78d4624..f94333b 100644 --- a/inventory/src/components/product-editor/ProductEditForm.tsx +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -740,7 +740,7 @@ export function ProductEditForm({ {/* AI Description Review Dialog */} {descriptionResult?.suggestion && ( - + diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx index 275d23e..74d0185 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx @@ -381,7 +381,12 @@ const MultilineInputComponent = ({ >
{/* Close button */} diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index e63c39d..71e44ed 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -32,6 +32,7 @@ import type { InlineAiValidationResult, } from './types'; import type { Field, SelectOption } from '../../../types'; +import { stripPriceFormatting } from '../utils/priceUtils'; // ============================================================================= // Initial State @@ -165,11 +166,24 @@ export const useValidationStore = create()( // Apply fresh state first (clean slate) Object.assign(state, freshState); - // Then set up with new data - state.rows = data.map((row) => ({ - ...row, - __index: row.__index || uuidv4(), - })); + // Identify price fields to clean on ingestion (strips $, commas, whitespace) + const priceFieldKeys = fields + .filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price) + .map((f) => f.key); + + // Then set up with new data, cleaning price fields + state.rows = data.map((row) => { + const cleanedRow: RowData = { + ...row, + __index: row.__index || uuidv4(), + }; + for (const key of priceFieldKeys) { + if (typeof cleanedRow[key] === 'string' && cleanedRow[key] !== '') { + cleanedRow[key] = stripPriceFormatting(cleanedRow[key] as string); + } + } + return cleanedRow; + }); state.originalRows = JSON.parse(JSON.stringify(state.rows)); // Cast to bypass immer's strict readonly type checking state.fields = fields as unknown as typeof state.fields; diff --git a/inventory/src/components/product-import/steps/ValidationStep/utils/priceUtils.ts b/inventory/src/components/product-import/steps/ValidationStep/utils/priceUtils.ts index 6d34db2..0f687b7 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/utils/priceUtils.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/utils/priceUtils.ts @@ -2,10 +2,84 @@ * Price field cleaning and formatting utilities */ +/** + * Normalizes a numeric string that may use US or European formatting conventions. + * + * Handles the ambiguity between comma-as-thousands (US: "1,234.56") and + * comma-as-decimal (European: "1.234,56" or "1,50") using these heuristics: + * + * 1. Both comma AND period present → last one is the decimal separator + * - "1,234.56" → period last → US → "1234.56" + * - "1.234,56" → comma last → EU → "1234.56" + * + * 2. Only comma, no period → check digit count after last comma: + * - 1-2 digits → decimal comma: "1,50" → "1.50" + * - 3 digits → thousands: "1,500" → "1500" + * + * 3. Only period or neither → return as-is + */ +function normalizeNumericSeparators(value: string): string { + if (value.includes(".") && value.includes(",")) { + const lastComma = value.lastIndexOf(","); + const lastPeriod = value.lastIndexOf("."); + if (lastPeriod > lastComma) { + // US: "1,234.56" → remove commas + return value.replace(/,/g, ""); + } else { + // European: "1.234,56" → remove periods, comma→period + return value.replace(/\./g, "").replace(",", "."); + } + } + + if (value.includes(",")) { + const match = value.match(/,(\d+)$/); + if (match && match[1].length <= 2) { + // Decimal comma: "1,50" → "1.50", "1,5" → "1.5" + return value.replace(",", "."); + } + // Thousands comma(s): "1,500" or "1,000,000" → remove all + return value.replace(/,/g, ""); + } + + return value; +} + +/** + * Strips currency formatting from a price string without rounding. + * + * Removes currency symbols and whitespace, normalizes European decimal commas, + * and returns the raw numeric string. Full precision is preserved. + * + * @returns Stripped numeric string, or original value if not a valid number + * + * @example + * stripPriceFormatting(" $ 1.50") // "1.50" + * stripPriceFormatting("$1,234.56") // "1234.56" + * stripPriceFormatting("1.234,56") // "1234.56" + * stripPriceFormatting("1,50") // "1.50" + * stripPriceFormatting("3.625") // "3.625" + * stripPriceFormatting("invalid") // "invalid" + */ +export function stripPriceFormatting(value: string): string { + // Step 1: Strip whitespace and currency symbols (keep commas/periods for separator detection) + let cleaned = value.replace(/[\s$€£¥]/g, ""); + + // Step 2: Normalize decimal/thousands separators + cleaned = normalizeNumericSeparators(cleaned); + + // Verify it's actually a number after normalization + const numValue = parseFloat(cleaned); + if (!isNaN(numValue) && cleaned !== "") { + return cleaned; + } + return value; +} + /** * Cleans a price field by removing currency symbols and formatting to 2 decimal places * - * - Removes dollar signs ($) and commas (,) + * - Removes currency symbols and whitespace + * - Normalizes European decimal commas * - Converts to number and formats with 2 decimal places * - Returns original value if conversion fails * @@ -14,13 +88,14 @@ * * @example * cleanPriceField("$1,234.56") // "1234.56" - * cleanPriceField("$99.9") // "99.90" - * cleanPriceField(123.456) // "123.46" - * cleanPriceField("invalid") // "invalid" + * cleanPriceField(" $ 99.9") // "99.90" + * cleanPriceField("1,50") // "1.50" + * cleanPriceField(123.456) // "123.46" + * cleanPriceField("invalid") // "invalid" */ export function cleanPriceField(value: string | number): string { if (typeof value === "string") { - const cleaned = value.replace(/[$,]/g, ""); + const cleaned = stripPriceFormatting(value); const numValue = parseFloat(cleaned); if (!isNaN(numValue)) { return numValue.toFixed(2); @@ -59,4 +134,4 @@ export function cleanPriceFields>( } return cleaned; -} \ No newline at end of file +} diff --git a/inventory/src/components/product-import/steps/ValidationStepOLD/utils/priceUtils.ts b/inventory/src/components/product-import/steps/ValidationStepOLD/utils/priceUtils.ts index 6d34db2..fe060c1 100644 --- a/inventory/src/components/product-import/steps/ValidationStepOLD/utils/priceUtils.ts +++ b/inventory/src/components/product-import/steps/ValidationStepOLD/utils/priceUtils.ts @@ -20,7 +20,7 @@ */ export function cleanPriceField(value: string | number): string { if (typeof value === "string") { - const cleaned = value.replace(/[$,]/g, ""); + const cleaned = value.replace(/[\s$,]/g, ""); const numValue = parseFloat(cleaned); if (!isNaN(numValue)) { return numValue.toFixed(2);