diff --git a/inventory-server/migrations/006_permissions_ui_cleanup.sql b/inventory-server/migrations/006_permissions_ui_cleanup.sql new file mode 100644 index 0000000..44cbe96 --- /dev/null +++ b/inventory-server/migrations/006_permissions_ui_cleanup.sql @@ -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; diff --git a/inventory-server/shared/auth/middleware.js b/inventory-server/shared/auth/middleware.js index c9a95c3..96f321d 100644 --- a/inventory-server/shared/auth/middleware.js +++ b/inventory-server/shared/auth/middleware.js @@ -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', diff --git a/inventory-server/shared/auth/middleware.test.js b/inventory-server/shared/auth/middleware.test.js index ac61333..6928585 100644 --- a/inventory-server/shared/auth/middleware.test.js +++ b/inventory-server/shared/auth/middleware.test.js @@ -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: '' }); diff --git a/inventory/src/components/product-editor/ProductEditForm.tsx b/inventory/src/components/product-editor/ProductEditForm.tsx index 2fefba8..3ab1112 100644 --- a/inventory/src/components/product-editor/ProductEditForm.tsx +++ b/inventory/src/components/product-editor/ProductEditForm.tsx @@ -37,7 +37,7 @@ const sublinesCache = new Map>(); function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise { 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 { 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[] = []; diff --git a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx index 9e714e3..a15720a 100644 --- a/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx +++ b/inventory/src/components/product-import/steps/MatchColumnsStep/MatchColumnsStep.tsx @@ -607,14 +607,21 @@ const MatchColumnsStepComponent = ({ headerValues, onContinue, onBack, - initialGlobalSelections + initialGlobalSelections, + initialColumns }: MatchColumnsProps): JSX.Element => { const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi() const queryClient = useQueryClient() const [isLoading, setIsLoading] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false) - + const [columns, setColumns] = useState>(() => { + // 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 = ({ 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; }) @@ -637,7 +644,10 @@ const MatchColumnsStepComponent = ({ const [showAllColumns, setShowAllColumns] = useState(true) const [expandedValues, setExpandedValues] = useState([]) const [userCollapsedColumns, setUserCollapsedColumns] = useState([]) - 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) => { diff --git a/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts b/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts index 93cec9a..f2ac1d1 100644 --- a/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts +++ b/inventory/src/components/product-import/steps/MatchColumnsStep/types.ts @@ -6,6 +6,12 @@ export type MatchColumnsProps = { onContinue: (data: any[], rawData: RawData[], columns: Columns, 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 } export type GlobalSelections = { diff --git a/inventory/src/components/product-import/steps/UploadFlow.tsx b/inventory/src/components/product-import/steps/UploadFlow.tsx index 45f1823..1cbf47b 100644 --- a/inventory/src/components/product-import/steps/UploadFlow.tsx +++ b/inventory/src/components/product-import/steps/UploadFlow.tsx @@ -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 | 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 & { __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 & { __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) => { { // If we started from scratch, we need to go back to the upload step if (state.isFromScratch) { diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx index 37d8d69..082208f 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -924,7 +924,7 @@ const CellWrapper = memo(({ {/* Copy-down button - appears on hover, positioned to avoid error icons */} {showCopyDownButton && ( - + + + + +

Calculate Cost Each

+
+
+
+ +
+

+ Calculate Cost Each +

+ - - -

{tooltipText}

-
- - + Fill from MSRP ÷ 2 + +
+ +

+ {selectedCount > 0 + ? `Divides cost ÷ Min Qty for ${selectedCount} selected row${selectedCount === 1 ? '' : 's'}.` + : 'Select rows first to divide their cost by Min Qty.'} +

+
+
+
+ ) )} {pinButton} diff --git a/inventory/src/components/product-import/steps/ValidationStep/index.tsx b/inventory/src/components/product-import/steps/ValidationStep/index.tsx index 457ea7b..6bd9319 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/index.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/index.tsx @@ -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 => { - // 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(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[], 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[], 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 diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts index 46f40c1..3c80b9b 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -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; // AI-corrected values __changes?: Record; // Fields changed by AI __aiSupplemental?: Record; // 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[], file?: File) => Promise; setFields: (fields: Field[]) => 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; } diff --git a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts index 71e44ed..92ac2ed 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -118,6 +118,7 @@ const getInitialState = (): ValidationState => ({ // Initialization initPhase: 'idle', + mappingSignature: null, // AI Validation aiValidation: { @@ -207,6 +208,67 @@ export const useValidationStore = create()( }); }, + 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(); + 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 // ========================================================================= diff --git a/inventory/src/components/product-import/steps/ValidationStep/utils/mappingSignature.ts b/inventory/src/components/product-import/steps/ValidationStep/utils/mappingSignature.ts new file mode 100644 index 0000000..eb72941 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/utils/mappingSignature.ts @@ -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; + /** 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[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, + globalSelections?: GlobalSelections, +): MappingSignature => { + const perField: Record = {}; + + 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, + }; +}; diff --git a/inventory/src/pages/BulkEdit.tsx b/inventory/src/pages/BulkEdit.tsx index 0add765..12f8a26 100644 --- a/inventory/src/pages/BulkEdit.tsx +++ b/inventory/src/pages/BulkEdit.tsx @@ -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; diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 328515e..09a96d3 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/index.ts","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/mappingsignature.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker-narrow.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toast.tsx","./src/components/ui/toaster.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/use-toast.ts","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file