diff --git a/inventory-server/src/services/ai/prompts/descriptionPrompts.js b/inventory-server/src/services/ai/prompts/descriptionPrompts.js index ea3defc..a7665b6 100644 --- a/inventory-server/src/services/ai/prompts/descriptionPrompts.js +++ b/inventory-server/src/services/ai/prompts/descriptionPrompts.js @@ -80,7 +80,6 @@ function buildDescriptionUserPrompt(product, prompts) { parts.push('CRITICAL RULES:'); parts.push('- If isValid is false, you MUST provide a suggestion with the improved description'); parts.push('- If there are ANY issues, isValid MUST be false and suggestion MUST contain the corrected text'); - parts.push('- If the description is empty or very short, write a complete description based on the product name'); parts.push('- Only set isValid to true if there are ZERO issues and the description needs no changes'); parts.push(''); parts.push('RESPOND WITH JSON:'); diff --git a/inventory-server/src/services/ai/prompts/namePrompts.js b/inventory-server/src/services/ai/prompts/namePrompts.js index a208bc0..187fbf2 100644 --- a/inventory-server/src/services/ai/prompts/namePrompts.js +++ b/inventory-server/src/services/ai/prompts/namePrompts.js @@ -41,7 +41,6 @@ function sanitizeIssue(issue) { * @param {string} [product.company_name] - Company name * @param {string} [product.line_name] - Product line name * @param {string} [product.subline_name] - Product subline name - * @param {string} [product.description] - Product description (for context) * @param {string[]} [product.siblingNames] - Names of other products in the same line * @param {Object} prompts - Prompts loaded from database * @param {string} prompts.general - General naming conventions @@ -73,10 +72,6 @@ function buildNameUserPrompt(product, prompts) { parts.push(`SUBLINE: ${product.subline_name}`); } - if (product.description) { - parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`); - } - // Add sibling context for naming decisions if (product.siblingNames && product.siblingNames.length > 0) { parts.push(''); @@ -84,15 +79,6 @@ function buildNameUserPrompt(product, prompts) { product.siblingNames.forEach(name => { parts.push(`- ${name}`); }); - parts.push(''); - parts.push('Use this context to determine:'); - parts.push('- If this product needs a differentiator (multiple similar products exist)'); - parts.push('- If naming is consistent with sibling products'); - parts.push('- Which naming pattern is appropriate (single vs multiple products in line)'); - } else if (product.line_name) { - parts.push(''); - parts.push('This appears to be the ONLY product in this line (no siblings in current batch).'); - parts.push('Use the single-product naming pattern: [Line Name] [Product Name] - [Company]'); } // Add response format instructions diff --git a/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js b/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js index bfc778e..fddd80e 100644 --- a/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js +++ b/inventory-server/src/services/ai/prompts/sanityCheckPrompts.js @@ -59,7 +59,7 @@ function buildSanityCheckUserPrompt(products, prompts) { suggestion: 'Suggested fix or verification (optional)' } ], - summary: 'Brief overall assessment of the batch quality' + summary: '1-2 sentences summarizing the batch quality' }, null, 2)); parts.push(''); diff --git a/inventory-server/src/services/ai/tasks/nameValidationTask.js b/inventory-server/src/services/ai/tasks/nameValidationTask.js index 82f5c8b..f949a3c 100644 --- a/inventory-server/src/services/ai/tasks/nameValidationTask.js +++ b/inventory-server/src/services/ai/tasks/nameValidationTask.js @@ -101,9 +101,9 @@ function createNameValidationTask() { { role: 'system', content: prompts.system }, { role: 'user', content: userPrompt } ], - model: MODELS.SMALL, // openai/gpt-oss-20b - reasoning model + model: MODELS.LARGE, // openai/gpt-oss-120b - reasoning model temperature: 0.2, // Low temperature for consistent results - maxTokens: 1500, // Reasoning models need extra tokens for thinking + maxTokens: 3000, // Reasoning models need extra tokens for thinking responseFormat: { type: 'json_object' } }); diff --git a/inventory/src/components/product-import/config.ts b/inventory/src/components/product-import/config.ts index a99e7f8..45a6714 100644 --- a/inventory/src/components/product-import/config.ts +++ b/inventory/src/components/product-import/config.ts @@ -158,7 +158,7 @@ export const BASE_IMPORT_FIELDS = [ label: "Case Pack", key: "case_qty", description: "Number of units per case", - alternateMatches: ["mc qty","case qty","case pack","box ct"], + alternateMatches: ["mc qty","case qty","case pack","box ct","master"], fieldType: { type: "input" }, width: 100, validations: [ @@ -250,11 +250,11 @@ export const BASE_IMPORT_FIELDS = [ width: 190, validations: [{ rule: "required", errorMessage: "Required", level: "error" }], }, - { + { label: "COO", key: "coo", description: "2-letter country code (ISO)", - alternateMatches: ["coo", "country of origin"], + alternateMatches: ["coo", "country of origin", "origin"], fieldType: { type: "input" }, width: 70, validations: [ 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 41141d4..68096e3 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/ValidationTable.tsx @@ -33,6 +33,10 @@ import { import { ErrorSource, ErrorType, type RowData, type ValidationError } from '../store/types'; import type { Field, SelectOption, Validation } from '../../../types'; import { correctUpcValue } from '../utils/upcUtils'; +import { + buildNameValidationPayload, + buildDescriptionValidationPayload, +} from '../utils/inlineAiPayload'; // Copy-down banner component import { CopyDownBanner } from './CopyDownBanner'; @@ -541,19 +545,9 @@ const CellWrapper = memo(({ // (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 { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields, rows } = 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; @@ -561,36 +555,17 @@ const CellWrapper = memo(({ const nameValue = String(currentRowForContext.name).trim(); if (nameValue && !nameIsDismissed && !nameIsValidating) { - // Trigger name validation + // Trigger name validation with line override (use new line value) 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); - } - } + const payload = buildNameValidationPayload(currentRowForContext, fields, rows, { + line: valueToSave as string | number, + }); 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, - }, - }), + body: JSON.stringify({ product: payload }), }) .then(res => res.json()) .then(result => { @@ -616,17 +591,12 @@ const CellWrapper = memo(({ // Trigger description validation setInlineAiValidating(`${contextProductIndex}-description`, true); + const payload = buildDescriptionValidationPayload(currentRowForContext, fields); + 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), - }, - }), + body: JSON.stringify({ product: payload }), }) .then(res => res.json()) .then(result => { @@ -740,56 +710,11 @@ const CellWrapper = memo(({ setInlineAiValidating(validationKey, true); markInlineAiAutoValidated(productIndex, fieldKey); - // 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 products (same company + line + subline if set) for naming context + // Build payload using centralized utility const rows = useValidationStore.getState().rows; - const siblingNames: string[] = []; - - if (currentRow.company && currentRow.line) { - const companyId = String(currentRow.company); - const lineId = String(currentRow.line); - const sublineId = currentRow.subline ? String(currentRow.subline) : null; - - for (const row of rows) { - // Skip self - if (row.__index === productIndex) continue; - - // Must match company and line - if (String(row.company) !== companyId) continue; - if (String(row.line) !== lineId) continue; - - // If current product has subline, siblings must match subline too - if (sublineId && String(row.subline) !== sublineId) continue; - - // Add name if it exists - if (row.name && typeof row.name === 'string' && row.name.trim()) { - siblingNames.push(row.name); - } - } - } - - // Build product payload for API - const productPayload = { - name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string), - description: fieldKey === 'description' ? String(valueToSave) : (currentRow.description as string), - company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined, - company_id: currentRow.company ? String(currentRow.company) : undefined, - line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : undefined, - line_id: currentRow.line ? String(currentRow.line) : undefined, - subline_name: currentRow.subline ? getFieldLabel('subline', currentRow.subline) : undefined, - subline_id: currentRow.subline ? String(currentRow.subline) : undefined, - categories: currentRow.categories as string | undefined, - siblingNames: siblingNames.length > 0 ? siblingNames : undefined, - }; + const productPayload = fieldKey === 'name' + ? buildNameValidationPayload(currentRow, fields, rows, { name: String(valueToSave) }) + : buildDescriptionValidationPayload(currentRow, fields, { description: String(valueToSave) }); // Call the appropriate API endpoint const endpoint = fieldKey === 'name' @@ -1120,61 +1045,21 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa // Trigger inline AI validation for name/description if template set those fields const productIndex = currentRow?.__index; if (productIndex) { - const { setInlineAiValidating, setInlineAiSuggestion } = state; - - // 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; - }; + const { setInlineAiValidating, setInlineAiSuggestion, rows } = state; // Get the updated row data (after template applied) - const updatedRow = { ...currentRow, ...updates }; - - // Compute sibling names for context - const rows = state.rows; - const siblingNames: string[] = []; - if (updatedRow.company && updatedRow.line) { - const companyId = String(updatedRow.company); - const lineId = String(updatedRow.line); - const sublineId = updatedRow.subline ? String(updatedRow.subline) : null; - - for (const row of rows) { - if (row.__index === productIndex) continue; - if (String(row.company) !== companyId) continue; - if (String(row.line) !== lineId) continue; - if (sublineId && String(row.subline) !== sublineId) continue; - if (row.name && typeof row.name === 'string' && row.name.trim()) { - siblingNames.push(row.name); - } - } - } + const updatedRow = { ...currentRow, ...updates } as RowData; // Trigger name validation if template set name if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) { setInlineAiValidating(`${productIndex}-name`, true); - const productPayload = { - name: String(updates.name), - description: updatedRow.description as string, - company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined, - company_id: updatedRow.company ? String(updatedRow.company) : undefined, - line_name: updatedRow.line ? getFieldLabel('line', updatedRow.line) : undefined, - line_id: updatedRow.line ? String(updatedRow.line) : undefined, - subline_name: updatedRow.subline ? getFieldLabel('subline', updatedRow.subline) : undefined, - subline_id: updatedRow.subline ? String(updatedRow.subline) : undefined, - categories: updatedRow.categories as string | undefined, - siblingNames: siblingNames.length > 0 ? siblingNames : undefined, - }; + const payload = buildNameValidationPayload(updatedRow, fields, rows); fetch('/api/ai/validate/inline/name', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ product: productPayload }), + body: JSON.stringify({ product: payload }), }) .then(res => res.json()) .then(result => { @@ -1195,18 +1080,12 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) { setInlineAiValidating(`${productIndex}-description`, true); - const productPayload = { - name: updatedRow.name as string, - description: String(updates.description), - company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined, - company_id: updatedRow.company ? String(updatedRow.company) : undefined, - categories: updatedRow.categories as string | undefined, - }; + const payload = buildDescriptionValidationPayload(updatedRow, fields); fetch('/api/ai/validate/inline/description', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ product: productPayload }), + body: JSON.stringify({ product: payload }), }) .then(res => res.json()) .then(result => { diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx index 640fd92..8ca64cb 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/ComboboxCell.tsx @@ -29,6 +29,10 @@ import { } from '@/components/ui/popover'; import type { Field, SelectOption } from '../../../../types'; import type { ValidationError } from '../../store/types'; +import { useValidationStore } from '../../store/validationStore'; + +/** Time window (ms) during which this cell should not open after a popover closes */ +const POPOVER_CLOSE_DELAY = 150; interface ComboboxCellProps { value: unknown; @@ -56,6 +60,9 @@ const ComboboxCellComponent = ({ const [isLoadingOptions, setIsLoadingOptions] = useState(false); const hasFetchedRef = useRef(false); + // Get store state for coordinating with popover close behavior + const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); + const stringValue = String(value ?? ''); const hasError = errors.length > 0; const errorMessage = errors[0]?.message; @@ -67,6 +74,10 @@ const ComboboxCellComponent = ({ // Handle popover open - trigger fetch if needed const handleOpenChange = useCallback( (isOpen: boolean) => { + // Block opening if a popover was just closed (click-outside behavior) + if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { + return; + } setOpen(isOpen); if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { hasFetchedRef.current = true; @@ -76,7 +87,7 @@ const ComboboxCellComponent = ({ }); } }, - [onFetchOptions, options.length] + [onFetchOptions, options.length, cellPopoverClosedAt] ); // Handle selection diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx index 643ab33..41509b7 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/InputCell.tsx @@ -19,6 +19,10 @@ import { cn } from '@/lib/utils'; import type { Field, SelectOption } from '../../../../types'; import type { ValidationError } from '../../store/types'; import { ErrorType } from '../../store/types'; +import { useValidationStore } from '../../store/validationStore'; + +/** Time window (ms) during which this cell should not focus after a popover closes */ +const POPOVER_CLOSE_DELAY = 150; interface InputCellProps { value: unknown; @@ -43,6 +47,9 @@ const InputCellComponent = ({ const [isFocused, setIsFocused] = useState(false); const inputRef = useRef(null); + // Get store state for coordinating with popover close behavior + const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); + // Sync local value with prop value when not focused useEffect(() => { if (!isFocused) { @@ -70,8 +77,13 @@ const InputCellComponent = ({ ); const handleFocus = useCallback(() => { + // Block focus if a popover was just closed (click-outside behavior) + if (Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { + inputRef.current?.blur(); + return; + } setIsFocused(true); - }, []); + }, [cellPopoverClosedAt]); // Update store only on blur - this is when validation runs too // Round price fields to 2 decimal places diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx index 6701f47..c7fbf00 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/MultiSelectCell.tsx @@ -41,6 +41,10 @@ import type { Field, SelectOption } from '../../../../types'; import type { ValidationError, TaxonomySuggestion } from '../../store/types'; import { ErrorType } from '../../store/types'; import { useCellSuggestions } from '../../contexts/AiSuggestionsContext'; +import { useValidationStore } from '../../store/validationStore'; + +/** Time window (ms) during which this cell should not open after a popover closes */ +const POPOVER_CLOSE_DELAY = 150; // Extended option type to include hex color values interface MultiSelectOption extends SelectOption { @@ -98,6 +102,18 @@ const MultiSelectCellComponent = ({ }: MultiSelectCellProps) => { const [open, setOpen] = useState(false); + // Get store state for coordinating with popover close behavior + const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); + + // Handle popover open/close with check for recent popover close + const handleOpenChange = useCallback((isOpen: boolean) => { + // Block opening if a popover was just closed (click-outside behavior) + if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { + return; + } + setOpen(isOpen); + }, [cellPopoverClosedAt]); + // Get AI suggestions for categories, themes, and colors const supportsSuggestions = SUGGESTION_FIELDS.includes(field.key as SuggestionField); const suggestions = useCellSuggestions(productIndex || ''); @@ -177,7 +193,7 @@ const MultiSelectCellComponent = ({ return (
- +
diff --git a/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx b/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx index cb8164b..712c844 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx +++ b/inventory/src/components/product-import/steps/ValidationStep/components/cells/SelectCell.tsx @@ -33,6 +33,10 @@ import { cn } from '@/lib/utils'; import type { Field, SelectOption } from '../../../../types'; import type { ValidationError } from '../../store/types'; import { ErrorType } from '../../store/types'; +import { useValidationStore } from '../../store/validationStore'; + +/** Time window (ms) during which this cell should not open after a popover closes */ +const POPOVER_CLOSE_DELAY = 150; interface SelectCellProps { value: unknown; @@ -62,6 +66,9 @@ const SelectCellComponent = ({ const [isFetchingOptions, setIsFetchingOptions] = useState(false); const hasFetchedRef = useRef(false); + // Get store state for coordinating with popover close behavior + const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt); + // Combined loading state - either internal fetch or external loading const isLoadingOptions = isFetchingOptions || externalLoadingOptions; @@ -78,6 +85,10 @@ const SelectCellComponent = ({ // Handle opening the dropdown - fetch options if needed const handleOpenChange = useCallback( async (isOpen: boolean) => { + // Block opening if a popover was just closed (click-outside behavior) + if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) { + return; + } if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { hasFetchedRef.current = true; setIsFetchingOptions(true); @@ -89,7 +100,7 @@ const SelectCellComponent = ({ } setOpen(isOpen); }, - [onFetchOptions, options.length] + [onFetchOptions, options.length, cellPopoverClosedAt] ); // Handle selection diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts index 2b1a06f..34ee091 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useAutoInlineAiValidation.ts @@ -16,66 +16,10 @@ 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, - }; -} +import { + buildNameValidationPayload, + buildDescriptionValidationPayload, +} from '../utils/inlineAiPayload'; /** * Trigger validation for a single field @@ -83,7 +27,7 @@ function buildProductPayload( async function triggerValidation( productIndex: string, field: 'name' | 'description', - payload: ReturnType + payload: Record ) { const { setInlineAiValidating, @@ -181,14 +125,14 @@ export function useAutoInlineAiValidation() { // Trigger name validation if context is sufficient if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) { - const payload = buildProductPayload(row, 'name', fields, rows); + const payload = buildNameValidationPayload(row, 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); + const payload = buildDescriptionValidationPayload(row, fields); triggerValidation(productIndex, 'description', payload); descCount++; } 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 9430cbb..c7ba445 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useCopyDownValidation.ts @@ -13,18 +13,10 @@ import { useEffect } from 'react'; import { useValidationStore } from '../store/validationStore'; import { useUpcValidation } from './useUpcValidation'; import type { Field } from '../../../types'; - -/** - * 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; -} +import { + buildNameValidationPayload, + buildDescriptionValidationPayload, +} from '../utils/inlineAiPayload'; /** * Trigger inline AI validation for a single row/field @@ -45,30 +37,10 @@ async function triggerInlineAiValidation( 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, - }; + // Build payload using centralized utility + const productPayload = field === 'name' + ? buildNameValidationPayload(row, fields, rows) + : buildDescriptionValidationPayload(row, fields); const endpoint = field === 'name' ? '/api/ai/validate/inline/name' diff --git a/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts b/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts index 8aa3994..884f760 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/hooks/useInlineAiValidation.ts @@ -23,15 +23,14 @@ export interface InlineAiValidationState { } // Product data structure for validation +// Note: company_id is needed by backend to load company-specific prompts, but line_id/subline_id are not needed export interface ProductForValidation { name?: string; description?: string; company_name?: string; - company_id?: string | number; + company_id?: string; // Needed by backend for prompt loading (not sent to AI model) line_name?: string; - line_id?: string | number; subline_name?: string; - subline_id?: string | number; categories?: string; // Sibling context for naming decisions siblingNames?: string[]; 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 72dbeac..2e52fc2 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/types.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/types.ts @@ -404,6 +404,10 @@ export interface ValidationState { // === File (for output) === file: File | null; + + // === UI State === + /** Timestamp when a MultilineInput popover was last closed (for click-outside behavior) */ + cellPopoverClosedAt: number; } // ============================================================================= @@ -510,6 +514,10 @@ export interface ValidationActions { // === Output === getCleanedData: () => CleanRowData[]; + // === UI State === + /** Called when a MultilineInput popover closes to prevent immediate focus on other cells */ + setCellPopoverClosed: () => void; + // === Reset === reset: () => void; } 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 17785eb..19ab9ca 100644 --- a/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts +++ b/inventory/src/components/product-import/steps/ValidationStep/store/validationStore.ts @@ -135,6 +135,9 @@ const getInitialState = (): ValidationState => ({ // File file: null, + + // UI State + cellPopoverClosedAt: 0, }); // ============================================================================= @@ -953,6 +956,16 @@ export const useValidationStore = create()( }); }, + // ========================================================================= + // UI State + // ========================================================================= + + setCellPopoverClosed: () => { + set((state) => { + state.cellPopoverClosedAt = Date.now(); + }); + }, + // ========================================================================= // Reset // ========================================================================= diff --git a/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts b/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts new file mode 100644 index 0000000..27c0275 --- /dev/null +++ b/inventory/src/components/product-import/steps/ValidationStep/utils/inlineAiPayload.ts @@ -0,0 +1,193 @@ +/** + * Inline AI Validation Payload Builder + * + * Centralized utility for building payloads sent to the inline AI validation endpoints. + * This ensures consistent payload structure across all validation triggers: + * - Blur handler in ValidationTable + * - Auto-validation on page load + * - Copy-down validation + * - Template application + * + * Note: IDs are not included as the AI model can't look them up - only names are useful. + */ + +import type { RowData } from '../store/types'; +import type { Field, SelectOption } from '../../../types'; +import { useValidationStore } from '../store/validationStore'; + +/** + * Helper to look up field option label from field definitions + */ +export 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; +} + +/** + * Look up line name from the productLinesCache + * Line options are loaded dynamically per-company and stored in a separate cache + */ +function getLineName(companyId: string | number, lineId: string | number): string | undefined { + const { productLinesCache } = useValidationStore.getState(); + const lineOptions = productLinesCache.get(String(companyId)) as SelectOption[] | undefined; + if (lineOptions) { + const option = lineOptions.find(o => o.value === String(lineId)); + return option?.label; + } + return undefined; +} + +/** + * Look up subline name from the sublinesCache + * Subline options are loaded dynamically per-line and stored in a separate cache + */ +function getSublineName(lineId: string | number, sublineId: string | number): string | undefined { + const { sublinesCache } = useValidationStore.getState(); + const sublineOptions = sublinesCache.get(String(lineId)) as SelectOption[] | undefined; + if (sublineOptions) { + const option = sublineOptions.find(o => o.value === String(sublineId)); + return option?.label; + } + return undefined; +} + +/** + * Compute sibling product names for naming context. + * Siblings are products with the same company + line (+ subline if set). + */ +export function computeSiblingNames( + row: RowData, + allRows: RowData[] +): string[] { + const siblingNames: string[] = []; + + if (!row.company || !row.line) { + return siblingNames; + } + + 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 siblingNames; +} + +/** + * Payload for name validation endpoint + */ +export interface NameValidationPayload { + name: string; + company_name?: string; + company_id?: string; // Needed by backend to load company-specific prompts (not sent to AI) + line_name?: string; + subline_name?: string; + siblingNames?: string[]; +} + +/** + * Payload for description validation endpoint + */ +export interface DescriptionValidationPayload { + name: string; + description: string; + company_name?: string; + company_id?: string; // Needed by backend to load company-specific prompts (not sent to AI) + categories?: string; +} + +/** + * Options for overriding row values when building payloads + */ +export interface PayloadOverrides { + name?: string; + description?: string; + line?: string | number; // Line ID override (for line change handler) +} + +/** + * Build payload for name validation API + * + * @param row - The row data + * @param fields - Field definitions for label lookup + * @param allRows - All rows for sibling computation + * @param overrides - Optional value overrides (e.g., new name from blur handler, new line from line change) + */ +export function buildNameValidationPayload( + row: RowData, + fields: Field[], + allRows: RowData[], + overrides?: PayloadOverrides +): NameValidationPayload { + // Use override line for sibling computation if provided + const effectiveRow = overrides?.line !== undefined + ? { ...row, line: overrides.line } + : row; + const siblingNames = computeSiblingNames(effectiveRow, allRows); + + // Determine line_name - use override if provided + // Line options are stored in productLinesCache (keyed by company ID), not field options + const lineValue = overrides?.line ?? row.line; + const lineName = row.company && lineValue + ? getLineName(row.company, lineValue) + : undefined; + + // Subline options are stored in sublinesCache (keyed by line ID), not field options + const sublineName = lineValue && row.subline + ? getSublineName(lineValue, row.subline) + : undefined; + + return { + name: overrides?.name ?? String(row.name || ''), + company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined, + company_id: row.company ? String(row.company) : undefined, // For backend prompt loading + line_name: lineName, + subline_name: sublineName, + siblingNames: siblingNames.length > 0 ? siblingNames : undefined, + }; +} + +/** + * Build payload for description validation API + * + * @param row - The row data + * @param fields - Field definitions for label lookup + * @param overrides - Optional value overrides (e.g., from blur handler) + */ +export function buildDescriptionValidationPayload( + row: RowData, + fields: Field[], + overrides?: PayloadOverrides +): DescriptionValidationPayload { + return { + name: overrides?.name ?? String(row.name || ''), + description: overrides?.description ?? String(row.description || ''), + company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined, + company_id: row.company ? String(row.company) : undefined, // For backend prompt loading + categories: row.categories as string | undefined, + }; +} diff --git a/inventory/src/components/settings/PromptManagement.tsx b/inventory/src/components/settings/PromptManagement.tsx index cde6f61..196f33a 100644 --- a/inventory/src/components/settings/PromptManagement.tsx +++ b/inventory/src/components/settings/PromptManagement.tsx @@ -484,7 +484,7 @@ export function PromptManagement() { { id: "actions", cell: ({ row }) => ( -
+