Lots of new AI tasks tweaks and fixes

This commit is contained in:
2026-01-20 13:15:10 -05:00
parent 167c13c572
commit 1dcb47cfc5
17 changed files with 1202 additions and 264 deletions

View File

@@ -409,8 +409,12 @@ router.post('/validate/sanity-check', async (req, res) => {
return res.status(400).json({ error: 'Products array is required' }); return res.status(400).json({ error: 'Products array is required' });
} }
// Get pool from app.locals (set by server.js)
const pool = req.app.locals.pool;
const result = await aiService.runTask(aiService.TASK_IDS.SANITY_CHECK, { const result = await aiService.runTask(aiService.TASK_IDS.SANITY_CHECK, {
products products,
pool
}); });
if (!result.success) { if (!result.success) {

View File

@@ -327,7 +327,8 @@ async function runTask(taskId, payload = {}) {
...payload, ...payload,
// Inject dependencies tasks may need // Inject dependencies tasks may need
provider: groqProvider, provider: groqProvider,
pool: appPool, // Use pool from payload if provided (from route), fall back to stored appPool
pool: payload.pool || appPool,
logger logger
}); });
} }

View File

@@ -5,6 +5,33 @@
* System and general prompts are loaded from the database. * System and general prompts are loaded from the database.
*/ */
/**
* Sanitize an issue string from AI response
* AI sometimes returns malformed strings with escape sequences
*
* @param {string} issue - Raw issue string
* @returns {string} Cleaned issue string
*/
function sanitizeIssue(issue) {
if (!issue || typeof issue !== 'string') return '';
let cleaned = issue
// Remove trailing backslashes (incomplete escapes)
.replace(/\\+$/, '')
// Fix malformed escaped quotes at end of string
.replace(/\\",?\)?$/, '')
// Clean up double-escaped quotes
.replace(/\\\\"/g, '"')
// Clean up single escaped quotes that aren't needed
.replace(/\\"/g, '"')
// Remove any remaining trailing punctuation artifacts
.replace(/[,\s]+$/, '')
// Trim whitespace
.trim();
return cleaned;
}
/** /**
* Build the user prompt for description validation * Build the user prompt for description validation
* Combines database prompts with product data * Combines database prompts with product data
@@ -50,13 +77,17 @@ function buildDescriptionUserPrompt(product, prompts) {
// Add response format instructions // Add response format instructions
parts.push(''); parts.push('');
parts.push('If the description is empty or very short, suggest a complete description based on the product name.'); parts.push('CRITICAL RULES:');
parts.push('- If isValid is false, you MUST provide a suggestion with the improved description');
parts.push('- If there are ANY issues, isValid MUST be false and suggestion MUST contain the corrected text');
parts.push('- If the description is empty or very short, write a complete description based on the product name');
parts.push('- Only set isValid to true if there are ZERO issues and the description needs no changes');
parts.push(''); parts.push('');
parts.push('RESPOND WITH JSON:'); parts.push('RESPOND WITH JSON:');
parts.push(JSON.stringify({ parts.push(JSON.stringify({
isValid: 'true/false', isValid: 'true if perfect, false if ANY changes needed',
suggestion: 'improved description if changes needed, or null if valid', suggestion: 'REQUIRED when isValid is false - the complete improved description',
issues: ['issue 1', 'issue 2 (empty array if valid)'] issues: ['list each problem found (empty array only if isValid is true)']
}, null, 2)); }, null, 2));
return parts.join('\n'); return parts.join('\n');
@@ -72,11 +103,35 @@ function buildDescriptionUserPrompt(product, prompts) {
function parseDescriptionResponse(parsed, content) { function parseDescriptionResponse(parsed, content) {
// If we got valid parsed JSON, use it // If we got valid parsed JSON, use it
if (parsed && typeof parsed.isValid === 'boolean') { if (parsed && typeof parsed.isValid === 'boolean') {
return { // Sanitize issues - AI sometimes returns malformed escape sequences
isValid: parsed.isValid, const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
suggestion: parsed.suggestion || null, const issues = rawIssues
issues: Array.isArray(parsed.issues) ? parsed.issues : [] .map(sanitizeIssue)
}; .filter(issue => issue.length > 0);
const suggestion = parsed.suggestion || null;
// IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues).
// If there are issues, treat as invalid regardless of what the AI said.
// Also if there's a suggestion, the AI thought something needed to change.
const isValid = parsed.isValid && issues.length === 0 && !suggestion;
return { isValid, suggestion, issues };
}
// Handle case where isValid is a string "true"/"false" instead of boolean
if (parsed && typeof parsed.isValid === 'string') {
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
const issues = rawIssues
.map(sanitizeIssue)
.filter(issue => issue.length > 0);
const suggestion = parsed.suggestion || null;
const rawIsValid = parsed.isValid.toLowerCase() !== 'false';
// Same defensive logic: if there are issues, it's not valid
const isValid = rawIsValid && issues.length === 0 && !suggestion;
return { isValid, suggestion, issues };
} }
// Try to extract from content if parsing failed // Try to extract from content if parsing failed
@@ -100,11 +155,16 @@ function parseDescriptionResponse(parsed, content) {
const issuesContent = issuesMatch[1]; const issuesContent = issuesMatch[1];
const issueStrings = issuesContent.match(/"([^"]+)"/g); const issueStrings = issuesContent.match(/"([^"]+)"/g);
if (issueStrings) { if (issueStrings) {
issues = issueStrings.map(s => s.replace(/"/g, '')); issues = issueStrings
.map(s => sanitizeIssue(s.replace(/"/g, '')))
.filter(issue => issue.length > 0);
} }
} }
return { isValid, suggestion, issues }; // Same logic: if there are issues, it's not valid
const finalIsValid = isValid && issues.length === 0 && !suggestion;
return { isValid: finalIsValid, suggestion, issues };
} catch { } catch {
// Default to valid if we can't parse anything // Default to valid if we can't parse anything
return { isValid: true, suggestion: null, issues: [] }; return { isValid: true, suggestion: null, issues: [] };

View File

@@ -5,6 +5,33 @@
* System and general prompts are loaded from the database. * System and general prompts are loaded from the database.
*/ */
/**
* Sanitize an issue string from AI response
* AI sometimes returns malformed strings with escape sequences
*
* @param {string} issue - Raw issue string
* @returns {string} Cleaned issue string
*/
function sanitizeIssue(issue) {
if (!issue || typeof issue !== 'string') return '';
let cleaned = issue
// Remove trailing backslashes (incomplete escapes)
.replace(/\\+$/, '')
// Fix malformed escaped quotes at end of string
.replace(/\\",?\)?$/, '')
// Clean up double-escaped quotes
.replace(/\\\\"/g, '"')
// Clean up single escaped quotes that aren't needed
.replace(/\\"/g, '"')
// Remove any remaining trailing punctuation artifacts
.replace(/[,\s]+$/, '')
// Trim whitespace
.trim();
return cleaned;
}
/** /**
* Build the user prompt for name validation * Build the user prompt for name validation
* Combines database prompts with product data * Combines database prompts with product data
@@ -13,7 +40,9 @@
* @param {string} product.name - Current product name * @param {string} product.name - Current product name
* @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.description] - Product description (for context) * @param {string} [product.description] - Product description (for context)
* @param {string[]} [product.siblingNames] - Names of other products in the same line
* @param {Object} prompts - Prompts loaded from database * @param {Object} prompts - Prompts loaded from database
* @param {string} prompts.general - General naming conventions * @param {string} prompts.general - General naming conventions
* @param {string} [prompts.companySpecific] - Company-specific rules * @param {string} [prompts.companySpecific] - Company-specific rules
@@ -40,11 +69,32 @@ function buildNameUserPrompt(product, prompts) {
parts.push(`NAME: "${product.name || ''}"`); parts.push(`NAME: "${product.name || ''}"`);
parts.push(`COMPANY: ${product.company_name || 'Unknown'}`); parts.push(`COMPANY: ${product.company_name || 'Unknown'}`);
parts.push(`LINE: ${product.line_name || 'None'}`); parts.push(`LINE: ${product.line_name || 'None'}`);
if (product.subline_name) {
parts.push(`SUBLINE: ${product.subline_name}`);
}
if (product.description) { if (product.description) {
parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`); parts.push(`DESCRIPTION (for context): ${product.description.substring(0, 200)}`);
} }
// Add sibling context for naming decisions
if (product.siblingNames && product.siblingNames.length > 0) {
parts.push('');
parts.push(`OTHER PRODUCTS IN THIS LINE (${product.siblingNames.length + 1} total including this one):`);
product.siblingNames.forEach(name => {
parts.push(`- ${name}`);
});
parts.push('');
parts.push('Use this context to determine:');
parts.push('- If this product needs a differentiator (multiple similar products exist)');
parts.push('- If naming is consistent with sibling products');
parts.push('- Which naming pattern is appropriate (single vs multiple products in line)');
} else if (product.line_name) {
parts.push('');
parts.push('This appears to be the ONLY product in this line (no siblings in current batch).');
parts.push('Use the single-product naming pattern: [Line Name] [Product Name] - [Company]');
}
// Add response format instructions // Add response format instructions
parts.push(''); parts.push('');
parts.push('RESPOND WITH JSON:'); parts.push('RESPOND WITH JSON:');
@@ -65,24 +115,62 @@ function buildNameUserPrompt(product, prompts) {
* @returns {Object} * @returns {Object}
*/ */
function parseNameResponse(parsed, content) { function parseNameResponse(parsed, content) {
// Debug: Log what we're trying to parse
console.log('[parseNameResponse] Input:', {
hasParsed: !!parsed,
parsedIsValid: parsed?.isValid,
parsedType: typeof parsed?.isValid,
contentPreview: content?.substring(0, 3000)
});
// If we got valid parsed JSON, use it // If we got valid parsed JSON, use it
if (parsed && typeof parsed.isValid === 'boolean') { if (parsed && typeof parsed.isValid === 'boolean') {
return { // Sanitize issues - AI sometimes returns malformed escape sequences
isValid: parsed.isValid, const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
suggestion: parsed.suggestion || null, const issues = rawIssues
issues: Array.isArray(parsed.issues) ? parsed.issues : [] .map(sanitizeIssue)
}; .filter(issue => issue.length > 0);
const suggestion = parsed.suggestion || null;
// IMPORTANT: LLMs sometimes return contradictory data (isValid: true with issues).
// If there are issues, treat as invalid regardless of what the AI said.
const isValid = parsed.isValid && issues.length === 0 && !suggestion;
return { isValid, suggestion, issues };
}
// Handle case where isValid is a string "true"/"false" instead of boolean
if (parsed && typeof parsed.isValid === 'string') {
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
const issues = rawIssues
.map(sanitizeIssue)
.filter(issue => issue.length > 0);
const suggestion = parsed.suggestion || null;
const rawIsValid = parsed.isValid.toLowerCase() !== 'false';
// Same defensive logic: if there are issues, it's not valid
const isValid = rawIsValid && issues.length === 0 && !suggestion;
console.log('[parseNameResponse] Parsed isValid as string:', parsed.isValid, '→', isValid);
return { isValid, suggestion, issues };
} }
// Try to extract from content if parsing failed // Try to extract from content if parsing failed
try { try {
// Look for isValid pattern // Look for isValid pattern - handle both boolean and quoted string
const isValidMatch = content.match(/"isValid"\s*:\s*(true|false)/i); // Matches: "isValid": true, "isValid": false, "isValid": "true", "isValid": "false"
const isValidMatch = content.match(/"isValid"\s*:\s*"?(true|false)"?/i);
const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true; const isValid = isValidMatch ? isValidMatch[1].toLowerCase() === 'true' : true;
// Look for suggestion console.log('[parseNameResponse] Regex extraction:', {
const suggestionMatch = content.match(/"suggestion"\s*:\s*"([^"]+)"/); isValidMatch: isValidMatch?.[0],
const suggestion = suggestionMatch ? suggestionMatch[1] : null; isValidValue: isValidMatch?.[1],
resultIsValid: isValid
});
// Look for suggestion - handle escaped quotes and null
const suggestionMatch = content.match(/"suggestion"\s*:\s*(?:"([^"\\]*(?:\\.[^"\\]*)*)"|null)/);
const suggestion = suggestionMatch ? (suggestionMatch[1] || null) : null;
// Look for issues array // Look for issues array
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/); const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
@@ -91,11 +179,16 @@ function parseNameResponse(parsed, content) {
const issuesContent = issuesMatch[1]; const issuesContent = issuesMatch[1];
const issueStrings = issuesContent.match(/"([^"]+)"/g); const issueStrings = issuesContent.match(/"([^"]+)"/g);
if (issueStrings) { if (issueStrings) {
issues = issueStrings.map(s => s.replace(/"/g, '')); issues = issueStrings
.map(s => sanitizeIssue(s.replace(/"/g, '')))
.filter(issue => issue.length > 0);
} }
} }
return { isValid, suggestion, issues }; // Same defensive logic: if there are issues, it's not valid
const finalIsValid = isValid && issues.length === 0 && !suggestion;
return { isValid: finalIsValid, suggestion, issues };
} catch { } catch {
// Default to valid if we can't parse anything // Default to valid if we can't parse anything
return { isValid: true, suggestion: null, issues: [] }; return { isValid: true, suggestion: null, issues: [] };

View File

@@ -63,8 +63,33 @@ class GroqProvider {
body.response_format = { type: 'json_object' }; body.response_format = { type: 'json_object' };
} }
// Debug: Log request being sent
console.log('[Groq] Request:', {
model: body.model,
temperature: body.temperature,
maxTokens: body.max_completion_tokens,
hasResponseFormat: !!body.response_format,
messageCount: body.messages?.length,
systemPromptLength: body.messages?.[0]?.content?.length,
userPromptLength: body.messages?.[1]?.content?.length
});
const response = await this._makeRequest('chat/completions', body, timeoutMs); const response = await this._makeRequest('chat/completions', body, timeoutMs);
// Debug: Log raw response structure
console.log('[Groq] Raw response:', {
hasChoices: !!response.choices,
choicesLength: response.choices?.length,
firstChoice: response.choices?.[0] ? {
finishReason: response.choices[0].finish_reason,
hasMessage: !!response.choices[0].message,
contentLength: response.choices[0].message?.content?.length,
contentPreview: response.choices[0].message?.content?.substring(0, 200)
} : null,
usage: response.usage,
model: response.model
});
const content = response.choices?.[0]?.message?.content || ''; const content = response.choices?.[0]?.message?.content || '';
const usage = response.usage || {}; const usage = response.usage || {};

View File

@@ -89,16 +89,25 @@ function createDescriptionValidationTask() {
], ],
model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis
temperature: 0.3, // Slightly higher for creative suggestions temperature: 0.3, // Slightly higher for creative suggestions
maxTokens: 500, // More tokens for description suggestions maxTokens: 2000, // Reasoning models need extra tokens for thinking
responseFormat: { type: 'json_object' } responseFormat: { type: 'json_object' }
}); });
// Log full raw response for debugging
log.info('[DescriptionValidation] Raw AI response:', {
parsed: response.parsed,
content: response.content,
contentLength: response.content?.length
});
// Parse the response // Parse the response
result = parseDescriptionResponse(response.parsed, response.content); result = parseDescriptionResponse(response.parsed, response.content);
} catch (jsonError) { } catch (jsonError) {
// If JSON mode failed, check if we have failedGeneration to parse // If JSON mode failed, check if we have failedGeneration to parse
if (jsonError.failedGeneration) { if (jsonError.failedGeneration) {
log.warn('[DescriptionValidation] JSON mode failed, attempting to parse failed_generation'); log.warn('[DescriptionValidation] JSON mode failed, attempting to parse failed_generation:', {
failedGeneration: jsonError.failedGeneration
});
result = parseDescriptionResponse(null, jsonError.failedGeneration); result = parseDescriptionResponse(null, jsonError.failedGeneration);
response = { latencyMs: 0, usage: {}, model: MODELS.LARGE }; response = { latencyMs: 0, usage: {}, model: MODELS.LARGE };
} else { } else {
@@ -111,9 +120,14 @@ function createDescriptionValidationTask() {
], ],
model: MODELS.LARGE, model: MODELS.LARGE,
temperature: 0.3, temperature: 0.3,
maxTokens: 500 maxTokens: 2000 // Reasoning models need extra tokens for thinking
// No responseFormat - let the model respond freely // No responseFormat - let the model respond freely
}); });
log.info('[DescriptionValidation] Raw AI response (no JSON mode):', {
parsed: response.parsed,
content: response.content,
contentLength: response.content?.length
});
result = parseDescriptionResponse(response.parsed, response.content); result = parseDescriptionResponse(response.parsed, response.content);
} }
} }

