Lots of AI related tweaks/fixes
This commit is contained in:
@@ -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:');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
@@ -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' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user