From 3d1e8862f9b2d1f2a9ce2c3f2e64e79d0eeb4354 Mon Sep 17 00:00:00 2001 From: Matt Date: Tue, 20 Jan 2026 19:38:35 -0500 Subject: [PATCH] New AI tasks tweaks/fixes --- .../src/components/product-import/config.ts | 4 +- .../components/ValidationFooter.tsx | 46 +-- .../components/ValidationTable.tsx | 363 +++++++++++++++++- .../components/cells/MultilineInput.tsx | 10 +- .../dialogs/SanityCheckDialog.tsx | 132 ++----- .../hooks/useAutoInlineAiValidation.ts | 201 ++++++++++ .../hooks/useCopyDownValidation.ts | 146 ++++++- .../steps/ValidationStep/index.tsx | 20 +- .../steps/ValidationStep/store/types.ts | 24 +- .../ValidationStep/store/validationStore.ts | 93 ++++- 10 files changed, 858 insertions(+), 181 deletions(-) create mode 100644 inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts index 21e668f..a99e7f8 100644 --- a/inventory/src/components/product-import/config.ts +++ b/inventory/src/components/product-import/config.ts @@ -121,7 +121,7 @@ export const BASE_IMPORT_FIELDS = [ type: "input", price: true }, - width: 100, + width: 110, validations: [ { rule: "required", errorMessage: "Required", level: "error" }, { rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" }, @@ -148,7 +148,7 @@ export const BASE_IMPORT_FIELDS = [ type: "input", price: true }, - width: 110, + width: 120, validations: [ { rule: "required", errorMessage: "Required", level: "error" }, { rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" }, diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx index 7424dc0..342c587 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationFooter.tsx @@ -9,7 +9,7 @@ import { useContext } from 'react'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; -import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react'; +import { CheckCircle, Loader2, Eye, RefreshCw } from 'lucide-react'; import { Tooltip, TooltipContent, @@ -83,52 +83,38 @@ export const ValidationFooter = ({ {/* Action buttons */}
{/* Skip sanity check toggle - only for admin:debug users */} - {hasDebugPermission && onSkipSanityCheckChange && ( + {hasDebugPermission && onSkipSanityCheckChange && !hasRunSanityCheck && ( -
+
-

Debug: Skip sanity check

+

Debug: Skip consistency check

)} - {/* Before first sanity check: single "Continue" button that runs the check */} + {/* Before first sanity check: single "Next" button that runs the check */} {!hasRunSanityCheck && !skipSanityCheck && ( )} @@ -141,16 +127,15 @@ export const ValidationFooter = ({ -

View previous sanity check results

+

Review previous consistency check results

@@ -161,7 +146,6 @@ export const ValidationFooter = ({ -

Run a fresh sanity check

+

Run a fresh consistency check

@@ -189,8 +173,7 @@ export const ValidationFooter = ({ : 'Continue to image upload' } > - Continue - + Next )} @@ -202,8 +185,7 @@ export const ValidationFooter = ({ disabled={rowCount === 0} title="Continue to image upload (sanity check skipped)" > - Continue - + Next )}
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 2cd52ad..41141d4 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; import { useVirtualizer } from '@tanstack/react-virtual'; import { Checkbox } from '@/components/ui/checkbox'; -import { ArrowDown, Wand2, Loader2 } from 'lucide-react'; +import { ArrowDown, Wand2, Loader2, Calculator } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from 'sonner'; import { cn } from '@/lib/utils'; @@ -258,9 +258,61 @@ const CellWrapper = memo(({ throw new Error(payload?.error || 'Unexpected response while generating UPC'); } - // Update the cell value - useValidationStore.getState().updateCell(rowIndex, 'upc', payload.upc); + // Update the cell value and clear any validation errors + const { updateCell, clearFieldError, setUpcStatus, setGeneratedItemNumber, + cacheUpcResult, getCachedItemNumber, startValidatingCell, stopValidatingCell, + setError } = useValidationStore.getState(); + updateCell(rowIndex, 'upc', payload.upc); + clearFieldError(rowIndex, 'upc'); toast.success('UPC generated'); + + // Trigger UPC validation to generate item number + // We have both supplier and the newly generated UPC, so validate immediately + const upc = payload.upc; + + // Check cache first + const cached = getCachedItemNumber(supplierIdString, upc); + if (cached) { + setGeneratedItemNumber(rowIndex, cached); + } else { + // Start validation + setUpcStatus(rowIndex, 'validating'); + startValidatingCell(rowIndex, 'item_number'); + + try { + const validationResponse = await fetch( + `${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upc)}&supplierId=${encodeURIComponent(supplierIdString)}` + ); + + const validationPayload = await validationResponse.json().catch(() => null); + + if (validationResponse.status === 409) { + // UPC already exists (shouldn't happen with newly generated UPC, but handle it) + setError(rowIndex, 'upc', { + message: 'A product with this UPC already exists', + level: 'error', + source: ErrorSource.Upc, + type: ErrorType.Unique, + }); + setUpcStatus(rowIndex, 'error'); + updateCell(rowIndex, 'item_number', ''); + } else if (validationResponse.ok && validationPayload?.success && validationPayload?.itemNumber) { + // Success - cache and apply + cacheUpcResult(supplierIdString, upc, validationPayload.itemNumber); + setGeneratedItemNumber(rowIndex, validationPayload.itemNumber); + clearFieldError(rowIndex, 'upc'); + setUpcStatus(rowIndex, 'done'); + } else { + setUpcStatus(rowIndex, 'error'); + updateCell(rowIndex, 'item_number', ''); + } + } catch (validationError) { + console.error('UPC validation error:', validationError); + setUpcStatus(rowIndex, 'error'); + } finally { + stopValidatingCell(rowIndex, 'item_number'); + } + } } catch (error) { console.error('Error generating UPC:', error); const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC'; @@ -484,6 +536,113 @@ const CellWrapper = memo(({ // Clear subline when line changes (it's no longer valid) updateCell(rowIndex, 'subline', ''); + + // Check if row now has sufficient context for inline AI validation + // (line was just set, check if company + name exist) + const currentRowForContext = useValidationStore.getState().rows[rowIndex]; + if (currentRowForContext?.company && currentRowForContext?.name) { + const { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields } = useValidationStore.getState(); + const contextProductIndex = currentRowForContext.__index; + + // Helper to look up field option label + const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => { + const fieldDef = fields.find(f => f.key === fieldKey); + if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) { + const option = fieldDef.fieldType.options?.find(o => o.value === String(val)); + return option?.label; + } + return undefined; + }; + + // Check if name should be validated + const nameSuggestion = inlineAi.suggestions.get(contextProductIndex); + const nameIsDismissed = nameSuggestion?.dismissed?.name; + const nameIsValidating = inlineAi.validating.has(`${contextProductIndex}-name`); + const nameValue = String(currentRowForContext.name).trim(); + + if (nameValue && !nameIsDismissed && !nameIsValidating) { + // Trigger name validation + setInlineAiValidating(`${contextProductIndex}-name`, true); + + const rows = useValidationStore.getState().rows; + const siblingNames: string[] = []; + const companyId = String(currentRowForContext.company); + const lineId = String(valueToSave); // Use the new line value + for (const row of rows) { + if (row.__index === contextProductIndex) continue; + if (String(row.company) !== companyId) continue; + if (String(row.line) !== lineId) continue; + if (row.name && typeof row.name === 'string' && row.name.trim()) { + siblingNames.push(row.name); + } + } + + fetch('/api/ai/validate/inline/name', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + product: { + name: nameValue, + description: currentRowForContext.description as string, + company_name: getFieldLabel('company', currentRowForContext.company), + company_id: String(currentRowForContext.company), + line_name: getFieldLabel('line', valueToSave), + line_id: String(valueToSave), + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, + }, + }), + }) + .then(res => res.json()) + .then(result => { + if (result.success !== false) { + setInlineAiSuggestion(contextProductIndex, 'name', { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + }) + .catch(err => console.error('[InlineAI] name validation error on line change:', err)) + .finally(() => setInlineAiValidating(`${contextProductIndex}-name`, false)); + } + + // Check if description should be validated + const descIsDismissed = nameSuggestion?.dismissed?.description; + const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`); + const descValue = currentRowForContext.description && String(currentRowForContext.description).trim(); + + if (descValue && !descIsDismissed && !descIsValidating) { + // Trigger description validation + setInlineAiValidating(`${contextProductIndex}-description`, true); + + fetch('/api/ai/validate/inline/description', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + product: { + name: nameValue, + description: descValue, + company_name: getFieldLabel('company', currentRowForContext.company), + company_id: String(currentRowForContext.company), + }, + }), + }) + .then(res => res.json()) + .then(result => { + if (result.success !== false) { + setInlineAiSuggestion(contextProductIndex, 'description', { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + }) + .catch(err => console.error('[InlineAI] description validation error on line change:', err)) + .finally(() => setInlineAiValidating(`${contextProductIndex}-description`, false)); + } + } } // Trigger UPC validation if supplier or UPC changed @@ -559,11 +718,27 @@ const CellWrapper = memo(({ const currentRow = useValidationStore.getState().rows[rowIndex]; const fields = useValidationStore.getState().fields; if (currentRow) { - const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState(); + const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, inlineAi } = useValidationStore.getState(); const fieldKey = field.key as 'name' | 'description'; + const validationKey = `${productIndex}-${fieldKey}`; - // Mark as validating - setInlineAiValidating(`${productIndex}-${fieldKey}`, true); + // Skip if validation is already in progress (auto-validation may have started) + if (inlineAi.validating.has(validationKey)) { + console.log(`[InlineAI] Skipping ${fieldKey} blur validation - already in progress`); + return; + } + + // Skip if accepting an AI suggestion (value matches current suggestion) + // This prevents re-validating when user clicks "Accept" on a suggestion + const currentSuggestion = inlineAi.suggestions.get(productIndex)?.[fieldKey]; + if (currentSuggestion?.suggestion && currentSuggestion.suggestion === String(valueToSave)) { + console.log(`[InlineAI] Skipping ${fieldKey} blur validation - accepting AI suggestion`); + return; + } + + // Mark as validating and auto-validated (prevents race with auto-validation hook) + setInlineAiValidating(validationKey, true); + markInlineAiAutoValidated(productIndex, fieldKey); // Helper to look up field option label const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => { @@ -744,8 +919,9 @@ const CellWrapper = memo(({ onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined} isLoadingOptions={isLoadingOptions} // Pass AI suggestion props for description field (MultilineInput handles it internally) + // Only pass aiSuggestion when showSuggestion is true (respects dismissed state) {...(field.key === 'description' && { - aiSuggestion: fieldSuggestion, + aiSuggestion: showSuggestion ? fieldSuggestion : undefined, isAiValidating: isInlineAiValidating, onDismissAiSuggestion: () => { useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description'); @@ -1433,6 +1609,144 @@ const HeaderCheckbox = memo(() => { HeaderCheckbox.displayName = 'HeaderCheckbox'; +/** + * PriceColumnHeader Component + * + * Renders a column header for MSRP or Cost Each with a hover button + * that fills empty cells based on the other price field. + * - MSRP: Fill with Cost Each × 2 + * - Cost Each: Fill with MSRP ÷ 2 + * + * PERFORMANCE: Uses local hover state and getState() for bulk updates. + * No store subscriptions - this is purely a UI component that triggers actions. + */ +interface PriceColumnHeaderProps { + fieldKey: 'msrp' | 'cost_each'; + label: string; + isRequired: boolean; +} + +const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => { + const [isHovered, setIsHovered] = useState(false); + const [hasFillableCells, setHasFillableCells] = useState(false); + + // Determine the source field and calculation + const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp'; + const tooltipText = fieldKey === 'msrp' + ? 'Fill empty cells with Cost Each × 2' + : 'Fill empty cells with MSRP ÷ 2'; + + // Check if there are any cells that can be filled (called on hover) + const checkFillableCells = useCallback(() => { + const { rows } = useValidationStore.getState(); + return rows.some((row) => { + const currentValue = row[fieldKey]; + const sourceValue = row[sourceField]; + const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; + if (isEmpty && hasSource) { + const sourceNum = parseFloat(String(sourceValue)); + return !isNaN(sourceNum) && sourceNum > 0; + } + return false; + }); + }, [fieldKey, sourceField]); + + // Update fillable check on hover + const handleMouseEnter = useCallback(() => { + setIsHovered(true); + setHasFillableCells(checkFillableCells()); + }, [checkFillableCells]); + + const handleCalculate = useCallback(() => { + const updatedIndices: number[] = []; + + // Use setState() for efficient batch update with Immer + useValidationStore.setState((draft) => { + draft.rows.forEach((row, index) => { + const currentValue = row[fieldKey]; + const sourceValue = row[sourceField]; + + // Only fill if current field is empty and source has a value + const isEmpty = currentValue === undefined || currentValue === null || currentValue === ''; + const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== ''; + + if (isEmpty && hasSource) { + const sourceNum = parseFloat(String(sourceValue)); + if (!isNaN(sourceNum) && sourceNum > 0) { + // Calculate the new value + let newValue: string; + if (fieldKey === 'msrp') { + let msrp = sourceNum * 2; + // Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99) + if (msrp === Math.floor(msrp)) { + msrp -= 0.01; + } + newValue = msrp.toFixed(2); + } else { + newValue = (sourceNum / 2).toFixed(2); + } + draft.rows[index][fieldKey] = newValue; + updatedIndices.push(index); + } + } + }); + }); + + // Clear validation errors for all updated cells (removes "required" error styling) + if (updatedIndices.length > 0) { + const { clearFieldError } = useValidationStore.getState(); + updatedIndices.forEach((rowIndex) => { + clearFieldError(rowIndex, fieldKey); + }); + + toast.success(`Updated ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`); + } + }, [fieldKey, sourceField, label]); + + return ( +
setIsHovered(false)} + > + {label} + {isRequired && ( + * + )} + {isHovered && hasFillableCells && ( + + + + + + +

{tooltipText}

+
+
+
+ )} +
+ ); +}); + +PriceColumnHeader.displayName = 'PriceColumnHeader'; + /** * Main table component * @@ -1535,18 +1849,29 @@ export const ValidationTable = () => { }; // Data columns from fields with widths from config.ts - const dataColumns: ColumnDef[] = fields.map((field) => ({ - id: field.key, - header: () => ( -
- {field.label} - {field.validations?.some((v: Validation) => v.rule === 'required') && ( - * - )} -
- ), - size: field.width || 150, - })); + const dataColumns: ColumnDef[] = fields.map((field) => { + const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false; + const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each'; + + return { + id: field.key, + header: () => isPriceColumn ? ( + + ) : ( +
+ {field.label} + {isRequired && ( + * + )} +
+ ), + size: field.width || 150, + }; + }); return [selectionColumn, templateColumn, ...dataColumns]; }, [fields]); // CRITICAL: No selection-related deps! diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx index a266dfa..ceb0416 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultilineInput.tsx @@ -197,8 +197,8 @@ const MultilineInputComponent = ({
{/* Close button */} @@ -267,7 +267,7 @@ const MultilineInputComponent = ({ value={editValue} onChange={handleChange} onWheel={handleTextareaWheel} - className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none p-2 pr-8 resize-none" + className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-none" placeholder={`Enter ${field.label || 'text'}...`} autoFocus /> @@ -337,7 +337,7 @@ const MultilineInputComponent = ({ onClick={handleAcceptSuggestion} > - Apply + Replace With Suggestion - )} +
- - {isChecking - ? 'Reviewing products for consistency and appropriateness...' - : error - ? 'An error occurred while checking your products.' - : allClear && !hasValidationErrors - ? 'All products look good! No issues detected.' - : hasAnyIssues - ? buildIssuesSummary(validationErrorCount, result?.issues?.length || 0) - : 'Checking your products...'} - {/* Show when results were cached */} - {!isChecking && result?.checkedAt && ( - - Last checked {formatTimeAgo(result.checkedAt)} - - )} - {/* Content */} @@ -173,9 +142,7 @@ export function SanityCheckDialog({ {/* Success state - only show if no validation errors either */} {allClear && !hasValidationErrors && !isChecking && (
-
-

All Clear!

{result?.summary || 'No consistency issues detected in your products.'}

@@ -185,28 +152,33 @@ export function SanityCheckDialog({ {/* Validation errors warning */} {hasValidationErrors && !isChecking && ( -
- +
-

Validation Errors

-

- There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields, invalid values, etc.) in your data. +

+ There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields missing, invalid values, etc.) in your data. These should be fixed before continuing.

)} + {hasSanityIssues && !isChecking && ( + <> + {/* Summary */} + {result?.summary && ( +
+
+

{result.summary}

+
+
+ )} + + )} {/* Sanity check issues list */} {hasSanityIssues && !isChecking && ( - +
- {/* Summary */} - {result?.summary && ( -
-

{result.summary}

-
- )} + {/* Issues grouped by field */} {Object.entries(issuesByField).map(([field, fieldIssues]) => ( @@ -249,7 +221,7 @@ export function SanityCheckDialog({

{issue.issue}

{issue.suggestion && (

- 💡 {issue.suggestion} + {issue.suggestion}

)}
@@ -288,8 +260,7 @@ export function SanityCheckDialog({ Go Back & Fix ) : null} @@ -320,47 +291,4 @@ function formatFieldName(field: string): string { }; return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} - -/** - * Format a timestamp as a relative time string - */ -function formatTimeAgo(timestamp: number): string { - const seconds = Math.floor((Date.now() - timestamp) / 1000); - - if (seconds < 5) return 'just now'; - if (seconds < 60) return `${seconds} seconds ago`; - - const minutes = Math.floor(seconds / 60); - if (minutes === 1) return '1 minute ago'; - if (minutes < 60) return `${minutes} minutes ago`; - - const hours = Math.floor(minutes / 60); - if (hours === 1) return '1 hour ago'; - if (hours < 24) return `${hours} hours ago`; - - return 'over a day ago'; -} - -/** - * Build a summary string describing both validation errors and sanity issues - */ -function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string { - const parts: string[] = []; - - if (validationErrorCount > 0) { - parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`); - } - - if (sanityIssueCount > 0) { - parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`); - } - - if (parts.length === 2) { - return `Found ${parts[0]} and ${parts[1]} to review.`; - } else if (parts.length === 1) { - return `Found ${parts[0]} to review.`; - } - - return 'Review the issues below.'; -} +} \ No newline at end of file diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts new file mode 100644 index 0000000..2b1a06f --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts @@ -0,0 +1,201 @@ +/** + * useAutoInlineAiValidation Hook + * + * Automatically triggers inline AI validation (name/description) for rows + * that have sufficient context when the validation step becomes ready. + * + * Context requirements: + * - Name validation: company + line + name value + * - Description validation: company + line + name + description value + * + * This runs once when the table is ready, firing all requests at once. + * The blur handler in ValidationTable.tsx handles subsequent validations + * when fields are edited. + */ + +import { useEffect, useRef } from 'react'; +import { useValidationStore } from '../store/validationStore'; +import { useInitPhase } from '../store/selectors'; +import type { RowData } from '../store/types'; +import type { Field } from '../../../types'; + +/** + * Build product payload for AI validation API + */ +function buildProductPayload( + row: RowData, + _field: 'name' | 'description', + fields: Field[], + allRows: RowData[] +) { + // Helper to look up field option label + const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => { + const fieldDef = fields.find(f => f.key === fieldKey); + if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) { + const option = fieldDef.fieldType.options?.find(o => o.value === String(val)); + return option?.label; + } + return undefined; + }; + + // Compute sibling names for context (same company + line + subline if set) + const siblingNames: string[] = []; + if (row.company && row.line) { + const companyId = String(row.company); + const lineId = String(row.line); + const sublineId = row.subline ? String(row.subline) : null; + + for (const otherRow of allRows) { + // Skip self + if (otherRow.__index === row.__index) continue; + + // Must match company and line + if (String(otherRow.company) !== companyId) continue; + if (String(otherRow.line) !== lineId) continue; + + // If current product has subline, siblings must match subline too + if (sublineId && String(otherRow.subline) !== sublineId) continue; + + // Add name if it exists + if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) { + siblingNames.push(otherRow.name); + } + } + } + + return { + name: row.name as string, + description: row.description as string | undefined, + company_name: row.company ? getFieldLabel('company', row.company) : undefined, + company_id: row.company ? String(row.company) : undefined, + line_name: row.line ? getFieldLabel('line', row.line) : undefined, + line_id: row.line ? String(row.line) : undefined, + subline_name: row.subline ? getFieldLabel('subline', row.subline) : undefined, + subline_id: row.subline ? String(row.subline) : undefined, + categories: row.categories as string | undefined, + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, + }; +} + +/** + * Trigger validation for a single field + */ +async function triggerValidation( + productIndex: string, + field: 'name' | 'description', + payload: ReturnType +) { + const { + setInlineAiValidating, + setInlineAiSuggestion, + markInlineAiAutoValidated, + } = useValidationStore.getState(); + + const validationKey = `${productIndex}-${field}`; + + // Mark as auto-validated BEFORE calling API (prevents blur handler race condition) + markInlineAiAutoValidated(productIndex, field); + + // Mark as validating + setInlineAiValidating(validationKey, true); + + const endpoint = field === 'name' + ? '/api/ai/validate/inline/name' + : '/api/ai/validate/inline/description'; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ product: payload }), + }); + + const result = await response.json(); + + if (result.success !== false) { + setInlineAiSuggestion(productIndex, field, { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + } catch (err) { + console.error(`[AutoInlineAI] ${field} validation error for ${productIndex}:`, err); + } finally { + setInlineAiValidating(validationKey, false); + } +} + +/** + * Hook that triggers inline AI validation for all rows with sufficient context + * when the validation step becomes ready. + */ +export function useAutoInlineAiValidation() { + const initPhase = useInitPhase(); + const hasRunRef = useRef(false); + + useEffect(() => { + // Only run when ready phase is reached, and only once + if (initPhase !== 'ready' || hasRunRef.current) { + return; + } + + hasRunRef.current = true; + + const state = useValidationStore.getState(); + const { rows, fields, inlineAi } = state; + + console.log('[AutoInlineAI] Starting auto-validation for', rows.length, 'rows'); + + let nameCount = 0; + let descCount = 0; + + // Process all rows - fire requests immediately (no batching) + for (const row of rows) { + const productIndex = row.__index; + if (!productIndex) continue; + + // Check name context: company + line + name + const hasNameContext = + row.company && + row.line && + row.name && + typeof row.name === 'string' && + row.name.trim(); + + // Check description context: company + line + name + description + const hasDescContext = + hasNameContext && + row.description && + typeof row.description === 'string' && + row.description.trim(); + + // Skip if already auto-validated (shouldn't happen on first run, but be safe) + const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`); + const descAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-description`); + + // Skip if currently validating (another process started validation) + const nameCurrentlyValidating = inlineAi.validating.has(`${productIndex}-name`); + const descCurrentlyValidating = inlineAi.validating.has(`${productIndex}-description`); + + // Trigger name validation if context is sufficient + if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) { + const payload = buildProductPayload(row, 'name', fields, rows); + triggerValidation(productIndex, 'name', payload); + nameCount++; + } + + // Trigger description validation if context is sufficient + if (hasDescContext && !descAlreadyValidated && !descCurrentlyValidating) { + const payload = buildProductPayload(row, 'description', fields, rows); + triggerValidation(productIndex, 'description', payload); + descCount++; + } + } + + console.log(`[AutoInlineAI] Triggered ${nameCount} name validations, ${descCount} description validations`); + }, [initPhase]); +} + +export default useAutoInlineAiValidation; diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts index b9e3332..9430cbb 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts @@ -1,54 +1,168 @@ /** * useCopyDownValidation Hook * - * Watches for copy-down operations on UPC-related fields (supplier, upc, barcode) - * and triggers UPC validation for affected rows using the existing validateUpc function. + * Watches for copy-down operations and triggers appropriate validations: + * - UPC-related fields (supplier, upc, barcode) -> UPC validation + * - Line field -> Inline AI validation for rows that gain sufficient context * - * This avoids duplicating UPC validation logic - we reuse the same code path - * that handles individual cell blur events. + * This avoids duplicating validation logic - we reuse the same code paths + * that handle individual cell blur events. */ import { useEffect } from 'react'; import { useValidationStore } from '../store/validationStore'; import { useUpcValidation } from './useUpcValidation'; +import type { Field } from '../../../types'; /** - * Hook that handles UPC validation after copy-down operations. + * Helper to look up field option label + */ +function getFieldLabel(fields: Field[], fieldKey: string, val: unknown): string | undefined { + const fieldDef = fields.find(f => f.key === fieldKey); + if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) { + const option = fieldDef.fieldType.options?.find(o => o.value === String(val)); + return option?.label; + } + return undefined; +} + +/** + * Trigger inline AI validation for a single row/field + */ +async function triggerInlineAiValidation( + rowIndex: number, + field: 'name' | 'description', + rows: ReturnType['rows'], + fields: Field[], + setInlineAiValidating: (key: string, validating: boolean) => void, + setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: { isValid: boolean; suggestion?: string; issues: string[]; latencyMs?: number }) => void +) { + const row = rows[rowIndex]; + if (!row?.__index) return; + + const productIndex = row.__index; + const validationKey = `${productIndex}-${field}`; + + setInlineAiValidating(validationKey, true); + + // Compute sibling names for context + const siblingNames: string[] = []; + if (row.company && row.line) { + const companyId = String(row.company); + const lineId = String(row.line); + for (const otherRow of rows) { + if (otherRow.__index === productIndex) continue; + if (String(otherRow.company) !== companyId) continue; + if (String(otherRow.line) !== lineId) continue; + if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) { + siblingNames.push(otherRow.name); + } + } + } + + const productPayload = { + name: String(row.name), + description: row.description ? String(row.description) : undefined, + company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined, + company_id: row.company ? String(row.company) : undefined, + line_name: row.line ? getFieldLabel(fields, 'line', row.line) : undefined, + line_id: row.line ? String(row.line) : undefined, + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, + }; + + const endpoint = field === 'name' + ? '/api/ai/validate/inline/name' + : '/api/ai/validate/inline/description'; + + try { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ product: productPayload }), + }); + + const result = await response.json(); + + if (result.success !== false) { + setInlineAiSuggestion(productIndex, field, { + isValid: result.isValid ?? true, + suggestion: result.suggestion, + issues: result.issues || [], + latencyMs: result.latencyMs, + }); + } + } catch (err) { + console.error(`[InlineAI] ${field} validation error on line copy-down:`, err); + } finally { + setInlineAiValidating(validationKey, false); + } +} + +/** + * Hook that handles validation after copy-down operations. * Should be called once in ValidationContainer to ensure validation runs. */ export const useCopyDownValidation = () => { const { validateUpc } = useUpcValidation(); - // Subscribe to pending copy-down validation - const pendingValidation = useValidationStore((state) => state.pendingCopyDownValidation); + // Subscribe to pending validations + const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation); + const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation); const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation); + const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation); + // Handle UPC validation useEffect(() => { - if (!pendingValidation) return; + if (!pendingUpcValidation) return; - const { fieldKey, affectedRows } = pendingValidation; - - // Get current rows to check supplier and UPC values + const { affectedRows } = pendingUpcValidation; const rows = useValidationStore.getState().rows; - // Process each affected row const validationPromises = affectedRows.map(async (rowIndex) => { const row = rows[rowIndex]; if (!row) return; - // Get supplier and UPC values const supplierId = row.supplier ? String(row.supplier) : ''; const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : ''); - // Only validate if we have both supplier and UPC if (supplierId && upcValue) { await validateUpc(rowIndex, supplierId, upcValue); } }); - // Run all validations and then clear the pending state Promise.all(validationPromises).then(() => { clearPendingCopyDownValidation(); }); - }, [pendingValidation, validateUpc, clearPendingCopyDownValidation]); + }, [pendingUpcValidation, validateUpc, clearPendingCopyDownValidation]); + + // Handle inline AI validation (triggered by line copy-down) + useEffect(() => { + if (!pendingInlineAiValidation) return; + + const { nameRows, descriptionRows } = pendingInlineAiValidation; + const state = useValidationStore.getState(); + const { rows, fields, setInlineAiValidating, setInlineAiSuggestion } = state; + + console.log(`[InlineAI] Line copy-down: validating ${nameRows.length} names, ${descriptionRows.length} descriptions`); + + const validationPromises: Promise[] = []; + + // Trigger name validation for applicable rows + for (const rowIndex of nameRows) { + validationPromises.push( + triggerInlineAiValidation(rowIndex, 'name', rows, fields, setInlineAiValidating, setInlineAiSuggestion) + ); + } + + // Trigger description validation for applicable rows + for (const rowIndex of descriptionRows) { + validationPromises.push( + triggerInlineAiValidation(rowIndex, 'description', rows, fields, setInlineAiValidating, setInlineAiSuggestion) + ); + } + + Promise.all(validationPromises).then(() => { + clearPendingInlineAiValidation(); + }); + }, [pendingInlineAiValidation, clearPendingInlineAiValidation]); }; diff --git a/inventory/src/components/product-import/steps/ValidationStep/index.tsx b/inventory/src/components/product-import/steps/ValidationStep/index.tsx index 5f0576d..7880b5c 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/index.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/index.tsx @@ -18,6 +18,7 @@ import { useTemplateManagement } from './hooks/useTemplateManagement'; import { useUpcValidation } from './hooks/useUpcValidation'; import { useValidationActions } from './hooks/useValidationActions'; 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'; @@ -120,6 +121,9 @@ export const ValidationStep = ({ const { validateAllRows } = useValidationActions(); const { prefetchAllLines } = useProductLines(); + // Auto inline AI validation - triggers when ready phase is reached + useAutoInlineAiValidation(); + // Fetch field options const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({ queryKey: ['field-options'], @@ -128,6 +132,9 @@ export const ValidationStep = ({ retry: 2, }); + // Get current store state to check if we're returning to an already-initialized store + const storeRows = useValidationStore((state) => state.rows); + // Initialize store with data useEffect(() => { console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase); @@ -140,6 +147,17 @@ export const ValidationStep = ({ return; } + // IMPORTANT: Skip initialization if we're returning to an already-ready store + // This happens when navigating back from ImageUploadStep - the store still has + // all the validated data, so we don't need to re-run the initialization sequence. + // We check that the store is 'ready' and has matching row count to avoid + // false positives from stale store data. + if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) { + console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows'); + initStartedRef.current = true; + return; + } + initStartedRef.current = true; console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows'); @@ -154,7 +172,7 @@ export const ValidationStep = ({ console.log('[ValidationStep] Calling initialize()'); initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field[], file); console.log('[ValidationStep] initialize() called'); - }, [initialData, file, initialize, initPhase]); + }, [initialData, file, initialize, initPhase, storeRows.length]); // 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 3765a11..72dbeac 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -160,6 +160,18 @@ export interface PendingCopyDownValidation { affectedRows: number[]; } +/** + * Tracks rows that need inline AI validation after line copy-down. + * When line is copied to rows that already have company + name/description, + * those rows now have sufficient context for validation. + */ +export interface PendingInlineAiValidation { + /** Row indices that need name validation */ + nameRows: number[]; + /** Row indices that need description validation */ + descriptionRows: number[]; +} + // ============================================================================= // Dialog State Types // ============================================================================= @@ -291,8 +303,14 @@ export interface InlineAiSuggestion { export interface InlineAiState { /** Map of product __index to their inline suggestions */ suggestions: Map; - /** Products currently being validated */ + /** Products currently being validated (format: "productIndex-field") */ validating: Set; + /** + * Fields that have been auto-validated on load (format: "productIndex-field") + * This prevents re-validation when blur fires for a field that was just auto-validated, + * and prevents auto-validation from firing for fields that were manually edited. + */ + autoValidated: Set; } // ============================================================================= @@ -370,6 +388,7 @@ export interface ValidationState { // === Copy-Down Mode === copyDownMode: CopyDownState; pendingCopyDownValidation: PendingCopyDownValidation | null; + pendingInlineAiValidation: PendingInlineAiValidation | null; // === Dialogs === dialogs: DialogState; @@ -458,6 +477,7 @@ export interface ValidationActions { completeCopyDown: (targetRowIndex: number) => void; setTargetRowHover: (rowIndex: number | null) => void; clearPendingCopyDownValidation: () => void; + clearPendingInlineAiValidation: () => void; // === Dialogs === setDialogs: (updates: Partial) => void; @@ -484,6 +504,8 @@ export interface ValidationActions { acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void; clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void; setInlineAiValidating: (productIndex: string, validating: boolean) => void; + markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => void; + isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => boolean; // === Output === getCleanedData: () => CleanRowData[]; 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 3469655..17785eb 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -110,6 +110,7 @@ const getInitialState = (): ValidationState => ({ // Copy-Down Mode copyDownMode: { ...initialCopyDownState }, pendingCopyDownValidation: null, + pendingInlineAiValidation: null, // Dialogs dialogs: { ...initialDialogState }, @@ -129,6 +130,7 @@ const getInitialState = (): ValidationState => ({ inlineAi: { suggestions: new Map(), validating: new Set(), + autoValidated: new Set(), }, // File @@ -256,13 +258,45 @@ export const useValidationStore = create()( copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => { set((state) => { - const sourceValue = state.rows[fromRowIndex]?.[fieldKey]; + const sourceRow = state.rows[fromRowIndex]; + const sourceValue = sourceRow?.[fieldKey]; if (sourceValue === undefined) return; const endIndex = toRowIndex ?? state.rows.length - 1; + const isInlineAiField = fieldKey === 'name' || fieldKey === 'description'; + + // For inline AI fields, check if source was validated/dismissed + // If so, we'll mark targets as autoValidated to skip re-validation + let sourceIsDismissed = false; + if (isInlineAiField && sourceRow?.__index) { + const sourceSuggestion = state.inlineAi.suggestions.get(sourceRow.__index); + sourceIsDismissed = sourceSuggestion?.dismissed?.[fieldKey as 'name' | 'description'] ?? false; + } + for (let i = fromRowIndex + 1; i <= endIndex; i++) { - if (state.rows[i]) { - state.rows[i][fieldKey] = sourceValue; + const targetRow = state.rows[i]; + if (targetRow) { + targetRow[fieldKey] = sourceValue; + + // For name/description fields: + // 1. Mark as autoValidated so blur won't re-validate + // 2. Clear any existing suggestion for this field (value changed) + // 3. Set dismissed state based on source (if source was dismissed, targets are also valid) + if (isInlineAiField && targetRow.__index) { + const field = fieldKey as 'name' | 'description'; + state.inlineAi.autoValidated.add(`${targetRow.__index}-${field}`); + + // Clear existing suggestion and set dismissed state + const existing = state.inlineAi.suggestions.get(targetRow.__index) || {}; + state.inlineAi.suggestions.set(targetRow.__index, { + ...existing, + [field]: undefined, // Clear the suggestion for this field + dismissed: { + ...existing.dismissed, + [field]: sourceIsDismissed, // Copy dismissed state from source + }, + }); + } } } }); @@ -620,6 +654,43 @@ export const useValidationStore = create()( affectedRows, }; } + + // If line is being copied, check which rows now have sufficient context + // for inline AI validation (company + line + name/description) + if (fieldKey === 'line' && affectedRows.length > 0) { + const nameRows: number[] = []; + const descriptionRows: number[] = []; + + for (const rowIdx of affectedRows) { + const row = state.rows[rowIdx]; + if (!row?.__index) continue; + + // Check if row has company + line (just set) + name + const hasNameContext = row.company && sourceValue && row.name && + typeof row.name === 'string' && row.name.trim(); + + if (hasNameContext) { + // Check if name hasn't been dismissed + const suggestion = state.inlineAi.suggestions.get(row.__index); + const nameIsDismissed = suggestion?.dismissed?.name; + if (!nameIsDismissed) { + nameRows.push(rowIdx); + } + + // Check if description also has sufficient context + const hasDescContext = row.description && + typeof row.description === 'string' && row.description.trim(); + const descIsDismissed = suggestion?.dismissed?.description; + if (hasDescContext && !descIsDismissed) { + descriptionRows.push(rowIdx); + } + } + } + + if (nameRows.length > 0 || descriptionRows.length > 0) { + state.pendingInlineAiValidation = { nameRows, descriptionRows }; + } + } }); }, @@ -637,6 +708,12 @@ export const useValidationStore = create()( }); }, + clearPendingInlineAiValidation: () => { + set((state) => { + state.pendingInlineAiValidation = null; + }); + }, + // ========================================================================= // Dialogs // ========================================================================= @@ -853,6 +930,16 @@ export const useValidationStore = create()( }); }, + markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => { + set((state) => { + state.inlineAi.autoValidated.add(`${productIndex}-${field}`); + }); + }, + + isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => { + return get().inlineAi.autoValidated.has(`${productIndex}-${field}`); + }, + // ========================================================================= // Output // =========================================================================