View File

@@ -71,12 +71,26 @@ function createNameValidationTask() {
const companyKey = product.company_id || product.company_name || product.company; const companyKey = product.company_id || product.company_name || product.company;
const prompts = await loadNameValidationPrompts(pool, companyKey); const prompts = await loadNameValidationPrompts(pool, companyKey);
// Debug: Log loaded prompts
log.info('[NameValidation] Loaded prompts:', {
hasSystem: !!prompts.system,
systemLength: prompts.system?.length || 0,
hasGeneral: !!prompts.general,
generalLength: prompts.general?.length || 0,
generalPreview: prompts.general?.substring(0, 100) || '(empty)',
hasCompanySpecific: !!prompts.companySpecific,
companyKey
});
// Validate required prompts exist // Validate required prompts exist
validateRequiredPrompts(prompts, 'name_validation'); validateRequiredPrompts(prompts, 'name_validation');
// Build the user prompt with database-loaded prompts // Build the user prompt with database-loaded prompts
const userPrompt = buildNameUserPrompt(product, prompts); const userPrompt = buildNameUserPrompt(product, prompts);
// Debug: Log the full user prompt being sent
log.info('[NameValidation] User prompt:', userPrompt.substring(0, 500));
let response; let response;
let result; let result;
@@ -87,18 +101,27 @@ 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 - fast for simple tasks model: MODELS.SMALL, // openai/gpt-oss-20b - reasoning model
temperature: 0.2, // Low temperature for consistent results temperature: 0.2, // Low temperature for consistent results
maxTokens: 300, maxTokens: 1500, // Reasoning models need extra tokens for thinking
responseFormat: { type: 'json_object' } responseFormat: { type: 'json_object' }
}); });
// Log full raw response for debugging
log.info('[NameValidation] Raw AI response:', {
parsed: response.parsed,
content: response.content,
contentLength: response.content?.length
});
// Parse the response // Parse the response
result = parseNameResponse(response.parsed, response.content); result = parseNameResponse(response.parsed, response.content);
} catch (jsonError) { } catch (jsonError) {
// If JSON mode failed, check if we have failedGeneration to parse // If JSON mode failed, check if we have failedGeneration to parse
if (jsonError.failedGeneration) { if (jsonError.failedGeneration) {
log.warn('[NameValidation] JSON mode failed, attempting to parse failed_generation'); log.warn('[NameValidation] JSON mode failed, attempting to parse failed_generation:', {
failedGeneration: jsonError.failedGeneration
});
result = parseNameResponse(null, jsonError.failedGeneration); result = parseNameResponse(null, jsonError.failedGeneration);
response = { latencyMs: 0, usage: {}, model: MODELS.SMALL }; response = { latencyMs: 0, usage: {}, model: MODELS.SMALL };
} else { } else {
@@ -111,9 +134,14 @@ function createNameValidationTask() {
], ],
model: MODELS.SMALL, model: MODELS.SMALL,
temperature: 0.2, temperature: 0.2,
maxTokens: 300 maxTokens: 1500 // Reasoning models need extra tokens for thinking
// No responseFormat - let the model respond freely // No responseFormat - let the model respond freely
}); });
log.info('[NameValidation] Raw AI response (no JSON mode):', {
parsed: response.parsed,
content: response.content,
contentLength: response.content?.length
});
result = parseNameResponse(response.parsed, response.content); result = parseNameResponse(response.parsed, response.content);
} }
} }

