Product import fixes/enhancements
This commit is contained in:
@@ -25,7 +25,7 @@ const getFullImageUrl = (url: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
// Otherwise, it's a relative URL, prepend the domain
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
// Make sure url starts with / for path
|
// Make sure url starts with / for path
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
return `${baseUrl}${path}`;
|
return `${baseUrl}${path}`;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const useProductImagesInit = (data: Product[]) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, it's a relative URL, prepend the domain
|
// Otherwise, it's a relative URL, prepend the domain
|
||||||
const baseUrl = 'https://acot.site';
|
const baseUrl = 'https://tools.acherryontop.com';
|
||||||
// Make sure url starts with / for path
|
// Make sure url starts with / for path
|
||||||
const path = url.startsWith('/') ? url : `/${url}`;
|
const path = url.startsWith('/') ? url : `/${url}`;
|
||||||
return `${baseUrl}${path}`;
|
return `${baseUrl}${path}`;
|
||||||
|
|||||||
@@ -30,7 +30,14 @@ export const Steps = () => {
|
|||||||
|
|
||||||
const onClickStep = (stepIndex: number) => {
|
const onClickStep = (stepIndex: number) => {
|
||||||
const type = stepIndexToStepType(stepIndex)
|
const type = stepIndexToStepType(stepIndex)
|
||||||
const historyIdx = history.current.findIndex((v) => v.type === type)
|
let historyIdx = history.current.findIndex((v) => v.type === type)
|
||||||
|
|
||||||
|
// Special case: step index 0 could be either upload or selectSheet
|
||||||
|
// If we didn't find upload, also check for selectSheet
|
||||||
|
if (historyIdx === -1 && stepIndex === 0) {
|
||||||
|
historyIdx = history.current.findIndex((v) => v.type === StepType.selectSheet)
|
||||||
|
}
|
||||||
|
|
||||||
if (historyIdx === -1) return
|
if (historyIdx === -1) return
|
||||||
const nextHistory = history.current.slice(0, historyIdx + 1)
|
const nextHistory = history.current.slice(0, historyIdx + 1)
|
||||||
history.current = nextHistory
|
history.current = nextHistory
|
||||||
@@ -39,7 +46,14 @@ export const Steps = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onBack = () => {
|
const onBack = () => {
|
||||||
onClickStep(Math.max(activeStep - 1, 0))
|
// For back navigation, we want to go to the previous entry in history
|
||||||
|
// rather than relying on step index, since selectSheet shares index with upload
|
||||||
|
if (history.current.length > 0) {
|
||||||
|
const previousState = history.current[history.current.length - 1]
|
||||||
|
history.current = history.current.slice(0, -1)
|
||||||
|
setState(previousState)
|
||||||
|
setActiveStep(stepTypeToStepIndex(previousState.type))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onNext = (v: StepState) => {
|
const onNext = (v: StepState) => {
|
||||||
|
|||||||
@@ -123,11 +123,12 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
// Keep track of global selections across steps
|
// Keep track of global selections across steps
|
||||||
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
||||||
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
|
state.type === StepType.validateData || state.type === StepType.validateDataNew || state.type === StepType.matchColumns
|
||||||
? state.globalSelections
|
? state.globalSelections
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
switch (state.type) {
|
switch (state.type) {
|
||||||
case StepType.upload:
|
case StepType.upload:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ArrowDown, Wand2, Loader2, Calculator, Scale } from 'lucide-react';
|
import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
@@ -1210,8 +1210,10 @@ interface VirtualRowProps {
|
|||||||
columns: ColumnDef<RowData>[];
|
columns: ColumnDef<RowData>[];
|
||||||
fields: Field<string>[];
|
fields: Field<string>[];
|
||||||
totalRowCount: number;
|
totalRowCount: number;
|
||||||
/** Whether table is scrolled horizontally - used for sticky column shadow */
|
/** Whether the name column sticky behavior is enabled */
|
||||||
isScrolledHorizontally: boolean;
|
nameColumnSticky: boolean;
|
||||||
|
/** Direction for sticky name column: 'left', 'right', or null (not sticky) */
|
||||||
|
stickyDirection: 'left' | 'right' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VirtualRow = memo(({
|
const VirtualRow = memo(({
|
||||||
@@ -1221,7 +1223,8 @@ const VirtualRow = memo(({
|
|||||||
columns,
|
columns,
|
||||||
fields,
|
fields,
|
||||||
totalRowCount,
|
totalRowCount,
|
||||||
isScrolledHorizontally,
|
nameColumnSticky,
|
||||||
|
stickyDirection,
|
||||||
}: VirtualRowProps) => {
|
}: VirtualRowProps) => {
|
||||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||||
const rowData = useValidationStore(
|
const rowData = useValidationStore(
|
||||||
@@ -1317,13 +1320,18 @@ const VirtualRow = memo(({
|
|||||||
<div
|
<div
|
||||||
data-row-index={rowIndex}
|
data-row-index={rowIndex}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex border-b absolute',
|
'flex absolute',
|
||||||
|
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
|
||||||
|
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
|
||||||
hasErrors && 'bg-destructive/5',
|
hasErrors && 'bg-destructive/5',
|
||||||
isSelected && 'bg-primary/5'
|
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
transform: `translateY(${virtualStart}px)`,
|
// Round to whole pixels to prevent sub-pixel rendering issues during scroll
|
||||||
|
transform: `translateY(${Math.round(virtualStart)}px)`,
|
||||||
|
// Promote to GPU layer for smoother rendering
|
||||||
|
willChange: 'transform',
|
||||||
// Elevate row when it has a visible AI suggestion so badge shows above next row
|
// Elevate row when it has a visible AI suggestion so badge shows above next row
|
||||||
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
|
||||||
}}
|
}}
|
||||||
@@ -1331,7 +1339,7 @@ const VirtualRow = memo(({
|
|||||||
>
|
>
|
||||||
{/* Selection checkbox cell */}
|
{/* Selection checkbox cell */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-3 border-r flex items-start justify-center"
|
className="px-2 py-3 flex items-start justify-center shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
|
||||||
style={{
|
style={{
|
||||||
width: columns[0]?.size || 40,
|
width: columns[0]?.size || 40,
|
||||||
minWidth: columns[0]?.size || 40,
|
minWidth: columns[0]?.size || 40,
|
||||||
@@ -1346,7 +1354,7 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
{/* Template column */}
|
{/* Template column */}
|
||||||
<div
|
<div
|
||||||
className="px-2 py-2 border-r flex items-start overflow-hidden"
|
className="px-2 py-2 flex items-start overflow-hidden shadow-[inset_-1px_0_0_0_hsl(var(--border))]"
|
||||||
style={{
|
style={{
|
||||||
width: TEMPLATE_COLUMN_WIDTH,
|
width: TEMPLATE_COLUMN_WIDTH,
|
||||||
minWidth: TEMPLATE_COLUMN_WIDTH,
|
minWidth: TEMPLATE_COLUMN_WIDTH,
|
||||||
@@ -1400,33 +1408,50 @@ const VirtualRow = memo(({
|
|||||||
|
|
||||||
const isNameColumn = field.key === 'name';
|
const isNameColumn = field.key === 'name';
|
||||||
|
|
||||||
|
// Determine sticky behavior for name column
|
||||||
|
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||||
|
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||||
|
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field.key}
|
key={field.key}
|
||||||
data-cell-field={field.key}
|
data-cell-field={field.key}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-2 border-r last:border-r-0 flex items-start",
|
"px-2 py-2 flex items-start",
|
||||||
|
// Use box-shadow for right border - renders more consistently with transforms
|
||||||
|
// last:shadow-none removes the shadow from the last cell
|
||||||
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
// Name column needs overflow-visible for the floating AI suggestion badge
|
// Name column needs overflow-visible for the floating AI suggestion badge
|
||||||
// Description handles AI suggestions inside its popover, so no overflow needed
|
// Description handles AI suggestions inside its popover, so no overflow needed
|
||||||
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
isNameColumn ? "overflow-visible" : "overflow-hidden",
|
||||||
// Name column is sticky - needs SOLID (opaque) background that matches row state
|
// Name column sticky behavior - only when enabled and scrolled appropriately
|
||||||
// Uses gradient trick to composite semi-transparent tint onto solid background
|
shouldBeSticky && "lg:sticky lg:z-10",
|
||||||
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
|
// Add left border when sticky-right since content scrolls behind from the left
|
||||||
isNameColumn && "lg:sticky lg:z-10",
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border))]",
|
||||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
// Directional drop shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||||
isNameColumn && (
|
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
hasErrors
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
// Solid background when sticky to overlay content
|
||||||
: isSelected
|
// Use explicit [background:] syntax for consistent specificity
|
||||||
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
|
// Selection (blue) takes priority over errors (red)
|
||||||
: "lg:bg-background"
|
shouldBeSticky && (
|
||||||
)
|
isSelected
|
||||||
|
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
|
||||||
|
: hasErrors
|
||||||
|
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
||||||
|
: "lg:[background:hsl(var(--background))]"
|
||||||
|
),
|
||||||
|
// Make child inputs/buttons transparent when sticky + selected so blue background shows through
|
||||||
|
shouldBeSticky && isSelected && "lg:[&_input]:!bg-transparent lg:[&_button]:!bg-transparent lg:[&_textarea]:!bg-transparent"
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: columnWidth,
|
width: columnWidth,
|
||||||
minWidth: columnWidth,
|
minWidth: columnWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
|
// Position sticky left or right based on scroll direction
|
||||||
|
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||||
|
...(stickyRight && { right: 0 }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CellWrapper
|
<CellWrapper
|
||||||
@@ -1462,27 +1487,77 @@ VirtualRow.displayName = 'VirtualRow';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Header checkbox component - isolated to prevent re-renders of the entire table
|
* Header checkbox component - isolated to prevent re-renders of the entire table
|
||||||
|
* When filtering is active, only selects/deselects visible (filtered) rows
|
||||||
*/
|
*/
|
||||||
const HeaderCheckbox = memo(() => {
|
const HeaderCheckbox = memo(() => {
|
||||||
const rowCount = useValidationStore((state) => state.rows.length);
|
const filters = useFilters();
|
||||||
const selectedCount = useValidationStore((state) => state.selectedRows.size);
|
|
||||||
|
|
||||||
const allSelected = rowCount > 0 && selectedCount === rowCount;
|
// Compute which rows are visible based on current filters
|
||||||
const someSelected = selectedCount > 0 && selectedCount < rowCount;
|
const { visibleRowIds, visibleCount } = useMemo(() => {
|
||||||
|
const { rows, errors } = useValidationStore.getState();
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
|
||||||
|
if (!isFiltering) {
|
||||||
|
// No filtering - all rows are visible
|
||||||
|
const ids = new Set(rows.map((row) => row.__index));
|
||||||
|
return { visibleRowIds: ids, visibleCount: rows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filters to get visible row IDs
|
||||||
|
const ids = new Set<string>();
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matches = Object.values(row).some((value) =>
|
||||||
|
String(value ?? '').toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (!matches) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply errors-only filter
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const rowErrors = errors.get(index);
|
||||||
|
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ids.add(row.__index);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { visibleRowIds: ids, visibleCount: ids.size };
|
||||||
|
}, [filters.searchText, filters.showErrorsOnly]);
|
||||||
|
|
||||||
|
// Check selection state against visible rows only
|
||||||
|
const selectedRows = useValidationStore((state) => state.selectedRows);
|
||||||
|
const selectedVisibleCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
visibleRowIds.forEach((id) => {
|
||||||
|
if (selectedRows.has(id)) count++;
|
||||||
|
});
|
||||||
|
return count;
|
||||||
|
}, [visibleRowIds, selectedRows]);
|
||||||
|
|
||||||
|
const allVisibleSelected = visibleCount > 0 && selectedVisibleCount === visibleCount;
|
||||||
|
const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleCount;
|
||||||
|
|
||||||
const handleChange = useCallback((value: boolean | 'indeterminate') => {
|
const handleChange = useCallback((value: boolean | 'indeterminate') => {
|
||||||
const { setSelectedRows, rows } = useValidationStore.getState();
|
const { setSelectedRows, selectedRows: currentSelected } = useValidationStore.getState();
|
||||||
if (value) {
|
if (value) {
|
||||||
const allIds = new Set(rows.map((row) => row.__index));
|
// Add all visible rows to selection (keep existing selections of non-visible rows)
|
||||||
setSelectedRows(allIds);
|
const newSelection = new Set(currentSelected);
|
||||||
|
visibleRowIds.forEach((id) => newSelection.add(id));
|
||||||
|
setSelectedRows(newSelection);
|
||||||
} else {
|
} else {
|
||||||
setSelectedRows(new Set());
|
// Remove all visible rows from selection (keep selections of non-visible rows)
|
||||||
|
const newSelection = new Set(currentSelected);
|
||||||
|
visibleRowIds.forEach((id) => newSelection.delete(id));
|
||||||
|
setSelectedRows(newSelection);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [visibleRowIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected || (someSelected && 'indeterminate')}
|
checked={allVisibleSelected || (someVisibleSelected && 'indeterminate')}
|
||||||
onCheckedChange={handleChange}
|
onCheckedChange={handleChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -1536,20 +1611,19 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
: 'Fill empty cells with MSRP ÷ 2';
|
: 'Fill empty cells with MSRP ÷ 2';
|
||||||
|
|
||||||
// Check if there are any cells that can be filled (called on hover)
|
// Check if there are any cells that can be filled (called on hover)
|
||||||
|
// Now returns true if ANY row has a valid source value (allows overwriting existing values)
|
||||||
const checkFillableCells = useCallback(() => {
|
const checkFillableCells = useCallback(() => {
|
||||||
const { rows } = useValidationStore.getState();
|
const { rows } = useValidationStore.getState();
|
||||||
return rows.some((row) => {
|
return rows.some((row) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
return !isNaN(sourceNum) && sourceNum > 0;
|
return !isNaN(sourceNum) && sourceNum > 0;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
}, [fieldKey, sourceField]);
|
}, [sourceField]);
|
||||||
|
|
||||||
// Update fillable check on hover
|
// Update fillable check on hover
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback(() => {
|
||||||
@@ -1563,29 +1637,28 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
// Use setState() for efficient batch update with Immer
|
// Use setState() for efficient batch update with Immer
|
||||||
useValidationStore.setState((draft) => {
|
useValidationStore.setState((draft) => {
|
||||||
draft.rows.forEach((row, index) => {
|
draft.rows.forEach((row, index) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
|
|
||||||
// Only fill if current field is empty and source has a value
|
// Fill if source has a value (overwrite existing values based on source)
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
let msrp = sourceNum * multiplier;
|
let msrp = sourceNum * multiplier;
|
||||||
|
|
||||||
if (multiplier === 2.0) {
|
if (multiplier === 2.0) {
|
||||||
// For 2x: auto-adjust by ±1 cent to get to .99 if close
|
// For 2x: auto-adjust by ±1 cent ONLY if result ends in .99
|
||||||
const cents = Math.round((msrp % 1) * 100);
|
const cents = Math.round((msrp % 1) * 100);
|
||||||
if (cents === 0) {
|
if (cents === 0 || cents === 98) {
|
||||||
// .00 → subtract 1 cent to get .99
|
const adjustment = cents === 0 ? -0.01 : 0.01;
|
||||||
msrp -= 0.01;
|
const adjusted = (msrp + adjustment).toFixed(2);
|
||||||
} else if (cents === 98) {
|
// Only apply if the adjusted value actually ends in .99
|
||||||
// .98 → add 1 cent to get .99
|
if (adjusted.endsWith('.99')) {
|
||||||
msrp += 0.01;
|
msrp = parseFloat(adjusted);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Otherwise leave as-is
|
// Otherwise leave as-is (exact 2x)
|
||||||
} else if (roundNine) {
|
} else if (roundNine) {
|
||||||
// For >2x with checkbox: round to nearest .X9
|
// For >2x with checkbox: round to nearest .X9
|
||||||
msrp = roundToNine(msrp);
|
msrp = roundToNine(msrp);
|
||||||
@@ -1616,13 +1689,12 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHead
|
|||||||
|
|
||||||
useValidationStore.setState((draft) => {
|
useValidationStore.setState((draft) => {
|
||||||
draft.rows.forEach((row, index) => {
|
draft.rows.forEach((row, index) => {
|
||||||
const currentValue = row[fieldKey];
|
|
||||||
const sourceValue = row[sourceField];
|
const sourceValue = row[sourceField];
|
||||||
|
|
||||||
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
// Fill if source has a value (overwrite existing values based on source)
|
||||||
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
|
||||||
if (isEmpty && hasSource) {
|
if (hasSource) {
|
||||||
const sourceNum = parseFloat(String(sourceValue));
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
if (!isNaN(sourceNum) && sourceNum > 0) {
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
draft.rows[index][fieldKey] = (sourceNum / 2).toFixed(2);
|
||||||
@@ -1928,6 +2000,165 @@ const UnitConversionColumnHeader = memo(({ fieldKey, label, isRequired }: UnitCo
|
|||||||
|
|
||||||
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
|
UnitConversionColumnHeader.displayName = 'UnitConversionColumnHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DefaultValueColumnHeader Component
|
||||||
|
*
|
||||||
|
* Renders a column header with a hover button that sets a default value for all rows.
|
||||||
|
* Used for Tax Category ("Not Specifically Set") and Shipping Restrictions ("None").
|
||||||
|
*
|
||||||
|
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||||
|
*/
|
||||||
|
interface DefaultValueColumnHeaderProps {
|
||||||
|
fieldKey: 'tax_cat' | 'ship_restrictions';
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_VALUE_CONFIG: Record<string, { value: string; displayName: string; buttonLabel: string }> = {
|
||||||
|
tax_cat: { value: '0', displayName: 'Not Specifically Set', buttonLabel: 'Set All Default' },
|
||||||
|
ship_restrictions: { value: '0', displayName: 'None', buttonLabel: 'Set All None' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const DefaultValueColumnHeader = memo(({ fieldKey, label, isRequired }: DefaultValueColumnHeaderProps) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [hasEmptyCells, setHasEmptyCells] = useState(false);
|
||||||
|
|
||||||
|
const config = DEFAULT_VALUE_CONFIG[fieldKey];
|
||||||
|
|
||||||
|
// Check if there are any empty cells that can be filled
|
||||||
|
const checkEmptyCells = useCallback(() => {
|
||||||
|
const { rows } = useValidationStore.getState();
|
||||||
|
return rows.some((row) => {
|
||||||
|
const value = row[fieldKey];
|
||||||
|
return value === undefined || value === null || value === '';
|
||||||
|
});
|
||||||
|
}, [fieldKey]);
|
||||||
|
|
||||||
|
// Update empty check on hover
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
setHasEmptyCells(checkEmptyCells());
|
||||||
|
}, [checkEmptyCells]);
|
||||||
|
|
||||||
|
const handleSetDefault = useCallback(() => {
|
||||||
|
const updatedIndices: number[] = [];
|
||||||
|
|
||||||
|
useValidationStore.setState((draft) => {
|
||||||
|
draft.rows.forEach((row, index) => {
|
||||||
|
const value = row[fieldKey];
|
||||||
|
const isEmpty = value === undefined || value === null || value === '';
|
||||||
|
|
||||||
|
if (isEmpty) {
|
||||||
|
draft.rows[index][fieldKey] = config.value;
|
||||||
|
updatedIndices.push(index);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updatedIndices.length > 0) {
|
||||||
|
const { clearFieldError } = useValidationStore.getState();
|
||||||
|
updatedIndices.forEach((rowIndex) => {
|
||||||
|
clearFieldError(rowIndex, fieldKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Set ${updatedIndices.length} row${updatedIndices.length === 1 ? '' : 's'} to "${config.displayName}"`);
|
||||||
|
}
|
||||||
|
}, [fieldKey, config]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 truncate w-full group relative"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
{isHovered && hasEmptyCells && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleSetDefault();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||||
|
'flex items-center gap-0.5',
|
||||||
|
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||||
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'transition-opacity whitespace-nowrap'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Wand2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>Fill empty cells with "{config.displayName}"</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DefaultValueColumnHeader.displayName = 'DefaultValueColumnHeader';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NameColumnHeader Component
|
||||||
|
*
|
||||||
|
* Renders the Name column header with a sticky toggle button.
|
||||||
|
* Pin icon toggles whether the name column sticks to edges when scrolling.
|
||||||
|
*/
|
||||||
|
interface NameColumnHeaderProps {
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
isSticky: boolean;
|
||||||
|
onToggleSticky: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NameColumnHeader = memo(({ label, isRequired, isSticky, onToggleSticky }: NameColumnHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 truncate w-full group relative">
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggleSticky();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'ml-auto flex items-center justify-center w-6 h-6 rounded',
|
||||||
|
'transition-colors',
|
||||||
|
isSticky
|
||||||
|
? 'text-primary bg-primary/10 hover:bg-primary/20'
|
||||||
|
: 'text-muted-foreground hover:bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSticky ? <Pin className="h-3.5 w-3.5" /> : <PinOff className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{isSticky ? 'Unpin column' : 'Pin column'}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NameColumnHeader.displayName = 'NameColumnHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main table component
|
* Main table component
|
||||||
*
|
*
|
||||||
@@ -1958,18 +2189,46 @@ export const ValidationTable = () => {
|
|||||||
return offset;
|
return offset;
|
||||||
}, [fields]);
|
}, [fields]);
|
||||||
|
|
||||||
// Track horizontal scroll for sticky column shadow
|
// Name column sticky toggle - when enabled, column sticks to left/right edges based on scroll
|
||||||
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
|
const [nameColumnSticky, setNameColumnSticky] = useState(true);
|
||||||
|
|
||||||
// Sync header scroll with body scroll + track horizontal scroll state
|
// Track scroll direction relative to name column: 'left' (stick to left) or 'right' (stick to right)
|
||||||
|
const [stickyDirection, setStickyDirection] = useState<'left' | 'right' | null>(null);
|
||||||
|
|
||||||
|
// Calculate name column width
|
||||||
|
const nameColumnWidth = useMemo(() => {
|
||||||
|
const nameField = fields.find(f => f.key === 'name');
|
||||||
|
return nameField?.width || 400;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Sync header scroll with body scroll + track sticky direction
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
if (tableContainerRef.current && headerRef.current) {
|
if (tableContainerRef.current && headerRef.current) {
|
||||||
const scrollLeft = tableContainerRef.current.scrollLeft;
|
const scrollLeft = tableContainerRef.current.scrollLeft;
|
||||||
|
const viewportWidth = tableContainerRef.current.clientWidth;
|
||||||
headerRef.current.scrollLeft = scrollLeft;
|
headerRef.current.scrollLeft = scrollLeft;
|
||||||
// Only show shadow when scrolled past the name column's natural position
|
|
||||||
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
|
// Calculate name column's position relative to viewport
|
||||||
|
const namePositionInViewport = nameColumnLeftOffset - scrollLeft;
|
||||||
|
const nameRightEdge = namePositionInViewport + nameColumnWidth;
|
||||||
|
|
||||||
|
// Determine sticky direction for name column
|
||||||
|
if (nameColumnSticky) {
|
||||||
|
if (scrollLeft > nameColumnLeftOffset) {
|
||||||
|
// Scrolled right past name column - stick to left
|
||||||
|
setStickyDirection('left');
|
||||||
|
} else if (nameRightEdge > viewportWidth) {
|
||||||
|
// Name column extends beyond viewport to the right - stick to right
|
||||||
|
setStickyDirection('right');
|
||||||
|
} else {
|
||||||
|
// Name column is fully visible - no sticky needed
|
||||||
|
setStickyDirection(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setStickyDirection(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [nameColumnLeftOffset]);
|
}, [nameColumnLeftOffset, nameColumnWidth, nameColumnSticky]);
|
||||||
|
|
||||||
// Compute filtered indices AND row IDs in a single pass
|
// Compute filtered indices AND row IDs in a single pass
|
||||||
// This avoids calling getState() during render for each row
|
// This avoids calling getState() during render for each row
|
||||||
@@ -2012,6 +2271,11 @@ export const ValidationTable = () => {
|
|||||||
return { filteredIndices: indices, rowIdMap: idMap };
|
return { filteredIndices: indices, rowIdMap: idMap };
|
||||||
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
}, [rowCount, filters.searchText, filters.showErrorsOnly]);
|
||||||
|
|
||||||
|
// Toggle for sticky name column
|
||||||
|
const toggleNameColumnSticky = useCallback(() => {
|
||||||
|
setNameColumnSticky(prev => !prev);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Build columns - ONLY depends on fields, NOT selection state
|
// Build columns - ONLY depends on fields, NOT selection state
|
||||||
// Selection state is handled by isolated HeaderCheckbox component
|
// Selection state is handled by isolated HeaderCheckbox component
|
||||||
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
const columns = useMemo<ColumnDef<RowData>[]>(() => {
|
||||||
@@ -2034,9 +2298,21 @@ export const ValidationTable = () => {
|
|||||||
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||||
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||||
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
const isUnitConversionColumn = field.key === 'weight' || field.key === 'length' || field.key === 'width' || field.key === 'height';
|
||||||
|
const isDefaultValueColumn = field.key === 'tax_cat' || field.key === 'ship_restrictions';
|
||||||
|
const isNameColumn = field.key === 'name';
|
||||||
|
|
||||||
// Determine which header component to render
|
// Determine which header component to render
|
||||||
const renderHeader = () => {
|
const renderHeader = () => {
|
||||||
|
if (isNameColumn) {
|
||||||
|
return (
|
||||||
|
<NameColumnHeader
|
||||||
|
label={field.label}
|
||||||
|
isRequired={isRequired}
|
||||||
|
isSticky={nameColumnSticky}
|
||||||
|
onToggleSticky={toggleNameColumnSticky}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (isPriceColumn) {
|
if (isPriceColumn) {
|
||||||
return (
|
return (
|
||||||
<PriceColumnHeader
|
<PriceColumnHeader
|
||||||
@@ -2055,6 +2331,15 @@ export const ValidationTable = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (isDefaultValueColumn) {
|
||||||
|
return (
|
||||||
|
<DefaultValueColumnHeader
|
||||||
|
fieldKey={field.key as 'tax_cat' | 'ship_restrictions'}
|
||||||
|
label={field.label}
|
||||||
|
isRequired={isRequired}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1 truncate">
|
<div className="flex items-center gap-1 truncate">
|
||||||
<span className="truncate">{field.label}</span>
|
<span className="truncate">{field.label}</span>
|
||||||
@@ -2073,7 +2358,7 @@ export const ValidationTable = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return [selectionColumn, templateColumn, ...dataColumns];
|
return [selectionColumn, templateColumn, ...dataColumns];
|
||||||
}, [fields]); // CRITICAL: No selection-related deps!
|
}, [fields, nameColumnSticky, toggleNameColumnSticky]); // Added nameColumnSticky dependencies
|
||||||
|
|
||||||
// Calculate total table width for horizontal scrolling
|
// Calculate total table width for horizontal scrolling
|
||||||
const totalTableWidth = useMemo(() => {
|
const totalTableWidth = useMemo(() => {
|
||||||
@@ -2109,20 +2394,31 @@ export const ValidationTable = () => {
|
|||||||
>
|
>
|
||||||
{columns.map((column, index) => {
|
{columns.map((column, index) => {
|
||||||
const isNameColumn = column.id === 'name';
|
const isNameColumn = column.id === 'name';
|
||||||
|
// Determine sticky behavior for header name column
|
||||||
|
const shouldBeSticky = isNameColumn && nameColumnSticky && stickyDirection !== null;
|
||||||
|
const stickyLeft = shouldBeSticky && stickyDirection === 'left';
|
||||||
|
const stickyRight = shouldBeSticky && stickyDirection === 'right';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.id || index}
|
key={column.id || index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
|
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground",
|
||||||
// Sticky header needs solid background matching the row's bg-muted/50 appearance
|
// Use box-shadow for right border - renders more consistently
|
||||||
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
"shadow-[inset_-1px_0_0_0_hsl(var(--border))] last:shadow-none",
|
||||||
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
|
// Sticky header - only when enabled and scrolled appropriately
|
||||||
|
shouldBeSticky && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
|
||||||
|
// Directional shadow on the outside edge where content scrolls behind (combined with border shadow)
|
||||||
|
stickyLeft && "lg:shadow-[inset_-1px_0_0_0_hsl(var(--border)),2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
|
stickyRight && "lg:shadow-[inset_1px_0_0_0_hsl(var(--border)),-2px_0_4px_-2px_rgba(0,0,0,0.15)]",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
width: column.size || 150,
|
width: column.size || 150,
|
||||||
minWidth: column.size || 150,
|
minWidth: column.size || 150,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
...(isNameColumn && { left: NAME_COLUMN_STICKY_LEFT }),
|
// Position sticky left or right based on scroll direction
|
||||||
|
...(stickyLeft && { left: NAME_COLUMN_STICKY_LEFT }),
|
||||||
|
...(stickyRight && { right: 0 }),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{typeof column.header === 'function'
|
{typeof column.header === 'function'
|
||||||
@@ -2159,7 +2455,8 @@ export const ValidationTable = () => {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
fields={fields}
|
fields={fields}
|
||||||
totalRowCount={rowCount}
|
totalRowCount={rowCount}
|
||||||
isScrolledHorizontally={isScrolledHorizontally}
|
nameColumnSticky={nameColumnSticky}
|
||||||
|
stickyDirection={stickyDirection}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo, useCallback, useState } from 'react';
|
import { useMemo, useCallback, useState } from 'react';
|
||||||
import { Search, Plus, FolderPlus, Edit3 } from 'lucide-react';
|
import { Search, Plus, FolderPlus, Edit3, X } from 'lucide-react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -39,6 +39,38 @@ export const ValidationToolbar = ({
|
|||||||
const filters = useFilters();
|
const filters = useFilters();
|
||||||
const fields = useFields();
|
const fields = useFields();
|
||||||
|
|
||||||
|
// Compute filtered count when filtering is active
|
||||||
|
const filteredCount = useMemo(() => {
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
if (!isFiltering) return rowCount;
|
||||||
|
|
||||||
|
const { rows, errors } = useValidationStore.getState();
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.searchText) {
|
||||||
|
const searchLower = filters.searchText.toLowerCase();
|
||||||
|
const matches = Object.values(row).some((value) =>
|
||||||
|
String(value ?? '').toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
if (!matches) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply errors-only filter
|
||||||
|
if (filters.showErrorsOnly) {
|
||||||
|
const rowErrors = errors.get(index);
|
||||||
|
if (!rowErrors || Object.keys(rowErrors).length === 0) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
count++;
|
||||||
|
});
|
||||||
|
|
||||||
|
return count;
|
||||||
|
}, [filters.searchText, filters.showErrorsOnly, rowCount]);
|
||||||
|
|
||||||
|
const isFiltering = filters.searchText || filters.showErrorsOnly;
|
||||||
|
|
||||||
// State for the product search template dialog
|
// State for the product search template dialog
|
||||||
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
const [isSearchDialogOpen, setIsSearchDialogOpen] = useState(false);
|
||||||
|
|
||||||
@@ -112,12 +144,24 @@ export const ValidationToolbar = ({
|
|||||||
placeholder="Filter products..."
|
placeholder="Filter products..."
|
||||||
value={filters.searchText}
|
value={filters.searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
className="pl-9"
|
className={filters.searchText ? "pl-9 pr-8" : "pl-9"}
|
||||||
/>
|
/>
|
||||||
|
{filters.searchText && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSearchText('')}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-0.5 rounded-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Product count */}
|
{/* Product count */}
|
||||||
<span className="text-sm text-muted-foreground">{rowCount} products</span>
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{isFiltering ? `${filteredCount} of ${rowCount} products shown` : `${rowCount} products`}
|
||||||
|
</span>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2 ml-auto">
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
|||||||
@@ -3,9 +3,14 @@
|
|||||||
*
|
*
|
||||||
* Editable input cell for text, numbers, and price values.
|
* Editable input cell for text, numbers, and price values.
|
||||||
* Memoized to prevent unnecessary re-renders when parent table updates.
|
* Memoized to prevent unnecessary re-renders when parent table updates.
|
||||||
|
*
|
||||||
|
* PRICE PRECISION: For price fields, we store FULL precision internally
|
||||||
|
* (e.g., "3.625") but display rounded to 2 decimals when not focused.
|
||||||
|
* This allows calculations like 2x to use full precision while showing
|
||||||
|
* user-friendly rounded values in the UI.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback, useEffect, useRef, memo } from 'react';
|
import { useState, useCallback, useEffect, useRef, memo, useMemo } from 'react';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { AlertCircle } from 'lucide-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +26,17 @@ import type { ValidationError } from '../../store/types';
|
|||||||
import { ErrorType } from '../../store/types';
|
import { ErrorType } from '../../store/types';
|
||||||
import { useValidationStore } from '../../store/validationStore';
|
import { useValidationStore } from '../../store/validationStore';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a price value for display (2 decimal places)
|
||||||
|
* Returns the original string if it's not a valid number
|
||||||
|
*/
|
||||||
|
const formatPriceForDisplay = (value: string): string => {
|
||||||
|
if (!value) return value;
|
||||||
|
const num = parseFloat(value);
|
||||||
|
if (isNaN(num)) return value;
|
||||||
|
return num.toFixed(2);
|
||||||
|
};
|
||||||
|
|
||||||
/** Time window (ms) during which this cell should not focus after a popover closes */
|
/** Time window (ms) during which this cell should not focus after a popover closes */
|
||||||
const POPOVER_CLOSE_DELAY = 150;
|
const POPOVER_CLOSE_DELAY = 150;
|
||||||
|
|
||||||
@@ -43,10 +59,14 @@ const InputCellComponent = ({
|
|||||||
errors,
|
errors,
|
||||||
onBlur,
|
onBlur,
|
||||||
}: InputCellProps) => {
|
}: InputCellProps) => {
|
||||||
|
// Store the full precision value internally
|
||||||
const [localValue, setLocalValue] = useState(String(value ?? ''));
|
const [localValue, setLocalValue] = useState(String(value ?? ''));
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Check if this is a price field
|
||||||
|
const isPriceField = 'price' in field.fieldType && field.fieldType.price;
|
||||||
|
|
||||||
// Get store state for coordinating with popover close behavior
|
// Get store state for coordinating with popover close behavior
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
|
|
||||||
@@ -57,6 +77,14 @@ const InputCellComponent = ({
|
|||||||
}
|
}
|
||||||
}, [value, isFocused]);
|
}, [value, isFocused]);
|
||||||
|
|
||||||
|
// For price fields: show formatted value when not focused, full precision when focused
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (isPriceField && !isFocused && localValue) {
|
||||||
|
return formatPriceForDisplay(localValue);
|
||||||
|
}
|
||||||
|
return localValue;
|
||||||
|
}, [isPriceField, isFocused, localValue]);
|
||||||
|
|
||||||
// PERFORMANCE: Only update local state while typing, NOT the store
|
// PERFORMANCE: Only update local state while typing, NOT the store
|
||||||
// The store is updated on blur, which prevents thousands of subscription
|
// The store is updated on blur, which prevents thousands of subscription
|
||||||
// checks per keystroke
|
// checks per keystroke
|
||||||
@@ -86,23 +114,13 @@ const InputCellComponent = ({
|
|||||||
}, [cellPopoverClosedAt]);
|
}, [cellPopoverClosedAt]);
|
||||||
|
|
||||||
// Update store only on blur - this is when validation runs too
|
// Update store only on blur - this is when validation runs too
|
||||||
// Round price fields to 2 decimal places
|
// IMPORTANT: We store FULL precision for price fields to allow accurate calculations
|
||||||
|
// The display formatting happens separately via displayValue
|
||||||
const handleBlur = useCallback(() => {
|
const handleBlur = useCallback(() => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
|
// Store the full precision value - no rounding here
|
||||||
let valueToSave = localValue;
|
onBlur(localValue);
|
||||||
|
}, [localValue, onBlur]);
|
||||||
// Round price fields to 2 decimal places
|
|
||||||
if ('price' in field.fieldType && field.fieldType.price && localValue) {
|
|
||||||
const numValue = parseFloat(localValue);
|
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
valueToSave = numValue.toFixed(2);
|
|
||||||
setLocalValue(valueToSave);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onBlur(valueToSave);
|
|
||||||
}, [localValue, onBlur, field.fieldType]);
|
|
||||||
|
|
||||||
// Process errors - show icon only for non-required errors when field has value
|
// Process errors - show icon only for non-required errors when field has value
|
||||||
// Don't show error icon while user is actively editing (focused)
|
// Don't show error icon while user is actively editing (focused)
|
||||||
@@ -129,7 +147,7 @@ const InputCellComponent = ({
|
|||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
value={localValue}
|
value={displayValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ const MultilineInputComponent = ({
|
|||||||
value,
|
value,
|
||||||
isValidating,
|
isValidating,
|
||||||
errors,
|
errors,
|
||||||
onChange,
|
onChange: _onChange, // Unused - onBlur handles both update and validation
|
||||||
onBlur,
|
onBlur,
|
||||||
aiSuggestion,
|
aiSuggestion,
|
||||||
isAiValidating,
|
isAiValidating,
|
||||||
@@ -68,10 +68,15 @@ const MultilineInputComponent = ({
|
|||||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||||
|
const [popoverWidth, setPopoverWidth] = useState(400);
|
||||||
const cellRef = useRef<HTMLDivElement>(null);
|
const cellRef = useRef<HTMLDivElement>(null);
|
||||||
const preventReopenRef = useRef(false);
|
const preventReopenRef = useRef(false);
|
||||||
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
|
||||||
const intentionalCloseRef = useRef(false);
|
const intentionalCloseRef = useRef(false);
|
||||||
|
const mainTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const suggestionTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
// Tracks the value when popover opened, to detect actual changes
|
||||||
|
const initialEditValueRef = useRef('');
|
||||||
|
|
||||||
// Get store state and actions for coordinating popover close behavior across cells
|
// Get store state and actions for coordinating popover close behavior across cells
|
||||||
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
|
||||||
@@ -112,11 +117,44 @@ const MultilineInputComponent = ({
|
|||||||
}
|
}
|
||||||
}, [aiSuggestion?.suggestion]);
|
}, [aiSuggestion?.suggestion]);
|
||||||
|
|
||||||
|
// Auto-resize a textarea to fit its content
|
||||||
|
const autoResizeTextarea = useCallback((textarea: HTMLTextAreaElement | null) => {
|
||||||
|
if (!textarea) return;
|
||||||
|
textarea.style.height = 'auto';
|
||||||
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-resize main textarea when value changes or popover opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (popoverOpen) {
|
||||||
|
// Small delay to ensure textarea is rendered
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
autoResizeTextarea(mainTextareaRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [popoverOpen, editValue, autoResizeTextarea]);
|
||||||
|
|
||||||
|
// Auto-resize suggestion textarea when expanded or value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiSuggestionExpanded) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
autoResizeTextarea(suggestionTextareaRef.current);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [aiSuggestionExpanded, editedSuggestion, autoResizeTextarea]);
|
||||||
|
|
||||||
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
|
||||||
const wasPopoverRecentlyClosed = useCallback(() => {
|
const wasPopoverRecentlyClosed = useCallback(() => {
|
||||||
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
|
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
|
||||||
}, [cellPopoverClosedAt]);
|
}, [cellPopoverClosedAt]);
|
||||||
|
|
||||||
|
// Calculate and set popover width based on cell width
|
||||||
|
const updatePopoverWidth = useCallback(() => {
|
||||||
|
if (cellRef.current) {
|
||||||
|
setPopoverWidth(Math.max(cellRef.current.offsetWidth, 200));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Handle trigger click to toggle the popover
|
// Handle trigger click to toggle the popover
|
||||||
const handleTriggerClick = useCallback(
|
const handleTriggerClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@@ -136,23 +174,26 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
// Only process if not already open
|
// Only process if not already open
|
||||||
if (!popoverOpen) {
|
if (!popoverOpen) {
|
||||||
|
updatePopoverWidth();
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
// Initialize edit value from the current display
|
// Initialize edit value from the current display and track it for change detection
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed]
|
[popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed, updatePopoverWidth]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle immediate close of popover (used by close button and actions - intentional closes)
|
// Handle immediate close of popover (used by close button and actions - intentional closes)
|
||||||
const handleClosePopover = useCallback(() => {
|
const handleClosePopover = useCallback(() => {
|
||||||
// Only process if we have changes
|
// Only process if the user actually changed the value
|
||||||
if (editValue !== value || editValue !== localDisplayValue) {
|
if (editValue !== initialEditValueRef.current) {
|
||||||
// Update local display immediately
|
// Update local display immediately
|
||||||
setLocalDisplayValue(editValue);
|
setLocalDisplayValue(editValue);
|
||||||
|
|
||||||
// Queue up the change
|
// onBlur handles both cell update and validation (don't call onChange first
|
||||||
onChange(editValue);
|
// as it would update the store before onBlur can capture previousValue)
|
||||||
onBlur(editValue);
|
onBlur(editValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +209,7 @@ const MultilineInputComponent = ({
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
preventReopenRef.current = false;
|
preventReopenRef.current = false;
|
||||||
}, 100);
|
}, 100);
|
||||||
}, [editValue, value, localDisplayValue, onChange, onBlur]);
|
}, [editValue, onBlur]);
|
||||||
|
|
||||||
// Handle popover open/close (called by Radix for click-outside and escape key)
|
// Handle popover open/close (called by Radix for click-outside and escape key)
|
||||||
const handlePopoverOpenChange = useCallback(
|
const handlePopoverOpenChange = useCallback(
|
||||||
@@ -183,10 +224,10 @@ const MultilineInputComponent = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is a click-outside close - save changes and signal other cells
|
// This is a click-outside close - only save if user actually changed the value
|
||||||
if (editValue !== value || editValue !== localDisplayValue) {
|
if (editValue !== initialEditValueRef.current) {
|
||||||
setLocalDisplayValue(editValue);
|
setLocalDisplayValue(editValue);
|
||||||
onChange(editValue);
|
// onBlur handles both cell update and validation
|
||||||
onBlur(editValue);
|
onBlur(editValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,28 +246,33 @@ const MultilineInputComponent = ({
|
|||||||
if (wasPopoverRecentlyClosed()) {
|
if (wasPopoverRecentlyClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
updatePopoverWidth();
|
||||||
|
// Initialize edit value and track it for change detection
|
||||||
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed]
|
[popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onBlur, setCellPopoverClosed, updatePopoverWidth, value]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle direct input change
|
// Handle direct input change
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setEditValue(e.target.value);
|
setEditValue(e.target.value);
|
||||||
}, []);
|
autoResizeTextarea(e.target);
|
||||||
|
}, [autoResizeTextarea]);
|
||||||
|
|
||||||
// Handle accepting the AI suggestion (possibly edited)
|
// Handle accepting the AI suggestion (possibly edited)
|
||||||
const handleAcceptSuggestion = useCallback(() => {
|
const handleAcceptSuggestion = useCallback(() => {
|
||||||
// Use the edited suggestion
|
// Use the edited suggestion
|
||||||
setEditValue(editedSuggestion);
|
setEditValue(editedSuggestion);
|
||||||
setLocalDisplayValue(editedSuggestion);
|
setLocalDisplayValue(editedSuggestion);
|
||||||
onChange(editedSuggestion);
|
// onBlur handles both cell update and validation
|
||||||
onBlur(editedSuggestion);
|
onBlur(editedSuggestion);
|
||||||
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
|
||||||
setAiSuggestionExpanded(false);
|
setAiSuggestionExpanded(false);
|
||||||
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
|
}, [editedSuggestion, onBlur, onDismissAiSuggestion]);
|
||||||
|
|
||||||
// Handle dismissing the AI suggestion
|
// Handle dismissing the AI suggestion
|
||||||
const handleDismissSuggestion = useCallback(() => {
|
const handleDismissSuggestion = useCallback(() => {
|
||||||
@@ -243,7 +289,7 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full relative" ref={cellRef}>
|
<div className="w-full relative" ref={cellRef}>
|
||||||
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
|
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange} modal>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
@@ -270,9 +316,13 @@ const MultilineInputComponent = ({
|
|||||||
if (wasPopoverRecentlyClosed()) {
|
if (wasPopoverRecentlyClosed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
updatePopoverWidth();
|
||||||
setAiSuggestionExpanded(true);
|
setAiSuggestionExpanded(true);
|
||||||
setPopoverOpen(true);
|
setPopoverOpen(true);
|
||||||
setEditValue(localDisplayValue || String(value ?? ''));
|
// Initialize edit value and track it for change detection
|
||||||
|
const initValue = localDisplayValue || String(value ?? '');
|
||||||
|
setEditValue(initValue);
|
||||||
|
initialEditValueRef.current = initValue;
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
||||||
title="View AI suggestion"
|
title="View AI suggestion"
|
||||||
@@ -303,7 +353,7 @@ const MultilineInputComponent = ({
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="p-0 shadow-lg rounded-md"
|
className="p-0 shadow-lg rounded-md"
|
||||||
style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
|
style={{ width: popoverWidth }}
|
||||||
align="start"
|
align="start"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
@@ -322,10 +372,11 @@ const MultilineInputComponent = ({
|
|||||||
|
|
||||||
{/* Main textarea */}
|
{/* Main textarea */}
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={mainTextareaRef}
|
||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onWheel={handleTextareaWheel}
|
onWheel={handleTextareaWheel}
|
||||||
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y"
|
className="overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-y min-h-[65px]"
|
||||||
placeholder={`Enter ${field.label || 'text'}...`}
|
placeholder={`Enter ${field.label || 'text'}...`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -379,10 +430,14 @@ const MultilineInputComponent = ({
|
|||||||
Suggested (editable):
|
Suggested (editable):
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
ref={suggestionTextareaRef}
|
||||||
value={editedSuggestion}
|
value={editedSuggestion}
|
||||||
onChange={(e) => setEditedSuggestion(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setEditedSuggestion(e.target.value);
|
||||||
|
autoResizeTextarea(e.target);
|
||||||
|
}}
|
||||||
onWheel={handleTextareaWheel}
|
onWheel={handleTextareaWheel}
|
||||||
className="min-h-[120px] max-h-[200px] 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"
|
className="min-h-[80px] 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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -403,7 +458,7 @@ const MultilineInputComponent = ({
|
|||||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||||
onClick={handleDismissSuggestion}
|
onClick={handleDismissSuggestion}
|
||||||
>
|
>
|
||||||
Dismiss
|
Ignore
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -185,22 +185,14 @@ export const useValidationActions = () => {
|
|||||||
*
|
*
|
||||||
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
|
* With 100 rows × 30 fields, the old approach would trigger ~3000 individual
|
||||||
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
|
* set() calls, each cloning the entire errors Map. This approach triggers ONE.
|
||||||
*
|
|
||||||
* Also handles:
|
|
||||||
* - Rounding currency fields to 2 decimal places
|
|
||||||
*/
|
*/
|
||||||
const validateAllRows = useCallback(async () => {
|
const validateAllRows = useCallback(async () => {
|
||||||
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults, updateCell: updateCellAction } = useValidationStore.getState();
|
const { rows: currentRows, fields: currentFields, errors: existingErrors, setBulkValidationResults } = useValidationStore.getState();
|
||||||
|
|
||||||
// Collect ALL errors in plain JS Maps (no Immer overhead)
|
// Collect ALL errors in plain JS Maps (no Immer overhead)
|
||||||
const allErrors = new Map<number, Record<string, ValidationError[]>>();
|
const allErrors = new Map<number, Record<string, ValidationError[]>>();
|
||||||
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
|
const allStatuses = new Map<number, 'pending' | 'validating' | 'validated' | 'error'>();
|
||||||
|
|
||||||
// Identify price fields for currency rounding
|
|
||||||
const priceFields = currentFields.filter((f: Field<string>) =>
|
|
||||||
'price' in f.fieldType && f.fieldType.price
|
|
||||||
).map((f: Field<string>) => f.key);
|
|
||||||
|
|
||||||
// Process all rows - collect errors without touching the store
|
// Process all rows - collect errors without touching the store
|
||||||
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
|
for (let rowIndex = 0; rowIndex < currentRows.length; rowIndex++) {
|
||||||
const row = currentRows[rowIndex];
|
const row = currentRows[rowIndex];
|
||||||
@@ -221,20 +213,11 @@ export const useValidationActions = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Round currency fields to 2 decimal places on initial load
|
// NOTE: We no longer round price fields on initial load.
|
||||||
for (const priceFieldKey of priceFields) {
|
// Full precision is preserved internally (e.g., "3.625") for accurate calculations.
|
||||||
const value = row[priceFieldKey];
|
// - Display: InputCell shows 2 decimals when not focused
|
||||||
if (value !== undefined && value !== null && value !== '') {
|
// - Calculations: 2x button uses full precision
|
||||||
const numValue = parseFloat(String(value));
|
// - API submission: getCleanedData() formats to 2 decimals
|
||||||
if (!isNaN(numValue)) {
|
|
||||||
const rounded = numValue.toFixed(2);
|
|
||||||
if (String(value) !== rounded) {
|
|
||||||
// Update the cell with rounded value (batched later)
|
|
||||||
updateCellAction(rowIndex, priceFieldKey, rounded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate each field
|
// Validate each field
|
||||||
for (const field of currentFields) {
|
for (const field of currentFields) {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* 4. Renders the ValidationContainer once initialized
|
* 4. Renders the ValidationContainer once initialized
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useDeferredValue } from 'react';
|
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useValidationStore } from './store/validationStore';
|
import { useValidationStore } from './store/validationStore';
|
||||||
import { useInitPhase, useIsReady } from './store/selectors';
|
import { useInitPhase, useIsReady } from './store/selectors';
|
||||||
@@ -24,6 +24,36 @@ import config from '@/config';
|
|||||||
import type { ValidationStepProps } from './store/types';
|
import type { ValidationStepProps } from './store/types';
|
||||||
import type { Field, SelectOption } from '../../types';
|
import type { Field, SelectOption } from '../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a fingerprint of the data to detect changes.
|
||||||
|
* This is used to determine if we need to re-initialize the store
|
||||||
|
* when navigating back to this step with potentially modified data.
|
||||||
|
*/
|
||||||
|
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
|
||||||
|
// Sample key fields that are likely to change when user modifies data in previous steps
|
||||||
|
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
|
||||||
|
|
||||||
|
// Create a simple hash from first few rows + last row + count
|
||||||
|
const sampleSize = Math.min(3, data.length);
|
||||||
|
const samples: string[] = [];
|
||||||
|
|
||||||
|
// First few rows
|
||||||
|
for (let i = 0; i < sampleSize; i++) {
|
||||||
|
const row = data[i];
|
||||||
|
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
|
||||||
|
samples.push(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last row (if different from samples)
|
||||||
|
if (data.length > sampleSize) {
|
||||||
|
const lastRow = data[data.length - 1];
|
||||||
|
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
|
||||||
|
samples.push(values);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${data.length}:${samples.join(';;')}`;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch field options from the API
|
* Fetch field options from the API
|
||||||
*/
|
*/
|
||||||
@@ -105,6 +135,7 @@ export const ValidationStep = ({
|
|||||||
const templatesLoadedRef = useRef(false);
|
const templatesLoadedRef = useRef(false);
|
||||||
const upcValidationStartedRef = useRef(false);
|
const upcValidationStartedRef = useRef(false);
|
||||||
const fieldValidationStartedRef = useRef(false);
|
const fieldValidationStartedRef = useRef(false);
|
||||||
|
const lastDataFingerprintRef = useRef<string | null>(null);
|
||||||
|
|
||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
||||||
@@ -132,12 +163,25 @@ export const ValidationStep = ({
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get current store state to check if we're returning to an already-initialized store
|
// Create a fingerprint of the incoming data to detect changes
|
||||||
const storeRows = useValidationStore((state) => state.rows);
|
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
|
||||||
|
|
||||||
// Initialize store with data
|
// Initialize store with data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
||||||
|
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
|
||||||
|
|
||||||
|
// Check if data has changed since last initialization
|
||||||
|
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
|
||||||
|
|
||||||
|
if (dataHasChanged) {
|
||||||
|
console.log('[ValidationStep] Data has changed - forcing re-initialization');
|
||||||
|
// Reset all refs to allow re-initialization
|
||||||
|
initStartedRef.current = false;
|
||||||
|
templatesLoadedRef.current = false;
|
||||||
|
upcValidationStartedRef.current = false;
|
||||||
|
fieldValidationStartedRef.current = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Skip if already initialized (check both ref AND store state)
|
// Skip if already initialized (check both ref AND store state)
|
||||||
// The ref prevents double-init within the same mount cycle
|
// The ref prevents double-init within the same mount cycle
|
||||||
@@ -148,17 +192,16 @@ export const ValidationStep = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
||||||
// This happens when navigating back from ImageUploadStep - the store still has
|
// with the SAME data. This happens when navigating back from ImageUploadStep.
|
||||||
// all the validated data, so we don't need to re-run the initialization sequence.
|
// We compare fingerprints to detect if the data has actually changed.
|
||||||
// We check that the store is 'ready' and has matching row count to avoid
|
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
|
||||||
// false positives from stale store data.
|
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
|
||||||
if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
|
|
||||||
console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
|
|
||||||
initStartedRef.current = true;
|
initStartedRef.current = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
initStartedRef.current = true;
|
initStartedRef.current = true;
|
||||||
|
lastDataFingerprintRef.current = dataFingerprint;
|
||||||
|
|
||||||
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
||||||
|
|
||||||
@@ -172,7 +215,7 @@ export const ValidationStep = ({
|
|||||||
console.log('[ValidationStep] Calling initialize()');
|
console.log('[ValidationStep] Calling initialize()');
|
||||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||||
console.log('[ValidationStep] initialize() called');
|
console.log('[ValidationStep] initialize() called');
|
||||||
}, [initialData, file, initialize, initPhase, storeRows.length]);
|
}, [initialData, file, initialize, initPhase, dataFingerprint]);
|
||||||
|
|
||||||
// Update fields when options are loaded
|
// Update fields when options are loaded
|
||||||
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
||||||
|
|||||||
@@ -215,6 +215,20 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
|
|
||||||
deleteRows: (rowIndexes: number[]) => {
|
deleteRows: (rowIndexes: number[]) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
|
// Collect row IDs to remove from selection before deleting
|
||||||
|
const rowIdsToDelete = new Set<string>();
|
||||||
|
rowIndexes.forEach((index) => {
|
||||||
|
if (index >= 0 && index < state.rows.length) {
|
||||||
|
const rowId = state.rows[index].__index;
|
||||||
|
if (rowId) rowIdsToDelete.add(rowId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear these rows from selectedRows
|
||||||
|
rowIdsToDelete.forEach((rowId) => {
|
||||||
|
state.selectedRows.delete(rowId);
|
||||||
|
});
|
||||||
|
|
||||||
// Sort descending to delete from end first (preserves indices)
|
// Sort descending to delete from end first (preserves indices)
|
||||||
const sorted = [...rowIndexes].sort((a, b) => b - a);
|
const sorted = [...rowIndexes].sort((a, b) => b - a);
|
||||||
sorted.forEach((index) => {
|
sorted.forEach((index) => {
|
||||||
@@ -949,9 +963,25 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
|
|
||||||
getCleanedData: (): CleanRowData[] => {
|
getCleanedData: (): CleanRowData[] => {
|
||||||
const { rows } = get();
|
const { rows } = get();
|
||||||
|
// Price fields that should be formatted to 2 decimal places for API submission
|
||||||
|
const priceFields = ['msrp', 'cost_each'];
|
||||||
|
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
|
const { __index, __template, __original, __corrected, __changes, __aiSupplemental, ...cleanRow } = row;
|
||||||
|
|
||||||
|
// Format price fields to 2 decimal places for API submission
|
||||||
|
// This ensures consistent precision while internal storage keeps full precision for calculations
|
||||||
|
for (const field of priceFields) {
|
||||||
|
const value = cleanRow[field];
|
||||||
|
if (value !== undefined && value !== null && value !== '') {
|
||||||
|
const num = parseFloat(String(value));
|
||||||
|
if (!isNaN(num)) {
|
||||||
|
cleanRow[field] = num.toFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return cleanRow as CleanRowData;
|
return cleanRow as CleanRowData;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -34,10 +34,16 @@ export function calculateEanCheckDigit(eanBody: string): number {
|
|||||||
|
|
||||||
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
|
||||||
const value = rawValue ?? '';
|
const value = rawValue ?? '';
|
||||||
const str = typeof value === 'string' ? value.trim() : String(value);
|
const originalStr = typeof value === 'string' ? value : String(value);
|
||||||
|
// Strip ALL whitespace (spaces, tabs, etc.) from UPC values - not just leading/trailing
|
||||||
|
const str = originalStr.replace(/\s+/g, '');
|
||||||
|
|
||||||
|
// Track if whitespace was stripped (this alone means we changed the value)
|
||||||
|
const whitespaceStripped = str !== originalStr;
|
||||||
|
|
||||||
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
if (str === '' || !NUMERIC_REGEX.test(str)) {
|
||||||
return { corrected: str, changed: false };
|
// Return stripped version even if not numeric (so non-numeric values still get spaces removed)
|
||||||
|
return { corrected: str, changed: whitespaceStripped };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str.length === 11) {
|
if (str.length === 11) {
|
||||||
@@ -49,15 +55,18 @@ export function correctUpcValue(rawValue: unknown): { corrected: string; changed
|
|||||||
const body = str.slice(0, 11);
|
const body = str.slice(0, 11);
|
||||||
const check = calculateUpcCheckDigit(body);
|
const check = calculateUpcCheckDigit(body);
|
||||||
const corrected = `${body}${check}`;
|
const corrected = `${body}${check}`;
|
||||||
return { corrected, changed: corrected !== str };
|
// Changed if whitespace was stripped OR if check digit was corrected
|
||||||
|
return { corrected, changed: whitespaceStripped || corrected !== str };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str.length === 13) {
|
if (str.length === 13) {
|
||||||
const body = str.slice(0, 12);
|
const body = str.slice(0, 12);
|
||||||
const check = calculateEanCheckDigit(body);
|
const check = calculateEanCheckDigit(body);
|
||||||
const corrected = `${body}${check}`;
|
const corrected = `${body}${check}`;
|
||||||
return { corrected, changed: corrected !== str };
|
// Changed if whitespace was stripped OR if check digit was corrected
|
||||||
|
return { corrected, changed: whitespaceStripped || corrected !== str };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { corrected: str, changed: false };
|
// For other lengths, return stripped value
|
||||||
|
return { corrected: str, changed: whitespaceStripped };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const isDev = import.meta.env.DEV;
|
|||||||
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||||||
|
|
||||||
// Use proxy paths when on inventory domains to avoid CORS
|
// Use proxy paths when on inventory domains to avoid CORS
|
||||||
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.acot.site' || window.location.hostname === 'acot.site');
|
const useProxy = !isLocal && (window.location.hostname === 'inventory.kent.pw' || window.location.hostname === 'inventory.tools.acherryontop.com' || window.location.hostname === 'tools.acherryontop.com');
|
||||||
|
|
||||||
const liveDashboardConfig = {
|
const liveDashboardConfig = {
|
||||||
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
auth: isDev || useProxy ? '/dashboard-auth' : 'https://tools.acherryontop.com/auth',
|
||||||
|
|||||||
@@ -90,31 +90,31 @@ export default defineConfig(({ mode }) => {
|
|||||||
cookieDomainRewrite: "localhost",
|
cookieDomainRewrite: "localhost",
|
||||||
},
|
},
|
||||||
"/api/aircall": {
|
"/api/aircall": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/klaviyo": {
|
"/api/klaviyo": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/meta": {
|
"/api/meta": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/gorgias": {
|
"/api/gorgias": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/dashboard-analytics": {
|
"/api/dashboard-analytics": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
cookieDomainRewrite: {
|
cookieDomainRewrite: {
|
||||||
@@ -122,25 +122,25 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/api/typeform": {
|
"/api/typeform": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/acot": {
|
"/api/acot": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api/clarity": {
|
"/api/clarity": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -161,14 +161,14 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/dashboard-auth": {
|
"/dashboard-auth": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
|
rewrite: (path) => path.replace("/dashboard-auth", "/auth"),
|
||||||
},
|
},
|
||||||
"/auth-inv": {
|
"/auth-inv": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -195,7 +195,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/chat-api": {
|
"/chat-api": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
ws: true,
|
ws: true,
|
||||||
@@ -216,7 +216,7 @@ export default defineConfig(({ mode }) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"/uploads": {
|
"/uploads": {
|
||||||
target: "https://acot.site",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
|
|||||||
Reference in New Issue
Block a user