Fix a few product editor issues, normalize prices on spreadsheet import
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -106,7 +106,7 @@ export function AiDescriptionCompare({
|
||||
}, [currentValue, editedSuggestion, syncTextareaHeights]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col lg:flex-row items-stretch", className)}>
|
||||
<div className={cn("flex flex-col lg:flex-row items-stretch w-full", className)}>
|
||||
{/* Left: current description */}
|
||||
<div className="flex flex-col min-h-0 w-full lg:w-1/2">
|
||||
<div className="px-3 py-2 bg-accent flex flex-col flex-1 min-h-0">
|
||||
@@ -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 */}
|
||||
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
|
||||
@@ -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]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -740,7 +740,7 @@ export function ProductEditForm({
|
||||
{/* AI Description Review Dialog */}
|
||||
{descriptionResult?.suggestion && (
|
||||
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh]">
|
||||
<DialogContent className="sm:max-w-4xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
|
||||
@@ -381,7 +381,12 @@ const MultilineInputComponent = ({
|
||||
>
|
||||
<div
|
||||
ref={resizeContainerRef}
|
||||
className="flex flex-col lg:flex-row items-stretch lg:resize-y lg:overflow-auto lg:min-h-[120px] max-h-[85vh] overflow-y-auto lg:max-h-none"
|
||||
className={cn(
|
||||
"flex flex-col lg:flex-row items-stretch max-h-[85vh]",
|
||||
hasAiSuggestion
|
||||
? "overflow-y-auto lg:overflow-hidden"
|
||||
: "lg:resize-y lg:overflow-auto lg:min-h-[120px] overflow-y-auto lg:max-h-none"
|
||||
)}
|
||||
style={popoverHeight ? { height: popoverHeight } : undefined}
|
||||
>
|
||||
{/* Close button */}
|
||||
|
||||
@@ -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<ValidationStore>()(
|
||||
// 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;
|
||||
|
||||
@@ -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<T extends Record<string, any>>(
|
||||
}
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user