Lots of AI related tweaks/fixes

This commit is contained in:
2026-01-22 11:06:05 -05:00
parent 3d1e8862f9
commit 1866cbae7e
18 changed files with 385 additions and 284 deletions

View File

@@ -80,7 +80,6 @@ function buildDescriptionUserPrompt(product, prompts) {
parts.push('CRITICAL RULES:'); parts.push('CRITICAL RULES:');
parts.push('- If isValid is false, you MUST provide a suggestion with the improved description'); 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 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('- Only set isValid to true if there are ZERO issues and the description needs no changes');
parts.push(''); parts.push('');
parts.push('RESPOND WITH JSON:'); parts.push('RESPOND WITH JSON:');

View File

@@ -41,7 +41,6 @@ function sanitizeIssue(issue) {
* @param {string} [product.company_name] - Company name * @param {string} [product.company_name] - Company name
* @param {string} [product.line_name] - Product line name * @param {string} [product.line_name] - Product line name
* @param {string} [product.subline_name] - Product subline 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 {string[]} [product.siblingNames] - Names of other products in the same line
* @param {Object} prompts - Prompts loaded from database * @param {Object} prompts - Prompts loaded from database
* @param {string} prompts.general - General naming conventions * @param {string} prompts.general - General naming conventions
@@ -73,10 +72,6 @@ function buildNameUserPrompt(product, prompts) {
parts.push(`SUBLINE: ${product.subline_name}`); 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 // Add sibling context for naming decisions
if (product.siblingNames && product.siblingNames.length > 0) { if (product.siblingNames && product.siblingNames.length > 0) {
parts.push(''); parts.push('');
@@ -84,15 +79,6 @@ function buildNameUserPrompt(product, prompts) {
product.siblingNames.forEach(name => { product.siblingNames.forEach(name => {
parts.push(`- ${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 // Add response format instructions

View File

@@ -59,7 +59,7 @@ function buildSanityCheckUserPrompt(products, prompts) {
suggestion: 'Suggested fix or verification (optional)' suggestion: 'Suggested fix or verification (optional)'
} }
], ],
summary: 'Brief overall assessment of the batch quality' summary: '1-2 sentences summarizing the batch quality'
}, null, 2)); }, null, 2));
parts.push(''); parts.push('');

View File

@@ -101,9 +101,9 @@ function createNameValidationTask() {
{ role: 'system', content: prompts.system }, { role: 'system', content: prompts.system },
{ role: 'user', content: userPrompt } { 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 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' } responseFormat: { type: 'json_object' }
}); });

View File

@@ -158,7 +158,7 @@ export const BASE_IMPORT_FIELDS = [
label: "Case Pack", label: "Case Pack",
key: "case_qty", key: "case_qty",
description: "Number of units per case", 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" }, fieldType: { type: "input" },
width: 100, width: 100,
validations: [ validations: [
@@ -254,7 +254,7 @@ export const BASE_IMPORT_FIELDS = [
label: "COO", label: "COO",
key: "coo", key: "coo",
description: "2-letter country code (ISO)", description: "2-letter country code (ISO)",
alternateMatches: ["coo", "country of origin"], alternateMatches: ["coo", "country of origin", "origin"],
fieldType: { type: "input" }, fieldType: { type: "input" },
width: 70, width: 70,
validations: [ validations: [

View File

@@ -33,6 +33,10 @@ import {
import { ErrorSource, ErrorType, type RowData, type ValidationError } from '../store/types'; import { ErrorSource, ErrorType, type RowData, type ValidationError } from '../store/types';
import type { Field, SelectOption, Validation } from '../../../types'; import type { Field, SelectOption, Validation } from '../../../types';
import { correctUpcValue } from '../utils/upcUtils'; import { correctUpcValue } from '../utils/upcUtils';
import {
buildNameValidationPayload,
buildDescriptionValidationPayload,
} from '../utils/inlineAiPayload';
// Copy-down banner component // Copy-down banner component
import { CopyDownBanner } from './CopyDownBanner'; import { CopyDownBanner } from './CopyDownBanner';
@@ -541,19 +545,9 @@ const CellWrapper = memo(({
// (line was just set, check if company + name exist) // (line was just set, check if company + name exist)
const currentRowForContext = useValidationStore.getState().rows[rowIndex]; const currentRowForContext = useValidationStore.getState().rows[rowIndex];
if (currentRowForContext?.company && currentRowForContext?.name) { if (currentRowForContext?.company && currentRowForContext?.name) {
const { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields } = useValidationStore.getState(); const { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields, rows } = useValidationStore.getState();
const contextProductIndex = currentRowForContext.__index; 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 // Check if name should be validated
const nameSuggestion = inlineAi.suggestions.get(contextProductIndex); const nameSuggestion = inlineAi.suggestions.get(contextProductIndex);
const nameIsDismissed = nameSuggestion?.dismissed?.name; const nameIsDismissed = nameSuggestion?.dismissed?.name;
@@ -561,36 +555,17 @@ const CellWrapper = memo(({
const nameValue = String(currentRowForContext.name).trim(); const nameValue = String(currentRowForContext.name).trim();
if (nameValue && !nameIsDismissed && !nameIsValidating) { if (nameValue && !nameIsDismissed && !nameIsValidating) {
// Trigger name validation // Trigger name validation with line override (use new line value)
setInlineAiValidating(`${contextProductIndex}-name`, true); setInlineAiValidating(`${contextProductIndex}-name`, true);
const rows = useValidationStore.getState().rows; const payload = buildNameValidationPayload(currentRowForContext, fields, rows, {
const siblingNames: string[] = []; line: valueToSave as string | number,
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', { fetch('/api/ai/validate/inline/name', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ product: payload }),
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(res => res.json())
.then(result => { .then(result => {
@@ -616,17 +591,12 @@ const CellWrapper = memo(({
// Trigger description validation // Trigger description validation
setInlineAiValidating(`${contextProductIndex}-description`, true); setInlineAiValidating(`${contextProductIndex}-description`, true);
const payload = buildDescriptionValidationPayload(currentRowForContext, fields);
fetch('/api/ai/validate/inline/description', { fetch('/api/ai/validate/inline/description', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ product: payload }),
product: {
name: nameValue,
description: descValue,
company_name: getFieldLabel('company', currentRowForContext.company),
company_id: String(currentRowForContext.company),
},
}),
}) })
.then(res => res.json()) .then(res => res.json())
.then(result => { .then(result => {
@@ -740,56 +710,11 @@ const CellWrapper = memo(({
setInlineAiValidating(validationKey, true); setInlineAiValidating(validationKey, true);
markInlineAiAutoValidated(productIndex, fieldKey); markInlineAiAutoValidated(productIndex, fieldKey);
// Helper to look up field option label // Build payload using centralized utility
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
const rows = useValidationStore.getState().rows; const rows = useValidationStore.getState().rows;
const siblingNames: string[] = []; const productPayload = fieldKey === 'name'
? buildNameValidationPayload(currentRow, fields, rows, { name: String(valueToSave) })
if (currentRow.company && currentRow.line) { : buildDescriptionValidationPayload(currentRow, fields, { description: String(valueToSave) });
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,
};
// Call the appropriate API endpoint // Call the appropriate API endpoint
const endpoint = fieldKey === 'name' 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 // Trigger inline AI validation for name/description if template set those fields
const productIndex = currentRow?.__index; const productIndex = currentRow?.__index;
if (productIndex) { if (productIndex) {
const { setInlineAiValidating, setInlineAiSuggestion } = state; const { setInlineAiValidating, setInlineAiSuggestion, rows } = 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;
};
// Get the updated row data (after template applied) // Get the updated row data (after template applied)
const updatedRow = { ...currentRow, ...updates }; const updatedRow = { ...currentRow, ...updates } as RowData;
// 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);
}
}
}
// Trigger name validation if template set name // Trigger name validation if template set name
if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) { if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) {
setInlineAiValidating(`${productIndex}-name`, true); setInlineAiValidating(`${productIndex}-name`, true);
const productPayload = { const payload = buildNameValidationPayload(updatedRow, fields, rows);
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,
};
fetch('/api/ai/validate/inline/name', { fetch('/api/ai/validate/inline/name', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productPayload }), body: JSON.stringify({ product: payload }),
}) })
.then(res => res.json()) .then(res => res.json())
.then(result => { .then(result => {
@@ -1195,18 +1080,12 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) { if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) {
setInlineAiValidating(`${productIndex}-description`, true); setInlineAiValidating(`${productIndex}-description`, true);
const productPayload = { const payload = buildDescriptionValidationPayload(updatedRow, fields);
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,
};
fetch('/api/ai/validate/inline/description', { fetch('/api/ai/validate/inline/description', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productPayload }), body: JSON.stringify({ product: payload }),
}) })
.then(res => res.json()) .then(res => res.json())
.then(result => { .then(result => {

View File

@@ -29,6 +29,10 @@ import {
} from '@/components/ui/popover'; } from '@/components/ui/popover';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/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 { interface ComboboxCellProps {
value: unknown; value: unknown;
@@ -56,6 +60,9 @@ const ComboboxCellComponent = ({
const [isLoadingOptions, setIsLoadingOptions] = useState(false); const [isLoadingOptions, setIsLoadingOptions] = useState(false);
const hasFetchedRef = useRef(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 stringValue = String(value ?? '');
const hasError = errors.length > 0; const hasError = errors.length > 0;
const errorMessage = errors[0]?.message; const errorMessage = errors[0]?.message;
@@ -67,6 +74,10 @@ const ComboboxCellComponent = ({
// Handle popover open - trigger fetch if needed // Handle popover open - trigger fetch if needed
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
(isOpen: boolean) => { (isOpen: boolean) => {
// Block opening if a popover was just closed (click-outside behavior)
if (isOpen && Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY) {
return;
}
setOpen(isOpen); setOpen(isOpen);
if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) { if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true; hasFetchedRef.current = true;
@@ -76,7 +87,7 @@ const ComboboxCellComponent = ({
}); });
} }
}, },
[onFetchOptions, options.length] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Handle selection // Handle selection

View File

@@ -19,6 +19,10 @@ import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
import { ErrorType } 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 { interface InputCellProps {
value: unknown; value: unknown;
@@ -43,6 +47,9 @@ const InputCellComponent = ({
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(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 // Sync local value with prop value when not focused
useEffect(() => { useEffect(() => {
if (!isFocused) { if (!isFocused) {
@@ -70,8 +77,13 @@ const InputCellComponent = ({
); );
const handleFocus = useCallback(() => { 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); setIsFocused(true);
}, []); }, [cellPopoverClosedAt]);
// Update store only on blur - this is when validation runs too // Update store only on blur - this is when validation runs too
// Round price fields to 2 decimal places // Round price fields to 2 decimal places

View File

@@ -41,6 +41,10 @@ import type { Field, SelectOption } from '../../../../types';
import type { ValidationError, TaxonomySuggestion } from '../../store/types'; import type { ValidationError, TaxonomySuggestion } from '../../store/types';
import { ErrorType } from '../../store/types'; import { ErrorType } from '../../store/types';
import { useCellSuggestions } from '../../contexts/AiSuggestionsContext'; 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 // Extended option type to include hex color values
interface MultiSelectOption extends SelectOption { interface MultiSelectOption extends SelectOption {
@@ -98,6 +102,18 @@ const MultiSelectCellComponent = ({
}: MultiSelectCellProps) => { }: MultiSelectCellProps) => {
const [open, setOpen] = useState(false); 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 // Get AI suggestions for categories, themes, and colors
const supportsSuggestions = SUGGESTION_FIELDS.includes(field.key as SuggestionField); const supportsSuggestions = SUGGESTION_FIELDS.includes(field.key as SuggestionField);
const suggestions = useCellSuggestions(productIndex || ''); const suggestions = useCellSuggestions(productIndex || '');
@@ -177,7 +193,7 @@ const MultiSelectCellComponent = ({
return ( return (
<div className="relative w-full"> <div className="relative w-full">
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"

View File

@@ -20,6 +20,10 @@ import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
import { useValidationStore } from '../../store/validationStore';
/** Time window (ms) during which other cells should not open after a popover closes */
const POPOVER_CLOSE_DELAY = 150;
/** AI suggestion data for a single field */ /** AI suggestion data for a single field */
interface AiFieldSuggestion { interface AiFieldSuggestion {
@@ -66,6 +70,12 @@ const MultilineInputComponent = ({
const [editedSuggestion, setEditedSuggestion] = useState(''); const [editedSuggestion, setEditedSuggestion] = useState('');
const cellRef = useRef<HTMLDivElement>(null); const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false); const preventReopenRef = useRef(false);
// Tracks intentional closes (close button, accept/dismiss) vs click-outside closes
const intentionalCloseRef = useRef(false);
// Get store state and actions for coordinating popover close behavior across cells
const cellPopoverClosedAt = useValidationStore((s) => s.cellPopoverClosedAt);
const setCellPopoverClosed = useValidationStore((s) => s.setCellPopoverClosed);
const hasError = errors.length > 0; const hasError = errors.length > 0;
const errorMessage = errors[0]?.message; const errorMessage = errors[0]?.message;
@@ -102,6 +112,11 @@ const MultilineInputComponent = ({
} }
}, [aiSuggestion?.suggestion]); }, [aiSuggestion?.suggestion]);
// Check if another cell's popover was recently closed (prevents immediate focus on click-outside)
const wasPopoverRecentlyClosed = useCallback(() => {
return Date.now() - cellPopoverClosedAt < POPOVER_CLOSE_DELAY;
}, [cellPopoverClosedAt]);
// Handle trigger click to toggle the popover // Handle trigger click to toggle the popover
const handleTriggerClick = useCallback( const handleTriggerClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@@ -112,6 +127,13 @@ const MultilineInputComponent = ({
return; return;
} }
// Block opening if another popover was just closed
if (wasPopoverRecentlyClosed()) {
e.preventDefault();
e.stopPropagation();
return;
}
// Only process if not already open // Only process if not already open
if (!popoverOpen) { if (!popoverOpen) {
setPopoverOpen(true); setPopoverOpen(true);
@@ -119,10 +141,10 @@ const MultilineInputComponent = ({
setEditValue(localDisplayValue || String(value ?? '')); setEditValue(localDisplayValue || String(value ?? ''));
} }
}, },
[popoverOpen, value, localDisplayValue] [popoverOpen, value, localDisplayValue, wasPopoverRecentlyClosed]
); );
// Handle immediate close of popover // Handle immediate close of popover (used by close button and actions - intentional closes)
const handleClosePopover = useCallback(() => { const handleClosePopover = useCallback(() => {
// Only process if we have changes // Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) { if (editValue !== value || editValue !== localDisplayValue) {
@@ -134,28 +156,60 @@ const MultilineInputComponent = ({
onBlur(editValue); onBlur(editValue);
} }
// Mark this as an intentional close (not click-outside)
intentionalCloseRef.current = true;
// Immediately close popover // Immediately close popover
setPopoverOpen(false); setPopoverOpen(false);
setAiSuggestionExpanded(false); setAiSuggestionExpanded(false);
// Prevent reopening // Prevent reopening this same cell
preventReopenRef.current = true; preventReopenRef.current = true;
setTimeout(() => { setTimeout(() => {
preventReopenRef.current = false; preventReopenRef.current = false;
}, 100); }, 100);
}, [editValue, value, localDisplayValue, onChange, onBlur]); }, [editValue, value, localDisplayValue, onChange, onBlur]);
// Handle popover open/close // Handle popover open/close (called by Radix for click-outside and escape key)
const handlePopoverOpenChange = useCallback( const handlePopoverOpenChange = useCallback(
(open: boolean) => { (open: boolean) => {
if (!open && popoverOpen) { if (!open && popoverOpen) {
handleClosePopover(); // Check if this was an intentional close (via close button or actions)
const wasIntentional = intentionalCloseRef.current;
intentionalCloseRef.current = false; // Reset for next time
if (wasIntentional) {
// Intentional close already handled by handleClosePopover
return;
}
// This is a click-outside close - save changes and signal other cells
if (editValue !== value || editValue !== localDisplayValue) {
setLocalDisplayValue(editValue);
onChange(editValue);
onBlur(editValue);
}
setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Signal to other cells that a popover just closed via click-outside
setCellPopoverClosed();
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
} else if (open && !popoverOpen) { } else if (open && !popoverOpen) {
// Block opening if another popover was just closed
if (wasPopoverRecentlyClosed()) {
return;
}
setEditValue(localDisplayValue || String(value ?? '')); setEditValue(localDisplayValue || String(value ?? ''));
setPopoverOpen(true); setPopoverOpen(true);
} }
}, },
[value, popoverOpen, handleClosePopover, localDisplayValue] [value, popoverOpen, localDisplayValue, wasPopoverRecentlyClosed, editValue, onChange, onBlur, setCellPopoverClosed]
); );
// Handle direct input change // Handle direct input change
@@ -212,6 +266,10 @@ const MultilineInputComponent = ({
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
// Block opening if another popover was just closed
if (wasPopoverRecentlyClosed()) {
return;
}
setAiSuggestionExpanded(true); setAiSuggestionExpanded(true);
setPopoverOpen(true); setPopoverOpen(true);
setEditValue(localDisplayValue || String(value ?? '')); setEditValue(localDisplayValue || String(value ?? ''));
@@ -267,7 +325,7 @@ const MultilineInputComponent = ({
value={editValue} value={editValue}
onChange={handleChange} onChange={handleChange}
onWheel={handleTextareaWheel} onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-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-y"
placeholder={`Enter ${field.label || 'text'}...`} placeholder={`Enter ${field.label || 'text'}...`}
autoFocus autoFocus
/> />
@@ -324,7 +382,7 @@ const MultilineInputComponent = ({
value={editedSuggestion} value={editedSuggestion}
onChange={(e) => setEditedSuggestion(e.target.value)} onChange={(e) => setEditedSuggestion(e.target.value)}
onWheel={handleTextareaWheel} onWheel={handleTextareaWheel}
className="min-h-[80px] max-h-[150px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-none" className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y"
/> />
</div> </div>

View File

@@ -33,6 +33,10 @@ import { cn } from '@/lib/utils';
import type { Field, SelectOption } from '../../../../types'; import type { Field, SelectOption } from '../../../../types';
import type { ValidationError } from '../../store/types'; import type { ValidationError } from '../../store/types';
import { ErrorType } 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 { interface SelectCellProps {
value: unknown; value: unknown;
@@ -62,6 +66,9 @@ const SelectCellComponent = ({
const [isFetchingOptions, setIsFetchingOptions] = useState(false); const [isFetchingOptions, setIsFetchingOptions] = useState(false);
const hasFetchedRef = useRef(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 // Combined loading state - either internal fetch or external loading
const isLoadingOptions = isFetchingOptions || externalLoadingOptions; const isLoadingOptions = isFetchingOptions || externalLoadingOptions;
@@ -78,6 +85,10 @@ const SelectCellComponent = ({
// Handle opening the dropdown - fetch options if needed // Handle opening the dropdown - fetch options if needed
const handleOpenChange = useCallback( const handleOpenChange = useCallback(
async (isOpen: boolean) => { 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) { if (isOpen && onFetchOptions && options.length === 0 && !hasFetchedRef.current) {
hasFetchedRef.current = true; hasFetchedRef.current = true;
setIsFetchingOptions(true); setIsFetchingOptions(true);
@@ -89,7 +100,7 @@ const SelectCellComponent = ({
} }
setOpen(isOpen); setOpen(isOpen);
}, },
[onFetchOptions, options.length] [onFetchOptions, options.length, cellPopoverClosedAt]
); );
// Handle selection // Handle selection

View File

@@ -16,66 +16,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { useInitPhase } from '../store/selectors'; import { useInitPhase } from '../store/selectors';
import type { RowData } from '../store/types'; import {
import type { Field } from '../../../types'; buildNameValidationPayload,
buildDescriptionValidationPayload,
/** } from '../utils/inlineAiPayload';
* Build product payload for AI validation API
*/
function buildProductPayload(
row: RowData,
_field: 'name' | 'description',
fields: Field<string>[],
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 * Trigger validation for a single field
@@ -83,7 +27,7 @@ function buildProductPayload(
async function triggerValidation( async function triggerValidation(
productIndex: string, productIndex: string,
field: 'name' | 'description', field: 'name' | 'description',
payload: ReturnType<typeof buildProductPayload> payload: Record<string, unknown>
) { ) {
const { const {
setInlineAiValidating, setInlineAiValidating,
@@ -181,14 +125,14 @@ export function useAutoInlineAiValidation() {
// Trigger name validation if context is sufficient // Trigger name validation if context is sufficient
if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) { if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) {
const payload = buildProductPayload(row, 'name', fields, rows); const payload = buildNameValidationPayload(row, fields, rows);
triggerValidation(productIndex, 'name', payload); triggerValidation(productIndex, 'name', payload);
nameCount++; nameCount++;
} }
// Trigger description validation if context is sufficient // Trigger description validation if context is sufficient
if (hasDescContext && !descAlreadyValidated && !descCurrentlyValidating) { if (hasDescContext && !descAlreadyValidated && !descCurrentlyValidating) {
const payload = buildProductPayload(row, 'description', fields, rows); const payload = buildDescriptionValidationPayload(row, fields);
triggerValidation(productIndex, 'description', payload); triggerValidation(productIndex, 'description', payload);
descCount++; descCount++;
} }

View File

@@ -13,18 +13,10 @@ import { useEffect } from 'react';
import { useValidationStore } from '../store/validationStore'; import { useValidationStore } from '../store/validationStore';
import { useUpcValidation } from './useUpcValidation'; import { useUpcValidation } from './useUpcValidation';
import type { Field } from '../../../types'; import type { Field } from '../../../types';
import {
/** buildNameValidationPayload,
* Helper to look up field option label buildDescriptionValidationPayload,
*/ } from '../utils/inlineAiPayload';
function getFieldLabel(fields: Field<string>[], 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 * Trigger inline AI validation for a single row/field
@@ -45,30 +37,10 @@ async function triggerInlineAiValidation(
setInlineAiValidating(validationKey, true); setInlineAiValidating(validationKey, true);
// Compute sibling names for context // Build payload using centralized utility
const siblingNames: string[] = []; const productPayload = field === 'name'
if (row.company && row.line) { ? buildNameValidationPayload(row, fields, rows)
const companyId = String(row.company); : buildDescriptionValidationPayload(row, fields);
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' const endpoint = field === 'name'
? '/api/ai/validate/inline/name' ? '/api/ai/validate/inline/name'

View File

@@ -23,15 +23,14 @@ export interface InlineAiValidationState {
} }
// Product data structure for validation // 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 { export interface ProductForValidation {
name?: string; name?: string;
description?: string; description?: string;
company_name?: 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_name?: string;
line_id?: string | number;
subline_name?: string; subline_name?: string;
subline_id?: string | number;
categories?: string; categories?: string;
// Sibling context for naming decisions // Sibling context for naming decisions
siblingNames?: string[]; siblingNames?: string[];

View File

@@ -404,6 +404,10 @@ export interface ValidationState {
// === File (for output) === // === File (for output) ===
file: File | null; 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 === // === Output ===
getCleanedData: () => CleanRowData[]; getCleanedData: () => CleanRowData[];
// === UI State ===
/** Called when a MultilineInput popover closes to prevent immediate focus on other cells */
setCellPopoverClosed: () => void;
// === Reset === // === Reset ===
reset: () => void; reset: () => void;
} }

View File

@@ -135,6 +135,9 @@ const getInitialState = (): ValidationState => ({
// File // File
file: null, file: null,
// UI State
cellPopoverClosedAt: 0,
}); });
// ============================================================================= // =============================================================================
@@ -953,6 +956,16 @@ export const useValidationStore = create<ValidationStore>()(
}); });
}, },
// =========================================================================
// UI State
// =========================================================================
setCellPopoverClosed: () => {
set((state) => {
state.cellPopoverClosedAt = Date.now();
});
},
// ========================================================================= // =========================================================================
// Reset // Reset
// ========================================================================= // =========================================================================

View File

@@ -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<string>[],
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<string>[],
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<string>[],
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,
};
}

View File

@@ -484,7 +484,7 @@ export function PromptManagement() {
{ {
id: "actions", id: "actions",
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex gap-2 justify-end pr-4"> <div className="flex gap-0 justify-end">
<Button variant="ghost" onClick={() => handleEdit(row.original)}> <Button variant="ghost" onClick={() => handleEdit(row.original)}>
<Pencil className="h-4 w-4" /> <Pencil className="h-4 w-4" />
Edit Edit
@@ -553,9 +553,9 @@ export function PromptManagement() {
<Table> <Table>
<TableHeader className="bg-muted"> <TableHeader className="bg-muted">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id} className="">
{headerGroup.headers.map((header) => ( {headerGroup.headers.map((header) => (
<TableHead key={header.id}> <TableHead key={header.id} className="whitespace-nowrap">
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender(header.column.columnDef.header, header.getContext())} : flexRender(header.column.columnDef.header, header.getContext())}
@@ -569,7 +569,7 @@ export function PromptManagement() {
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-100"> <TableRow key={row.id} className="hover:bg-gray-100">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="pl-6"> <TableCell key={cell.id} className="pl-3 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
))} ))}
@@ -614,7 +614,7 @@ export function PromptManagement() {
<SelectLabel className="text-xs text-muted-foreground">Predefined Tasks</SelectLabel> <SelectLabel className="text-xs text-muted-foreground">Predefined Tasks</SelectLabel>
{PREDEFINED_TASKS.map((task) => ( {PREDEFINED_TASKS.map((task) => (
<SelectItem key={task.value} value={task.value}> <SelectItem key={task.value} value={task.value}>
<span className="flex flex-col"> <span className="flex items-center gap-2">
<span>{task.label}</span> <span>{task.label}</span>
<span className="text-xs text-muted-foreground">{task.description}</span> <span className="text-xs text-muted-foreground">{task.description}</span>
</span> </span>