Fix a few product editor issues, normalize prices on spreadsheet import

This commit is contained in:
2026-03-05 10:45:39 -05:00
parent ebef903f3b
commit c344fdc3b8
7 changed files with 120 additions and 24 deletions

View File

@@ -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,

View File

@@ -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>

View File

@@ -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" />

View File

@@ -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 */}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);