Lots of new AI tasks tweaks and fixes
This commit is contained in:
@@ -409,8 +409,12 @@ router.post('/validate/sanity-check', async (req, res) => {
|
||||
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, {
|
||||
products
|
||||
products,
|
||||
pool
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -327,7 +327,8 @@ async function runTask(taskId, payload = {}) {
|
||||
...payload,
|
||||
// Inject dependencies tasks may need
|
||||
provider: groqProvider,
|
||||
pool: appPool,
|
||||
// Use pool from payload if provided (from route), fall back to stored appPool
|
||||
pool: payload.pool || appPool,
|
||||
logger
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,6 +5,33 @@
|
||||
* 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
|
||||
* Combines database prompts with product data
|
||||
@@ -50,13 +77,17 @@ function buildDescriptionUserPrompt(product, prompts) {
|
||||
|
||||
// Add response format instructions
|
||||
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('RESPOND WITH JSON:');
|
||||
parts.push(JSON.stringify({
|
||||
isValid: 'true/false',
|
||||
suggestion: 'improved description if changes needed, or null if valid',
|
||||
issues: ['issue 1', 'issue 2 (empty array if valid)']
|
||||
isValid: 'true if perfect, false if ANY changes needed',
|
||||
suggestion: 'REQUIRED when isValid is false - the complete improved description',
|
||||
issues: ['list each problem found (empty array only if isValid is true)']
|
||||
}, null, 2));
|
||||
|
||||
return parts.join('\n');
|
||||
@@ -72,11 +103,35 @@ function buildDescriptionUserPrompt(product, prompts) {
|
||||
function parseDescriptionResponse(parsed, content) {
|
||||
// If we got valid parsed JSON, use it
|
||||
if (parsed && typeof parsed.isValid === 'boolean') {
|
||||
return {
|
||||
isValid: parsed.isValid,
|
||||
suggestion: parsed.suggestion || null,
|
||||
issues: Array.isArray(parsed.issues) ? parsed.issues : []
|
||||
};
|
||||
// Sanitize issues - AI sometimes returns malformed escape sequences
|
||||
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||
const issues = rawIssues
|
||||
.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
|
||||
@@ -100,11 +155,16 @@ function parseDescriptionResponse(parsed, content) {
|
||||
const issuesContent = issuesMatch[1];
|
||||
const issueStrings = issuesContent.match(/"([^"]+)"/g);
|
||||
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 {
|
||||
// Default to valid if we can't parse anything
|
||||
return { isValid: true, suggestion: null, issues: [] };
|
||||
|
||||
@@ -5,6 +5,33 @@
|
||||
* 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
|
||||
* Combines database prompts with product data
|
||||
@@ -13,7 +40,9 @@
|
||||
* @param {string} product.name - Current product name
|
||||
* @param {string} [product.company_name] - Company name
|
||||
* @param {string} [product.line_name] - Product line name
|
||||
* @param {string} [product.subline_name] - Product subline name
|
||||
* @param {string} [product.description] - Product description (for context)
|
||||
* @param {string[]} [product.siblingNames] - Names of other products in the same line
|
||||
* @param {Object} prompts - Prompts loaded from database
|
||||
* @param {string} prompts.general - General naming conventions
|
||||
* @param {string} [prompts.companySpecific] - Company-specific rules
|
||||
@@ -40,11 +69,32 @@ function buildNameUserPrompt(product, prompts) {
|
||||
parts.push(`NAME: "${product.name || ''}"`);
|
||||
parts.push(`COMPANY: ${product.company_name || 'Unknown'}`);
|
||||
parts.push(`LINE: ${product.line_name || 'None'}`);
|
||||
if (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
|
||||
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
|
||||
parts.push('');
|
||||
parts.push('RESPOND WITH JSON:');
|
||||
@@ -65,24 +115,62 @@ function buildNameUserPrompt(product, prompts) {
|
||||
* @returns {Object}
|
||||
*/
|
||||
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 (parsed && typeof parsed.isValid === 'boolean') {
|
||||
return {
|
||||
isValid: parsed.isValid,
|
||||
suggestion: parsed.suggestion || null,
|
||||
issues: Array.isArray(parsed.issues) ? parsed.issues : []
|
||||
};
|
||||
// Sanitize issues - AI sometimes returns malformed escape sequences
|
||||
const rawIssues = Array.isArray(parsed.issues) ? parsed.issues : [];
|
||||
const issues = rawIssues
|
||||
.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 {
|
||||
// Look for isValid pattern
|
||||
const isValidMatch = content.match(/"isValid"\s*:\s*(true|false)/i);
|
||||
// Look for isValid pattern - handle both boolean and quoted string
|
||||
// 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;
|
||||
|
||||
// Look for suggestion
|
||||
const suggestionMatch = content.match(/"suggestion"\s*:\s*"([^"]+)"/);
|
||||
const suggestion = suggestionMatch ? suggestionMatch[1] : null;
|
||||
console.log('[parseNameResponse] Regex extraction:', {
|
||||
isValidMatch: isValidMatch?.[0],
|
||||
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
|
||||
const issuesMatch = content.match(/"issues"\s*:\s*\[([\s\S]*?)\]/);
|
||||
@@ -91,11 +179,16 @@ function parseNameResponse(parsed, content) {
|
||||
const issuesContent = issuesMatch[1];
|
||||
const issueStrings = issuesContent.match(/"([^"]+)"/g);
|
||||
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 {
|
||||
// Default to valid if we can't parse anything
|
||||
return { isValid: true, suggestion: null, issues: [] };
|
||||
|
||||
@@ -63,8 +63,33 @@ class GroqProvider {
|
||||
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);
|
||||
|
||||
// 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 usage = response.usage || {};
|
||||
|
||||
|
||||
@@ -89,16 +89,25 @@ function createDescriptionValidationTask() {
|
||||
],
|
||||
model: MODELS.LARGE, // openai/gpt-oss-120b - better for content analysis
|
||||
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' }
|
||||
});
|
||||
|
||||
// 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
|
||||
result = parseDescriptionResponse(response.parsed, response.content);
|
||||
} catch (jsonError) {
|
||||
// If JSON mode failed, check if we have failedGeneration to parse
|
||||
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);
|
||||
response = { latencyMs: 0, usage: {}, model: MODELS.LARGE };
|
||||
} else {
|
||||
@@ -111,9 +120,14 @@ function createDescriptionValidationTask() {
|
||||
],
|
||||
model: MODELS.LARGE,
|
||||
temperature: 0.3,
|
||||
maxTokens: 500
|
||||
maxTokens: 2000 // Reasoning models need extra tokens for thinking
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,12 +71,26 @@ function createNameValidationTask() {
|
||||
const companyKey = product.company_id || product.company_name || product.company;
|
||||
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
|
||||
validateRequiredPrompts(prompts, 'name_validation');
|
||||
|
||||
// Build the user prompt with database-loaded 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 result;
|
||||
|
||||
@@ -87,18 +101,27 @@ function createNameValidationTask() {
|
||||
{ role: 'system', content: prompts.system },
|
||||
{ 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
|
||||
maxTokens: 300,
|
||||
maxTokens: 1500, // Reasoning models need extra tokens for thinking
|
||||
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
|
||||
result = parseNameResponse(response.parsed, response.content);
|
||||
} catch (jsonError) {
|
||||
// If JSON mode failed, check if we have failedGeneration to parse
|
||||
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);
|
||||
response = { latencyMs: 0, usage: {}, model: MODELS.SMALL };
|
||||
} else {
|
||||
@@ -111,9 +134,14 @@ function createNameValidationTask() {
|
||||
],
|
||||
model: MODELS.SMALL,
|
||||
temperature: 0.2,
|
||||
maxTokens: 300
|
||||
maxTokens: 1500 // Reasoning models need extra tokens for thinking
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,20 @@
|
||||
*
|
||||
* Displays an AI suggestion with accept/dismiss actions.
|
||||
* 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 {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AiSuggestionBadgeProps {
|
||||
@@ -20,8 +30,10 @@ interface AiSuggestionBadgeProps {
|
||||
onDismiss: () => void;
|
||||
/** Additional CSS classes */
|
||||
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;
|
||||
/** Whether to start in collapsible mode (icon + count) - used for description field */
|
||||
collapsible?: boolean;
|
||||
}
|
||||
|
||||
export function AiSuggestionBadge({
|
||||
@@ -30,96 +42,212 @@ export function AiSuggestionBadge({
|
||||
onAccept,
|
||||
onDismiss,
|
||||
className,
|
||||
compact = false
|
||||
compact = false,
|
||||
collapsible = false
|
||||
}: AiSuggestionBadgeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Compact mode for name fields - inline suggestion with accept/dismiss
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
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',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-3 w-3 text-purple-500 flex-shrink-0" />
|
||||
<span className="text-purple-700 dark:text-purple-300 truncate max-w-[200px]">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
title="Accept suggestion"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</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();
|
||||
}}
|
||||
title="Dismiss"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 p-0 text-green-600 hover:text-green-700 hover:bg-green-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Accept suggestion</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={200}>
|
||||
<TooltipTrigger asChild>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
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',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
AI Suggestion
|
||||
</span>
|
||||
{/* Header with collapse button if collapsible */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
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>
|
||||
|
||||
{/* Suggestion content */}
|
||||
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed">
|
||||
{suggestion}
|
||||
</div>
|
||||
|
||||
{/* Issues list (if any) */}
|
||||
{/* Issues list */}
|
||||
{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) => (
|
||||
<div
|
||||
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>
|
||||
</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 */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<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"
|
||||
onClick={onAccept}
|
||||
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={(e) => {
|
||||
e.stopPropagation();
|
||||
onAccept();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept
|
||||
@@ -127,8 +255,11 @@ export function AiSuggestionBadge({
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={onDismiss}
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
}}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
|
||||
@@ -242,7 +242,7 @@ const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
|
||||
disabled={disabled}
|
||||
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" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -65,6 +65,8 @@ export const ValidationContainer = ({
|
||||
|
||||
// Sanity check dialog state
|
||||
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
|
||||
useCopyDownValidation();
|
||||
@@ -128,9 +130,8 @@ export const ValidationContainer = ({
|
||||
}
|
||||
}, [onBack]);
|
||||
|
||||
// Trigger sanity check when user clicks Continue
|
||||
const handleTriggerSanityCheck = useCallback(() => {
|
||||
// Get current rows and prepare for sanity check
|
||||
// Build products array for sanity check
|
||||
const buildProductsForSanityCheck = useCallback((): ProductForSanityCheck[] => {
|
||||
const rows = useValidationStore.getState().rows;
|
||||
const fields = useValidationStore.getState().fields;
|
||||
|
||||
@@ -145,7 +146,7 @@ export const ValidationContainer = ({
|
||||
};
|
||||
|
||||
// Convert rows to sanity check format
|
||||
const products: ProductForSanityCheck[] = rows.map((row) => ({
|
||||
return rows.map((row) => ({
|
||||
name: row.name as string | undefined,
|
||||
supplier: row.supplier as string | undefined,
|
||||
supplier_name: getFieldLabel('supplier', row.supplier),
|
||||
@@ -166,11 +167,30 @@ export const ValidationContainer = ({
|
||||
width: row.width 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);
|
||||
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
|
||||
const handleSanityCheckProceed = useCallback(() => {
|
||||
@@ -179,11 +199,11 @@ export const ValidationContainer = ({
|
||||
handleNext();
|
||||
}, [handleNext, sanityCheck]);
|
||||
|
||||
// Handle going back from sanity check dialog
|
||||
// Handle going back from sanity check dialog (keeps results cached)
|
||||
const handleSanityCheckGoBack = useCallback(() => {
|
||||
setSanityCheckDialogOpen(false);
|
||||
sanityCheck.clearResults();
|
||||
}, [sanityCheck]);
|
||||
// Don't clear results - keep them cached for next time
|
||||
}, []);
|
||||
|
||||
// Handle scrolling to a specific product from sanity check issue
|
||||
const handleScrollToProduct = useCallback((productIndex: number) => {
|
||||
@@ -232,16 +252,17 @@ export const ValidationContainer = ({
|
||||
{/* Footer with navigation */}
|
||||
<ValidationFooter
|
||||
onBack={handleBack}
|
||||
onNext={handleNext}
|
||||
onProceedDirect={handleProceedDirect}
|
||||
onViewResults={handleViewResults}
|
||||
onRunCheck={handleRunCheck}
|
||||
canGoBack={!!onBack}
|
||||
canProceed={totalErrorCount === 0}
|
||||
errorCount={totalErrorCount}
|
||||
rowCount={rowCount}
|
||||
onAiValidate={aiValidation.validate}
|
||||
isAiValidating={aiValidation.isValidating}
|
||||
onShowDebug={aiValidation.showPromptPreview}
|
||||
onTriggerSanityCheck={handleTriggerSanityCheck}
|
||||
sanityCheckAvailable={true}
|
||||
isSanityChecking={sanityCheck.isChecking}
|
||||
hasRunSanityCheck={sanityCheck.hasRun}
|
||||
skipSanityCheck={skipSanityCheck}
|
||||
onSkipSanityCheckChange={setSkipSanityCheck}
|
||||
/>
|
||||
|
||||
{/* Floating selection bar - appears when rows selected */}
|
||||
@@ -272,7 +293,7 @@ export const ValidationContainer = ({
|
||||
debugData={aiValidation.debugPrompt}
|
||||
/>
|
||||
|
||||
{/* Sanity Check Dialog - auto-triggered on Continue */}
|
||||
{/* Sanity Check Dialog - shows cached results or runs new check */}
|
||||
<SanityCheckDialog
|
||||
open={sanityCheckDialogOpen}
|
||||
onOpenChange={setSanityCheckDialogOpen}
|
||||
@@ -281,8 +302,10 @@ export const ValidationContainer = ({
|
||||
result={sanityCheck.result}
|
||||
onProceed={handleSanityCheckProceed}
|
||||
onGoBack={handleSanityCheckGoBack}
|
||||
onRefresh={handleRefreshSanityCheck}
|
||||
onScrollToProduct={handleScrollToProduct}
|
||||
productNames={productNames}
|
||||
validationErrorCount={totalErrorCount}
|
||||
/>
|
||||
|
||||
{/* Template form dialog - for saving row as template */}
|
||||
|
||||
@@ -1,62 +1,61 @@
|
||||
/**
|
||||
* ValidationFooter Component
|
||||
*
|
||||
* Navigation footer with back/next buttons, AI validate, and summary info.
|
||||
* Triggers sanity check automatically when user clicks "Continue".
|
||||
* Navigation footer with back/next buttons and summary info.
|
||||
* 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 { CheckCircle, Wand2, FileText, Sparkles } from 'lucide-react';
|
||||
import { Protected } from '@/components/auth/Protected';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 {
|
||||
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;
|
||||
canProceed: boolean;
|
||||
errorCount: number;
|
||||
rowCount: number;
|
||||
onAiValidate?: () => void;
|
||||
isAiValidating?: boolean;
|
||||
onShowDebug?: () => void;
|
||||
/** Called when user clicks Continue - triggers sanity check */
|
||||
onTriggerSanityCheck?: () => void;
|
||||
/** Whether sanity check is available (Groq enabled) */
|
||||
sanityCheckAvailable?: boolean;
|
||||
/** Whether sanity check is currently running */
|
||||
isSanityChecking?: boolean;
|
||||
/** Whether sanity check has been run at least once */
|
||||
hasRunSanityCheck?: boolean;
|
||||
/** Whether to skip sanity check (debug mode) */
|
||||
skipSanityCheck?: boolean;
|
||||
/** Called when skip sanity check toggle changes */
|
||||
onSkipSanityCheckChange?: (skip: boolean) => void;
|
||||
}
|
||||
|
||||
export const ValidationFooter = ({
|
||||
onBack,
|
||||
onNext,
|
||||
onProceedDirect,
|
||||
onViewResults,
|
||||
onRunCheck,
|
||||
canGoBack,
|
||||
canProceed,
|
||||
errorCount,
|
||||
rowCount,
|
||||
onAiValidate,
|
||||
isAiValidating = false,
|
||||
onShowDebug,
|
||||
onTriggerSanityCheck,
|
||||
sanityCheckAvailable = false,
|
||||
isSanityChecking = false,
|
||||
hasRunSanityCheck = false,
|
||||
skipSanityCheck = false,
|
||||
onSkipSanityCheckChange,
|
||||
}: ValidationFooterProps) => {
|
||||
const [showErrorDialog, setShowErrorDialog] = useState(false);
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
const { user } = useContext(AuthContext);
|
||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes('admin:debug'));
|
||||
|
||||
return (
|
||||
<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 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Show Prompt Debug - Admin only */}
|
||||
{onShowDebug && (
|
||||
<Protected permission="admin:debug">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onShowDebug}
|
||||
disabled={isAiValidating}
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
Show Prompt
|
||||
</Button>
|
||||
</Protected>
|
||||
{/* Skip sanity check toggle - only for admin:debug users */}
|
||||
{hasDebugPermission && onSkipSanityCheckChange && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-2 mr-2">
|
||||
<Switch
|
||||
id="skip-sanity"
|
||||
checked={skipSanityCheck}
|
||||
onCheckedChange={onSkipSanityCheckChange}
|
||||
className="data-[state=checked]:bg-amber-500"
|
||||
/>
|
||||
<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 */}
|
||||
{onAiValidate && (
|
||||
{/* Before first sanity check: single "Continue" button that runs the check */}
|
||||
{!hasRunSanityCheck && !skipSanityCheck && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onAiValidate}
|
||||
disabled={isAiValidating || rowCount === 0}
|
||||
onClick={onRunCheck}
|
||||
disabled={isSanityChecking || rowCount === 0}
|
||||
title={
|
||||
!canProceed
|
||||
? `There are ${errorCount} validation errors`
|
||||
: 'Continue to image upload'
|
||||
}
|
||||
>
|
||||
<Wand2 className="h-4 w-4 mr-1" />
|
||||
{isAiValidating ? 'Validating...' : 'AI Validate'}
|
||||
{isSanityChecking ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Continue'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
{onNext && (
|
||||
{/* After first sanity check: show all three options */}
|
||||
{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
|
||||
onClick={handleContinueClick}
|
||||
onClick={onProceedDirect}
|
||||
disabled={isSanityChecking}
|
||||
title={
|
||||
!canProceed
|
||||
? `There are ${errorCount} validation errors`
|
||||
: sanityCheckAvailable
|
||||
? 'Run sanity check and continue to image upload'
|
||||
: 'Continue to image upload'
|
||||
: 'Continue to image upload'
|
||||
}
|
||||
>
|
||||
{sanityCheckAvailable && canProceed && (
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{canProceed ? 'Continue' : 'Next'}
|
||||
Continue
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -93,7 +93,7 @@ const getCellComponent = (field: Field<string>, optionCount: number = 0) => {
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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 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)
|
||||
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
|
||||
// Uses setTimeout(0) to defer validation AFTER browser paint
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
// Defer validation to after the browser paints
|
||||
@@ -534,7 +554,8 @@ const CellWrapper = memo(({
|
||||
|
||||
// Trigger inline AI validation for name/description fields
|
||||
// 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 fields = useValidationStore.getState().fields;
|
||||
if (currentRow) {
|
||||
@@ -554,6 +575,33 @@ const CellWrapper = memo(({
|
||||
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
|
||||
const productPayload = {
|
||||
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_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
|
||||
@@ -691,6 +743,14 @@ const CellWrapper = memo(({
|
||||
onBlur={handleBlur}
|
||||
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
|
||||
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>
|
||||
|
||||
@@ -748,24 +808,24 @@ const CellWrapper = memo(({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline AI validation spinner */}
|
||||
{isInlineAiValidating && isInlineAiField && (
|
||||
{/* Inline AI validation spinner - only for name field (description handles it internally) */}
|
||||
{isInlineAiValidating && field.key === 'name' && (
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggestion badge - shows when AI has a suggestion for this field */}
|
||||
{showSuggestion && fieldSuggestion && (
|
||||
{/* AI Suggestion badge - only for name field (description handles it inside its popover) */}
|
||||
{showSuggestion && fieldSuggestion && field.key === 'name' && (
|
||||
<div className="absolute top-full left-0 right-0 z-20 mt-1">
|
||||
<AiSuggestionBadge
|
||||
suggestion={fieldSuggestion.suggestion!}
|
||||
issues={fieldSuggestion.issues}
|
||||
onAccept={() => {
|
||||
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
|
||||
useValidationStore.getState().acceptInlineAiSuggestion(productIndex, 'name');
|
||||
}}
|
||||
onDismiss={() => {
|
||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
|
||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'name');
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
@@ -881,6 +941,113 @@ const TemplateCell = memo(({ rowIndex, currentTemplateId, defaultBrand }: Templa
|
||||
|
||||
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
|
||||
const finalSupplier = updates.supplier ?? currentRow?.supplier;
|
||||
const finalUpc = updates.upc ?? currentRow?.upc;
|
||||
@@ -986,6 +1153,8 @@ interface VirtualRowProps {
|
||||
columns: ColumnDef<RowData>[];
|
||||
fields: Field<string>[];
|
||||
totalRowCount: number;
|
||||
/** Whether table is scrolled horizontally - used for sticky column shadow */
|
||||
isScrolledHorizontally: boolean;
|
||||
}
|
||||
|
||||
const VirtualRow = memo(({
|
||||
@@ -995,6 +1164,7 @@ const VirtualRow = memo(({
|
||||
columns,
|
||||
fields,
|
||||
totalRowCount,
|
||||
isScrolledHorizontally,
|
||||
}: VirtualRowProps) => {
|
||||
// Subscribe to row data - this is THE subscription for all cell values in this row
|
||||
const rowData = useValidationStore(
|
||||
@@ -1044,7 +1214,14 @@ const VirtualRow = memo(({
|
||||
|
||||
// Subscribe to inline AI suggestions for this row (for name/description validation)
|
||||
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
|
||||
@@ -1065,6 +1242,13 @@ const VirtualRow = memo(({
|
||||
|
||||
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
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (copyDownMode.isActive && copyDownMode.sourceRowIndex !== null && rowIndex > copyDownMode.sourceRowIndex) {
|
||||
@@ -1083,12 +1267,14 @@ const VirtualRow = memo(({
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
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}
|
||||
>
|
||||
{/* Selection checkbox cell */}
|
||||
<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={{
|
||||
width: columns[0]?.size || 40,
|
||||
minWidth: columns[0]?.size || 40,
|
||||
@@ -1103,7 +1289,7 @@ const VirtualRow = memo(({
|
||||
|
||||
{/* Template column */}
|
||||
<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={{
|
||||
width: TEMPLATE_COLUMN_WIDTH,
|
||||
minWidth: TEMPLATE_COLUMN_WIDTH,
|
||||
@@ -1162,8 +1348,22 @@ const VirtualRow = memo(({
|
||||
key={field.key}
|
||||
data-cell-field={field.key}
|
||||
className={cn(
|
||||
"px-2 py-1 border-r last:border-r-0 flex items-center overflow-hidden",
|
||||
isNameColumn && "lg:sticky lg:z-10 lg:bg-background lg:shadow-md"
|
||||
"px-2 py-2 border-r last:border-r-0 flex items-start",
|
||||
// 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={{
|
||||
width: columnWidth,
|
||||
@@ -1252,12 +1452,29 @@ export const ValidationTable = () => {
|
||||
const tableContainerRef = 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(() => {
|
||||
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
|
||||
// This avoids calling getState() during render for each row
|
||||
@@ -1373,7 +1590,9 @@ export const ValidationTable = () => {
|
||||
key={column.id || index}
|
||||
className={cn(
|
||||
"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={{
|
||||
width: column.size || 150,
|
||||
@@ -1416,6 +1635,7 @@ export const ValidationTable = () => {
|
||||
columns={columns}
|
||||
fields={fields}
|
||||
totalRowCount={rowCount}
|
||||
isScrolledHorizontally={isScrolledHorizontally}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* MultilineInput Component
|
||||
*
|
||||
* Expandable textarea cell for long text content.
|
||||
* Includes AI suggestion display when available.
|
||||
* Memoized to prevent unnecessary re-renders when parent table updates.
|
||||
*/
|
||||
|
||||
@@ -15,21 +16,36 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} 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 type { Field, SelectOption } from '../../../../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 {
|
||||
value: unknown;
|
||||
field: Field<string>;
|
||||
options?: SelectOption[];
|
||||
rowIndex: number;
|
||||
productIndex: string;
|
||||
isValidating: boolean;
|
||||
errors: ValidationError[];
|
||||
onChange: (value: unknown) => void;
|
||||
onBlur: (value: unknown) => 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 = ({
|
||||
@@ -39,16 +55,38 @@ const MultilineInputComponent = ({
|
||||
errors,
|
||||
onChange,
|
||||
onBlur,
|
||||
aiSuggestion,
|
||||
isAiValidating,
|
||||
onDismissAiSuggestion,
|
||||
}: MultilineInputProps) => {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
|
||||
const [aiSuggestionExpanded, setAiSuggestionExpanded] = useState(false);
|
||||
const [editedSuggestion, setEditedSuggestion] = useState('');
|
||||
const cellRef = useRef<HTMLDivElement>(null);
|
||||
const preventReopenRef = useRef(false);
|
||||
|
||||
const hasError = errors.length > 0;
|
||||
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
|
||||
useEffect(() => {
|
||||
const strValue = String(value ?? '');
|
||||
@@ -57,6 +95,13 @@ const MultilineInputComponent = ({
|
||||
}
|
||||
}, [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
|
||||
const handleTriggerClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -91,6 +136,7 @@ const MultilineInputComponent = ({
|
||||
|
||||
// Immediately close popover
|
||||
setPopoverOpen(false);
|
||||
setAiSuggestionExpanded(false);
|
||||
|
||||
// Prevent reopening
|
||||
preventReopenRef.current = true;
|
||||
@@ -117,6 +163,23 @@ const MultilineInputComponent = ({
|
||||
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
|
||||
const displayValue = localDisplayValue !== null ? localDisplayValue : String(value ?? '');
|
||||
|
||||
@@ -134,14 +197,38 @@ const MultilineInputComponent = ({
|
||||
<div
|
||||
onClick={handleTriggerClick}
|
||||
className={cn(
|
||||
'px-2 py-1 h-8 rounded-md text-sm w-full cursor-pointer',
|
||||
'overflow-hidden whitespace-nowrap text-ellipsis',
|
||||
'px-2 py-1 rounded-md text-sm w-full cursor-pointer relative',
|
||||
'overflow-hidden leading-tight h-[65px]',
|
||||
'border',
|
||||
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'
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
@@ -158,14 +245,14 @@ const MultilineInputComponent = ({
|
||||
</TooltipProvider>
|
||||
<PopoverContent
|
||||
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"
|
||||
side="bottom"
|
||||
alignOffset={0}
|
||||
sideOffset={4}
|
||||
onInteractOutside={handleClosePopover}
|
||||
>
|
||||
<div className="flex flex-col relative">
|
||||
<div className="flex flex-col">
|
||||
{/* Close button */}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -175,13 +262,96 @@ const MultilineInputComponent = ({
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
|
||||
{/* Main textarea */}
|
||||
<Textarea
|
||||
value={editValue}
|
||||
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'}...`}
|
||||
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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
XCircle
|
||||
XCircle,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -41,10 +42,14 @@ interface SanityCheckDialogProps {
|
||||
onProceed: () => void;
|
||||
/** Called when user wants to go back and fix issues */
|
||||
onGoBack: () => void;
|
||||
/** Called to refresh/re-run the sanity check */
|
||||
onRefresh?: () => void;
|
||||
/** Called to scroll to a specific product */
|
||||
onScrollToProduct?: (productIndex: number) => void;
|
||||
/** Product names for display (indexed by product index) */
|
||||
productNames?: Record<number, string>;
|
||||
/** Number of validation errors (required fields, etc.) */
|
||||
validationErrorCount?: number;
|
||||
}
|
||||
|
||||
export function SanityCheckDialog({
|
||||
@@ -55,11 +60,15 @@ export function SanityCheckDialog({
|
||||
result,
|
||||
onProceed,
|
||||
onGoBack,
|
||||
onRefresh,
|
||||
onScrollToProduct,
|
||||
productNames = {}
|
||||
productNames = {},
|
||||
validationErrorCount = 0
|
||||
}: SanityCheckDialogProps) {
|
||||
const hasIssues = result?.issues && result.issues.length > 0;
|
||||
const passed = !isChecking && !error && !hasIssues && result;
|
||||
const hasSanityIssues = result?.issues && result.issues.length > 0;
|
||||
const hasValidationErrors = validationErrorCount > 0;
|
||||
const hasAnyIssues = hasSanityIssues || hasValidationErrors;
|
||||
const allClear = !isChecking && !error && !hasSanityIssues && result;
|
||||
|
||||
// Group issues by severity/field for better organization
|
||||
const issuesByField = result?.issues?.reduce((acc, issue) => {
|
||||
@@ -75,41 +84,66 @@ export function SanityCheckDialog({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isChecking ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
|
||||
Running Sanity Check...
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
Sanity Check Failed
|
||||
</>
|
||||
) : passed ? (
|
||||
<>
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
Sanity Check Passed
|
||||
</>
|
||||
) : hasIssues ? (
|
||||
<>
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Issues Found
|
||||
</>
|
||||
) : (
|
||||
'Sanity Check'
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{isChecking ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
|
||||
Running Sanity Check...
|
||||
</>
|
||||
) : error ? (
|
||||
<>
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
Sanity Check Failed
|
||||
</>
|
||||
) : hasAnyIssues ? (
|
||||
<>
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
{hasValidationErrors && hasSanityIssues
|
||||
? 'Validation Errors & Issues Found'
|
||||
: hasValidationErrors
|
||||
? 'Validation Errors'
|
||||
: 'Issues Found'}
|
||||
</>
|
||||
) : allClear ? (
|
||||
<>
|
||||
<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>
|
||||
{isChecking
|
||||
? 'Reviewing products for consistency and appropriateness...'
|
||||
: error
|
||||
? 'An error occurred while checking your products.'
|
||||
: passed
|
||||
? 'All products look good! No consistency issues detected.'
|
||||
: hasIssues
|
||||
? `Found ${result?.issues.length} potential issue${result?.issues.length === 1 ? '' : 's'} to review.`
|
||||
: allClear && !hasValidationErrors
|
||||
? 'All products look good! No issues detected.'
|
||||
: hasAnyIssues
|
||||
? buildIssuesSummary(validationErrorCount, result?.issues?.length || 0)
|
||||
: 'Checking your products...'}
|
||||
{/* Show when results were cached */}
|
||||
{!isChecking && result?.checkedAt && (
|
||||
<span className="block text-xs text-muted-foreground mt-1">
|
||||
Last checked {formatTimeAgo(result.checkedAt)}
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -136,8 +170,8 @@ export function SanityCheckDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{passed && !isChecking && (
|
||||
{/* Success state - only show if no validation errors either */}
|
||||
{allClear && !hasValidationErrors && !isChecking && (
|
||||
<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" />
|
||||
<div>
|
||||
@@ -149,8 +183,22 @@ export function SanityCheckDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{hasIssues && !isChecking && (
|
||||
{/* Validation errors warning */}
|
||||
{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">
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
@@ -229,18 +277,18 @@ export function SanityCheckDialog({
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
) : passed ? (
|
||||
) : allClear && !hasValidationErrors ? (
|
||||
<Button onClick={onProceed}>
|
||||
Continue to Next Step
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : hasIssues ? (
|
||||
) : hasAnyIssues ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onGoBack}>
|
||||
Review Issues
|
||||
Go Back & Fix
|
||||
</Button>
|
||||
<Button onClick={onProceed}>
|
||||
Proceed Anyway
|
||||
<Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
|
||||
{hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'}
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</>
|
||||
@@ -273,3 +321,46 @@ function formatFieldName(field: string): string {
|
||||
|
||||
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a timestamp as a relative time string
|
||||
*/
|
||||
function formatTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds} seconds ago`;
|
||||
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes === 1) return '1 minute ago';
|
||||
if (minutes < 60) return `${minutes} minutes ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours === 1) return '1 hour ago';
|
||||
if (hours < 24) return `${hours} hours ago`;
|
||||
|
||||
return 'over a day ago';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a summary string describing both validation errors and sanity issues
|
||||
*/
|
||||
function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (validationErrorCount > 0) {
|
||||
parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
if (sanityIssueCount > 0) {
|
||||
parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
if (parts.length === 2) {
|
||||
return `Found ${parts[0]} and ${parts[1]} to review.`;
|
||||
} else if (parts.length === 1) {
|
||||
return `Found ${parts[0]} to review.`;
|
||||
}
|
||||
|
||||
return 'Review the issues below.';
|
||||
}
|
||||
|
||||
@@ -29,7 +29,12 @@ export interface ProductForValidation {
|
||||
company_name?: string;
|
||||
company_id?: string | number;
|
||||
line_name?: string;
|
||||
line_id?: string | number;
|
||||
subline_name?: string;
|
||||
subline_id?: string | number;
|
||||
categories?: string;
|
||||
// Sibling context for naming decisions
|
||||
siblingNames?: string[];
|
||||
}
|
||||
|
||||
// Debounce delay in milliseconds
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
*
|
||||
* Runs batch sanity check on products before proceeding to next step.
|
||||
* 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';
|
||||
@@ -21,6 +24,8 @@ export interface SanityCheckResult {
|
||||
latencyMs?: number;
|
||||
totalProducts?: number;
|
||||
issueCount?: number;
|
||||
/** Timestamp when check was run */
|
||||
checkedAt?: number;
|
||||
}
|
||||
|
||||
export interface SanityCheckState {
|
||||
@@ -114,7 +119,8 @@ export function useSanityCheck() {
|
||||
summary: data.summary || 'Check complete',
|
||||
latencyMs: data.latencyMs,
|
||||
totalProducts: products.length,
|
||||
issueCount: data.issues?.length || 0
|
||||
issueCount: data.issues?.length || 0,
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
@@ -208,6 +214,11 @@ export function useSanityCheck() {
|
||||
*/
|
||||
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 {
|
||||
// State
|
||||
isChecking: state.isChecking,
|
||||
@@ -216,11 +227,13 @@ export function useSanityCheck() {
|
||||
hasRun: state.hasRun,
|
||||
hasIssues,
|
||||
passed,
|
||||
hasCachedResults,
|
||||
|
||||
// Computed
|
||||
issues: state.result?.issues || [],
|
||||
summary: state.result?.summary || null,
|
||||
issueCount: state.result?.issueCount || 0,
|
||||
checkedAt: state.result?.checkedAt || null,
|
||||
|
||||
// Actions
|
||||
runCheck,
|
||||
|
||||
@@ -761,17 +761,28 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
// =========================================================================
|
||||
|
||||
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => {
|
||||
// Debug: Log what we're setting
|
||||
console.log('[Store] setInlineAiSuggestion called:', {
|
||||
productIndex,
|
||||
field,
|
||||
result,
|
||||
});
|
||||
|
||||
set((state) => {
|
||||
const existing = state.inlineAi.suggestions.get(productIndex) || {};
|
||||
state.inlineAi.suggestions.set(productIndex, {
|
||||
const newSuggestion = {
|
||||
...existing,
|
||||
[field]: result,
|
||||
dismissed: {
|
||||
...existing.dismissed,
|
||||
[field]: false, // Reset dismissed state when new suggestion arrives
|
||||
},
|
||||
});
|
||||
};
|
||||
state.inlineAi.suggestions.set(productIndex, newSuggestion);
|
||||
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));
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user