Add Groq as AI provider + new inline AI tasks, extend database to support more prompt types
This commit is contained in:
+159
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* AiSuggestionBadge Component
|
||||
*
|
||||
* Displays an AI suggestion with accept/dismiss actions.
|
||||
* Used for inline validation suggestions on Name and Description fields.
|
||||
*/
|
||||
|
||||
import { Check, X, Sparkles, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface AiSuggestionBadgeProps {
|
||||
/** The suggested value */
|
||||
suggestion: string;
|
||||
/** List of issues found (optional) */
|
||||
issues?: string[];
|
||||
/** Called when user accepts the suggestion */
|
||||
onAccept: () => void;
|
||||
/** Called when user dismisses the suggestion */
|
||||
onDismiss: () => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show the suggestion as compact (inline) or expanded */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function AiSuggestionBadge({
|
||||
suggestion,
|
||||
issues = [],
|
||||
onAccept,
|
||||
onDismiss,
|
||||
className,
|
||||
compact = false
|
||||
}: AiSuggestionBadgeProps) {
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center 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]">
|
||||
{suggestion}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-2 p-2 mt-1 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>
|
||||
</div>
|
||||
|
||||
{/* Suggestion content */}
|
||||
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed">
|
||||
{suggestion}
|
||||
</div>
|
||||
|
||||
{/* Issues list (if any) */}
|
||||
{issues.length > 0 && (
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{issues.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" />
|
||||
<span>{issue}</span>
|
||||
</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}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading state for AI validation
|
||||
*/
|
||||
export function AiValidationLoading({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 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
|
||||
)}
|
||||
>
|
||||
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="text-purple-600 dark:text-purple-400">
|
||||
Validating with AI...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+104
-1
@@ -6,7 +6,7 @@
|
||||
* Note: Initialization effects are in index.tsx so they run before this mounts.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { useValidationStore } from '../store/validationStore';
|
||||
import {
|
||||
useTotalErrorCount,
|
||||
@@ -22,12 +22,15 @@ import { useAiValidationFlow } from '../hooks/useAiValidation';
|
||||
import { useFieldOptions } from '../hooks/useFieldOptions';
|
||||
import { useTemplateManagement } from '../hooks/useTemplateManagement';
|
||||
import { useCopyDownValidation } from '../hooks/useCopyDownValidation';
|
||||
import { useSanityCheck } from '../hooks/useSanityCheck';
|
||||
import { AiValidationProgressDialog } from '../dialogs/AiValidationProgress';
|
||||
import { AiValidationResultsDialog } from '../dialogs/AiValidationResults';
|
||||
import { AiDebugDialog } from '../dialogs/AiDebugDialog';
|
||||
import { SanityCheckDialog } from '../dialogs/SanityCheckDialog';
|
||||
import { TemplateForm } from '@/components/templates/TemplateForm';
|
||||
import { AiSuggestionsProvider } from '../contexts/AiSuggestionsContext';
|
||||
import type { CleanRowData, RowData } from '../store/types';
|
||||
import type { ProductForSanityCheck } from '../hooks/useSanityCheck';
|
||||
|
||||
interface ValidationContainerProps {
|
||||
onBack?: () => void;
|
||||
@@ -58,6 +61,10 @@ export const ValidationContainer = ({
|
||||
const aiValidation = useAiValidationFlow();
|
||||
const { data: fieldOptionsData } = useFieldOptions();
|
||||
const { loadTemplates } = useTemplateManagement();
|
||||
const sanityCheck = useSanityCheck();
|
||||
|
||||
// Sanity check dialog state
|
||||
const [sanityCheckDialogOpen, setSanityCheckDialogOpen] = useState(false);
|
||||
|
||||
// Handle UPC validation after copy-down operations on supplier/upc fields
|
||||
useCopyDownValidation();
|
||||
@@ -121,6 +128,87 @@ export const ValidationContainer = ({
|
||||
}
|
||||
}, [onBack]);
|
||||
|
||||
// Trigger sanity check when user clicks Continue
|
||||
const handleTriggerSanityCheck = useCallback(() => {
|
||||
// Get current rows and prepare for sanity check
|
||||
const rows = useValidationStore.getState().rows;
|
||||
const fields = useValidationStore.getState().fields;
|
||||
|
||||
// Build lookup for field options (for display names)
|
||||
const getFieldLabel = (fieldKey: string, value: unknown): string | undefined => {
|
||||
const field = fields.find(f => f.key === fieldKey);
|
||||
if (field && field.fieldType.type === 'select' && 'options' in field.fieldType) {
|
||||
const option = field.fieldType.options?.find(o => o.value === String(value));
|
||||
return option?.label;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Convert rows to sanity check format
|
||||
const products: ProductForSanityCheck[] = rows.map((row) => ({
|
||||
name: row.name as string | undefined,
|
||||
supplier: row.supplier as string | undefined,
|
||||
supplier_name: getFieldLabel('supplier', row.supplier),
|
||||
company: row.company as string | undefined,
|
||||
company_name: getFieldLabel('company', row.company),
|
||||
supplier_no: row.supplier_no as string | undefined,
|
||||
msrp: row.msrp as string | number | undefined,
|
||||
cost_each: row.cost_each as string | number | undefined,
|
||||
qty_per_unit: row.qty_per_unit as string | number | undefined,
|
||||
case_qty: row.case_qty as string | number | undefined,
|
||||
tax_cat: row.tax_cat as string | number | undefined,
|
||||
tax_cat_name: getFieldLabel('tax_cat', row.tax_cat),
|
||||
size_cat: row.size_cat as string | number | undefined,
|
||||
size_cat_name: getFieldLabel('size_cat', row.size_cat),
|
||||
themes: row.themes as string | undefined,
|
||||
weight: row.weight as string | number | undefined,
|
||||
length: row.length as string | number | undefined,
|
||||
width: row.width as string | number | undefined,
|
||||
height: row.height as string | number | undefined,
|
||||
}));
|
||||
|
||||
// Open dialog and run check
|
||||
setSanityCheckDialogOpen(true);
|
||||
sanityCheck.runCheck(products);
|
||||
}, [sanityCheck]);
|
||||
|
||||
// Handle proceeding after sanity check
|
||||
const handleSanityCheckProceed = useCallback(() => {
|
||||
setSanityCheckDialogOpen(false);
|
||||
sanityCheck.clearResults();
|
||||
handleNext();
|
||||
}, [handleNext, sanityCheck]);
|
||||
|
||||
// Handle going back from sanity check dialog
|
||||
const handleSanityCheckGoBack = useCallback(() => {
|
||||
setSanityCheckDialogOpen(false);
|
||||
sanityCheck.clearResults();
|
||||
}, [sanityCheck]);
|
||||
|
||||
// Handle scrolling to a specific product from sanity check issue
|
||||
const handleScrollToProduct = useCallback((productIndex: number) => {
|
||||
// Find the row element and scroll to it
|
||||
const rowElement = document.querySelector(`[data-row-index="${productIndex}"]`);
|
||||
if (rowElement) {
|
||||
rowElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Briefly highlight the row
|
||||
rowElement.classList.add('ring-2', 'ring-purple-500');
|
||||
setTimeout(() => {
|
||||
rowElement.classList.remove('ring-2', 'ring-purple-500');
|
||||
}, 2000);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Build product names lookup for sanity check dialog
|
||||
const productNames = useMemo(() => {
|
||||
const rows = useValidationStore.getState().rows;
|
||||
const names: Record<number, string> = {};
|
||||
rows.forEach((row, index) => {
|
||||
names[index] = (row.name as string) || `Product ${index + 1}`;
|
||||
});
|
||||
return names;
|
||||
}, [rowCount]); // Depend on rowCount to update when rows change
|
||||
|
||||
return (
|
||||
<AiSuggestionsProvider
|
||||
getCompanyName={getCompanyName}
|
||||
@@ -152,6 +240,8 @@ export const ValidationContainer = ({
|
||||
onAiValidate={aiValidation.validate}
|
||||
isAiValidating={aiValidation.isValidating}
|
||||
onShowDebug={aiValidation.showPromptPreview}
|
||||
onTriggerSanityCheck={handleTriggerSanityCheck}
|
||||
sanityCheckAvailable={true}
|
||||
/>
|
||||
|
||||
{/* Floating selection bar - appears when rows selected */}
|
||||
@@ -182,6 +272,19 @@ export const ValidationContainer = ({
|
||||
debugData={aiValidation.debugPrompt}
|
||||
/>
|
||||
|
||||
{/* Sanity Check Dialog - auto-triggered on Continue */}
|
||||
<SanityCheckDialog
|
||||
open={sanityCheckDialogOpen}
|
||||
onOpenChange={setSanityCheckDialogOpen}
|
||||
isChecking={sanityCheck.isChecking}
|
||||
error={sanityCheck.error}
|
||||
result={sanityCheck.result}
|
||||
onProceed={handleSanityCheckProceed}
|
||||
onGoBack={handleSanityCheckGoBack}
|
||||
onScrollToProduct={handleScrollToProduct}
|
||||
productNames={productNames}
|
||||
/>
|
||||
|
||||
{/* Template form dialog - for saving row as template */}
|
||||
<TemplateForm
|
||||
isOpen={isTemplateFormOpen}
|
||||
|
||||
+32
-10
@@ -2,11 +2,12 @@
|
||||
* ValidationFooter Component
|
||||
*
|
||||
* Navigation footer with back/next buttons, AI validate, and summary info.
|
||||
* Triggers sanity check automatically when user clicks "Continue".
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CheckCircle, Wand2, FileText } from 'lucide-react';
|
||||
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';
|
||||
|
||||
@@ -20,6 +21,10 @@ interface ValidationFooterProps {
|
||||
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;
|
||||
}
|
||||
|
||||
export const ValidationFooter = ({
|
||||
@@ -32,9 +37,27 @@ export const ValidationFooter = ({
|
||||
onAiValidate,
|
||||
isAiValidating = false,
|
||||
onShowDebug,
|
||||
onTriggerSanityCheck,
|
||||
sanityCheckAvailable = false,
|
||||
}: 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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t bg-muted/50 px-6 py-4">
|
||||
{/* Back button */}
|
||||
@@ -90,20 +113,19 @@ export const ValidationFooter = ({
|
||||
{onNext && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (canProceed) {
|
||||
onNext();
|
||||
} else {
|
||||
setShowErrorDialog(true);
|
||||
}
|
||||
}}
|
||||
onClick={handleContinueClick}
|
||||
title={
|
||||
!canProceed
|
||||
? `There are ${errorCount} validation errors`
|
||||
: 'Continue to image upload'
|
||||
: sanityCheckAvailable
|
||||
? 'Run sanity check and continue to image upload'
|
||||
: 'Continue to image upload'
|
||||
}
|
||||
>
|
||||
Next
|
||||
{sanityCheckAvailable && canProceed && (
|
||||
<Sparkles className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{canProceed ? 'Continue' : 'Next'}
|
||||
</Button>
|
||||
|
||||
<Dialog open={showErrorDialog} onOpenChange={setShowErrorDialog}>
|
||||
|
||||
+125
-1
@@ -50,9 +50,16 @@ import { MultilineInput } from './cells/MultilineInput';
|
||||
// AI Suggestions context
|
||||
import { useAiSuggestionsContext } from '../contexts/AiSuggestionsContext';
|
||||
|
||||
// AI Suggestion Badge for inline validation
|
||||
import { AiSuggestionBadge } from './AiSuggestionBadge';
|
||||
import type { InlineAiSuggestion } from '../store/types';
|
||||
|
||||
// Fields that trigger AI suggestion refresh when changed
|
||||
const AI_EMBEDDING_FIELDS = ['company', 'line', 'name', 'description'] as const;
|
||||
|
||||
// Fields that trigger inline AI validation (Groq)
|
||||
const INLINE_AI_VALIDATION_FIELDS = ['name', 'description'] as const;
|
||||
|
||||
// Threshold for switching to ComboboxCell (with search) instead of SelectCell
|
||||
const COMBOBOX_OPTION_THRESHOLD = 50;
|
||||
|
||||
@@ -120,6 +127,9 @@ interface CellWrapperProps {
|
||||
isInCopyDownRange: boolean;
|
||||
isCopyDownTarget: boolean;
|
||||
totalRowCount: number;
|
||||
// Inline AI validation (Groq-powered)
|
||||
inlineAiSuggestion?: InlineAiSuggestion;
|
||||
isInlineAiValidating?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,6 +155,8 @@ const CellWrapper = memo(({
|
||||
isInCopyDownRange,
|
||||
isCopyDownTarget,
|
||||
totalRowCount,
|
||||
inlineAiSuggestion,
|
||||
isInlineAiValidating = false,
|
||||
}: CellWrapperProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isGeneratingUpc, setIsGeneratingUpc] = useState(false);
|
||||
@@ -156,6 +168,14 @@ const CellWrapper = memo(({
|
||||
const aiSuggestions = useAiSuggestionsContext();
|
||||
const isEmbeddingField = AI_EMBEDDING_FIELDS.includes(field.key as typeof AI_EMBEDDING_FIELDS[number]);
|
||||
|
||||
// Check if this field supports inline AI validation
|
||||
const isInlineAiField = INLINE_AI_VALIDATION_FIELDS.includes(field.key as typeof INLINE_AI_VALIDATION_FIELDS[number]);
|
||||
|
||||
// Get the suggestion for this specific field
|
||||
const fieldSuggestion = isInlineAiField ? inlineAiSuggestion?.[field.key as 'name' | 'description'] : undefined;
|
||||
const isDismissed = isInlineAiField ? inlineAiSuggestion?.dismissed?.[field.key as 'name' | 'description'] : false;
|
||||
const showSuggestion = fieldSuggestion && !fieldSuggestion.isValid && fieldSuggestion.suggestion && !isDismissed;
|
||||
|
||||
// Check if cell has a value (for showing copy-down button)
|
||||
const hasValue = value !== undefined && value !== null && value !== '';
|
||||
|
||||
@@ -511,8 +531,70 @@ const CellWrapper = memo(({
|
||||
aiSuggestions.handleFieldBlur(currentRow, field.key);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger inline AI validation for name/description fields
|
||||
// This validates spelling, grammar, and naming conventions using Groq
|
||||
if (isInlineAiField && valueToSave && String(valueToSave).trim()) {
|
||||
const currentRow = useValidationStore.getState().rows[rowIndex];
|
||||
const fields = useValidationStore.getState().fields;
|
||||
if (currentRow) {
|
||||
const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState();
|
||||
const fieldKey = field.key as 'name' | 'description';
|
||||
|
||||
// Mark as validating
|
||||
setInlineAiValidating(`${productIndex}-${fieldKey}`, true);
|
||||
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Build product payload for API
|
||||
const productPayload = {
|
||||
name: fieldKey === 'name' ? String(valueToSave) : (currentRow.name as string),
|
||||
description: fieldKey === 'description' ? String(valueToSave) : (currentRow.description as string),
|
||||
company_name: currentRow.company ? getFieldLabel('company', currentRow.company) : undefined,
|
||||
company_id: currentRow.company ? String(currentRow.company) : undefined,
|
||||
line_name: currentRow.line ? getFieldLabel('line', currentRow.line) : undefined,
|
||||
categories: currentRow.categories as string | undefined,
|
||||
};
|
||||
|
||||
// Call the appropriate API endpoint
|
||||
const endpoint = fieldKey === 'name'
|
||||
? '/api/ai/validate/inline/name'
|
||||
: '/api/ai/validate/inline/description';
|
||||
|
||||
fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product: productPayload }),
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.success !== false) {
|
||||
setInlineAiSuggestion(productIndex, fieldKey, {
|
||||
isValid: result.isValid ?? true,
|
||||
suggestion: result.suggestion,
|
||||
issues: result.issues || [],
|
||||
latencyMs: result.latencyMs,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`[InlineAI] ${fieldKey} validation error:`, err);
|
||||
})
|
||||
.finally(() => {
|
||||
setInlineAiValidating(`${productIndex}-${fieldKey}`, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 0);
|
||||
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions]);
|
||||
}, [rowIndex, field.key, isEmbeddingField, aiSuggestions, isInlineAiField, productIndex]);
|
||||
|
||||
// Stable callback for fetching options (for line/subline dropdowns)
|
||||
const handleFetchOptions = useCallback(async () => {
|
||||
@@ -665,6 +747,30 @@ const CellWrapper = memo(({
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline AI validation spinner */}
|
||||
{isInlineAiValidating && isInlineAiField && (
|
||||
<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 && (
|
||||
<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');
|
||||
}}
|
||||
onDismiss={() => {
|
||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, field.key as 'name' | 'description');
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -936,6 +1042,19 @@ const VirtualRow = memo(({
|
||||
useCallback((state) => state.copyDownMode, [])
|
||||
);
|
||||
|
||||
// Subscribe to inline AI suggestions for this row (for name/description validation)
|
||||
const inlineAiSuggestion = useValidationStore(
|
||||
useCallback((state) => state.inlineAi.suggestions.get(rowId), [rowId])
|
||||
);
|
||||
|
||||
// Check if inline AI validation is running for this row
|
||||
const isInlineAiValidatingName = useValidationStore(
|
||||
useCallback((state) => state.inlineAi.validating.has(`${rowId}-name`), [rowId])
|
||||
);
|
||||
const isInlineAiValidatingDescription = useValidationStore(
|
||||
useCallback((state) => state.inlineAi.validating.has(`${rowId}-description`), [rowId])
|
||||
);
|
||||
|
||||
// DON'T subscribe to caches - read via getState() when needed
|
||||
// Subscribing to caches causes ALL rows with same company to re-render when cache updates!
|
||||
// Note: company and line are already declared above for loading state subscriptions
|
||||
@@ -1069,6 +1188,11 @@ const VirtualRow = memo(({
|
||||
isInCopyDownRange={isInCopyDownRange}
|
||||
isCopyDownTarget={isCopyDownTarget}
|
||||
totalRowCount={totalRowCount}
|
||||
inlineAiSuggestion={inlineAiSuggestion}
|
||||
isInlineAiValidating={
|
||||
field.key === 'name' ? isInlineAiValidatingName :
|
||||
field.key === 'description' ? isInlineAiValidatingDescription : false
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
+275
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* SanityCheckDialog Component
|
||||
*
|
||||
* Modal dialog that shows sanity check progress and results.
|
||||
* Automatically triggered when user clicks Continue to next step.
|
||||
*/
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
ChevronRight,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import type { SanityIssue, SanityCheckResult } from '../hooks/useSanityCheck';
|
||||
|
||||
interface SanityCheckDialogProps {
|
||||
/** Whether the dialog is open */
|
||||
open: boolean;
|
||||
/** Called when dialog should close */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Whether the check is currently running */
|
||||
isChecking: boolean;
|
||||
/** Error message if check failed */
|
||||
error: string | null;
|
||||
/** Results of the sanity check */
|
||||
result: SanityCheckResult | null;
|
||||
/** Called when user wants to proceed despite issues */
|
||||
onProceed: () => void;
|
||||
/** Called when user wants to go back and fix issues */
|
||||
onGoBack: () => void;
|
||||
/** Called to scroll to a specific product */
|
||||
onScrollToProduct?: (productIndex: number) => void;
|
||||
/** Product names for display (indexed by product index) */
|
||||
productNames?: Record<number, string>;
|
||||
}
|
||||
|
||||
export function SanityCheckDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isChecking,
|
||||
error,
|
||||
result,
|
||||
onProceed,
|
||||
onGoBack,
|
||||
onScrollToProduct,
|
||||
productNames = {}
|
||||
}: SanityCheckDialogProps) {
|
||||
const hasIssues = result?.issues && result.issues.length > 0;
|
||||
const passed = !isChecking && !error && !hasIssues && result;
|
||||
|
||||
// Group issues by severity/field for better organization
|
||||
const issuesByField = result?.issues?.reduce((acc, issue) => {
|
||||
const field = issue.field;
|
||||
if (!acc[field]) {
|
||||
acc[field] = [];
|
||||
}
|
||||
acc[field].push(issue);
|
||||
return acc;
|
||||
}, {} as Record<string, SanityIssue[]>) || {};
|
||||
|
||||
return (
|
||||
<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'
|
||||
)}
|
||||
</DialogTitle>
|
||||
<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.`
|
||||
: 'Checking your products...'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Content */}
|
||||
<div className="py-4">
|
||||
{/* Loading state */}
|
||||
{isChecking && (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-purple-500" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Analyzing {result?.totalProducts || '...'} products...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && !isChecking && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-red-800">Error</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success state */}
|
||||
{passed && !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>
|
||||
<p className="font-medium text-green-800">All Clear!</p>
|
||||
<p className="text-sm text-green-600 mt-1">
|
||||
{result?.summary || 'No consistency issues detected in your products.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues list */}
|
||||
{hasIssues && !isChecking && (
|
||||
<ScrollArea className="max-h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
{result?.summary && (
|
||||
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||
<p className="text-sm text-amber-800">{result.summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Issues grouped by field */}
|
||||
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
|
||||
<div key={field} className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{formatFieldName(field)}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{fieldIssues.length} issue{fieldIssues.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{fieldIssues.map((issue, index) => (
|
||||
<div
|
||||
key={`${field}-${index}`}
|
||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-medium">
|
||||
{productNames[issue.productIndex] || `Product ${issue.productIndex + 1}`}
|
||||
</span>
|
||||
{onScrollToProduct && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1 text-xs text-blue-600 hover:text-blue-800"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onScrollToProduct(issue.productIndex);
|
||||
}}
|
||||
>
|
||||
Go to
|
||||
<ChevronRight className="h-3 w-3 ml-0.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{issue.issue}</p>
|
||||
{issue.suggestion && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
💡 {issue.suggestion}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<DialogFooter>
|
||||
{isChecking ? (
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
) : error ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onGoBack}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</>
|
||||
) : passed ? (
|
||||
<Button onClick={onProceed}>
|
||||
Continue to Next Step
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
) : hasIssues ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onGoBack}>
|
||||
Review Issues
|
||||
</Button>
|
||||
<Button onClick={onProceed}>
|
||||
Proceed Anyway
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a field key into a human-readable name
|
||||
*/
|
||||
function formatFieldName(field: string): string {
|
||||
const fieldNames: Record<string, string> = {
|
||||
supplier_no: 'Supplier #',
|
||||
msrp: 'MSRP',
|
||||
cost_each: 'Cost Each',
|
||||
qty_per_unit: 'Min Qty',
|
||||
case_qty: 'Case Pack',
|
||||
tax_cat: 'Tax Category',
|
||||
size_cat: 'Size Category',
|
||||
name: 'Name',
|
||||
themes: 'Themes',
|
||||
weight: 'Weight',
|
||||
length: 'Length',
|
||||
width: 'Width',
|
||||
height: 'Height'
|
||||
};
|
||||
|
||||
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
}
|
||||
+268
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* useInlineAiValidation Hook
|
||||
*
|
||||
* Provides inline AI validation for product names and descriptions.
|
||||
* Calls the backend Groq-powered validation endpoints.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
// Types for the validation results
|
||||
export interface InlineAiResult {
|
||||
isValid: boolean;
|
||||
suggestion: string | null;
|
||||
issues: string[];
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
export interface InlineAiValidationState {
|
||||
isValidating: boolean;
|
||||
error: string | null;
|
||||
nameResult: InlineAiResult | null;
|
||||
descriptionResult: InlineAiResult | null;
|
||||
}
|
||||
|
||||
// Product data structure for validation
|
||||
export interface ProductForValidation {
|
||||
name?: string;
|
||||
description?: string;
|
||||
company_name?: string;
|
||||
company_id?: string | number;
|
||||
line_name?: string;
|
||||
categories?: string;
|
||||
}
|
||||
|
||||
// Debounce delay in milliseconds
|
||||
const DEBOUNCE_DELAY = 500;
|
||||
|
||||
/**
|
||||
* Hook for inline AI validation of product fields
|
||||
*/
|
||||
export function useInlineAiValidation() {
|
||||
const [state, setState] = useState<InlineAiValidationState>({
|
||||
isValidating: false,
|
||||
error: null,
|
||||
nameResult: null,
|
||||
descriptionResult: null
|
||||
});
|
||||
|
||||
// Track pending requests for cancellation
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
/**
|
||||
* Validate a product name
|
||||
*/
|
||||
const validateName = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
|
||||
if (!product.name?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
setState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/validate/inline/name', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product }),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Validation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const aiResult: InlineAiResult = {
|
||||
isValid: result.isValid ?? true,
|
||||
suggestion: result.suggestion || null,
|
||||
issues: result.issues || [],
|
||||
latencyMs: result.latencyMs
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
nameResult: aiResult,
|
||||
error: null
|
||||
}));
|
||||
|
||||
return aiResult;
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
// Request was cancelled, ignore
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (error as Error).message || 'Validation failed';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
error: message
|
||||
}));
|
||||
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Validate a product description
|
||||
*/
|
||||
const validateDescription = useCallback(async (product: ProductForValidation): Promise<InlineAiResult | null> => {
|
||||
if (!product.name?.trim() && !product.description?.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
setState(prev => ({ ...prev, isValidating: true, error: null }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/validate/inline/description', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ product }),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Validation failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const aiResult: InlineAiResult = {
|
||||
isValid: result.isValid ?? true,
|
||||
suggestion: result.suggestion || null,
|
||||
issues: result.issues || [],
|
||||
latencyMs: result.latencyMs
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
descriptionResult: aiResult,
|
||||
error: null
|
||||
}));
|
||||
|
||||
return aiResult;
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
// Request was cancelled, ignore
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (error as Error).message || 'Validation failed';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isValidating: false,
|
||||
error: message
|
||||
}));
|
||||
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Debounced name validation - call on blur or after typing stops
|
||||
*/
|
||||
const validateNameDebounced = useCallback((product: ProductForValidation) => {
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
validateName(product);
|
||||
}, DEBOUNCE_DELAY);
|
||||
}, [validateName]);
|
||||
|
||||
/**
|
||||
* Debounced description validation
|
||||
*/
|
||||
const validateDescriptionDebounced = useCallback((product: ProductForValidation) => {
|
||||
// Clear existing timer
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
}
|
||||
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
validateDescription(product);
|
||||
}, DEBOUNCE_DELAY);
|
||||
}, [validateDescription]);
|
||||
|
||||
/**
|
||||
* Clear validation results
|
||||
*/
|
||||
const clearResults = useCallback(() => {
|
||||
// Cancel any pending requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current);
|
||||
debounceTimerRef.current = null;
|
||||
}
|
||||
|
||||
setState({
|
||||
isValidating: false,
|
||||
error: null,
|
||||
nameResult: null,
|
||||
descriptionResult: null
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear name result only
|
||||
*/
|
||||
const clearNameResult = useCallback(() => {
|
||||
setState(prev => ({ ...prev, nameResult: null }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear description result only
|
||||
*/
|
||||
const clearDescriptionResult = useCallback(() => {
|
||||
setState(prev => ({ ...prev, descriptionResult: null }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isValidating: state.isValidating,
|
||||
error: state.error,
|
||||
nameResult: state.nameResult,
|
||||
descriptionResult: state.descriptionResult,
|
||||
|
||||
// Actions - immediate
|
||||
validateName,
|
||||
validateDescription,
|
||||
|
||||
// Actions - debounced
|
||||
validateNameDebounced,
|
||||
validateDescriptionDebounced,
|
||||
|
||||
// Clear
|
||||
clearResults,
|
||||
clearNameResult,
|
||||
clearDescriptionResult
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* useSanityCheck Hook
|
||||
*
|
||||
* Runs batch sanity check on products before proceeding to next step.
|
||||
* Checks for consistency and appropriateness across products.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
|
||||
// Types for sanity check results
|
||||
export interface SanityIssue {
|
||||
productIndex: number;
|
||||
field: string;
|
||||
issue: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface SanityCheckResult {
|
||||
issues: SanityIssue[];
|
||||
summary: string;
|
||||
latencyMs?: number;
|
||||
totalProducts?: number;
|
||||
issueCount?: number;
|
||||
}
|
||||
|
||||
export interface SanityCheckState {
|
||||
isChecking: boolean;
|
||||
error: string | null;
|
||||
result: SanityCheckResult | null;
|
||||
hasRun: boolean;
|
||||
}
|
||||
|
||||
// Product data for sanity check (simplified structure)
|
||||
export interface ProductForSanityCheck {
|
||||
name?: string;
|
||||
supplier?: string;
|
||||
supplier_name?: string;
|
||||
company?: string;
|
||||
company_name?: string;
|
||||
supplier_no?: string;
|
||||
msrp?: string | number;
|
||||
cost_each?: string | number;
|
||||
qty_per_unit?: string | number;
|
||||
case_qty?: string | number;
|
||||
tax_cat?: string | number;
|
||||
tax_cat_name?: string;
|
||||
size_cat?: string | number;
|
||||
size_cat_name?: string;
|
||||
themes?: string;
|
||||
theme_names?: string;
|
||||
weight?: string | number;
|
||||
length?: string | number;
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for batch sanity check of products
|
||||
*/
|
||||
export function useSanityCheck() {
|
||||
const [state, setState] = useState<SanityCheckState>({
|
||||
isChecking: false,
|
||||
error: null,
|
||||
result: null,
|
||||
hasRun: false
|
||||
});
|
||||
|
||||
// Track pending request for cancellation
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
/**
|
||||
* Run sanity check on products
|
||||
*/
|
||||
const runCheck = useCallback(async (products: ProductForSanityCheck[]): Promise<SanityCheckResult | null> => {
|
||||
if (!products || products.length === 0) {
|
||||
return {
|
||||
issues: [],
|
||||
summary: 'No products to check'
|
||||
};
|
||||
}
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: true,
|
||||
error: null,
|
||||
hasRun: true
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/validate/sanity-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ products }),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || `Sanity check failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const result: SanityCheckResult = {
|
||||
issues: data.issues || [],
|
||||
summary: data.summary || 'Check complete',
|
||||
latencyMs: data.latencyMs,
|
||||
totalProducts: products.length,
|
||||
issueCount: data.issues?.length || 0
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
result,
|
||||
error: null
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
// Request was cancelled
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (error as Error).message || 'Sanity check failed';
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false,
|
||||
error: message
|
||||
}));
|
||||
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel the current check
|
||||
*/
|
||||
const cancelCheck = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isChecking: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear results and reset state
|
||||
*/
|
||||
const clearResults = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
|
||||
setState({
|
||||
isChecking: false,
|
||||
error: null,
|
||||
result: null,
|
||||
hasRun: false
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get issues for a specific product index
|
||||
*/
|
||||
const getIssuesForProduct = useCallback((productIndex: number): SanityIssue[] => {
|
||||
if (!state.result?.issues) return [];
|
||||
return state.result.issues.filter(issue => issue.productIndex === productIndex);
|
||||
}, [state.result]);
|
||||
|
||||
/**
|
||||
* Get issues grouped by field
|
||||
*/
|
||||
const getIssuesByField = useCallback((): Record<string, SanityIssue[]> => {
|
||||
if (!state.result?.issues) return {};
|
||||
|
||||
return state.result.issues.reduce((acc, issue) => {
|
||||
const field = issue.field;
|
||||
if (!acc[field]) {
|
||||
acc[field] = [];
|
||||
}
|
||||
acc[field].push(issue);
|
||||
return acc;
|
||||
}, {} as Record<string, SanityIssue[]>);
|
||||
}, [state.result]);
|
||||
|
||||
/**
|
||||
* Check if there are any issues
|
||||
*/
|
||||
const hasIssues = state.result?.issues && state.result.issues.length > 0;
|
||||
|
||||
/**
|
||||
* Check if the sanity check passed (ran with no issues)
|
||||
*/
|
||||
const passed = state.hasRun && !state.isChecking && !state.error && !hasIssues;
|
||||
|
||||
return {
|
||||
// State
|
||||
isChecking: state.isChecking,
|
||||
error: state.error,
|
||||
result: state.result,
|
||||
hasRun: state.hasRun,
|
||||
hasIssues,
|
||||
passed,
|
||||
|
||||
// Computed
|
||||
issues: state.result?.issues || [],
|
||||
summary: state.result?.summary || null,
|
||||
issueCount: state.result?.issueCount || 0,
|
||||
|
||||
// Actions
|
||||
runCheck,
|
||||
cancelCheck,
|
||||
clearResults,
|
||||
|
||||
// Helpers
|
||||
getIssuesForProduct,
|
||||
getIssuesByField
|
||||
};
|
||||
}
|
||||
@@ -258,6 +258,43 @@ export interface AiSuggestionsState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Inline AI Validation Types (Groq-powered)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Result from inline AI validation (name or description)
|
||||
*/
|
||||
export interface InlineAiValidationResult {
|
||||
isValid: boolean;
|
||||
suggestion?: string;
|
||||
issues: string[];
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-product inline AI suggestions (keyed by product __index)
|
||||
*/
|
||||
export interface InlineAiSuggestion {
|
||||
name?: InlineAiValidationResult;
|
||||
description?: InlineAiValidationResult;
|
||||
/** Whether the suggestion has been dismissed by the user */
|
||||
dismissed?: {
|
||||
name?: boolean;
|
||||
description?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* State for inline AI validation
|
||||
*/
|
||||
export interface InlineAiState {
|
||||
/** Map of product __index to their inline suggestions */
|
||||
suggestions: Map<string, InlineAiSuggestion>;
|
||||
/** Products currently being validated */
|
||||
validating: Set<string>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Initialization Types
|
||||
// =============================================================================
|
||||
@@ -343,6 +380,9 @@ export interface ValidationState {
|
||||
// === AI Validation ===
|
||||
aiValidation: AiValidationState;
|
||||
|
||||
// === Inline AI Validation (Groq) ===
|
||||
inlineAi: InlineAiState;
|
||||
|
||||
// === File (for output) ===
|
||||
file: File | null;
|
||||
}
|
||||
@@ -438,6 +478,13 @@ export interface ValidationActions {
|
||||
clearAiValidation: () => void;
|
||||
storeOriginalValues: () => void;
|
||||
|
||||
// === Inline AI Validation ===
|
||||
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => void;
|
||||
dismissInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
|
||||
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
|
||||
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
|
||||
setInlineAiValidating: (productIndex: string, validating: boolean) => void;
|
||||
|
||||
// === Output ===
|
||||
getCleanedData: () => CleanRowData[];
|
||||
|
||||
|
||||
+93
-1
@@ -29,7 +29,7 @@ import type {
|
||||
AiValidationResults,
|
||||
CopyDownState,
|
||||
DialogState,
|
||||
PendingCopyDownValidation,
|
||||
InlineAiValidationResult,
|
||||
} from './types';
|
||||
import type { Field, SelectOption } from '../../../types';
|
||||
|
||||
@@ -125,6 +125,12 @@ const getInitialState = (): ValidationState => ({
|
||||
revertedChanges: new Set(),
|
||||
},
|
||||
|
||||
// Inline AI Validation (Groq)
|
||||
inlineAi: {
|
||||
suggestions: new Map(),
|
||||
validating: new Set(),
|
||||
},
|
||||
|
||||
// File
|
||||
file: null,
|
||||
});
|
||||
@@ -750,6 +756,92 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Inline AI Validation (Groq)
|
||||
// =========================================================================
|
||||
|
||||
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: InlineAiValidationResult) => {
|
||||
set((state) => {
|
||||
const existing = state.inlineAi.suggestions.get(productIndex) || {};
|
||||
state.inlineAi.suggestions.set(productIndex, {
|
||||
...existing,
|
||||
[field]: result,
|
||||
dismissed: {
|
||||
...existing.dismissed,
|
||||
[field]: false, // Reset dismissed state when new suggestion arrives
|
||||
},
|
||||
});
|
||||
state.inlineAi.validating.delete(`${productIndex}-${field}`);
|
||||
});
|
||||
},
|
||||
|
||||
dismissInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => {
|
||||
set((state) => {
|
||||
const existing = state.inlineAi.suggestions.get(productIndex);
|
||||
if (existing) {
|
||||
state.inlineAi.suggestions.set(productIndex, {
|
||||
...existing,
|
||||
dismissed: {
|
||||
...existing.dismissed,
|
||||
[field]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => {
|
||||
set((state) => {
|
||||
const suggestion = state.inlineAi.suggestions.get(productIndex)?.[field];
|
||||
if (suggestion?.suggestion) {
|
||||
// Find the row by __index and update the field
|
||||
const rowIndex = state.rows.findIndex((row: RowData) => row.__index === productIndex);
|
||||
if (rowIndex >= 0) {
|
||||
state.rows[rowIndex][field] = suggestion.suggestion;
|
||||
}
|
||||
// Mark as dismissed after accepting
|
||||
const existing = state.inlineAi.suggestions.get(productIndex);
|
||||
if (existing) {
|
||||
state.inlineAi.suggestions.set(productIndex, {
|
||||
...existing,
|
||||
dismissed: {
|
||||
...existing.dismissed,
|
||||
[field]: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => {
|
||||
set((state) => {
|
||||
if (field) {
|
||||
const existing = state.inlineAi.suggestions.get(productIndex);
|
||||
if (existing) {
|
||||
const { [field]: _, ...rest } = existing;
|
||||
if (Object.keys(rest).length === 0 || (Object.keys(rest).length === 1 && 'dismissed' in rest)) {
|
||||
state.inlineAi.suggestions.delete(productIndex);
|
||||
} else {
|
||||
state.inlineAi.suggestions.set(productIndex, rest);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.inlineAi.suggestions.delete(productIndex);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setInlineAiValidating: (productIndex: string, validating: boolean) => {
|
||||
set((state) => {
|
||||
if (validating) {
|
||||
state.inlineAi.validating.add(productIndex);
|
||||
} else {
|
||||
state.inlineAi.validating.delete(productIndex);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Output
|
||||
// =========================================================================
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowUpDown, Pencil, Trash2, PlusCircle } from "lucide-react";
|
||||
import config from "@/config";
|
||||
import {
|
||||
@@ -48,18 +49,21 @@ interface FieldOption {
|
||||
value: string;
|
||||
}
|
||||
|
||||
// Form uses task + role, which gets composed into prompt_type on submit
|
||||
interface PromptFormData {
|
||||
id?: number;
|
||||
prompt_text: string;
|
||||
prompt_type: 'general' | 'company_specific' | 'system';
|
||||
task: string;
|
||||
role: "system" | "general" | "company_specific";
|
||||
company: string | null;
|
||||
}
|
||||
|
||||
interface AiPrompt {
|
||||
id: number;
|
||||
prompt_text: string;
|
||||
prompt_type: 'general' | 'company_specific' | 'system';
|
||||
prompt_type: string;
|
||||
company: string | null;
|
||||
is_singleton: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -68,19 +72,74 @@ interface FieldOptions {
|
||||
companies: FieldOption[];
|
||||
}
|
||||
|
||||
// Predefined tasks (can also enter custom)
|
||||
const PREDEFINED_TASKS = [
|
||||
{ value: "name_validation", label: "Name Validation", description: "Inline validation of product names (Groq)" },
|
||||
{ value: "description_validation", label: "Description Validation", description: "Inline validation of product descriptions (Groq)" },
|
||||
{ value: "sanity_check", label: "Sanity Check", description: "Batch product consistency review (Groq)" },
|
||||
{ value: "bulk_validation", label: "Bulk Validation", description: "Full product validation during import (GPT-5)" },
|
||||
];
|
||||
|
||||
// Role options
|
||||
const ROLES = [
|
||||
{ value: "system", label: "System", description: "Initial instructions that set the AI's behavior" },
|
||||
{ value: "general", label: "General", description: "Rules that apply to all products" },
|
||||
{ value: "company_specific", label: "Company-Specific", description: "Rules unique to a specific company" },
|
||||
];
|
||||
|
||||
// Parse prompt_type into task and role
|
||||
function parsePromptType(promptType: string): { task: string; role: "system" | "general" | "company_specific" } {
|
||||
if (promptType.endsWith("_company_specific")) {
|
||||
return { task: promptType.replace("_company_specific", ""), role: "company_specific" };
|
||||
}
|
||||
if (promptType.endsWith("_general")) {
|
||||
return { task: promptType.replace("_general", ""), role: "general" };
|
||||
}
|
||||
if (promptType.endsWith("_system")) {
|
||||
return { task: promptType.replace("_system", ""), role: "system" };
|
||||
}
|
||||
// Fallback - assume it's a system prompt
|
||||
return { task: promptType, role: "system" };
|
||||
}
|
||||
|
||||
// Compose task + role into prompt_type
|
||||
function composePromptType(task: string, role: string): string {
|
||||
return `${task}_${role}`;
|
||||
}
|
||||
|
||||
// Get human-readable task name
|
||||
function getTaskDisplayName(task: string): string {
|
||||
const predefined = PREDEFINED_TASKS.find(t => t.value === task);
|
||||
if (predefined) return predefined.label;
|
||||
// Format custom task nicely
|
||||
return task
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
// Get human-readable role name
|
||||
function getRoleDisplayName(role: string): string {
|
||||
const roleInfo = ROLES.find(r => r.value === role);
|
||||
return roleInfo?.label || role;
|
||||
}
|
||||
|
||||
export function PromptManagement() {
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [promptToDelete, setPromptToDelete] = useState<AiPrompt | null>(null);
|
||||
const [editingPrompt, setEditingPrompt] = useState<AiPrompt | null>(null);
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: "prompt_type", desc: true },
|
||||
{ id: "prompt_type", desc: false },
|
||||
{ id: "company", desc: false }
|
||||
]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [useCustomTask, setUseCustomTask] = useState(false);
|
||||
const [customTask, setCustomTask] = useState("");
|
||||
const [formData, setFormData] = useState<PromptFormData>({
|
||||
prompt_text: "",
|
||||
prompt_type: "general",
|
||||
task: "",
|
||||
role: "system",
|
||||
company: null,
|
||||
});
|
||||
|
||||
@@ -108,34 +167,45 @@ export function PromptManagement() {
|
||||
},
|
||||
});
|
||||
|
||||
// Check if general and system prompts already exist
|
||||
const generalPromptExists = useMemo(() => {
|
||||
return prompts?.some(prompt => prompt.prompt_type === 'general');
|
||||
}, [prompts]);
|
||||
|
||||
const systemPromptExists = useMemo(() => {
|
||||
return prompts?.some(prompt => prompt.prompt_type === 'system');
|
||||
// Track which prompts exist for disabling options
|
||||
const existingPrompts = useMemo(() => {
|
||||
if (!prompts) return { system: new Set<string>(), general: new Set<string>(), companySpecific: new Map<string, Set<string>>() };
|
||||
|
||||
const system = new Set<string>();
|
||||
const general = new Set<string>();
|
||||
const companySpecific = new Map<string, Set<string>>();
|
||||
|
||||
prompts.forEach(p => {
|
||||
const { task, role } = parsePromptType(p.prompt_type);
|
||||
if (role === "system") {
|
||||
system.add(task);
|
||||
} else if (role === "general") {
|
||||
general.add(task);
|
||||
} else if (role === "company_specific" && p.company) {
|
||||
if (!companySpecific.has(task)) {
|
||||
companySpecific.set(task, new Set());
|
||||
}
|
||||
companySpecific.get(task)!.add(p.company);
|
||||
}
|
||||
});
|
||||
|
||||
return { system, general, companySpecific };
|
||||
}, [prompts]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: PromptFormData) => {
|
||||
mutationFn: async (data: { prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean }) => {
|
||||
const response = await fetch(`${config.apiUrl}/ai-prompts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || error.error || "Failed to create prompt");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (newPrompt) => {
|
||||
// Optimistically update the cache with the new prompt
|
||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||
if (!old) return [newPrompt];
|
||||
return [...old, newPrompt];
|
||||
@@ -149,29 +219,22 @@ export function PromptManagement() {
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (data: PromptFormData) => {
|
||||
if (!data.id) throw new Error("Prompt ID is required for update");
|
||||
|
||||
mutationFn: async (data: { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean }) => {
|
||||
const response = await fetch(`${config.apiUrl}/ai-prompts/${data.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || error.error || "Failed to update prompt");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (updatedPrompt) => {
|
||||
// Optimistically update the cache with the returned data
|
||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||
if (!old) return [updatedPrompt];
|
||||
return old.map((prompt) =>
|
||||
return old.map((prompt) =>
|
||||
prompt.id === updatedPrompt.id ? updatedPrompt : prompt
|
||||
);
|
||||
});
|
||||
@@ -194,7 +257,6 @@ export function PromptManagement() {
|
||||
return id;
|
||||
},
|
||||
onSuccess: (deletedId) => {
|
||||
// Optimistically update the cache by removing the deleted prompt
|
||||
queryClient.setQueryData<AiPrompt[]>(["ai-prompts"], (old) => {
|
||||
if (!old) return [];
|
||||
return old.filter((prompt) => prompt.id !== deletedId);
|
||||
@@ -208,10 +270,15 @@ export function PromptManagement() {
|
||||
|
||||
const handleEdit = (prompt: AiPrompt) => {
|
||||
setEditingPrompt(prompt);
|
||||
const { task, role } = parsePromptType(prompt.prompt_type);
|
||||
const isPredefinedTask = PREDEFINED_TASKS.some(t => t.value === task);
|
||||
setUseCustomTask(!isPredefinedTask);
|
||||
setCustomTask(isPredefinedTask ? "" : task);
|
||||
setFormData({
|
||||
id: prompt.id,
|
||||
prompt_text: prompt.prompt_text,
|
||||
prompt_type: prompt.prompt_type,
|
||||
task: task,
|
||||
role: role,
|
||||
company: prompt.company,
|
||||
});
|
||||
setIsFormOpen(true);
|
||||
@@ -232,15 +299,35 @@ export function PromptManagement() {
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// If prompt_type is general or system, ensure company is null
|
||||
|
||||
const actualTask = useCustomTask ? customTask.trim() : formData.task;
|
||||
|
||||
if (!actualTask) {
|
||||
toast.error("Please select or enter a task");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.role) {
|
||||
toast.error("Please select a role");
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.role === "company_specific" && !formData.company) {
|
||||
toast.error("Please select a company");
|
||||
return;
|
||||
}
|
||||
|
||||
const promptType = composePromptType(actualTask, formData.role);
|
||||
const submitData = {
|
||||
...formData,
|
||||
company: formData.prompt_type === 'company_specific' ? formData.company : null,
|
||||
id: formData.id,
|
||||
prompt_text: formData.prompt_text,
|
||||
prompt_type: promptType,
|
||||
company: formData.role === "company_specific" ? formData.company : null,
|
||||
is_singleton: true, // Always singleton
|
||||
};
|
||||
|
||||
|
||||
if (editingPrompt) {
|
||||
updateMutation.mutate(submitData);
|
||||
updateMutation.mutate(submitData as { id: number; prompt_text: string; prompt_type: string; company: string | null; is_singleton: boolean });
|
||||
} else {
|
||||
createMutation.mutate(submitData);
|
||||
}
|
||||
@@ -249,39 +336,76 @@ export function PromptManagement() {
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
prompt_text: "",
|
||||
prompt_type: "general",
|
||||
task: "",
|
||||
role: "system",
|
||||
company: null,
|
||||
});
|
||||
setEditingPrompt(null);
|
||||
setUseCustomTask(false);
|
||||
setCustomTask("");
|
||||
setIsFormOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
resetForm();
|
||||
|
||||
// If general prompt and system prompt exist, default to company-specific
|
||||
if (generalPromptExists && systemPromptExists) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
prompt_type: 'company_specific'
|
||||
}));
|
||||
} else if (generalPromptExists && !systemPromptExists) {
|
||||
// If general exists but system doesn't, suggest system prompt
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
prompt_type: 'system'
|
||||
}));
|
||||
} else if (!generalPromptExists) {
|
||||
// If no general prompt, suggest that first
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
prompt_type: 'general'
|
||||
}));
|
||||
// Find first task + role combo that doesn't exist
|
||||
for (const task of PREDEFINED_TASKS) {
|
||||
if (!existingPrompts.system.has(task.value)) {
|
||||
setFormData(prev => ({ ...prev, task: task.value, role: "system" }));
|
||||
setIsFormOpen(true);
|
||||
return;
|
||||
}
|
||||
if (!existingPrompts.general.has(task.value)) {
|
||||
setFormData(prev => ({ ...prev, task: task.value, role: "general" }));
|
||||
setIsFormOpen(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// All base prompts exist, default to first task with company-specific
|
||||
setFormData(prev => ({ ...prev, task: PREDEFINED_TASKS[0].value, role: "company_specific" }));
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const handleTaskChange = (value: string) => {
|
||||
if (value === "__custom__") {
|
||||
setUseCustomTask(true);
|
||||
setFormData(prev => ({ ...prev, task: "" }));
|
||||
} else {
|
||||
setUseCustomTask(false);
|
||||
setCustomTask("");
|
||||
setFormData(prev => ({ ...prev, task: value }));
|
||||
}
|
||||
};
|
||||
|
||||
// Get the effective task for checking what exists
|
||||
const effectiveTask = useCustomTask ? customTask.trim() : formData.task;
|
||||
|
||||
// Check if current selection would conflict
|
||||
const wouldConflict = useMemo(() => {
|
||||
if (!effectiveTask) return false;
|
||||
|
||||
// If editing the same prompt, no conflict
|
||||
if (editingPrompt) {
|
||||
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
|
||||
if (editTask === effectiveTask && editRole === formData.role) {
|
||||
if (formData.role !== "company_specific") return false;
|
||||
if (editingPrompt.company === formData.company) return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.role === "system") {
|
||||
return existingPrompts.system.has(effectiveTask);
|
||||
}
|
||||
if (formData.role === "general") {
|
||||
return existingPrompts.general.has(effectiveTask);
|
||||
}
|
||||
if (formData.role === "company_specific" && formData.company) {
|
||||
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
|
||||
return taskCompanies?.has(formData.company) || false;
|
||||
}
|
||||
return false;
|
||||
}, [effectiveTask, formData.role, formData.company, existingPrompts, editingPrompt]);
|
||||
|
||||
const columns = useMemo<ColumnDef<AiPrompt>[]>(() => [
|
||||
{
|
||||
accessorKey: "prompt_type",
|
||||
@@ -290,15 +414,24 @@ export function PromptManagement() {
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Type
|
||||
Task
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue("prompt_type") as string;
|
||||
if (type === 'general') return 'General';
|
||||
if (type === 'system') return 'System';
|
||||
return 'Company Specific';
|
||||
const { task } = parsePromptType(row.getValue("prompt_type") as string);
|
||||
return (
|
||||
<span className="font-medium">{getTaskDisplayName(task)}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "role",
|
||||
header: "Role",
|
||||
cell: ({ row }) => {
|
||||
const { role } = parsePromptType(row.getValue("prompt_type") as string);
|
||||
const variant = role === "system" ? "default" : role === "general" ? "secondary" : "outline";
|
||||
return <Badge variant={variant}>{getRoleDisplayName(role)}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,7 +464,7 @@ export function PromptManagement() {
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const companyId = row.getValue("company");
|
||||
if (!companyId) return 'N/A';
|
||||
if (!companyId) return <span className="text-muted-foreground">—</span>;
|
||||
return fieldOptions?.companies.find(c => c.value === companyId)?.label || companyId;
|
||||
},
|
||||
},
|
||||
@@ -352,10 +485,7 @@ export function PromptManagement() {
|
||||
id: "actions",
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2 justify-end pr-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(row.original)}
|
||||
>
|
||||
<Button variant="ghost" onClick={() => handleEdit(row.original)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</Button>
|
||||
@@ -376,7 +506,12 @@ export function PromptManagement() {
|
||||
if (!prompts) return [];
|
||||
return prompts.filter((prompt) => {
|
||||
const searchString = searchQuery.toLowerCase();
|
||||
const { task, role } = parsePromptType(prompt.prompt_type);
|
||||
const taskName = getTaskDisplayName(task).toLowerCase();
|
||||
const roleName = getRoleDisplayName(role).toLowerCase();
|
||||
return (
|
||||
taskName.includes(searchString) ||
|
||||
roleName.includes(searchString) ||
|
||||
prompt.prompt_type.toLowerCase().includes(searchString) ||
|
||||
(prompt.company && prompt.company.toLowerCase().includes(searchString))
|
||||
);
|
||||
@@ -386,9 +521,7 @@ export function PromptManagement() {
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -425,10 +558,7 @@ export function PromptManagement() {
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
@@ -463,108 +593,174 @@ export function PromptManagement() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingPrompt ? "Edit Prompt" : "Create New Prompt"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingPrompt
|
||||
{editingPrompt
|
||||
? "Update this AI validation prompt."
|
||||
: "Create a new AI validation prompt that will be used during product validation."}
|
||||
: "Create a new AI validation prompt. Select a task and role, then enter the prompt text."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Task Selector */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="prompt_type">Prompt Type</Label>
|
||||
<Select
|
||||
value={formData.prompt_type}
|
||||
onValueChange={(value: 'general' | 'company_specific' | 'system') =>
|
||||
setFormData({ ...formData, prompt_type: value })
|
||||
}
|
||||
disabled={(generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id) ||
|
||||
(systemPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select prompt type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
value="general"
|
||||
disabled={generalPromptExists && !editingPrompt?.prompt_type?.includes('general')}
|
||||
<Label>Task</Label>
|
||||
{!useCustomTask ? (
|
||||
<Select value={formData.task} onValueChange={handleTaskChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select task" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">Predefined Tasks</SelectLabel>
|
||||
{PREDEFINED_TASKS.map((task) => (
|
||||
<SelectItem key={task.value} value={task.value}>
|
||||
<span className="flex flex-col">
|
||||
<span>{task.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{task.description}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">Custom</SelectLabel>
|
||||
<SelectItem value="__custom__">Custom Task...</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={customTask}
|
||||
onChange={(e) => setCustomTask(e.target.value.toLowerCase().replace(/\s+/g, '_'))}
|
||||
placeholder="e.g., my_custom_task"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setUseCustomTask(false);
|
||||
setCustomTask("");
|
||||
}}
|
||||
>
|
||||
General
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
value="system"
|
||||
disabled={systemPromptExists && !editingPrompt?.prompt_type?.includes('system')}
|
||||
>
|
||||
System
|
||||
</SelectItem>
|
||||
<SelectItem value="company_specific">Company Specific</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{generalPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && systemPromptExists && formData.prompt_type !== 'system' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
General and system prompts already exist. You can only create company-specific prompts.
|
||||
</p>
|
||||
)}
|
||||
{generalPromptExists && !systemPromptExists && formData.prompt_type !== 'general' && !editingPrompt?.id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A general prompt already exists. You can create a system prompt or company-specific prompts.
|
||||
</p>
|
||||
)}
|
||||
{systemPromptExists && !generalPromptExists && formData.prompt_type !== 'system' && !editingPrompt?.id && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
A system prompt already exists. You can create a general prompt or company-specific prompts.
|
||||
</p>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.prompt_type === 'company_specific' && (
|
||||
|
||||
{/* Role Selector */}
|
||||
<div className="grid gap-2">
|
||||
<Label>Role</Label>
|
||||
<Select
|
||||
value={formData.role}
|
||||
onValueChange={(value: "system" | "general" | "company_specific") =>
|
||||
setFormData(prev => ({ ...prev, role: value, company: value !== "company_specific" ? null : prev.company }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ROLES.map((role) => {
|
||||
// Check if this role is already taken for the current task
|
||||
let isDisabled = false;
|
||||
if (effectiveTask) {
|
||||
if (role.value === "system") {
|
||||
isDisabled = existingPrompts.system.has(effectiveTask);
|
||||
} else if (role.value === "general") {
|
||||
isDisabled = existingPrompts.general.has(effectiveTask);
|
||||
}
|
||||
// Company-specific is never disabled at the role level
|
||||
}
|
||||
// Allow if editing the same prompt
|
||||
if (editingPrompt && effectiveTask) {
|
||||
const { task: editTask, role: editRole } = parsePromptType(editingPrompt.prompt_type);
|
||||
if (editTask === effectiveTask && editRole === role.value) {
|
||||
isDisabled = false;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<SelectItem key={role.value} value={role.value} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{role.label}
|
||||
{isDisabled && <Badge variant="secondary" className="text-xs">exists</Badge>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ROLES.find(r => r.value === formData.role)?.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Company Selector (only for company_specific role) */}
|
||||
{formData.role === "company_specific" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="company">Company</Label>
|
||||
<Label>Company</Label>
|
||||
<Select
|
||||
value={formData.company || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, company: value })}
|
||||
required={formData.prompt_type === 'company_specific'}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, company: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select company" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fieldOptions?.companies.map((company) => (
|
||||
<SelectItem key={company.value} value={company.value}>
|
||||
{company.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{fieldOptions?.companies.map((company) => {
|
||||
const taskCompanies = existingPrompts.companySpecific.get(effectiveTask);
|
||||
const isExisting = taskCompanies?.has(company.value);
|
||||
const isCurrentEdit = editingPrompt?.company === company.value;
|
||||
return (
|
||||
<SelectItem
|
||||
key={company.value}
|
||||
value={company.value}
|
||||
disabled={isExisting && !isCurrentEdit}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{company.label}
|
||||
{isExisting && !isCurrentEdit && (
|
||||
<Badge variant="secondary" className="text-xs">exists</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Conflict Warning */}
|
||||
{wouldConflict && (
|
||||
<p className="text-sm text-destructive">
|
||||
A prompt for this task + role combination already exists.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Prompt Text */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="prompt_text">Prompt Text</Label>
|
||||
<Textarea
|
||||
id="prompt_text"
|
||||
value={formData.prompt_text}
|
||||
onChange={(e) => setFormData({ ...formData, prompt_text: e.target.value })}
|
||||
placeholder={`Enter your ${formData.prompt_type === 'system' ? 'system instructions' : 'validation prompt'} text...`}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, prompt_text: e.target.value }))}
|
||||
placeholder="Enter your prompt text..."
|
||||
className="h-80 font-mono text-sm"
|
||||
required
|
||||
/>
|
||||
{formData.prompt_type === 'system' && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
System prompts provide the initial instructions to the AI. This sets the tone and approach for all validations.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => {
|
||||
resetForm();
|
||||
setIsFormOpen(false);
|
||||
}}>
|
||||
<Button type="button" variant="outline" onClick={() => { resetForm(); setIsFormOpen(false); }}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={wouldConflict || !effectiveTask || !formData.role || (formData.role === "company_specific" && !formData.company)}
|
||||
>
|
||||
{editingPrompt ? "Update" : "Create"} Prompt
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -582,10 +778,7 @@ export function PromptManagement() {
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setPromptToDelete(null);
|
||||
}}>
|
||||
<AlertDialogCancel onClick={() => { setIsDeleteOpen(false); setPromptToDelete(null); }}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>
|
||||
@@ -596,4 +789,4 @@ export function PromptManagement() {
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user