View File

@@ -3,10 +3,20 @@
* *
* Displays an AI suggestion with accept/dismiss actions. * Displays an AI suggestion with accept/dismiss actions.
* Used for inline validation suggestions on Name and Description fields. * Used for inline validation suggestions on Name and Description fields.
*
* For description fields, starts collapsed (just icon + count) and expands on click.
* For name fields, uses compact inline mode.
*/ */
import { Check, X, Sparkles, AlertCircle } from 'lucide-react'; import { useState } from 'react';
import { Check, X, Sparkles, AlertCircle, ChevronDown, ChevronUp, Info } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
interface AiSuggestionBadgeProps { interface AiSuggestionBadgeProps {
@@ -20,8 +30,10 @@ interface AiSuggestionBadgeProps {
onDismiss: () => void; onDismiss: () => void;
/** Additional CSS classes */ /** Additional CSS classes */
className?: string; className?: string;
/** Whether to show the suggestion as compact (inline) or expanded */ /** Whether to show the suggestion as compact (inline) - used for name field */
compact?: boolean; compact?: boolean;
/** Whether to start in collapsible mode (icon + count) - used for description field */
collapsible?: boolean;
} }
export function AiSuggestionBadge({ export function AiSuggestionBadge({
@@ -30,96 +42,212 @@ export function AiSuggestionBadge({
onAccept, onAccept,
onDismiss, onDismiss,
className, className,
compact = false compact = false,
collapsible = false
}: AiSuggestionBadgeProps) { }: AiSuggestionBadgeProps) {
const [isExpanded, setIsExpanded] = useState(false);
// Compact mode for name fields - inline suggestion with accept/dismiss
if (compact) { if (compact) {
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs', 'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200', 'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800', 'dark:bg-purple-950/30 dark:border-purple-800',
className className
)} )}
> >
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0" /> <div className="flex items-start gap-1.5">
<span className="text-purple-700 dark:text-purple-300 truncate max-w-[200px]"> <Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0 mt-0.5" />
<span className="text-purple-700 dark:text-purple-300">
{suggestion} {suggestion}
</span> </span>
</div>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
<Button <TooltipProvider>
size="sm" <Tooltip delayDuration={200}>
variant="ghost" <TooltipTrigger asChild>
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100" <Button
onClick={(e) => { size="sm"
e.stopPropagation(); variant="ghost"
onAccept(); className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
}} onClick={(e) => {
title="Accept suggestion" e.stopPropagation();
> onAccept();
<Check className="h-3 w-3" /> }}
</Button> >
<Button <Check className="h-3 w-3" />
size="sm" </Button>
variant="ghost" </TooltipTrigger>
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100" <TooltipContent side="top">
onClick={(e) => { <p>Accept suggestion</p>
e.stopPropagation(); </TooltipContent>
onDismiss(); </Tooltip>
}} </TooltipProvider>
title="Dismiss" <TooltipProvider>
> <Tooltip delayDuration={200}>
<X className="h-3 w-3" /> <TooltipTrigger asChild>
</Button> <Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
>
<X className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Ignore</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Info icon with issues tooltip */}
{issues.length > 0 && (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<button
type="button"
className="flex-shrink-0 text-purple-400 hover:text-purple-600 transition-colors"
onClick={(e) => e.stopPropagation()}
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-[300px] p-2"
>
<div className="flex flex-col gap-1">
<div className="text-xs font-medium text-purple-300 mb-1">
Issues found:
</div>
{issues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-300" />
<span>{issue}</span>
</div>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div> </div>
</div> </div>
); );
} }
// Collapsible mode for description fields
if (collapsible && !isExpanded) {
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(true);
}}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200 hover:bg-purple-100',
'dark:bg-purple-950/30 dark:border-purple-800 dark:hover:bg-purple-900/40',
'transition-colors cursor-pointer',
className
)}
title="Click to see AI suggestion"
>
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-purple-600 dark:text-purple-400 font-medium">
{issues.length} {issues.length === 1 ? 'issue' : 'issues'}
</span>
<ChevronDown className="h-3 w-3 text-purple-400" />
</button>
);
}
// Expanded view (default for non-compact, or when collapsible is expanded)
return ( return (
<div <div
className={cn( className={cn(
'flex flex-col gap-2 p-2 mt-1 rounded-md', 'flex flex-col gap-2 p-3 rounded-md',
'bg-purple-50 border border-purple-200', 'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800', 'dark:bg-purple-950/30 dark:border-purple-800',
className className
)} )}
> >
{/* Header */} {/* Header with collapse button if collapsible */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between">
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" /> <div className="flex items-center gap-2">
<span className="text-xs font-medium text-purple-600 dark:text-purple-400"> <Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
AI Suggestion <span className="text-xs font-medium text-purple-600 dark:text-purple-400">
</span> AI Suggestion
</span>
{issues.length > 0 && (
<span className="text-xs text-purple-500 dark:text-purple-400">
({issues.length} {issues.length === 1 ? 'issue' : 'issues'})
</span>
)}
</div>
{collapsible && (
<Button
size="sm"
variant="ghost"
className="h-5 w-5 p-0 text-purple-400 hover:text-purple-600"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(false);
}}
title="Collapse"
>
<ChevronUp className="h-3.5 w-3.5" />
</Button>
)}
</div> </div>
{/* Suggestion content */} {/* Issues list */}
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed">
{suggestion}
</div>
{/* Issues list (if any) */}
{issues.length > 0 && ( {issues.length > 0 && (
<div className="flex flex-col gap-1 mt-1"> <div className="flex flex-col gap-1 text-xs">
{issues.map((issue, index) => ( {issues.map((issue, index) => (
<div <div
key={index} key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400" className="flex items-start gap-1.5 text-purple-600 dark:text-purple-400"
> >
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0" /> <AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span> <span>{issue}</span>
</div> </div>
))} ))}
</div> </div>
)} )}
{/* Suggested description */}
<div className="mt-1">
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
Suggested:
</div>
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed bg-white/50 dark:bg-black/20 rounded p-2 border border-purple-100 dark:border-purple-800">
{suggestion}
</div>
</div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400" className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={onAccept} onClick={(e) => {
e.stopPropagation();
onAccept();
}}
> >
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />
Accept Accept
@@ -127,8 +255,11 @@ export function AiSuggestionBadge({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700" className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={onDismiss} onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
> >
Dismiss Dismiss
</Button> </Button>

View File

@@ -242,7 +242,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
disabled={disabled} disabled={disabled}
className={cn('w-full justify-between overflow-hidden', triggerClassName)} className={cn('w-full justify-between overflow-hidden', triggerClassName)}
> >
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span> <span className="truncate overflow-hidden mr-1 text-sm font-normal">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" /> <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>

View File

@@ -65,6 +65,8 @@ export const ValidationContainer = ({
// Sanity check dialog state // Sanity check dialog state
const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false); const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false);
// Debug: skip sanity check toggle (admin:debug only)
const [skipSanityCheck, setSkipSanityCheck] = useState(false);
// Handle UPC validation after copy-down operations on supplier/upc fields // Handle UPC validation after copy-down operations on supplier/upc fields
useCopyDownValidation(); useCopyDownValidation();
@@ -128,9 +130,8 @@ export const ValidationContainer = ({
} }
}, [onBack]); }, [onBack]);
// Trigger sanity check when user clicks Continue // Build products array for sanity check
const handleTriggerSanityCheck = useCallback(() => { const buildProductsForSanityCheck = useCallback((): ProductForSanityCheck[] => {
// Get current rows and prepare for sanity check
const rows = useValidationStore.getState().rows; const rows = useValidationStore.getState().rows;
const fields = useValidationStore.getState().fields; const fields = useValidationStore.getState().fields;
@@ -145,7 +146,7 @@ export const ValidationContainer = ({
}; };
// Convert rows to sanity check format // Convert rows to sanity check format
const products: ProductForSanityCheck[] = rows.map((row) => ({ return rows.map((row) => ({
name: row.name as string | undefined, name: row.name as string | undefined,
supplier: row.supplier as string | undefined, supplier: row.supplier as string | undefined,
supplier_name: getFieldLabel('supplier', row.supplier), supplier_name: getFieldLabel('supplier', row.supplier),
@@ -166,11 +167,30 @@ export const ValidationContainer = ({
width: row.width as string | number | undefined, width: row.width as string | number | undefined,
height: row.height as string | number | undefined, height: row.height as string | number | undefined,
})); }));
}, []);
// Open dialog and run check // Handle viewing cached sanity check results
const handleViewResults = useCallback(() => {
setSanityCheckDialogOpen(true);
}, []);
// Handle running a fresh sanity check
const handleRunCheck = useCallback(() => {
const products = buildProductsForSanityCheck();
setSanityCheckDialogOpen(true); setSanityCheckDialogOpen(true);
sanityCheck.runCheck(products); sanityCheck.runCheck(products);
}, [sanityCheck]); }, [sanityCheck, buildProductsForSanityCheck]);
// Handle proceeding directly to next step (skipping sanity check)
const handleProceedDirect = useCallback(() => {
handleNext();
}, [handleNext]);
// Force a new sanity check (refresh button in dialog)
const handleRefreshSanityCheck = useCallback(() => {
const products = buildProductsForSanityCheck();
sanityCheck.runCheck(products);
}, [sanityCheck, buildProductsForSanityCheck]);
// Handle proceeding after sanity check // Handle proceeding after sanity check
const handleSanityCheckProceed = useCallback(() => { const handleSanityCheckProceed = useCallback(() => {
@@ -179,11 +199,11 @@ export const ValidationContainer = ({
handleNext(); handleNext();
}, [handleNext, sanityCheck]); }, [handleNext, sanityCheck]);
// Handle going back from sanity check dialog // Handle going back from sanity check dialog (keeps results cached)
const handleSanityCheckGoBack = useCallback(() => { const handleSanityCheckGoBack = useCallback(() => {
setSanityCheckDialogOpen(false); setSanityCheckDialogOpen(false);
sanityCheck.clearResults(); // Don't clear results - keep them cached for next time
}, [sanityCheck]); }, []);
// Handle scrolling to a specific product from sanity check issue // Handle scrolling to a specific product from sanity check issue
const handleScrollToProduct = useCallback((productIndex: number) => { const handleScrollToProduct = useCallback((productIndex: number) => {
@@ -232,16 +252,17 @@ export const ValidationContainer = ({
{/* Footer with navigation */} {/* Footer with navigation */}
<ValidationFooter <ValidationFooter
onBack={handleBack} onBack={handleBack}
onNext={handleNext} onProceedDirect={handleProceedDirect}
onViewResults={handleViewResults}
onRunCheck={handleRunCheck}
canGoBack={!!onBack} canGoBack={!!onBack}
canProceed={totalErrorCount === 0} canProceed={totalErrorCount === 0}
errorCount={totalErrorCount} errorCount={totalErrorCount}
rowCount={rowCount} rowCount={rowCount}
onAiValidate={aiValidation.validate} isSanityChecking={sanityCheck.isChecking}
isAiValidating={aiValidation.isValidating} hasRunSanityCheck={sanityCheck.hasRun}
onShowDebug={aiValidation.showPromptPreview} skipSanityCheck={skipSanityCheck}
onTriggerSanityCheck={handleTriggerSanityCheck} onSkipSanityCheckChange={setSkipSanityCheck}
sanityCheckAvailable={true}
/> />
{/* Floating selection bar - appears when rows selected */} {/* Floating selection bar - appears when rows selected */}
@@ -272,7 +293,7 @@ export const ValidationContainer = ({
debugData={aiValidation.debugPrompt} debugData={aiValidation.debugPrompt}
/> />
{/* Sanity Check Dialog - auto-triggered on Continue */} {/* Sanity Check Dialog - shows cached results or runs new check */}
<SanityCheckDialog <SanityCheckDialog
open={sanityCheckDialogOpen} open={sanityCheckDialogOpen}
onOpenChange={setSanityCheckDialogOpen} onOpenChange={setSanityCheckDialogOpen}
@@ -281,8 +302,10 @@ export const ValidationContainer = ({
result={sanityCheck.result} result={sanityCheck.result}
onProceed={handleSanityCheckProceed} onProceed={handleSanityCheckProceed}
onGoBack={handleSanityCheckGoBack} onGoBack={handleSanityCheckGoBack}
onRefresh={handleRefreshSanityCheck}
onScrollToProduct={handleScrollToProduct} onScrollToProduct={handleScrollToProduct}
productNames={productNames} productNames={productNames}
validationErrorCount={totalErrorCount}
/> />
{/* Template form dialog - for saving row as template */} {/* Template form dialog - for saving row as template */}

View File

@@ -1,62 +1,61 @@
/** /**
* ValidationFooter Component * ValidationFooter Component
* *
* Navigation footer with back/next buttons, AI validate, and summary info. * Navigation footer with back/next buttons and summary info.
* Triggers sanity check automatically when user clicks "Continue". * After first sanity check, shows options to view results, recheck, or proceed directly.
*/ */
import { useState } from 'react'; import { useContext } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { CheckCircle, Wand2, FileText, Sparkles } from 'lucide-react'; import { Switch } from '@/components/ui/switch';
import { Protected } from '@/components/auth/Protected'; import { Label } from '@/components/ui/label';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog'; import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { AuthContext } from '@/contexts/AuthContext';
interface ValidationFooterProps { interface ValidationFooterProps {
onBack?: () => void; onBack?: () => void;
onNext?: () => void; /** Called to proceed directly to next step (no sanity check) */
onProceedDirect?: () => void;
/** Called to view cached sanity check results */
onViewResults?: () => void;
/** Called to run a fresh sanity check */
onRunCheck?: () => void;
canGoBack: boolean; canGoBack: boolean;
canProceed: boolean; canProceed: boolean;
errorCount: number; errorCount: number;
rowCount: number; rowCount: number;
onAiValidate?: () => void; /** Whether sanity check is currently running */
isAiValidating?: boolean; isSanityChecking?: boolean;
onShowDebug?: () => void; /** Whether sanity check has been run at least once */
/** Called when user clicks Continue - triggers sanity check */ hasRunSanityCheck?: boolean;
onTriggerSanityCheck?: () => void; /** Whether to skip sanity check (debug mode) */
/** Whether sanity check is available (Groq enabled) */ skipSanityCheck?: boolean;
sanityCheckAvailable?: boolean; /** Called when skip sanity check toggle changes */
onSkipSanityCheckChange?: (skip: boolean) => void;
} }
export const ValidationFooter = ({ export const ValidationFooter = ({
onBack, onBack,
onNext, onProceedDirect,
onViewResults,
onRunCheck,
canGoBack, canGoBack,
canProceed, canProceed,
errorCount, errorCount,
rowCount, rowCount,
onAiValidate, isSanityChecking = false,
isAiValidating = false, hasRunSanityCheck = false,
onShowDebug, skipSanityCheck = false,
onTriggerSanityCheck, onSkipSanityCheckChange,
sanityCheckAvailable = false,
}: ValidationFooterProps) => { }: ValidationFooterProps) => {
const [showErrorDialog, setShowErrorDialog] = useState(false); const { user } = useContext(AuthContext);
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes('admin:debug'));
// Handle Continue click - either trigger sanity check or proceed directly
const handleContinueClick = () => {
if (canProceed) {
// If sanity check is available, trigger it first
if (sanityCheckAvailable && onTriggerSanityCheck) {
onTriggerSanityCheck();
} else if (onNext) {
// No sanity check available, proceed directly
onNext();
}
} else {
// Show error dialog if there are validation errors
setShowErrorDialog(true);
}
};
return ( return (
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4"> <div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
@@ -83,80 +82,130 @@ export const ValidationFooter = ({
{/* Action buttons */} {/* Action buttons */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Show Prompt Debug - Admin only */} {/* Skip sanity check toggle - only for admin:debug users */}
{onShowDebug && ( {hasDebugPermission && onSkipSanityCheckChange && (
<Protected permission="admin:debug"> <TooltipProvider>
<Button <Tooltip delayDuration={300}>
variant="outline" <TooltipTrigger asChild>
onClick={onShowDebug} <div className="flex items-center gap-2 mr-2">
disabled={isAiValidating} <Switch
> id="skip-sanity"
<FileText className="h-4 w-4 mr-1" /> checked={skipSanityCheck}
Show Prompt onCheckedChange={onSkipSanityCheckChange}
</Button> className="data-[state=checked]:bg-amber-500"
</Protected> />
<Label
htmlFor="skip-sanity"
className="text-xs text-muted-foreground cursor-pointer flex items-center gap-1"
>
<Bug className="h-3 w-3" />
Skip
</Label>
</div>
</TooltipTrigger>
<TooltipContent side="top">
<p>Debug: Skip sanity check</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
{/* AI Validate */} {/* Before first sanity check: single "Continue" button that runs the check */}
{onAiValidate && ( {!hasRunSanityCheck && !skipSanityCheck && (
<Button <Button
variant="outline" onClick={onRunCheck}
onClick={onAiValidate} disabled={isSanityChecking || rowCount === 0}
disabled={isAiValidating || rowCount === 0} title={
!canProceed
? `There are ${errorCount} validation errors`
: 'Continue to image upload'
}
> >
<Wand2 className="h-4 w-4 mr-1" /> {isSanityChecking ? (
{isAiValidating ? 'Validating...' : 'AI Validate'} <>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Checking...
</>
) : (
'Continue'
)}
</Button> </Button>
)} )}
{/* Next button */} {/* After first sanity check: show all three options */}
{onNext && ( {hasRunSanityCheck && !skipSanityCheck && (
<> <>
{/* View previous results */}
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onViewResults}
disabled={isSanityChecking}
>
<Eye className="h-4 w-4 mr-1" />
Results
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>View previous sanity check results</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Run fresh check */}
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onRunCheck}
disabled={isSanityChecking || rowCount === 0}
>
{isSanityChecking ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
{isSanityChecking ? 'Checking...' : 'Recheck'}
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Run a fresh sanity check</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Proceed directly */}
<Button <Button
onClick={handleContinueClick} onClick={onProceedDirect}
disabled={isSanityChecking}
title={ title={
!canProceed !canProceed
? `There are ${errorCount} validation errors` ? `There are ${errorCount} validation errors`
: sanityCheckAvailable : 'Continue to image upload'
? 'Run sanity check and continue to image upload'
: 'Continue to image upload'
} }
> >
{sanityCheckAvailable && canProceed && ( Continue
<Sparkles className="h-4 w-4 mr-1" /> <ChevronRight className="h-4 w-4 ml-1" />
)}
{canProceed ? 'Continue' : 'Next'}
</Button> </Button>
<Dialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle className="pb-3">Are you sure?</DialogTitle>
<DialogDescription>
There are still {errorCount} validation error{errorCount !== 1 ? 's' : ''} in your data.
Are you sure you want to continue?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowErrorDialog(false)}
>
Cancel
</Button>
<Button
onClick={() => {
setShowErrorDialog(false);
onNext();
}}
>
Continue Anyway
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
)} )}
{/* Skip mode: just show Continue */}
{skipSanityCheck && (
<Button
onClick={onProceedDirect}
disabled={rowCount === 0}
title="Continue to image upload (sanity check skipped)"
>
Continue
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
)}
</div> </div>
</div> </div>
); );

View File

@@ -93,7 +93,7 @@ const getCellComponent = (field: Field<string>, optionCount: number = 0) => {
/** /**
* Row height for virtualization * Row height for virtualization
*/ */
const ROW_HEIGHT = 40; const ROW_HEIGHT = 80; // Taller rows to show 2 lines of description + space for AI badges
const HEADER_HEIGHT = 40; const HEADER_HEIGHT = 40;
// Stable empty references to avoid creating new objects in selectors // Stable empty references to avoid creating new objects in selectors
@@ -176,6 +176,19 @@ const CellWrapper = memo(({
const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false; const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false;
const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed; const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed;
// Debug: Log when we have a suggestion for name field
if (isInlineAiField && field.key === 'name' && inlineAiSuggestion) {
console.log('[CellWrapper] Name field suggestion:', {
productIndex,
inlineAiSuggestion,
fieldSuggestion,
isDismissed,
showSuggestion,
isValid: fieldSuggestion?.isValid,
suggestion: fieldSuggestion?.suggestion
});
}
// Check if cell has a value (for showing copy-down button) // Check if cell has a value (for showing copy-down button)
const hasValue = value !== undefined && value !== null && value !== ''; const hasValue = value !== undefined && value !== null && value !== '';
@@ -289,7 +302,11 @@ const CellWrapper = memo(({
// Stable callback for onBlur - validates field and triggers UPC validation if needed // Stable callback for onBlur - validates field and triggers UPC validation if needed
// Uses setTimeout(0) to defer validation AFTER browser paint // Uses setTimeout(0) to defer validation AFTER browser paint
const handleBlur = useCallback((newValue: unknown) => { const handleBlur = useCallback((newValue: unknown) => {
const { updateCell } = useValidationStore.getState(); const state = useValidationStore.getState();
const { updateCell } = state;
// Capture previous value BEFORE updating - needed to detect actual changes
const previousValue = state.rows[rowIndex]?.[field.key];
let valueToSave = newValue; let valueToSave = newValue;
@@ -302,6 +319,9 @@ const CellWrapper = memo(({
} }
} }
// Check if the value actually changed (for AI validation trigger)
const valueChanged = String(valueToSave ?? '') !== String(previousValue ?? '');
updateCell(rowIndex, field.key, valueToSave); updateCell(rowIndex, field.key, valueToSave);
// Defer validation to after the browser paints // Defer validation to after the browser paints
@@ -534,7 +554,8 @@ const CellWrapper = memo(({
// Trigger inline AI validation for name/description fields // Trigger inline AI validation for name/description fields
// This validates spelling, grammar, and naming conventions using Groq // This validates spelling, grammar, and naming conventions using Groq
if (isInlineAiField && valueToSave && String(valueToSave).trim()) { // Only trigger if value actually changed to avoid unnecessary API calls
if (isInlineAiField && valueChanged && valueToSave && String(valueToSave).trim()) {
const currentRow = useValidationStore.getState().rows[rowIndex]; const currentRow = useValidationStore.getState().rows[rowIndex];
const fields = useValidationStore.getState().fields; const fields = useValidationStore.getState().fields;
if (currentRow) { if (currentRow) {
@@ -554,6 +575,33 @@ const CellWrapper = memo(({
return undefined; return undefined;
}; };
// Compute sibling products (same company + line + subline if set) for naming context
const rows = useValidationStore.getState().rows;
const siblingNames: string[] = [];
if (currentRow.company && currentRow.line) {
const companyId = String(currentRow.company);
const lineId = String(currentRow.line);
const sublineId = currentRow.subline ? String(currentRow.subline) : null;
for (const row of rows) {
// Skip self
if (row.__index === productIndex) continue;
// Must match company and line
if (String(row.company) !== companyId) continue;
if (String(row.line) !== lineId) continue;
// If current product has subline, siblings must match subline too
if (sublineId && String(row.subline) !== sublineId) continue;
// Add name if it exists
if (row.name && typeof row.name === 'string' && row.name.trim()) {
siblingNames.push(row.name);
}
}
}
// Build product payload for API // Build product payload for API
const productPayload = { const productPayload = {
name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string), name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string),
@@ -561,7 +609,11 @@ const CellWrapper = memo(({
company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined, company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined,
company_id: currentRow.company ? String(currentRow.company) : undefined, company_id: currentRow.company ? String(currentRow.company) : undefined,
line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : 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, categories: currentRow.categories as string | undefined,
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
}; };
// Call the appropriate API endpoint // Call the appropriate API endpoint
@@ -691,6 +743,14 @@ const CellWrapper = memo(({
onBlur={handleBlur} onBlur={handleBlur}
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined} onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
isLoadingOptions={isLoadingOptions} isLoadingOptions={isLoadingOptions}
// Pass AI suggestion props for description field (MultilineInput handles it internally)
{...(field.key === 'description' && {
aiSuggestion: fieldSuggestion,
isAiValidating: isInlineAiValidating,
onDismissAiSuggestion: () => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
},
})}
/> />
</div> </div>
@@ -748,24 +808,24 @@ const CellWrapper = memo(({
</div> </div>
)} )}
{/* Inline AI validation spinner */} {/* Inline AI validation spinner - only for name field (description handles it internally) */}
{isInlineAiValidating && isInlineAiField && ( {isInlineAiValidating && field.key === 'name' && (
<div className="absolute right-1 top-1/2 -translate-y-1/2 z-10"> <div className="absolute right-1 top-1/2 -translate-y-1/2 z-10">
<Loader2 className="h-4 w-4 animate-spin text-purple-500" /> <Loader2 className="h-4 w-4 animate-spin text-purple-500" />
</div> </div>
)} )}
{/* AI Suggestion badge - shows when AI has a suggestion for this field */} {/* AI Suggestion badge - only for name field (description handles it inside its popover) */}
{showSuggestion && fieldSuggestion && ( {showSuggestion && fieldSuggestion && field.key === 'name' && (
<div className="absolute top-full left-0 right-0 z-20 mt-1"> <div className="absolute top-full left-0 right-0 z-20 mt-1">
<AiSuggestionBadge <AiSuggestionBadge
suggestion={fieldSuggestion.suggestion!} suggestion={fieldSuggestion.suggestion!}
issues={fieldSuggestion.issues} issues={fieldSuggestion.issues}
onAccept={() => { onAccept={() => {
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
}} }}
onDismiss={() => { onDismiss={() => {
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description'); useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
}} }}
compact compact
/> />
@@ -881,6 +941,113 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
toast.success('Template applied'); toast.success('Template applied');
// Trigger inline AI validation for name/description if template set those fields
const productIndex = currentRow?.__index;
if (productIndex) {
const { setInlineAiValidating, setInlineAiSuggestion } = state;
// Helper to look up field option label
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
const fieldDef = fields.find(f => f.key === fieldKey);
if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
return option?.label;
}
return undefined;
};
// Get the updated row data (after template applied)
const updatedRow = { ...currentRow, ...updates };
// Compute sibling names for context
const rows = state.rows;
const siblingNames: string[] = [];
if (updatedRow.company && updatedRow.line) {
const companyId = String(updatedRow.company);
const lineId = String(updatedRow.line);
const sublineId = updatedRow.subline ? String(updatedRow.subline) : null;
for (const row of rows) {
if (row.__index === productIndex) continue;
if (String(row.company) !== companyId) continue;
if (String(row.line) !== lineId) continue;
if (sublineId && String(row.subline) !== sublineId) continue;
if (row.name && typeof row.name === 'string' && row.name.trim()) {
siblingNames.push(row.name);
}
}
}
// Trigger name validation if template set name
if (templateFieldsSet.has('name') && updates.name && String(updates.name).trim()) {
setInlineAiValidating(`${productIndex}-name`, true);
const productPayload = {
name: String(updates.name),
description: updatedRow.description as string,
company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
company_id: updatedRow.company ? String(updatedRow.company) : undefined,
line_name: updatedRow.line ? getFieldLabel('line', updatedRow.line) : undefined,
line_id: updatedRow.line ? String(updatedRow.line) : undefined,
subline_name: updatedRow.subline ? getFieldLabel('subline', updatedRow.subline) : undefined,
subline_id: updatedRow.subline ? String(updatedRow.subline) : undefined,
categories: updatedRow.categories as string | undefined,
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
};
fetch('/api/ai/validate/inline/name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productPayload }),
})
.then(res => res.json())
.then(result => {
if (result.success !== false) {
setInlineAiSuggestion(productIndex, 'name', {
isValid: result.isValid ?? true,
suggestion: result.suggestion,
issues: result.issues || [],
latencyMs: result.latencyMs,
});
}
})
.catch(err => console.error('[InlineAI] name validation error:', err))
.finally(() => setInlineAiValidating(`${productIndex}-name`, false));
}
// Trigger description validation if template set description
if (templateFieldsSet.has('description') && updates.description && String(updates.description).trim()) {
setInlineAiValidating(`${productIndex}-description`, true);
const productPayload = {
name: updatedRow.name as string,
description: String(updates.description),
company_name: updatedRow.company ? getFieldLabel('company', updatedRow.company) : undefined,
company_id: updatedRow.company ? String(updatedRow.company) : undefined,
categories: updatedRow.categories as string | undefined,
};
fetch('/api/ai/validate/inline/description', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product: productPayload }),
})
.then(res => res.json())
.then(result => {
if (result.success !== false) {
setInlineAiSuggestion(productIndex, 'description', {
isValid: result.isValid ?? true,
suggestion: result.suggestion,
issues: result.issues || [],
latencyMs: result.latencyMs,
});
}
})
.catch(err => console.error('[InlineAI] description validation error:', err))
.finally(() => setInlineAiValidating(`${productIndex}-description`, false));
}
}
// Trigger UPC validation if template set supplier or upc, and we have both values // Trigger UPC validation if template set supplier or upc, and we have both values
const finalSupplier = updates.supplier ?? currentRow?.supplier; const finalSupplier = updates.supplier ?? currentRow?.supplier;
const finalUpc = updates.upc ?? currentRow?.upc; const finalUpc = updates.upc ?? currentRow?.upc;
@@ -986,6 +1153,8 @@ interface VirtualRowProps {
columns: ColumnDef<RowData>[]; columns: ColumnDef<RowData>[];
fields: Field<string>[]; fields: Field<string>[];
totalRowCount: number; totalRowCount: number;
/** Whether table is scrolled horizontally - used for sticky column shadow */
isScrolledHorizontally: boolean;
} }
const VirtualRow = memo(({ const VirtualRow = memo(({
@@ -995,6 +1164,7 @@ const VirtualRow = memo(({
columns, columns,
fields, fields,
totalRowCount, totalRowCount,
isScrolledHorizontally,
}: VirtualRowProps) => { }: VirtualRowProps) => {
// Subscribe to row data - this is THE subscription for all cell values in this row // Subscribe to row data - this is THE subscription for all cell values in this row
const rowData = useValidationStore( const rowData = useValidationStore(
@@ -1044,7 +1214,14 @@ const VirtualRow = memo(({
// Subscribe to inline AI suggestions for this row (for name/description validation) // Subscribe to inline AI suggestions for this row (for name/description validation)
const inlineAiSuggestion = useValidationStore( const inlineAiSuggestion = useValidationStore(
useCallback((state) => state.inlineAi.suggestions.get(rowId), [rowId]) useCallback((state) => {
const suggestion = state.inlineAi.suggestions.get(rowId);
// Debug: Log when subscription returns a value
if (suggestion) {
console.log('[VirtualRow] Got suggestion for rowId:', rowId, suggestion);
}
return suggestion;
}, [rowId])
); );
// Check if inline AI validation is running for this row // Check if inline AI validation is running for this row
@@ -1065,6 +1242,13 @@ const VirtualRow = memo(({
const hasErrors = Object.keys(rowErrors).length > 0; const hasErrors = Object.keys(rowErrors).length > 0;
// Check if this row has a visible AI suggestion badge (needs higher z-index to show above next row)
// Only name field shows a floating badge - description handles AI suggestions inside its popover
const hasVisibleAiSuggestion = inlineAiSuggestion?.name &&
!inlineAiSuggestion.name.isValid &&
inlineAiSuggestion.name.suggestion &&
!inlineAiSuggestion.dismissed?.name;
// Handle mouse enter for copy-down target selection // Handle mouse enter for copy-down target selection
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback(() => {
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) { if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
@@ -1083,12 +1267,14 @@ const VirtualRow = memo(({
style={{ style={{
height: ROW_HEIGHT, height: ROW_HEIGHT,
transform: `translateY(${virtualStart}px)`, transform: `translateY(${virtualStart}px)`,
// Elevate row when it has a visible AI suggestion so badge shows above next row
zIndex: hasVisibleAiSuggestion ? 10 : undefined,
}} }}
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
> >
{/* Selection checkbox cell */} {/* Selection checkbox cell */}
<div <div
className="px-2 py-1 border-r flex items-center justify-center" className="px-2 py-3 border-r flex items-start justify-center"
style={{ style={{
width: columns[0]?.size || 40, width: columns[0]?.size || 40,
minWidth: columns[0]?.size || 40, minWidth: columns[0]?.size || 40,
@@ -1103,7 +1289,7 @@ const VirtualRow = memo(({
{/* Template column */} {/* Template column */}
<div <div
className="px-2 py-1 border-r flex items-center overflow-hidden" className="px-2 py-2 border-r flex items-start overflow-hidden"
style={{ style={{
width: TEMPLATE_COLUMN_WIDTH, width: TEMPLATE_COLUMN_WIDTH,
minWidth: TEMPLATE_COLUMN_WIDTH, minWidth: TEMPLATE_COLUMN_WIDTH,
@@ -1162,8 +1348,22 @@ const VirtualRow = memo(({
key={field.key} key={field.key}
data-cell-field={field.key} data-cell-field={field.key}
className={cn( className={cn(
"px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden", "px-2 py-2 border-r last:border-r-0 flex items-start",
isNameColumn && "lg:sticky lg:z-10 lg:bg-background lg:shadow-md" // Name column needs overflow-visible for the floating AI suggestion badge
// Description handles AI suggestions inside its popover, so no overflow needed
isNameColumn ? "overflow-visible" : "overflow-hidden",
// Name column is sticky - needs SOLID (opaque) background that matches row state
// Uses gradient trick to composite semi-transparent tint onto solid background
// Shadow only shows when scrolled horizontally (column is actually overlaying content)
isNameColumn && "lg:sticky lg:z-10",
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
isNameColumn && (
hasErrors
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
: isSelected
? "lg:[background:linear-gradient(hsl(var(--primary)/0.05),hsl(var(--primary)/0.05)),hsl(var(--background))]"
: "lg:bg-background"
)
)} )}
style={{ style={{
width: columnWidth, width: columnWidth,
@@ -1252,12 +1452,29 @@ export const ValidationTable = () => {
const tableContainerRef = useRef<HTMLDivElement>(null); const tableContainerRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null); const headerRef = useRef<HTMLDivElement>(null);
// Sync header scroll with body scroll // Calculate name column's natural left position (before it becomes sticky)
// Selection (40) + Template (200) + all field columns before 'name'
const nameColumnLeftOffset = useMemo(() => {
let offset = 40 + TEMPLATE_COLUMN_WIDTH; // Selection + Template columns
for (const field of fields) {
if (field.key === 'name') break;
offset += field.width || 150;
}
return offset;
}, [fields]);
// Track horizontal scroll for sticky column shadow
const [isScrolledHorizontally, setIsScrolledHorizontally] = useState(false);
// Sync header scroll with body scroll + track horizontal scroll state
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
if (tableContainerRef.current && headerRef.current) { if (tableContainerRef.current && headerRef.current) {
headerRef.current.scrollLeft = tableContainerRef.current.scrollLeft; const scrollLeft = tableContainerRef.current.scrollLeft;
headerRef.current.scrollLeft = scrollLeft;
// Only show shadow when scrolled past the name column's natural position
setIsScrolledHorizontally(scrollLeft > nameColumnLeftOffset);
} }
}, []); }, [nameColumnLeftOffset]);
// Compute filtered indices AND row IDs in a single pass // Compute filtered indices AND row IDs in a single pass
// This avoids calling getState() during render for each row // This avoids calling getState() during render for each row
@@ -1373,7 +1590,9 @@ export const ValidationTable = () => {
key={column.id || index} key={column.id || index}
className={cn( className={cn(
"px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0", "px-3 flex items-center text-left text-sm font-medium text-muted-foreground border-r last:border-r-0",
isNameColumn && "lg:sticky lg:z-20 lg:bg-muted lg:shadow-md" // Sticky header needs solid background matching the row's bg-muted/50 appearance
isNameColumn && "lg:sticky lg:z-20 lg:[background:linear-gradient(hsl(var(--muted)/0.5),hsl(var(--muted)/0.5)),hsl(var(--background))]",
isNameColumn && isScrolledHorizontally && "lg:shadow-md",
)} )}
style={{ style={{
width: column.size || 150, width: column.size || 150,
@@ -1416,6 +1635,7 @@ export const ValidationTable = () => {
columns={columns} columns={columns}
fields={fields} fields={fields}
totalRowCount={rowCount} totalRowCount={rowCount}
isScrolledHorizontally={isScrolledHorizontally}
/> />
); );
})} })}

View File

@@ -2,6 +2,7 @@
* MultilineInput Component * MultilineInput Component
* *
* Expandable textarea cell for long text content. * Expandable textarea cell for long text content.
* Includes AI suggestion display when available.
* Memoized to prevent unnecessary re-renders when parent table updates. * Memoized to prevent unnecessary re-renders when parent table updates.
*/ */
@@ -15,21 +16,36 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from '@/components/ui/tooltip'; } from '@/components/ui/tooltip';
import { X, Loader2 } from 'lucide-react'; import { X, Loader2, Sparkles, AlertCircle, Check, ChevronDown, ChevronUp } from 'lucide-react';
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';
/** AI suggestion data for a single field */
interface AiFieldSuggestion {
isValid: boolean;
suggestion?: string | null;
issues?: string[];
}
interface MultilineInputProps { interface MultilineInputProps {
value: unknown; value: unknown;
field: Field<string>; field: Field<string>;
options?: SelectOption[]; options?: SelectOption[];
rowIndex: number; rowIndex: number;
productIndex: string;
isValidating: boolean; isValidating: boolean;
errors: ValidationError[]; errors: ValidationError[];
onChange: (value: unknown) => void; onChange: (value: unknown) => void;
onBlur: (value: unknown) => void; onBlur: (value: unknown) => void;
onFetchOptions?: () => void; onFetchOptions?: () => void;
isLoadingOptions?: boolean;
/** AI suggestion for this field */
aiSuggestion?: AiFieldSuggestion | null;
/** Whether AI is currently validating */
isAiValidating?: boolean;
/** Called when user dismisses/clears the AI suggestion (also called after applying) */
onDismissAiSuggestion?: () => void;
} }
const MultilineInputComponent = ({ const MultilineInputComponent = ({
@@ -39,16 +55,38 @@ const MultilineInputComponent = ({
errors, errors,
onChange, onChange,
onBlur, onBlur,
aiSuggestion,
isAiValidating,
onDismissAiSuggestion,
}: MultilineInputProps) => { }: MultilineInputProps) => {
const [popoverOpen, setPopoverOpen] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState(''); const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null); const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
const [editedSuggestion, setEditedSuggestion] = useState('');
const cellRef = useRef<HTMLDivElement>(null); const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false); const preventReopenRef = useRef(false);
const hasError = errors.length > 0; const hasError = errors.length > 0;
const errorMessage = errors[0]?.message; const errorMessage = errors[0]?.message;
// Check if we have a displayable AI suggestion
const hasAiSuggestion = aiSuggestion && !aiSuggestion.isValid && aiSuggestion.suggestion;
const aiIssues = aiSuggestion?.issues || [];
// Handle wheel scroll in textarea - stop propagation to prevent table scroll
const handleTextareaWheel = useCallback((e: React.WheelEvent<HTMLTextAreaElement>) => {
const target = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = target;
const atTop = scrollTop === 0;
const atBottom = scrollTop + clientHeight >= scrollHeight - 1;
// Only stop propagation if we can scroll in the direction of the wheel
if ((e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom)) {
e.stopPropagation();
}
}, []);
// Initialize localDisplayValue on mount and when value changes externally // Initialize localDisplayValue on mount and when value changes externally
useEffect(() => { useEffect(() => {
const strValue = String(value ?? ''); const strValue = String(value ?? '');
@@ -57,6 +95,13 @@ const MultilineInputComponent = ({
} }
}, [value, localDisplayValue]); }, [value, localDisplayValue]);
// Initialize edited suggestion when AI suggestion changes
useEffect(() => {
if (aiSuggestion?.suggestion) {
setEditedSuggestion(aiSuggestion.suggestion);
}
}, [aiSuggestion?.suggestion]);
// 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) => {
@@ -91,6 +136,7 @@ const MultilineInputComponent = ({
// Immediately close popover // Immediately close popover
setPopoverOpen(false); setPopoverOpen(false);
setAiSuggestionExpanded(false);
// Prevent reopening // Prevent reopening
preventReopenRef.current = true; preventReopenRef.current = true;
@@ -117,6 +163,23 @@ const MultilineInputComponent = ({
setEditValue(e.target.value); setEditValue(e.target.value);
}, []); }, []);
// Handle accepting the AI suggestion (possibly edited)
const handleAcceptSuggestion = useCallback(() => {
// Use the edited suggestion
setEditValue(editedSuggestion);
setLocalDisplayValue(editedSuggestion);
onChange(editedSuggestion);
onBlur(editedSuggestion);
onDismissAiSuggestion?.(); // Clear the suggestion after accepting
setAiSuggestionExpanded(false);
}, [editedSuggestion, onChange, onBlur, onDismissAiSuggestion]);
// Handle dismissing the AI suggestion
const handleDismissSuggestion = useCallback(() => {
onDismissAiSuggestion?.();
setAiSuggestionExpanded(false);
}, [onDismissAiSuggestion]);
// Calculate display value // Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? ''); const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
@@ -134,14 +197,38 @@ const MultilineInputComponent = ({
<div <div
onClick={handleTriggerClick} onClick={handleTriggerClick}
className={cn( className={cn(
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer', 'px-2 py-1 rounded-md text-sm w-full cursor-pointer relative',
'overflow-hidden whitespace-nowrap text-ellipsis', 'overflow-hidden leading-tight h-[65px]',
'border', 'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input', hasError ? 'border-destructive bg-destructive/5' : 'border-input',
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
isValidating && 'opacity-50' isValidating && 'opacity-50'
)} )}
> >
{displayValue} {displayValue}
{/* AI suggestion indicator - small badge in corner, clickable to open with AI expanded */}
{hasAiSuggestion && !popoverOpen && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setAiSuggestionExpanded(true);
setPopoverOpen(true);
setEditValue(localDisplayValue || String(value ?? ''));
}}
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
title="View AI suggestion"
>
<Sparkles className="h-3 w-3" />
<span>{aiIssues.length}</span>
</button>
)}
{/* AI validating indicator */}
{isAiValidating && (
<div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/50">
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
</div>
)}
</div> </div>
</PopoverTrigger> </PopoverTrigger>
</TooltipTrigger> </TooltipTrigger>
@@ -158,14 +245,14 @@ const MultilineInputComponent = ({
</TooltipProvider> </TooltipProvider>
<PopoverContent <PopoverContent
className="p-0 shadow-lg rounded-md" className="p-0 shadow-lg rounded-md"
style={{ width: Math.max(cellRef.current?.offsetWidth || 300, 300) }} style={{ width: Math.max(cellRef.current?.offsetWidth || 400, 400) }}
align="start" align="start"
side="bottom" side="bottom"
alignOffset={0} alignOffset={0}
sideOffset={4} sideOffset={4}
onInteractOutside={handleClosePopover}
> >
<div className="flex flex-col relative"> <div className="flex flex-col">
{/* Close button */}
<Button <Button
size="icon" size="icon"
variant="ghost" variant="ghost"
@@ -175,13 +262,96 @@ const MultilineInputComponent = ({
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
{/* Main textarea */}
<Textarea <Textarea
value={editValue} value={editValue}
onChange={handleChange} onChange={handleChange}
className="min-h-[150px] border-none focus-visible:ring-0 rounded-md p-2 pr-8" onWheel={handleTextareaWheel}
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none p-2 pr-8 resize-none"
placeholder={`Enter ${field.label || 'text'}...`} placeholder={`Enter ${field.label || 'text'}...`}
autoFocus autoFocus
/> />
{/* AI Suggestion section */}
{hasAiSuggestion && (
<div className="border-t border-purple-200 dark:border-purple-800 bg-purple-50/80 dark:bg-purple-950/30">
{/* Collapsed header - always visible */}
<button
type="button"
onClick={() => setAiSuggestionExpanded(!aiSuggestionExpanded)}
className="w-full flex items-center justify-between px-3 py-2 hover:bg-purple-100/50 dark:hover:bg-purple-900/30 transition-colors"
>
<div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
AI Suggestion
</span>
<span className="text-xs text-purple-500 dark:text-purple-400">
({aiIssues.length} {aiIssues.length === 1 ? 'issue' : 'issues'})
</span>
</div>
{aiSuggestionExpanded ? (
<ChevronUp className="h-4 w-4 text-purple-400" />
) : (
<ChevronDown className="h-4 w-4 text-purple-400" />
)}
</button>
{/* Expanded content */}
{aiSuggestionExpanded && (
<div className="px-3 pb-3 space-y-3">
{/* Issues list */}
{aiIssues.length > 0 && (
<div className="flex flex-col gap-1">
{aiIssues.map((issue, index) => (
<div
key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
>
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span>
</div>
))}
</div>
)}
{/* Editable suggestion */}
<div>
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
Suggested (editable):
</div>
<Textarea
value={editedSuggestion}
onChange={(e) => setEditedSuggestion(e.target.value)}
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"
/>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
onClick={handleAcceptSuggestion}
>
<Check className="h-3 w-3 mr-1" />
Apply
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
onClick={handleDismissSuggestion}
>
Dismiss
</Button>
</div>
</div>
)}
</div>
)}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View File

@@ -11,7 +11,8 @@ import {
Loader2, Loader2,
AlertTriangle, AlertTriangle,
ChevronRight, ChevronRight,
XCircle XCircle,
RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { import {
Dialog, Dialog,
@@ -41,10 +42,14 @@ interface SanityCheckDialogProps {
onProceed: () => void; onProceed: () => void;
/** Called when user wants to go back and fix issues */ /** Called when user wants to go back and fix issues */
onGoBack: () => void; onGoBack: () => void;
/** Called to refresh/re-run the sanity check */
onRefresh?: () => void;
/** Called to scroll to a specific product */ /** Called to scroll to a specific product */
onScrollToProduct?: (productIndex: number) => void; onScrollToProduct?: (productIndex: number) => void;
/** Product names for display (indexed by product index) */ /** Product names for display (indexed by product index) */
productNames?: Record<number, string>; productNames?: Record<number, string>;
/** Number of validation errors (required fields, etc.) */
validationErrorCount?: number;
} }
export function SanityCheckDialog({ export function SanityCheckDialog({
@@ -55,11 +60,15 @@ export function SanityCheckDialog({
result, result,
onProceed, onProceed,
onGoBack, onGoBack,
onRefresh,
onScrollToProduct, onScrollToProduct,
productNames = {} productNames = {},
validationErrorCount = 0
}: SanityCheckDialogProps) { }: SanityCheckDialogProps) {
const hasIssues = result?.issues && result.issues.length > 0; const hasSanityIssues = result?.issues && result.issues.length > 0;
const passed = !isChecking && !error && !hasIssues && result; const hasValidationErrors = validationErrorCount > 0;
const hasAnyIssues = hasSanityIssues || hasValidationErrors;
const allClear = !isChecking && !error && !hasSanityIssues && result;
// Group issues by severity/field for better organization // Group issues by severity/field for better organization
const issuesByField = result?.issues?.reduce((acc, issue) => { const issuesByField = result?.issues?.reduce((acc, issue) => {
@@ -75,41 +84,66 @@ export function SanityCheckDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]"> <DialogContent className="sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <div className="flex items-center justify-between">
{isChecking ? ( <DialogTitle className="flex items-center gap-2">
<> {isChecking ? (
<Loader2 className="h-5 w-5 animate-spin text-purple-500" /> <>
Running Sanity Check... <Loader2 className="h-5 w-5 animate-spin text-purple-500" />
</> Running Sanity Check...
) : error ? ( </>
<> ) : error ? (
<XCircle className="h-5 w-5 text-red-500" /> <>
Sanity Check Failed <XCircle className="h-5 w-5 text-red-500" />
</> Sanity Check Failed
) : passed ? ( </>
<> ) : hasAnyIssues ? (
<CheckCircle className="h-5 w-5 text-green-500" /> <>
Sanity Check Passed <AlertTriangle className="h-5 w-5 text-amber-500" />
</> {hasValidationErrors && hasSanityIssues
) : hasIssues ? ( ? 'Validation Errors & Issues Found'
<> : hasValidationErrors
<AlertTriangle className="h-5 w-5 text-amber-500" /> ? 'Validation Errors'
Issues Found : 'Issues Found'}
</> </>
) : ( ) : allClear ? (
'Sanity Check' <>
<CheckCircle className="h-5 w-5 text-green-500" />
Ready to Continue
</>
) : (
'Pre-flight Check'
)}
</DialogTitle>
{/* Refresh button - only show when not checking */}
{!isChecking && onRefresh && result && (
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
className="h-8 px-2 text-muted-foreground hover:text-foreground"
title="Run sanity check again"
>
<RefreshCw className="h-4 w-4 mr-1" />
Refresh
</Button>
)} )}
</DialogTitle> </div>
<DialogDescription> <DialogDescription>
{isChecking {isChecking
? 'Reviewing products for consistency and appropriateness...' ? 'Reviewing products for consistency and appropriateness...'
: error : error
? 'An error occurred while checking your products.' ? 'An error occurred while checking your products.'
: passed : allClear && !hasValidationErrors
? 'All products look good! No consistency issues detected.' ? 'All products look good! No issues detected.'
: hasIssues : hasAnyIssues
? `Found ${result?.issues.length} potential issue${result?.issues.length === 1 ? '' : 's'} to review.` ? buildIssuesSummary(validationErrorCount, result?.issues?.length || 0)
: 'Checking your products...'} : 'Checking your products...'}
{/* Show when results were cached */}
{!isChecking && result?.checkedAt && (
<span className="block text-xs text-muted-foreground mt-1">
Last checked {formatTimeAgo(result.checkedAt)}
</span>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -136,8 +170,8 @@ export function SanityCheckDialog({
</div> </div>
)} )}
{/* Success state */} {/* Success state - only show if no validation errors either */}
{passed && !isChecking && ( {allClear && !hasValidationErrors && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200"> <div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
<div> <div>
@@ -149,8 +183,22 @@ export function SanityCheckDialog({
</div> </div>
)} )}
{/* Issues list */} {/* Validation errors warning */}
{hasIssues && !isChecking && ( {hasValidationErrors && !isChecking && (
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-4">
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-red-800">Validation Errors</p>
<p className="text-sm text-red-600 mt-1">
There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields, invalid values, etc.) in your data.
These should be fixed before continuing.
</p>
</div>
</div>
)}
{/* Sanity check issues list */}
{hasSanityIssues && !isChecking && (
<ScrollArea className="max-h-[400px] pr-4"> <ScrollArea className="max-h-[400px] pr-4">
<div className="space-y-4"> <div className="space-y-4">
{/* Summary */} {/* Summary */}
@@ -229,18 +277,18 @@ export function SanityCheckDialog({
Close Close
</Button> </Button>
</> </>
) : passed ? ( ) : allClear && !hasValidationErrors ? (
<Button onClick={onProceed}> <Button onClick={onProceed}>
Continue to Next Step Continue to Next Step
<ChevronRight className="h-4 w-4 ml-1" /> <ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
) : hasIssues ? ( ) : hasAnyIssues ? (
<> <>
<Button variant="outline" onClick={onGoBack}> <Button variant="outline" onClick={onGoBack}>
Review Issues Go Back & Fix
</Button> </Button>
<Button onClick={onProceed}> <Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
Proceed Anyway {hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'}
<ChevronRight className="h-4 w-4 ml-1" /> <ChevronRight className="h-4 w-4 ml-1" />
</Button> </Button>
</> </>
@@ -273,3 +321,46 @@ function formatFieldName(field: string): string {
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
} }
/**
* Format a timestamp as a relative time string
*/
function formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds} seconds ago`;
const minutes = Math.floor(seconds / 60);
if (minutes === 1) return '1 minute ago';
if (minutes < 60) return `${minutes} minutes ago`;
const hours = Math.floor(minutes / 60);
if (hours === 1) return '1 hour ago';
if (hours < 24) return `${hours} hours ago`;
return 'over a day ago';
}
/**
* Build a summary string describing both validation errors and sanity issues
*/
function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string {
const parts: string[] = [];
if (validationErrorCount > 0) {
parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`);
}
if (sanityIssueCount > 0) {
parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`);
}
if (parts.length === 2) {
return `Found ${parts[0]} and ${parts[1]} to review.`;
} else if (parts.length === 1) {
return `Found ${parts[0]} to review.`;
}
return 'Review the issues below.';
}

View File

@@ -29,7 +29,12 @@ export interface ProductForValidation {
company_name?: string; company_name?: string;
company_id?: string | number; company_id?: string | number;
line_name?: string; line_name?: string;
line_id?: string | number;
subline_name?: string;
subline_id?: string | number;
categories?: string; categories?: string;
// Sibling context for naming decisions
siblingNames?: string[];
} }
// Debounce delay in milliseconds // Debounce delay in milliseconds

View File

@@ -3,6 +3,9 @@
* *
* Runs batch sanity check on products before proceeding to next step. * Runs batch sanity check on products before proceeding to next step.
* Checks for consistency and appropriateness across products. * Checks for consistency and appropriateness across products.
*
* Results are cached locally - clicking "Sanity Check" again shows cached
* results without making a new API call. Use "Refresh" to force a new check.
*/ */
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef } from 'react';
@@ -21,6 +24,8 @@ export interface SanityCheckResult {
latencyMs?: number; latencyMs?: number;
totalProducts?: number; totalProducts?: number;
issueCount?: number; issueCount?: number;
/** Timestamp when check was run */
checkedAt?: number;
} }
export interface SanityCheckState { export interface SanityCheckState {
@@ -114,7 +119,8 @@ export function useSanityCheck() {
summary: data.summary || 'Check complete', summary: data.summary || 'Check complete',
latencyMs: data.latencyMs, latencyMs: data.latencyMs,
totalProducts: products.length, totalProducts: products.length,
issueCount: data.issues?.length || 0 issueCount: data.issues?.length || 0,
checkedAt: Date.now()
}; };
setState(prev => ({ setState(prev => ({
@@ -208,6 +214,11 @@ export function useSanityCheck() {
*/ */
const passed = state.hasRun && !state.isChecking && !state.error && !hasIssues; const passed = state.hasRun && !state.isChecking && !state.error && !hasIssues;
/**
* Check if we have cached results that can be displayed
*/
const hasCachedResults = state.hasRun && state.result !== null && !state.isChecking;
return { return {
// State // State
isChecking: state.isChecking, isChecking: state.isChecking,
@@ -216,11 +227,13 @@ export function useSanityCheck() {
hasRun: state.hasRun, hasRun: state.hasRun,
hasIssues, hasIssues,
passed, passed,
hasCachedResults,
// Computed // Computed
issues: state.result?.issues || [], issues: state.result?.issues || [],
summary: state.result?.summary || null, summary: state.result?.summary || null,
issueCount: state.result?.issueCount || 0, issueCount: state.result?.issueCount || 0,
checkedAt: state.result?.checkedAt || null,
// Actions // Actions
runCheck, runCheck,

View File

@@ -761,17 +761,28 @@ export const useValidationStore = create<ValidationStore>()(
// ========================================================================= // =========================================================================
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => { setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => {
// Debug: Log what we're setting
console.log('[Store] setInlineAiSuggestion called:', {
productIndex,
field,
result,
});
set((state) => { set((state) => {
const existing = state.inlineAi.suggestions.get(productIndex) || {}; const existing = state.inlineAi.suggestions.get(productIndex) || {};
state.inlineAi.suggestions.set(productIndex, { const newSuggestion = {
...existing, ...existing,
[field]: result, [field]: result,
dismissed: { dismissed: {
...existing.dismissed, ...existing.dismissed,
[field]: false, // Reset dismissed state when new suggestion arrives [field]: false, // Reset dismissed state when new suggestion arrives
}, },
}); };
state.inlineAi.suggestions.set(productIndex, newSuggestion);
state.inlineAi.validating.delete(`${productIndex}-${field}`); state.inlineAi.validating.delete(`${productIndex}-${field}`);
// Debug: Log what's in the Map now
console.log('[Store] After set, suggestions Map has:', productIndex, state.inlineAi.suggestions.get(productIndex));
}); });
}, },