2 Commits

Author SHA1 Message Date
c344fdc3b8 Fix a few product editor issues, normalize prices on spreadsheet import 2026-03-05 10:45:39 -05:00
ebef903f3b Switch column order in import 2026-02-26 17:00:11 -05:00
8 changed files with 132 additions and 36 deletions

View File

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

View File

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

View File

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

View File

@@ -193,18 +193,6 @@ export const BASE_IMPORT_FIELDS = [
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 120, width: 120,
}, },
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
alternateMatches: ["weight (lbs.)"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{ {
label: "Length", label: "Length",
key: "length", key: "length",
@@ -238,6 +226,18 @@ export const BASE_IMPORT_FIELDS = [
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" }, { rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
], ],
}, },
{
label: "Weight",
key: "weight",
description: "Product weight (in lbs)",
alternateMatches: ["weight (lbs.)"],
fieldType: { type: "input" },
width: 100,
validations: [
{ rule: "required", errorMessage: "Required", level: "error" },
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
],
},
{ {
label: "Shipping Restrictions", label: "Shipping Restrictions",
key: "ship_restrictions", key: "ship_restrictions",

View File

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

View File

@@ -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
.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, ...row,
__index: row.__index || uuidv4(), __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;

View File

@@ -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
* *
@@ -15,12 +89,13 @@
* @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("1,50") // "1.50"
* cleanPriceField(123.456) // "123.46" * cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid" * 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);

View File

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