Refactor API calls in product editor and bulk edit components to use apiClient instead of axios. Enhance UploadFlow and MatchColumnsStep to support restoring previously matched columns for better user experience during back navigation. Introduce mapping signature handling in ValidationStep for improved data integrity on navigation. Update types and store management to accommodate new features.

This commit is contained in:
2026-06-05 15:07:28 -04:00
parent 8c707e28ea
commit 3e38d0e5ce
14 changed files with 648 additions and 127 deletions
@@ -0,0 +1,175 @@
-- Permissions UI cleanup — 2026-05-28
--
-- WHAT THIS DOES
-- Rewrites permissions.name and permissions.category for clarity.
-- Consolidates 17 categories down to 10. Renames ambiguous entries so
-- the User Management UI reads cleanly. Does NOT touch permissions.code,
-- so every existing route gate (backend requirePermission and frontend
-- Protected page=) and every row in user_permissions keeps working
-- without any code change or remapping.
--
-- WHAT THIS DOES *NOT* DO
-- No DROP/DELETE of any permission (except in the optional block at the
-- very bottom — commented out by default). No changes to permissions.code.
-- No INSERT of new permissions. No changes to other tables.
--
-- SAFETY
-- Wrapped in a transaction. Run end-to-end; if any row count is wrong,
-- ROLLBACK and inspect. The auth middleware caches the user's loaded
-- permissions for 60s — after this runs, names refresh on next cache
-- miss for the admin UI. user_permissions joins by id, so granted
-- permissions remain granted across renames.
BEGIN;
------------------------------------------------------------------------
-- 1. Category consolidation
------------------------------------------------------------------------
-- 1a. Orphan "Pages" (just the Settings page) → Pages/Settings
UPDATE permissions SET category = 'Pages/Settings'
WHERE code = 'access:settings';
-- 1b. Existing settings:* tab-access permissions stay under a renamed
-- "Settings Tabs" category (was "Settings") so it reads less ambiguously
-- next to "Pages/Settings" and "Write Actions".
UPDATE permissions SET category = 'Settings Tabs'
WHERE category = 'Settings';
-- 1c. Dashboard widget perms keep their codes but get a clearer category name.
UPDATE permissions SET category = 'Dashboard Widgets'
WHERE category = 'Dashboard Components';
-- 1d. Collapse the 7 single-member "new permission" categories
-- (Imports, Data, AI, Templates, Images, Audit, ACOT, Dashboard
-- [the dashboard-server one — distinct from Pages/Dashboard])
-- into a single "Write Actions" category.
UPDATE permissions SET category = 'Write Actions'
WHERE category IN ('Imports', 'Data', 'AI', 'Templates',
'Images', 'Audit', 'ACOT', 'Dashboard');
-- 1e. The "Admin" category (just Show Debug) stays as-is — single member
-- but conceptually distinct enough that bucketing under Write Actions
-- would muddy the meaning. Leaving it alone.
------------------------------------------------------------------------
-- 2. Name renames for clarity
------------------------------------------------------------------------
-- 2a. Distinguish "page that lets you do X" from "permission to do X"
-- by suffixing the page-access permissions with " (page)" where the
-- name otherwise collides with the corresponding write permission.
UPDATE permissions SET name = 'Import Products (page)'
WHERE code = 'access:import';
UPDATE permissions SET name = 'Product Editor (page)'
WHERE code = 'access:product_editor';
UPDATE permissions SET name = 'Bulk Edit (page)'
WHERE code = 'access:bulk_edit';
-- 2b. Settings tab-access perms get a uniform "Settings: X Tab" name so
-- they sort together and read as "what you're seeing access to" not
-- "the feature itself."
UPDATE permissions SET name = 'Settings: Data Management Tab'
WHERE code = 'settings:data_management';
UPDATE permissions SET name = 'Settings: Reusable Images Tab'
WHERE code = 'settings:library_management';
UPDATE permissions SET name = 'Settings: AI Prompts Tab'
WHERE code = 'settings:prompt_management';
UPDATE permissions SET name = 'Settings: Templates Tab'
WHERE code = 'settings:templates';
UPDATE permissions SET name = 'Settings: User Management Tab'
WHERE code = 'settings:user_management';
UPDATE permissions SET name = 'Settings: Audit Log Tab'
WHERE code = 'settings:audit_log';
UPDATE permissions SET name = 'Settings: Global Tab'
WHERE code = 'settings:global';
UPDATE permissions SET name = 'Settings: Products Tab'
WHERE code = 'settings:products';
UPDATE permissions SET name = 'Settings: Vendors Tab'
WHERE code = 'settings:vendors';
-- 2c. Write-action perms get verb-leading names so it's obvious what
-- granting them actually allows.
UPDATE permissions
SET name = 'Product Import: Upload & Submit',
description = 'Allows POST/PUT/DELETE on /api/import — image uploads, '
|| 'product submission, deletions, and generate-upc. Does NOT '
|| 'grant access to the Import Products page (access:import).'
WHERE code = 'product_import';
UPDATE permissions
SET name = 'Data Management: Run Operations',
description = 'Allows POST/PUT/DELETE on /api/csv — CSV operations, '
|| 'full updates, full resets. Does NOT grant access to the '
|| 'Data Management settings tab (settings:data_management).'
WHERE code = 'data_management';
UPDATE permissions
SET name = 'Reusable Images: Upload & Delete',
description = 'Allows uploads and deletions on /api/reusable-images. '
|| 'Distinct from product_import (which gates uploads inside '
|| 'the product import flow).'
WHERE code = 'image_admin';
UPDATE permissions
SET name = 'Templates: Create & Edit',
description = 'Allows POST/PUT/DELETE on /api/templates.'
WHERE code = 'templates_write';
UPDATE permissions
SET name = 'AI: Edit Prompts & Validation',
description = 'Allows write access to /api/ai-prompts and /api/ai-validation.'
WHERE code = 'ai_admin';
UPDATE permissions
SET name = 'Klaviyo: Clear Cache',
description = 'Allows POST /api/klaviyo/events/clearCache.'
WHERE code = 'klaviyo_admin';
UPDATE permissions
SET name = 'Meta: Mutate Campaigns',
description = 'Allows PATCH/POST on /api/meta/campaigns/*.'
WHERE code = 'meta_write';
------------------------------------------------------------------------
-- 3. Verification — should all return non-zero
------------------------------------------------------------------------
-- Uncomment to inspect before commit:
-- SELECT category, COUNT(*) FROM permissions GROUP BY category ORDER BY category;
-- SELECT code, name, category FROM permissions
-- WHERE category IN ('Write Actions', 'Settings Tabs', 'Pages/Settings')
-- ORDER BY category, name;
COMMIT;
------------------------------------------------------------------------
-- 4. OPTIONAL — drop unused "Reserved for future" codes
------------------------------------------------------------------------
-- These five codes are referenced only in their own description ("Reserved
-- for…") and appear in NO route gate, NO Protected page=, and NO frontend
-- permissions.includes() check. Verified 2026-05-28.
--
-- Run this block separately if you want to drop them. user_permissions has
-- ON DELETE CASCADE on permission_id is NOT configured (only on user_id),
-- so we must clear user_permissions rows first.
--
-- BEGIN;
-- DELETE FROM user_permissions
-- WHERE permission_id IN (SELECT id FROM permissions
-- WHERE code IN ('klaviyo_write', 'google_write',
-- 'typeform_write', 'acot_admin',
-- 'audit_read'));
-- DELETE FROM permissions
-- WHERE code IN ('klaviyo_write', 'google_write', 'typeform_write',
-- 'acot_admin', 'audit_read');
-- COMMIT;
+10 -1
View File
@@ -66,7 +66,16 @@ export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps =
const kioskIpSet = parseKioskIps(kioskIps);
return async function authenticateMiddleware(req, res, next) {
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
// Kiosk IP bypass ONLY when no Authorization header was provided. A real
// user on the same network (e.g. logged-in staff sharing the office NAT)
// must keep their actual identity and permissions — otherwise this bypass
// silently downgrades them to the permissionless kiosk user and they get
// 403 on every gated route.
if (
kioskIpSet.size > 0 &&
kioskIpSet.has(req.ip) &&
!req.headers.authorization
) {
req.user = {
id: 'kiosk',
username: 'kiosk',
@@ -159,6 +159,25 @@ describe('authenticate middleware', () => {
expect(req.user.is_kiosk).toBeUndefined();
});
it('does NOT bypass when a Bearer token is present, even from a kiosk IP', async () => {
// A real user logged in from the same NAT'd network as the kiosk must
// keep their actual identity — otherwise the bypass silently strips
// their permissions and they 403 on gated routes.
const pool = makeFakePool({ 1: activeUser }, { 1: ['product_import'] });
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
const req = {
headers: { authorization: `Bearer ${validToken}` },
ip: '203.0.113.7',
};
const res = makeRes();
const next = vi.fn();
await mw(req, res, next);
expect(next).toHaveBeenCalledOnce();
expect(req.user.id).toBe(1);
expect(req.user.is_kiosk).toBeUndefined();
expect(req.user.permissions).toEqual(['product_import']);
});
it('does not bypass when KIOSK_IPS is empty, even if req.ip is undefined', async () => {
const pool = makeFakePool({ 1: activeUser });
const mw = authenticate({ pool, secret: SECRET, kioskIps: '' });
@@ -37,7 +37,7 @@ const sublinesCache = new Map<string, Promise<LineOption[]>>();
function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = linesCache.get(companyId);
if (cached) return cached;
const p = axios
const p = apiClient
.get(`/api/import/product-lines/${companyId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
@@ -51,7 +51,7 @@ function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<Line
function fetchSublinesCached(lineId: string, signal?: AbortSignal): Promise<LineOption[]> {
const cached = sublinesCache.get(lineId);
if (cached) return cached;
const p = axios
const p = apiClient
.get(`/api/import/sublines/${lineId}`, { signal })
.then((res) => res.data as LineOption[])
.catch(() => {
@@ -275,7 +275,7 @@ export function ProductEditForm({
originalImagesRef.current = initialImages;
} else {
setIsLoadingImages(true);
axios
apiClient
.get(`/api/import/product-images/${product.pid}`, { signal })
.then((res) => {
setProductImages(res.data);
@@ -285,7 +285,7 @@ export function ProductEditForm({
.finally(() => setIsLoadingImages(false));
}
axios
apiClient
.get(`/api/import/product-categories/${product.pid}`, { signal })
.then((res) => {
const cats: string[] = [];
@@ -607,14 +607,21 @@ const MatchColumnsStepComponent = <T extends string>({
headerValues,
onContinue,
onBack,
initialGlobalSelections
initialGlobalSelections,
initialColumns
}: MatchColumnsProps<T>): JSX.Element => {
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
const queryClient = useQueryClient()
const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [columns, setColumns] = useState<Columns<T>>(() => {
// Restoring from a previous visit (back-nav): reuse the prior mappings verbatim
// instead of re-deriving empty columns + auto-mapping.
if (initialColumns && initialColumns.length > 0) {
return initialColumns;
}
// Helper function to check if a column is completely empty
const isColumnEmpty = (columnIndex: number) => {
return data.every(row => {
@@ -622,14 +629,14 @@ const MatchColumnsStepComponent = <T extends string>({
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
});
};
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty as ColumnType.empty,
index,
header: value ?? ""
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
type: ColumnType.empty as ColumnType.empty,
index,
header: value ?? ""
}));
// Filter out completely empty columns
return allColumns.filter(col => !isColumnEmpty(col.index)) as Columns<T>;
})
@@ -637,7 +644,10 @@ const MatchColumnsStepComponent = <T extends string>({
const [showAllColumns, setShowAllColumns] = useState(true)
const [expandedValues, setExpandedValues] = useState<number[]>([])
const [userCollapsedColumns, setUserCollapsedColumns] = useState<number[]>([])
const hasAutoMappedRef = useRef(false)
// When restoring prior columns, suppress the initial header auto-map so it doesn't
// overwrite the restored mappings (the ref guards within a mount; seeding it true
// covers the fresh-mount-on-back-nav case).
const hasAutoMappedRef = useRef(!!(initialColumns && initialColumns.length > 0))
// Toggle with immediate visual feedback
const toggleValueMappingOptimized = useCallback((columnIndex: number) => {
@@ -6,6 +6,12 @@ export type MatchColumnsProps<T extends string> = {
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void
onBack?: () => void
initialGlobalSelections?: GlobalSelections
/**
* Previously-matched columns to restore when navigating back to this step.
* When provided, columns seed from these instead of re-deriving + auto-mapping,
* so the user's mappings/value-mappings/ignored/AI-supplemental state survive back-nav.
*/
initialColumns?: Columns<T>
}
export type GlobalSelections = {
@@ -8,13 +8,14 @@ import { mapWorkbook } from "../utils/mapWorkbook"
import { ValidationStep } from "./ValidationStep"
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
import type { GlobalSelections } from "./MatchColumnsStep/types"
import type { GlobalSelections, Columns } from "./MatchColumnsStep/types"
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
import { useRsi } from "../hooks/useRsi"
import type { RawData, Data } from "../types"
import { Progress } from "@/components/ui/progress"
import { useToast } from "@/hooks/use-toast"
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
import { useValidationStore } from "./ValidationStep/store/validationStore"
import { useImportSession } from "@/contexts/ImportSessionContext"
import type { ImportSession } from "@/types/importSession"
@@ -52,6 +53,7 @@ export type StepState =
data: any[]
globalSelections?: GlobalSelections
isFromScratch?: boolean
mappingSignature?: MappingSignature
}
| {
type: StepType.validateDataNew
@@ -59,6 +61,7 @@ export type StepState =
file?: File
globalSelections?: GlobalSelections
isFromScratch?: boolean
mappingSignature?: MappingSignature
}
| {
type: StepType.imageUpload
@@ -129,6 +132,11 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
: undefined
)
// Keep the user's matched columns so navigating back to Match Columns restores them
// (UploadFlow never unmounts during step nav, so this survives back-nav — same pattern
// as persistedGlobalSelections above).
const [persistedColumns, setPersistedColumns] = useState<Columns<string> | undefined>(undefined)
// Import session context for session restoration
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
@@ -254,30 +262,36 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
data={state.data}
headerValues={state.headerValues}
initialGlobalSelections={persistedGlobalSelections}
initialColumns={persistedColumns}
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
try {
const data = await matchColumnsStepHook(values, rawData, columns)
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook, undefined, { costIsTotalCost: globalSelections?.costIsTotalCost })
// Apply global selections to each row of data if they exist
const dataWithGlobalSelections = globalSelections
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
const newRow = { ...row } as any;
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections.company) newRow.company = globalSelections.company;
if (globalSelections.line) newRow.line = globalSelections.line;
if (globalSelections.subline) newRow.subline = globalSelections.subline;
return newRow;
})
: dataWithMeta;
// Apply global selections to each row of data if they exist, and stamp a
// stable positional id (__sourceRow) so the Validation store can align these
// freshly-mapped rows with previously-edited rows on back-nav re-entry.
const dataWithGlobalSelections = dataWithMeta.map(
(row: Data<string> & { __index?: string }, rowIndex: number) => {
const newRow = { ...row } as any;
newRow.__sourceRow = rowIndex;
if (globalSelections?.supplier) newRow.supplier = globalSelections.supplier;
if (globalSelections?.company) newRow.company = globalSelections.company;
if (globalSelections?.line) newRow.line = globalSelections.line;
if (globalSelections?.subline) newRow.subline = globalSelections.subline;
return newRow;
},
);
setPersistedGlobalSelections(globalSelections)
setPersistedColumns(columns)
// Route to new or old validation step based on user choice
onNext({
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
data: dataWithGlobalSelections,
globalSelections,
mappingSignature: computeMappingSignature(columns, globalSelections),
})
} catch (e) {
errorToast((e as Error).message)
@@ -293,6 +307,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
<ValidationStep
initialData={state.data}
file={uploadedFile || new File([], "empty.xlsx")}
mappingSignature={state.mappingSignature}
onBack={() => {
// If we started from scratch, we need to go back to the upload step
if (state.isFromScratch) {
@@ -924,7 +924,7 @@ const CellWrapper = memo(({
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
{showCopyDownButton && (
<TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -1707,6 +1707,10 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
const isMsrp = fieldKey === 'msrp';
// Cost Each offers a per-row "divide by Min Qty" action that operates on the current
// row selection only — subscribe to the selection count to enable/disable it.
const selectedCount = useValidationStore((state) => (isMsrp ? 0 : state.selectedRows.size));
// Determine the source field
const sourceField = isMsrp ? 'cost_each' : 'msrp';
const tooltipText = isMsrp
@@ -1817,6 +1821,40 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
}
}, [fieldKey, sourceField, label]);
/**
* Divide cost_each by Min Qty (qty_per_unit) for the currently-SELECTED rows only.
* For cases where only some rows list a pack total but are sold individually.
* Mirrors the global "divide by min qty" math in dataMutations.ts.
*/
const handleDivideSelectedByMinQty = useCallback(() => {
const { selectedRows } = useValidationStore.getState();
const updatedIndices: number[] = [];
useValidationStore.setState((draft) => {
draft.rows.forEach((row, index) => {
if (!selectedRows.has(row.__index)) return;
const cost = parseFloat(String(row.cost_each ?? ''));
const qty = parseInt(String(row.qty_per_unit ?? ''), 10);
if (!isNaN(cost) && qty > 0) {
draft.rows[index].cost_each = (cost / qty).toFixed(2);
updatedIndices.push(index);
}
});
});
if (updatedIndices.length > 0) {
const { clearFieldError } = useValidationStore.getState();
updatedIndices.forEach((rowIndex) => {
clearFieldError(rowIndex, 'cost_each');
});
toast.success(`Divided ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'} by Min Qty`);
} else {
toast.error('No selected rows had both a cost and a Min Qty > 0');
}
setIsPopoverOpen(false);
}, [label]);
return (
<div
className="flex items-center gap-1 min-w-0 w-full group relative"
@@ -1831,7 +1869,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
)}
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
{(isHovered || isPopoverOpen) && hasFillableCells && (
{(isHovered || isPopoverOpen) && (hasFillableCells || (!isMsrp && selectedCount > 0)) && (
isMsrp ? (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
@@ -1905,29 +1943,64 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
</PopoverContent>
</Popover>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
onClick={(e) => e.stopPropagation()}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Calculate Cost Each</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<PopoverContent className="w-60 p-3" align="end">
<div className="space-y-3">
<p className="text-xs font-medium text-muted-foreground">
Calculate Cost Each
</p>
<Button
size="sm"
variant="outline"
className="w-full h-7 text-xs"
disabled={!hasFillableCells}
onClick={() => {
handleCalculateCostEach();
setIsPopoverOpen(false);
}}
className={cn(
'flex items-center gap-0.5',
'rounded border border-input bg-background p-1 shadow-sm',
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Calculator className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{tooltipText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
Fill from MSRP ÷ 2
</Button>
<div className="border-t pt-3 space-y-1.5">
<Button
size="sm"
className="w-full h-7 text-xs"
disabled={selectedCount === 0}
onClick={handleDivideSelectedByMinQty}
>
Divide selected by Min Qty
</Button>
<p className="text-xs text-muted-foreground">
{selectedCount > 0
? `Divides cost ÷ Min Qty for ${selectedCount} selected row${selectedCount === 1 ? '' : 's'}.`
: 'Select rows first to divide their cost by Min Qty.'}
</p>
</div>
</div>
</PopoverContent>
</Popover>
)
)}
{pinButton}
@@ -8,7 +8,7 @@
* 4. Renders the ValidationContainer once initialized
*/
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
import { useEffect, useRef, useDeferredValue } from 'react';
import { apiFetch } from '@/utils/api';
import { useQuery } from '@tanstack/react-query';
import { useValidationStore } from './store/validationStore';
@@ -22,39 +22,10 @@ import { useProductLines } from './hooks/useProductLines';
import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
import { BASE_IMPORT_FIELDS } from '../../config';
import config from '@/config';
import type { ValidationStepProps } from './store/types';
import { diffMappingSignatures } from './utils/mappingSignature';
import type { ValidationStepProps, RowData } from './store/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
*/
@@ -122,6 +93,7 @@ export const ValidationStep = ({
onBack,
onNext,
isFromScratch,
mappingSignature,
}: ValidationStepProps) => {
const initPhase = useInitPhase();
const isReady = useIsReady();
@@ -136,7 +108,6 @@ export const ValidationStep = ({
const templatesLoadedRef = useRef(false);
const upcValidationStartedRef = useRef(false);
const fieldValidationStartedRef = useRef(false);
const lastDataFingerprintRef = useRef<string | null>(null);
// Debug logging
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
@@ -146,6 +117,8 @@ export const ValidationStep = ({
const setFields = useValidationStore((state) => state.setFields);
const setFieldOptionsLoaded = useValidationStore((state) => state.setFieldOptionsLoaded);
const setInitPhase = useValidationStore((state) => state.setInitPhase);
const setMappingSignature = useValidationStore((state) => state.setMappingSignature);
const reconcileMappedData = useValidationStore((state) => state.reconcileMappedData);
// Initialization hooks
const { loadTemplates } = useTemplateManagement();
@@ -164,59 +137,78 @@ export const ValidationStep = ({
retry: 2,
});
// Create a fingerprint of the incoming data to detect changes
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
// Initialize store with data
// Initialize / reconcile store with data.
//
// Three cases, decided once per mount (refs are fresh because this component unmounts
// whenever another step is shown):
// A. Fresh store (not yet ready) -> full initialize() + phase chain
// B. Ready store, mapping unchanged -> skip everything (preserve all edits + UPC results)
// C. Ready store, mapping changed -> merge changed fields only, then re-run
// field validation (and UPC only if the
// supplier / UPC column mapping changed)
useEffect(() => {
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)
// The ref prevents double-init within the same mount cycle
// Checking initPhase handles StrictMode remounts where store was initialized but ref persisted
if (initStartedRef.current && initPhase !== 'idle') {
console.log('[ValidationStep] Skipping init - already initialized');
if (initStartedRef.current) {
return;
}
// IMPORTANT: Skip initialization if we're returning to an already-ready store
// with the SAME data. This happens when navigating back from ImageUploadStep.
// We compare fingerprints to detect if the data has actually changed.
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
const store = useValidationStore.getState();
const storeReady = store.initPhase === 'ready';
const incomingSig = mappingSignature ?? null;
// --- Case A: fresh store -> full initialization ---
if (!storeReady) {
initStartedRef.current = true;
console.log('[ValidationStep] Case A - full init with', initialData.length, 'rows');
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${store.rows.length}`,
}));
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
setMappingSignature(incomingSig);
return;
}
// Returning to an already-ready store.
initStartedRef.current = true;
lastDataFingerprintRef.current = dataFingerprint;
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
// No signature (e.g. returning from ImageUpload, or restored/from-scratch flows) ->
// nothing about the mapping could have changed; keep the store as-is.
const diff = incomingSig
? diffMappingSignatures(store.mappingSignature, incomingSig)
: null;
// Convert initialData to RowData format
const rowData = initialData.map((row, index) => ({
...row,
__index: row.__index || `row-${index}-${Date.now()}`,
}));
// --- Case B: nothing relevant changed -> preserve everything ---
if (!diff || diff.equal) {
console.log('[ValidationStep] Case B - returning with unchanged mapping, preserving edits');
return;
}
// Start with base fields
console.log('[ValidationStep] Calling initialize()');
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
console.log('[ValidationStep] initialize() called');
}, [initialData, file, initialize, initPhase, dataFingerprint]);
// --- Case C: mapping changed -> merge changed fields, re-validate selectively ---
console.log('[ValidationStep] Case C - mapping changed', diff);
reconcileMappedData(initialData as RowData[], diff.changedFieldKeys, diff.clearedFieldKeys);
setMappingSignature(incomingSig);
// Re-run the relevant phase of the validation chain. The existing phase-chain effects
// pick these transitions up (their refs are fresh on this mount):
// - UPC inputs (supplier / upc column) changed -> re-run UPC + item-number, then fields
// - otherwise -> only re-run field validation (no UPC network calls)
if (diff.upcAffected) {
store.setInitialUpcValidationDone(false);
setInitPhase('validating-upcs');
} else {
setInitPhase('validating-fields');
}
}, [
initialData,
file,
initialize,
reconcileMappedData,
setMappingSignature,
setInitPhase,
mappingSignature,
]);
// Update fields when options are loaded
// CRITICAL: Check store state (not ref) because initialize() resets the store
@@ -6,6 +6,7 @@
*/
import type { Field, SelectOption, ErrorLevel } from '../../../types';
import type { MappingSignature } from '../utils/mappingSignature';
// =============================================================================
// Core Data Types
@@ -22,6 +23,7 @@ export interface RowData {
__corrected?: Record<string, unknown>; // AI-corrected values
__changes?: Record<string, boolean>; // Fields changed by AI
__aiSupplemental?: Record<string, string>; // AI supplemental columns from MatchColumnsStep (header -> value)
__sourceRow?: number; // Stable positional id from the raw spreadsheet (for back-nav merge)
// Standard fields (from config.ts)
supplier?: string;
@@ -395,6 +397,8 @@ export interface ValidationState {
// === Initialization ===
initPhase: InitPhase;
/** Mapping signature that produced the current rows (for back-nav merge decisions) */
mappingSignature: MappingSignature | null;
// === AI Validation ===
aiValidation: AiValidationState;
@@ -419,6 +423,17 @@ export interface ValidationActions {
initialize: (data: RowData[], fields: Field<string>[], file?: File) => Promise<void>;
setFields: (fields: Field<string>[]) => void;
setFieldOptionsLoaded: (loaded: boolean) => void;
setMappingSignature: (signature: MappingSignature | null) => void;
/**
* Merge freshly-mapped rows into the existing edited rows on back-nav re-entry.
* Only `changedFieldKeys` are overwritten (from the matching __sourceRow), and
* `clearedFieldKeys` are removed; all other fields keep their edits.
*/
reconcileMappedData: (
freshRows: RowData[],
changedFieldKeys: string[],
clearedFieldKeys: string[],
) => void;
// === Row Operations ===
updateCell: (rowIndex: number, field: string, value: unknown) => void;
@@ -538,4 +553,10 @@ export interface ValidationStepProps {
onBack?: () => void;
onNext?: (data: CleanRowData[]) => void;
isFromScratch?: boolean;
/**
* Signature describing how columns were mapped. Used on back-nav re-entry to decide
* whether to preserve edits (merge changed fields only) vs. fully re-initialize, and
* whether UPC validation needs to re-run. Undefined for from-scratch / restored flows.
*/
mappingSignature?: MappingSignature;
}
@@ -118,6 +118,7 @@ const getInitialState = (): ValidationState => ({
// Initialization
initPhase: 'idle',
mappingSignature: null,
// AI Validation
aiValidation: {
@@ -207,6 +208,67 @@ export const useValidationStore = create<ValidationStore>()(
});
},
setMappingSignature: (signature) => {
set((state) => {
state.mappingSignature = signature;
});
},
/**
* Merge freshly-mapped rows into the existing (edited) rows when returning from
* Match Columns. Aligns by __sourceRow so edits, selection, and __index are
* preserved; only fields whose mapping changed are overwritten, and fields that
* became unmapped are cleared. Rows deleted in Validation are not re-added.
*/
reconcileMappedData: (freshRows: RowData[], changedFieldKeys: string[], clearedFieldKeys: string[]) => {
if (changedFieldKeys.length === 0 && clearedFieldKeys.length === 0) return;
set((state) => {
// Price fields are stripped of $/commas on ingestion; mirror that here so
// re-pulled values match how they were cleaned during initialize().
const priceFieldKeys = new Set(
state.fields
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
.map((f) => f.key),
);
const cleanValue = (key: string, value: unknown) =>
priceFieldKeys.has(key) && typeof value === 'string' && value !== ''
? stripPriceFormatting(value)
: value;
// Index fresh rows by their stable source-row id.
const freshBySource = new Map<number, RowData>();
for (const row of freshRows) {
if (typeof row.__sourceRow === 'number') {
freshBySource.set(row.__sourceRow, row);
}
}
state.rows.forEach((row, idx) => {
const fresh = typeof row.__sourceRow === 'number' ? freshBySource.get(row.__sourceRow) : undefined;
// Overwrite changed fields from the freshly-mapped source row.
if (fresh) {
for (const key of changedFieldKeys) {
const value = cleanValue(key, fresh[key]);
row[key] = value;
if (state.originalRows[idx]) {
state.originalRows[idx][key] = value;
}
}
}
// Clear fields that are no longer mapped.
for (const key of clearedFieldKeys) {
row[key] = undefined;
if (state.originalRows[idx]) {
state.originalRows[idx][key] = undefined;
}
}
});
});
},
// =========================================================================
// Row Operations
// =========================================================================
@@ -0,0 +1,139 @@
/**
* Mapping signature utilities
*
* When the user navigates back to Match Columns and forward again, we need to know
* *what* about the mapping actually changed so the Validation store can:
* - preserve edits for fields whose source did not change,
* - overwrite only the fields whose source did change,
* - re-run UPC validation / item-number generation only when the supplier or the
* UPC column mapping changed.
*
* A "signature" is a compact, comparable description of how every field is sourced.
*/
import { ColumnType, type Columns, type GlobalSelections } from '../../MatchColumnsStep/types';
export interface MappingSignature {
/** field key -> a string describing where that field's value comes from */
perField: Record<string, string>;
/** resolved supplier (global selection), surfaced for UPC re-run gating */
supplier: string;
}
/**
* Encode a single column's contribution to the signature.
* matchedOptions are folded in so re-mapping a select value counts as a change.
*/
const encodeColumn = (column: Columns<string>[number], costIsTotalCost?: boolean): string => {
let sig = `col:${column.index}:${column.type}`;
if ('matchedOptions' in column && Array.isArray(column.matchedOptions)) {
const opts = column.matchedOptions
.map((o) => `${o.entry ?? ''}=>${o.value ?? ''}`)
.join('|');
sig += `:opts(${opts})`;
}
// cost_each is post-processed by the "divide by min qty" flag, so the resulting
// value differs even when the source column is identical — fold the flag in.
if ('value' in column && column.value === 'cost_each' && costIsTotalCost) {
sig += ':total';
}
return sig;
};
/**
* Build a signature from the current columns + global selections.
*/
export const computeMappingSignature = (
columns: Columns<string>,
globalSelections?: GlobalSelections,
): MappingSignature => {
const perField: Record<string, string> = {};
for (const column of columns) {
if (
column.type === ColumnType.empty ||
column.type === ColumnType.ignored ||
column.type === ColumnType.aiSupplemental
) {
continue;
}
if ('value' in column) {
perField[column.value] = encodeColumn(column, globalSelections?.costIsTotalCost);
}
}
// Global selections act as the source for these fields when not column-mapped.
const globalKeys: (keyof GlobalSelections)[] = ['supplier', 'company', 'line', 'subline'];
for (const key of globalKeys) {
const val = globalSelections?.[key];
if (val && !perField[key]) {
perField[key] = `global:${String(val)}`;
}
}
return {
perField,
supplier: String(globalSelections?.supplier ?? ''),
};
};
export interface MappingDiff {
/** true when nothing relevant changed (safe to skip re-init entirely) */
equal: boolean;
/** fields whose source changed (or were newly mapped) — values should be re-pulled */
changedFieldKeys: string[];
/** fields that were mapped before but are now unmapped — values should be cleared */
clearedFieldKeys: string[];
/** whether UPC validation / item-number generation needs to re-run */
upcAffected: boolean;
}
const UPC_INPUT_FIELDS = ['supplier', 'upc', 'barcode'];
/**
* Compare two signatures and describe what changed.
*/
export const diffMappingSignatures = (
prev: MappingSignature | null | undefined,
next: MappingSignature,
): MappingDiff => {
// No previous signature => treat everything as changed (first-time init handles this
// path separately, but be safe).
if (!prev) {
return {
equal: false,
changedFieldKeys: Object.keys(next.perField),
clearedFieldKeys: [],
upcAffected: true,
};
}
const changedFieldKeys: string[] = [];
const clearedFieldKeys: string[] = [];
const allKeys = new Set([...Object.keys(prev.perField), ...Object.keys(next.perField)]);
for (const key of allKeys) {
const before = prev.perField[key];
const after = next.perField[key];
if (before === after) continue;
if (after === undefined) {
clearedFieldKeys.push(key);
} else {
changedFieldKeys.push(key);
}
}
const upcAffected = [...changedFieldKeys, ...clearedFieldKeys].some((k) =>
UPC_INPUT_FIELDS.includes(k),
);
return {
equal: changedFieldKeys.length === 0 && clearedFieldKeys.length === 0,
changedFieldKeys,
clearedFieldKeys,
upcAffected,
};
};
+4 -4
View File
@@ -109,7 +109,7 @@ export default function BulkEdit() {
// Load field options on mount (but don't auto-load products)
useEffect(() => {
axios
apiClient
.get("/api/import/field-options")
.then((res) => setFieldOptions(res.data))
.catch((err) => {
@@ -127,7 +127,7 @@ export default function BulkEdit() {
setSublineOptions([]);
if (!lineCompany) return;
setIsLoadingLines(true);
axios
apiClient
.get(`/api/import/product-lines/${lineCompany}`)
.then((res) => setLineOptions(res.data))
.catch(() => setLineOptions([]))
@@ -140,7 +140,7 @@ export default function BulkEdit() {
setSublineOptions([]);
if (!lineLine) return;
setIsLoadingSublines(true);
axios
apiClient
.get(`/api/import/sublines/${lineLine}`)
.then((res) => setSublineOptions(res.data))
.catch(() => setSublineOptions([]))
@@ -303,7 +303,7 @@ export default function BulkEdit() {
if (pidsNeedingImages.length === 0) return;
pidsNeedingImages.forEach((pid) => {
axios
apiClient
.get(`/api/import/product-images/${pid}`)
.then((res) => {
const images = res.data;
File diff suppressed because one or more lines are too long