Fix a few product editor issues, normalize prices on spreadsheet import
This commit is contained in:
@@ -1145,9 +1145,10 @@ router.get('/search-products', async (req, res) => {
|
|||||||
pcp.price_each AS price,
|
pcp.price_each AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
WHEN sid.supplier_id = 92 THEN
|
||||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
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,
|
END AS cost_price,
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
sid.supplier_itemnumber AS vendor_reference,
|
sid.supplier_itemnumber AS vendor_reference,
|
||||||
@@ -1266,9 +1267,10 @@ const PRODUCT_SELECT = `
|
|||||||
pcp.price_each AS price,
|
pcp.price_each AS price,
|
||||||
p.sellingprice AS regular_price,
|
p.sellingprice AS regular_price,
|
||||||
CASE
|
CASE
|
||||||
WHEN EXISTS (SELECT 1 FROM product_inventory WHERE pid = p.pid AND count > 0)
|
WHEN sid.supplier_id = 92 THEN
|
||||||
THEN (SELECT ROUND(AVG(costeach), 5) FROM product_inventory WHERE pid = p.pid AND count > 0)
|
CASE WHEN COALESCE(sid.notions_cost_each, 0) > 0 THEN sid.notions_cost_each ELSE sid.supplier_cost_each END
|
||||||
ELSE (SELECT costeach FROM product_inventory WHERE pid = p.pid ORDER BY daterec DESC LIMIT 1)
|
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,
|
END AS cost_price,
|
||||||
s.companyname AS vendor,
|
s.companyname AS vendor,
|
||||||
sid.supplier_itemnumber AS vendor_reference,
|
sid.supplier_itemnumber AS vendor_reference,
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export function AiDescriptionCompare({
|
|||||||
}, [currentValue, editedSuggestion, syncTextareaHeights]);
|
}, [currentValue, editedSuggestion, syncTextareaHeights]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Left: current description */}
|
||||||
<div className="flex flex-col min-h-0 w-full lg:w-1/2">
|
<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">
|
<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);
|
onCurrentChange(e.target.value);
|
||||||
syncTextareaHeights();
|
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 */}
|
{/* Footer spacer matching the action buttons height on the right */}
|
||||||
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
|
<div className="h-[43px] flex-shrink-0 hidden lg:block" />
|
||||||
@@ -203,7 +203,7 @@ export function AiDescriptionCompare({
|
|||||||
setEditedSuggestion(e.target.value);
|
setEditedSuggestion(e.target.value);
|
||||||
syncTextareaHeights();
|
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>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -740,7 +740,7 @@ export function ProductEditForm({
|
|||||||
{/* AI Description Review Dialog */}
|
{/* AI Description Review Dialog */}
|
||||||
{descriptionResult?.suggestion && (
|
{descriptionResult?.suggestion && (
|
||||||
<Dialog open={descDialogOpen} onOpenChange={setDescDialogOpen}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||||
|
|||||||
@@ -381,7 +381,12 @@ const MultilineInputComponent = ({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={resizeContainerRef}
|
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}
|
style={popoverHeight ? { height: popoverHeight } : undefined}
|
||||||
>
|
>
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
InlineAiValidationResult,
|
InlineAiValidationResult,
|
||||||
} from './types';
|
} from './types';
|
||||||
import type { Field, SelectOption } from '../../../types';
|
import type { Field, SelectOption } from '../../../types';
|
||||||
|
import { stripPriceFormatting } from '../utils/priceUtils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Initial State
|
// Initial State
|
||||||
@@ -165,11 +166,24 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
// Apply fresh state first (clean slate)
|
// Apply fresh state first (clean slate)
|
||||||
Object.assign(state, freshState);
|
Object.assign(state, freshState);
|
||||||
|
|
||||||
// Then set up with new data
|
// Identify price fields to clean on ingestion (strips $, commas, whitespace)
|
||||||
state.rows = data.map((row) => ({
|
const priceFieldKeys = fields
|
||||||
...row,
|
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
|
||||||
__index: row.__index || uuidv4(),
|
.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));
|
state.originalRows = JSON.parse(JSON.stringify(state.rows));
|
||||||
// Cast to bypass immer's strict readonly type checking
|
// Cast to bypass immer's strict readonly type checking
|
||||||
state.fields = fields as unknown as typeof state.fields;
|
state.fields = fields as unknown as typeof state.fields;
|
||||||
|
|||||||
@@ -2,10 +2,84 @@
|
|||||||
* Price field cleaning and formatting utilities
|
* 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
|
* 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
|
* - Converts to number and formats with 2 decimal places
|
||||||
* - Returns original value if conversion fails
|
* - Returns original value if conversion fails
|
||||||
*
|
*
|
||||||
@@ -14,13 +88,14 @@
|
|||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* cleanPriceField("$1,234.56") // "1234.56"
|
* cleanPriceField("$1,234.56") // "1234.56"
|
||||||
* cleanPriceField("$99.9") // "99.90"
|
* cleanPriceField(" $ 99.9") // "99.90"
|
||||||
* cleanPriceField(123.456) // "123.46"
|
* cleanPriceField("1,50") // "1.50"
|
||||||
* cleanPriceField("invalid") // "invalid"
|
* cleanPriceField(123.456) // "123.46"
|
||||||
|
* cleanPriceField("invalid") // "invalid"
|
||||||
*/
|
*/
|
||||||
export function cleanPriceField(value: string | number): string {
|
export function cleanPriceField(value: string | number): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const cleaned = value.replace(/[$,]/g, "");
|
const cleaned = stripPriceFormatting(value);
|
||||||
const numValue = parseFloat(cleaned);
|
const numValue = parseFloat(cleaned);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toFixed(2);
|
return numValue.toFixed(2);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
export function cleanPriceField(value: string | number): string {
|
export function cleanPriceField(value: string | number): string {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const cleaned = value.replace(/[$,]/g, "");
|
const cleaned = value.replace(/[\s$,]/g, "");
|
||||||
const numValue = parseFloat(cleaned);
|
const numValue = parseFloat(cleaned);
|
||||||
if (!isNaN(numValue)) {
|
if (!isNaN(numValue)) {
|
||||||
return numValue.toFixed(2);
|
return numValue.toFixed(2);
|
||||||
|
|||||||
Reference in New Issue
Block a user