New AI tasks tweaks/fixes
This commit is contained in:
@@ -121,7 +121,7 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
type: "input",
|
type: "input",
|
||||||
price: true
|
price: true
|
||||||
},
|
},
|
||||||
width: 100,
|
width: 110,
|
||||||
validations: [
|
validations: [
|
||||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
@@ -148,7 +148,7 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
type: "input",
|
type: "input",
|
||||||
price: true
|
price: true
|
||||||
},
|
},
|
||||||
width: 110,
|
width: 120,
|
||||||
validations: [
|
validations: [
|
||||||
{ rule: "required", errorMessage: "Required", level: "error" },
|
{ rule: "required", errorMessage: "Required", level: "error" },
|
||||||
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
{ rule: "regex", value: "^[0-9]*.?[0-9]+$", errorMessage: "Must be a number", level: "error" },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useContext } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { CheckCircle, Loader2, Bug, Eye, RefreshCw, ChevronRight } from 'lucide-react';
|
import { CheckCircle, Loader2, Eye, RefreshCw } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -83,52 +83,38 @@ export const ValidationFooter = ({
|
|||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{/* Skip sanity check toggle - only for admin:debug users */}
|
{/* Skip sanity check toggle - only for admin:debug users */}
|
||||||
{hasDebugPermission && onSkipSanityCheckChange && (
|
{hasDebugPermission && onSkipSanityCheckChange && !hasRunSanityCheck && (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="flex items-center gap-2 mr-2">
|
<div className="flex items-center gap-2 mr-4">
|
||||||
<Switch
|
<Switch
|
||||||
id="skip-sanity"
|
id="skip-sanity"
|
||||||
checked={skipSanityCheck}
|
checked={skipSanityCheck}
|
||||||
onCheckedChange={onSkipSanityCheckChange}
|
onCheckedChange={onSkipSanityCheckChange}
|
||||||
className="data-[state=checked]:bg-amber-500"
|
|
||||||
/>
|
/>
|
||||||
<Label
|
<Label
|
||||||
htmlFor="skip-sanity"
|
htmlFor="skip-sanity"
|
||||||
className="text-xs text-muted-foreground cursor-pointer flex items-center gap-1"
|
className="text-sm text-muted-foreground cursor-pointer flex items-center gap-1"
|
||||||
>
|
>
|
||||||
<Bug className="h-3 w-3" />
|
Skip Consistency Check
|
||||||
Skip
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p>Debug: Skip sanity check</p>
|
<p>Debug: Skip consistency check</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Before first sanity check: single "Continue" button that runs the check */}
|
{/* Before first sanity check: single "Next" button that runs the check */}
|
||||||
{!hasRunSanityCheck && !skipSanityCheck && (
|
{!hasRunSanityCheck && !skipSanityCheck && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onRunCheck}
|
onClick={onRunCheck}
|
||||||
disabled={isSanityChecking || rowCount === 0}
|
disabled={isSanityChecking || rowCount === 0}
|
||||||
title={
|
|
||||||
!canProceed
|
|
||||||
? `There are ${errorCount} validation errors`
|
|
||||||
: 'Continue to image upload'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{isSanityChecking ? (
|
Next
|
||||||
<>
|
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
||||||
Checking...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Continue'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -141,16 +127,15 @@ export const ValidationFooter = ({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
onClick={onViewResults}
|
onClick={onViewResults}
|
||||||
disabled={isSanityChecking}
|
disabled={isSanityChecking}
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4 mr-1" />
|
<Eye className="h-4 w-4 mr-1" />
|
||||||
Results
|
Review Check Results
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p>View previous sanity check results</p>
|
<p>Review previous consistency check results</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -161,7 +146,6 @@ export const ValidationFooter = ({
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
|
||||||
onClick={onRunCheck}
|
onClick={onRunCheck}
|
||||||
disabled={isSanityChecking || rowCount === 0}
|
disabled={isSanityChecking || rowCount === 0}
|
||||||
>
|
>
|
||||||
@@ -170,11 +154,11 @@ export const ValidationFooter = ({
|
|||||||
) : (
|
) : (
|
||||||
<RefreshCw className="h-4 w-4 mr-1" />
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
)}
|
)}
|
||||||
{isSanityChecking ? 'Checking...' : 'Recheck'}
|
{isSanityChecking ? 'Checking...' : 'Check Again'}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top">
|
<TooltipContent side="top">
|
||||||
<p>Run a fresh sanity check</p>
|
<p>Run a fresh consistency check</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -189,8 +173,7 @@ export const ValidationFooter = ({
|
|||||||
: 'Continue to image upload'
|
: 'Continue to image upload'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Continue
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -202,8 +185,7 @@ export const ValidationFooter = ({
|
|||||||
disabled={rowCount === 0}
|
disabled={rowCount === 0}
|
||||||
title="Continue to image upload (sanity check skipped)"
|
title="Continue to image upload (sanity check skipped)"
|
||||||
>
|
>
|
||||||
Continue
|
Next
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useMemo, useRef, useCallback, memo, useState } from 'react';
|
|||||||
import { type ColumnDef } from '@tanstack/react-table';
|
import { type ColumnDef } from '@tanstack/react-table';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { ArrowDown, Wand2, Loader2 } from 'lucide-react';
|
import { ArrowDown, Wand2, Loader2, Calculator } from 'lucide-react';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -258,9 +258,61 @@ const CellWrapper = memo(({
|
|||||||
throw new Error(payload?.error || 'Unexpected response while generating UPC');
|
throw new Error(payload?.error || 'Unexpected response while generating UPC');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the cell value
|
// Update the cell value and clear any validation errors
|
||||||
useValidationStore.getState().updateCell(rowIndex, 'upc', payload.upc);
|
const { updateCell, clearFieldError, setUpcStatus, setGeneratedItemNumber,
|
||||||
|
cacheUpcResult, getCachedItemNumber, startValidatingCell, stopValidatingCell,
|
||||||
|
setError } = useValidationStore.getState();
|
||||||
|
updateCell(rowIndex, 'upc', payload.upc);
|
||||||
|
clearFieldError(rowIndex, 'upc');
|
||||||
toast.success('UPC generated');
|
toast.success('UPC generated');
|
||||||
|
|
||||||
|
// Trigger UPC validation to generate item number
|
||||||
|
// We have both supplier and the newly generated UPC, so validate immediately
|
||||||
|
const upc = payload.upc;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedItemNumber(supplierIdString, upc);
|
||||||
|
if (cached) {
|
||||||
|
setGeneratedItemNumber(rowIndex, cached);
|
||||||
|
} else {
|
||||||
|
// Start validation
|
||||||
|
setUpcStatus(rowIndex, 'validating');
|
||||||
|
startValidatingCell(rowIndex, 'item_number');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const validationResponse = await fetch(
|
||||||
|
`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upc)}&supplierId=${encodeURIComponent(supplierIdString)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const validationPayload = await validationResponse.json().catch(() => null);
|
||||||
|
|
||||||
|
if (validationResponse.status === 409) {
|
||||||
|
// UPC already exists (shouldn't happen with newly generated UPC, but handle it)
|
||||||
|
setError(rowIndex, 'upc', {
|
||||||
|
message: 'A product with this UPC already exists',
|
||||||
|
level: 'error',
|
||||||
|
source: ErrorSource.Upc,
|
||||||
|
type: ErrorType.Unique,
|
||||||
|
});
|
||||||
|
setUpcStatus(rowIndex, 'error');
|
||||||
|
updateCell(rowIndex, 'item_number', '');
|
||||||
|
} else if (validationResponse.ok && validationPayload?.success && validationPayload?.itemNumber) {
|
||||||
|
// Success - cache and apply
|
||||||
|
cacheUpcResult(supplierIdString, upc, validationPayload.itemNumber);
|
||||||
|
setGeneratedItemNumber(rowIndex, validationPayload.itemNumber);
|
||||||
|
clearFieldError(rowIndex, 'upc');
|
||||||
|
setUpcStatus(rowIndex, 'done');
|
||||||
|
} else {
|
||||||
|
setUpcStatus(rowIndex, 'error');
|
||||||
|
updateCell(rowIndex, 'item_number', '');
|
||||||
|
}
|
||||||
|
} catch (validationError) {
|
||||||
|
console.error('UPC validation error:', validationError);
|
||||||
|
setUpcStatus(rowIndex, 'error');
|
||||||
|
} finally {
|
||||||
|
stopValidatingCell(rowIndex, 'item_number');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating UPC:', error);
|
console.error('Error generating UPC:', error);
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC';
|
const errorMessage = error instanceof Error ? error.message : 'Failed to generate UPC';
|
||||||
@@ -484,6 +536,113 @@ const CellWrapper = memo(({
|
|||||||
|
|
||||||
// Clear subline when line changes (it's no longer valid)
|
// Clear subline when line changes (it's no longer valid)
|
||||||
updateCell(rowIndex, 'subline', '');
|
updateCell(rowIndex, 'subline', '');
|
||||||
|
|
||||||
|
// Check if row now has sufficient context for inline AI validation
|
||||||
|
// (line was just set, check if company + name exist)
|
||||||
|
const currentRowForContext = useValidationStore.getState().rows[rowIndex];
|
||||||
|
if (currentRowForContext?.company && currentRowForContext?.name) {
|
||||||
|
const { setInlineAiValidating, setInlineAiSuggestion, inlineAi, fields } = useValidationStore.getState();
|
||||||
|
const contextProductIndex = currentRowForContext.__index;
|
||||||
|
|
||||||
|
// Helper to look up field option label
|
||||||
|
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
|
||||||
|
const fieldDef = fields.find(f => f.key === fieldKey);
|
||||||
|
if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
|
||||||
|
const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
|
||||||
|
return option?.label;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if name should be validated
|
||||||
|
const nameSuggestion = inlineAi.suggestions.get(contextProductIndex);
|
||||||
|
const nameIsDismissed = nameSuggestion?.dismissed?.name;
|
||||||
|
const nameIsValidating = inlineAi.validating.has(`${contextProductIndex}-name`);
|
||||||
|
const nameValue = String(currentRowForContext.name).trim();
|
||||||
|
|
||||||
|
if (nameValue && !nameIsDismissed && !nameIsValidating) {
|
||||||
|
// Trigger name validation
|
||||||
|
setInlineAiValidating(`${contextProductIndex}-name`, true);
|
||||||
|
|
||||||
|
const rows = useValidationStore.getState().rows;
|
||||||
|
const siblingNames: string[] = [];
|
||||||
|
const companyId = String(currentRowForContext.company);
|
||||||
|
const lineId = String(valueToSave); // Use the new line value
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.__index === contextProductIndex) continue;
|
||||||
|
if (String(row.company) !== companyId) continue;
|
||||||
|
if (String(row.line) !== lineId) continue;
|
||||||
|
if (row.name && typeof row.name === 'string' && row.name.trim()) {
|
||||||
|
siblingNames.push(row.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/api/ai/validate/inline/name', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
product: {
|
||||||
|
name: nameValue,
|
||||||
|
description: currentRowForContext.description as string,
|
||||||
|
company_name: getFieldLabel('company', currentRowForContext.company),
|
||||||
|
company_id: String(currentRowForContext.company),
|
||||||
|
line_name: getFieldLabel('line', valueToSave),
|
||||||
|
line_id: String(valueToSave),
|
||||||
|
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(contextProductIndex, 'name', {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[InlineAI] name validation error on line change:', err))
|
||||||
|
.finally(() => setInlineAiValidating(`${contextProductIndex}-name`, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if description should be validated
|
||||||
|
const descIsDismissed = nameSuggestion?.dismissed?.description;
|
||||||
|
const descIsValidating = inlineAi.validating.has(`${contextProductIndex}-description`);
|
||||||
|
const descValue = currentRowForContext.description && String(currentRowForContext.description).trim();
|
||||||
|
|
||||||
|
if (descValue && !descIsDismissed && !descIsValidating) {
|
||||||
|
// Trigger description validation
|
||||||
|
setInlineAiValidating(`${contextProductIndex}-description`, true);
|
||||||
|
|
||||||
|
fetch('/api/ai/validate/inline/description', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
product: {
|
||||||
|
name: nameValue,
|
||||||
|
description: descValue,
|
||||||
|
company_name: getFieldLabel('company', currentRowForContext.company),
|
||||||
|
company_id: String(currentRowForContext.company),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(result => {
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(contextProductIndex, 'description', {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('[InlineAI] description validation error on line change:', err))
|
||||||
|
.finally(() => setInlineAiValidating(`${contextProductIndex}-description`, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Trigger UPC validation if supplier or UPC changed
|
// Trigger UPC validation if supplier or UPC changed
|
||||||
@@ -559,11 +718,27 @@ const CellWrapper = memo(({
|
|||||||
const currentRow = useValidationStore.getState().rows[rowIndex];
|
const currentRow = useValidationStore.getState().rows[rowIndex];
|
||||||
const fields = useValidationStore.getState().fields;
|
const fields = useValidationStore.getState().fields;
|
||||||
if (currentRow) {
|
if (currentRow) {
|
||||||
const { setInlineAiValidating, setInlineAiSuggestion } = useValidationStore.getState();
|
const { setInlineAiValidating, setInlineAiSuggestion, markInlineAiAutoValidated, inlineAi } = useValidationStore.getState();
|
||||||
const fieldKey = field.key as 'name' | 'description';
|
const fieldKey = field.key as 'name' | 'description';
|
||||||
|
const validationKey = `${productIndex}-${fieldKey}`;
|
||||||
|
|
||||||
// Mark as validating
|
// Skip if validation is already in progress (auto-validation may have started)
|
||||||
setInlineAiValidating(`${productIndex}-${fieldKey}`, true);
|
if (inlineAi.validating.has(validationKey)) {
|
||||||
|
console.log(`[InlineAI] Skipping ${fieldKey} blur validation - already in progress`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if accepting an AI suggestion (value matches current suggestion)
|
||||||
|
// This prevents re-validating when user clicks "Accept" on a suggestion
|
||||||
|
const currentSuggestion = inlineAi.suggestions.get(productIndex)?.[fieldKey];
|
||||||
|
if (currentSuggestion?.suggestion && currentSuggestion.suggestion === String(valueToSave)) {
|
||||||
|
console.log(`[InlineAI] Skipping ${fieldKey} blur validation - accepting AI suggestion`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as validating and auto-validated (prevents race with auto-validation hook)
|
||||||
|
setInlineAiValidating(validationKey, true);
|
||||||
|
markInlineAiAutoValidated(productIndex, fieldKey);
|
||||||
|
|
||||||
// Helper to look up field option label
|
// Helper to look up field option label
|
||||||
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
|
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
|
||||||
@@ -744,8 +919,9 @@ const CellWrapper = memo(({
|
|||||||
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
|
onFetchOptions={needsCompany || needsLine ? handleFetchOptions : undefined}
|
||||||
isLoadingOptions={isLoadingOptions}
|
isLoadingOptions={isLoadingOptions}
|
||||||
// Pass AI suggestion props for description field (MultilineInput handles it internally)
|
// Pass AI suggestion props for description field (MultilineInput handles it internally)
|
||||||
|
// Only pass aiSuggestion when showSuggestion is true (respects dismissed state)
|
||||||
{...(field.key === 'description' && {
|
{...(field.key === 'description' && {
|
||||||
aiSuggestion: fieldSuggestion,
|
aiSuggestion: showSuggestion ? fieldSuggestion : undefined,
|
||||||
isAiValidating: isInlineAiValidating,
|
isAiValidating: isInlineAiValidating,
|
||||||
onDismissAiSuggestion: () => {
|
onDismissAiSuggestion: () => {
|
||||||
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
useValidationStore.getState().dismissInlineAiSuggestion(productIndex, 'description');
|
||||||
@@ -1433,6 +1609,144 @@ const HeaderCheckbox = memo(() => {
|
|||||||
|
|
||||||
HeaderCheckbox.displayName = 'HeaderCheckbox';
|
HeaderCheckbox.displayName = 'HeaderCheckbox';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PriceColumnHeader Component
|
||||||
|
*
|
||||||
|
* Renders a column header for MSRP or Cost Each with a hover button
|
||||||
|
* that fills empty cells based on the other price field.
|
||||||
|
* - MSRP: Fill with Cost Each × 2
|
||||||
|
* - Cost Each: Fill with MSRP ÷ 2
|
||||||
|
*
|
||||||
|
* PERFORMANCE: Uses local hover state and getState() for bulk updates.
|
||||||
|
* No store subscriptions - this is purely a UI component that triggers actions.
|
||||||
|
*/
|
||||||
|
interface PriceColumnHeaderProps {
|
||||||
|
fieldKey: 'msrp' | 'cost_each';
|
||||||
|
label: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PriceColumnHeader = memo(({ fieldKey, label, isRequired }: PriceColumnHeaderProps) => {
|
||||||
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const [hasFillableCells, setHasFillableCells] = useState(false);
|
||||||
|
|
||||||
|
// Determine the source field and calculation
|
||||||
|
const sourceField = fieldKey === 'msrp' ? 'cost_each' : 'msrp';
|
||||||
|
const tooltipText = fieldKey === 'msrp'
|
||||||
|
? 'Fill empty cells with Cost Each × 2'
|
||||||
|
: 'Fill empty cells with MSRP ÷ 2';
|
||||||
|
|
||||||
|
// Check if there are any cells that can be filled (called on hover)
|
||||||
|
const checkFillableCells = useCallback(() => {
|
||||||
|
const { rows } = useValidationStore.getState();
|
||||||
|
return rows.some((row) => {
|
||||||
|
const currentValue = row[fieldKey];
|
||||||
|
const sourceValue = row[sourceField];
|
||||||
|
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
||||||
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
if (isEmpty && hasSource) {
|
||||||
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
|
return !isNaN(sourceNum) && sourceNum > 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}, [fieldKey, sourceField]);
|
||||||
|
|
||||||
|
// Update fillable check on hover
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setIsHovered(true);
|
||||||
|
setHasFillableCells(checkFillableCells());
|
||||||
|
}, [checkFillableCells]);
|
||||||
|
|
||||||
|
const handleCalculate = useCallback(() => {
|
||||||
|
const updatedIndices: number[] = [];
|
||||||
|
|
||||||
|
// Use setState() for efficient batch update with Immer
|
||||||
|
useValidationStore.setState((draft) => {
|
||||||
|
draft.rows.forEach((row, index) => {
|
||||||
|
const currentValue = row[fieldKey];
|
||||||
|
const sourceValue = row[sourceField];
|
||||||
|
|
||||||
|
// Only fill if current field is empty and source has a value
|
||||||
|
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '';
|
||||||
|
const hasSource = sourceValue !== undefined && sourceValue !== null && sourceValue !== '';
|
||||||
|
|
||||||
|
if (isEmpty && hasSource) {
|
||||||
|
const sourceNum = parseFloat(String(sourceValue));
|
||||||
|
if (!isNaN(sourceNum) && sourceNum > 0) {
|
||||||
|
// Calculate the new value
|
||||||
|
let newValue: string;
|
||||||
|
if (fieldKey === 'msrp') {
|
||||||
|
let msrp = sourceNum * 2;
|
||||||
|
// Round down .00 to .99 for better pricing (e.g., 13.00 → 12.99)
|
||||||
|
if (msrp === Math.floor(msrp)) {
|
||||||
|
msrp -= 0.01;
|
||||||
|
}
|
||||||
|
newValue = msrp.toFixed(2);
|
||||||
|
} else {
|
||||||
|
newValue = (sourceNum / 2).toFixed(2);
|
||||||
|
}
|
||||||
|
draft.rows[index][fieldKey] = newValue;
|
||||||
|
updatedIndices.push(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear validation errors for all updated cells (removes "required" error styling)
|
||||||
|
if (updatedIndices.length > 0) {
|
||||||
|
const { clearFieldError } = useValidationStore.getState();
|
||||||
|
updatedIndices.forEach((rowIndex) => {
|
||||||
|
clearFieldError(rowIndex, fieldKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(`Updated ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'}`);
|
||||||
|
}
|
||||||
|
}, [fieldKey, sourceField, label]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-1 truncate w-full group relative"
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={() => setIsHovered(false)}
|
||||||
|
>
|
||||||
|
<span className="">{label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
{isHovered && hasFillableCells && (
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleCalculate();
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1/2 -translate-y-1/2',
|
||||||
|
'flex items-center gap-0.5',
|
||||||
|
'rounded border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||||
|
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||||
|
'transition-opacity'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calculator className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">
|
||||||
|
<p>{tooltipText}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PriceColumnHeader.displayName = 'PriceColumnHeader';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main table component
|
* Main table component
|
||||||
*
|
*
|
||||||
@@ -1535,18 +1849,29 @@ export const ValidationTable = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Data columns from fields with widths from config.ts
|
// Data columns from fields with widths from config.ts
|
||||||
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => ({
|
const dataColumns: ColumnDef<RowData>[] = fields.map((field) => {
|
||||||
id: field.key,
|
const isRequired = field.validations?.some((v: Validation) => v.rule === 'required') ?? false;
|
||||||
header: () => (
|
const isPriceColumn = field.key === 'msrp' || field.key === 'cost_each';
|
||||||
<div className="flex items-center gap-1 truncate">
|
|
||||||
<span className="truncate">{field.label}</span>
|
return {
|
||||||
{field.validations?.some((v: Validation) => v.rule === 'required') && (
|
id: field.key,
|
||||||
<span className="text-destructive flex-shrink-0">*</span>
|
header: () => isPriceColumn ? (
|
||||||
)}
|
<PriceColumnHeader
|
||||||
</div>
|
fieldKey={field.key as 'msrp' | 'cost_each'}
|
||||||
),
|
label={field.label}
|
||||||
size: field.width || 150,
|
isRequired={isRequired}
|
||||||
}));
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1 truncate">
|
||||||
|
<span className="truncate">{field.label}</span>
|
||||||
|
{isRequired && (
|
||||||
|
<span className="text-destructive flex-shrink-0">*</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
size: field.width || 150,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
return [selectionColumn, templateColumn, ...dataColumns];
|
return [selectionColumn, templateColumn, ...dataColumns];
|
||||||
}, [fields]); // CRITICAL: No selection-related deps!
|
}, [fields]); // CRITICAL: No selection-related deps!
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ const MultilineInputComponent = ({
|
|||||||
<div
|
<div
|
||||||
onClick={handleTriggerClick}
|
onClick={handleTriggerClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md text-sm w-full cursor-pointer relative',
|
'pl-2 pr-4 py-1 rounded-md text-sm w-full cursor-pointer relative',
|
||||||
'overflow-hidden leading-tight h-[65px]',
|
'overflow-hidden leading-tight h-[65px] top-0',
|
||||||
'border',
|
'border',
|
||||||
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
||||||
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
|
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
|
||||||
@@ -249,7 +249,7 @@ const MultilineInputComponent = ({
|
|||||||
align="start"
|
align="start"
|
||||||
side="bottom"
|
side="bottom"
|
||||||
alignOffset={0}
|
alignOffset={0}
|
||||||
sideOffset={4}
|
sideOffset={-65}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* Close button */}
|
{/* Close button */}
|
||||||
@@ -267,7 +267,7 @@ const MultilineInputComponent = ({
|
|||||||
value={editValue}
|
value={editValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onWheel={handleTextareaWheel}
|
onWheel={handleTextareaWheel}
|
||||||
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none p-2 pr-8 resize-none"
|
className="min-h-[120px] max-h-[200px] overflow-y-auto overscroll-contain border-none focus-visible:ring-0 rounded-t-md rounded-b-none pl-2 pr-4 py-1 resize-none"
|
||||||
placeholder={`Enter ${field.label || 'text'}...`}
|
placeholder={`Enter ${field.label || 'text'}...`}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
@@ -337,7 +337,7 @@ const MultilineInputComponent = ({
|
|||||||
onClick={handleAcceptSuggestion}
|
onClick={handleAcceptSuggestion}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3 mr-1" />
|
<Check className="h-3 w-3 mr-1" />
|
||||||
Apply
|
Replace With Suggestion
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
XCircle,
|
XCircle,
|
||||||
RefreshCw
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
@@ -60,7 +58,6 @@ export function SanityCheckDialog({
|
|||||||
result,
|
result,
|
||||||
onProceed,
|
onProceed,
|
||||||
onGoBack,
|
onGoBack,
|
||||||
onRefresh,
|
|
||||||
onScrollToProduct,
|
onScrollToProduct,
|
||||||
productNames = {},
|
productNames = {},
|
||||||
validationErrorCount = 0
|
validationErrorCount = 0
|
||||||
@@ -88,63 +85,35 @@ export function SanityCheckDialog({
|
|||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
{isChecking ? (
|
{isChecking ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-purple-500" />
|
Running Consistency Check...
|
||||||
Running Sanity Check...
|
|
||||||
</>
|
</>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<>
|
<>
|
||||||
<XCircle className="h-5 w-5 text-red-500" />
|
<XCircle className="h-5 w-5 text-red-500" />
|
||||||
Sanity Check Failed
|
Consistency Check Failed
|
||||||
</>
|
</>
|
||||||
) : hasAnyIssues ? (
|
) : hasAnyIssues ? (
|
||||||
<>
|
<>
|
||||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||||
{hasValidationErrors && hasSanityIssues
|
{hasValidationErrors && hasSanityIssues
|
||||||
? 'Validation Errors & Issues Found'
|
? 'Validation Errors & Consistency Issues Found'
|
||||||
: hasValidationErrors
|
: hasValidationErrors
|
||||||
? 'Validation Errors'
|
? 'Validation Errors'
|
||||||
: 'Issues Found'}
|
: 'Consistency Issues Found'}
|
||||||
</>
|
</>
|
||||||
) : allClear ? (
|
) : allClear ? (
|
||||||
<>
|
<>
|
||||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
Continue
|
||||||
Ready to Continue
|
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Pre-flight Check'
|
<>
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
Consistency Check
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</DialogTitle>
|
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription>
|
|
||||||
{isChecking
|
|
||||||
? 'Reviewing products for consistency and appropriateness...'
|
|
||||||
: error
|
|
||||||
? 'An error occurred while checking your products.'
|
|
||||||
: 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>
|
</DialogHeader>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -173,9 +142,7 @@ export function SanityCheckDialog({
|
|||||||
{/* Success state - only show if no validation errors either */}
|
{/* Success state - only show if no validation errors either */}
|
||||||
{allClear && !hasValidationErrors && !isChecking && (
|
{allClear && !hasValidationErrors && !isChecking && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-green-50 border border-green-200">
|
||||||
<CheckCircle className="h-5 w-5 text-green-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-green-800">All Clear!</p>
|
|
||||||
<p className="text-sm text-green-600 mt-1">
|
<p className="text-sm text-green-600 mt-1">
|
||||||
{result?.summary || 'No consistency issues detected in your products.'}
|
{result?.summary || 'No consistency issues detected in your products.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -185,28 +152,33 @@ export function SanityCheckDialog({
|
|||||||
|
|
||||||
{/* Validation errors warning */}
|
{/* Validation errors warning */}
|
||||||
{hasValidationErrors && !isChecking && (
|
{hasValidationErrors && !isChecking && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-4">
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-50 border border-red-200 mb-2">
|
||||||
<XCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-red-800">Validation Errors</p>
|
<p className="text-sm text-red-600">
|
||||||
<p className="text-sm text-red-600 mt-1">
|
There {validationErrorCount === 1 ? 'is' : 'are'} {validationErrorCount} validation error{validationErrorCount === 1 ? '' : 's'} (required fields missing, invalid values, etc.) in your data.
|
||||||
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.
|
These should be fixed before continuing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{hasSanityIssues && !isChecking && (
|
||||||
|
<>
|
||||||
|
{/* Summary */}
|
||||||
|
{result?.summary && (
|
||||||
|
<div className="flex items-start gap-3 p-4 rounded-lg bg-amber-50 border border-amber-200">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-amber-800">{result.summary}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Sanity check issues list */}
|
{/* Sanity check issues list */}
|
||||||
{hasSanityIssues && !isChecking && (
|
{hasSanityIssues && !isChecking && (
|
||||||
<ScrollArea className="max-h-[400px] pr-4">
|
<ScrollArea className="max-h-[400px] overflow-y-auto mt-4">
|
||||||
<div className="space-y-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 */}
|
{/* Issues grouped by field */}
|
||||||
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
|
{Object.entries(issuesByField).map(([field, fieldIssues]) => (
|
||||||
@@ -249,7 +221,7 @@ export function SanityCheckDialog({
|
|||||||
<p className="text-sm text-gray-600">{issue.issue}</p>
|
<p className="text-sm text-gray-600">{issue.issue}</p>
|
||||||
{issue.suggestion && (
|
{issue.suggestion && (
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<p className="text-xs text-blue-600 mt-1">
|
||||||
💡 {issue.suggestion}
|
{issue.suggestion}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -288,8 +260,7 @@ export function SanityCheckDialog({
|
|||||||
Go Back & Fix
|
Go Back & Fix
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
|
<Button onClick={onProceed} variant={hasValidationErrors ? 'destructive' : 'default'}>
|
||||||
{hasValidationErrors ? 'Proceed Despite Errors' : 'Proceed Anyway'}
|
Proceed Anyway
|
||||||
<ChevronRight className="h-4 w-4 ml-1" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -320,47 +291,4 @@ function formatFieldName(field: string): string {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
return fieldNames[field] || field.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format a timestamp as a relative time string
|
|
||||||
*/
|
|
||||||
function formatTimeAgo(timestamp: number): string {
|
|
||||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
||||||
|
|
||||||
if (seconds < 5) return 'just now';
|
|
||||||
if (seconds < 60) return `${seconds} seconds ago`;
|
|
||||||
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes === 1) return '1 minute ago';
|
|
||||||
if (minutes < 60) return `${minutes} minutes ago`;
|
|
||||||
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours === 1) return '1 hour ago';
|
|
||||||
if (hours < 24) return `${hours} hours ago`;
|
|
||||||
|
|
||||||
return 'over a day ago';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a summary string describing both validation errors and sanity issues
|
|
||||||
*/
|
|
||||||
function buildIssuesSummary(validationErrorCount: number, sanityIssueCount: number): string {
|
|
||||||
const parts: string[] = [];
|
|
||||||
|
|
||||||
if (validationErrorCount > 0) {
|
|
||||||
parts.push(`${validationErrorCount} validation error${validationErrorCount === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sanityIssueCount > 0) {
|
|
||||||
parts.push(`${sanityIssueCount} consistency issue${sanityIssueCount === 1 ? '' : 's'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return `Found ${parts[0]} and ${parts[1]} to review.`;
|
|
||||||
} else if (parts.length === 1) {
|
|
||||||
return `Found ${parts[0]} to review.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'Review the issues below.';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
/**
|
||||||
|
* useAutoInlineAiValidation Hook
|
||||||
|
*
|
||||||
|
* Automatically triggers inline AI validation (name/description) for rows
|
||||||
|
* that have sufficient context when the validation step becomes ready.
|
||||||
|
*
|
||||||
|
* Context requirements:
|
||||||
|
* - Name validation: company + line + name value
|
||||||
|
* - Description validation: company + line + name + description value
|
||||||
|
*
|
||||||
|
* This runs once when the table is ready, firing all requests at once.
|
||||||
|
* The blur handler in ValidationTable.tsx handles subsequent validations
|
||||||
|
* when fields are edited.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useValidationStore } from '../store/validationStore';
|
||||||
|
import { useInitPhase } from '../store/selectors';
|
||||||
|
import type { RowData } from '../store/types';
|
||||||
|
import type { Field } from '../../../types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build product payload for AI validation API
|
||||||
|
*/
|
||||||
|
function buildProductPayload(
|
||||||
|
row: RowData,
|
||||||
|
_field: 'name' | 'description',
|
||||||
|
fields: Field<string>[],
|
||||||
|
allRows: RowData[]
|
||||||
|
) {
|
||||||
|
// Helper to look up field option label
|
||||||
|
const getFieldLabel = (fieldKey: string, val: unknown): string | undefined => {
|
||||||
|
const fieldDef = fields.find(f => f.key === fieldKey);
|
||||||
|
if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
|
||||||
|
const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
|
||||||
|
return option?.label;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Compute sibling names for context (same company + line + subline if set)
|
||||||
|
const siblingNames: string[] = [];
|
||||||
|
if (row.company && row.line) {
|
||||||
|
const companyId = String(row.company);
|
||||||
|
const lineId = String(row.line);
|
||||||
|
const sublineId = row.subline ? String(row.subline) : null;
|
||||||
|
|
||||||
|
for (const otherRow of allRows) {
|
||||||
|
// Skip self
|
||||||
|
if (otherRow.__index === row.__index) continue;
|
||||||
|
|
||||||
|
// Must match company and line
|
||||||
|
if (String(otherRow.company) !== companyId) continue;
|
||||||
|
if (String(otherRow.line) !== lineId) continue;
|
||||||
|
|
||||||
|
// If current product has subline, siblings must match subline too
|
||||||
|
if (sublineId && String(otherRow.subline) !== sublineId) continue;
|
||||||
|
|
||||||
|
// Add name if it exists
|
||||||
|
if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) {
|
||||||
|
siblingNames.push(otherRow.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: row.name as string,
|
||||||
|
description: row.description as string | undefined,
|
||||||
|
company_name: row.company ? getFieldLabel('company', row.company) : undefined,
|
||||||
|
company_id: row.company ? String(row.company) : undefined,
|
||||||
|
line_name: row.line ? getFieldLabel('line', row.line) : undefined,
|
||||||
|
line_id: row.line ? String(row.line) : undefined,
|
||||||
|
subline_name: row.subline ? getFieldLabel('subline', row.subline) : undefined,
|
||||||
|
subline_id: row.subline ? String(row.subline) : undefined,
|
||||||
|
categories: row.categories as string | undefined,
|
||||||
|
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger validation for a single field
|
||||||
|
*/
|
||||||
|
async function triggerValidation(
|
||||||
|
productIndex: string,
|
||||||
|
field: 'name' | 'description',
|
||||||
|
payload: ReturnType<typeof buildProductPayload>
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
setInlineAiValidating,
|
||||||
|
setInlineAiSuggestion,
|
||||||
|
markInlineAiAutoValidated,
|
||||||
|
} = useValidationStore.getState();
|
||||||
|
|
||||||
|
const validationKey = `${productIndex}-${field}`;
|
||||||
|
|
||||||
|
// Mark as auto-validated BEFORE calling API (prevents blur handler race condition)
|
||||||
|
markInlineAiAutoValidated(productIndex, field);
|
||||||
|
|
||||||
|
// Mark as validating
|
||||||
|
setInlineAiValidating(validationKey, true);
|
||||||
|
|
||||||
|
const endpoint = field === 'name'
|
||||||
|
? '/api/ai/validate/inline/name'
|
||||||
|
: '/api/ai/validate/inline/description';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: payload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(productIndex, field, {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[AutoInlineAI] ${field} validation error for ${productIndex}:`, err);
|
||||||
|
} finally {
|
||||||
|
setInlineAiValidating(validationKey, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that triggers inline AI validation for all rows with sufficient context
|
||||||
|
* when the validation step becomes ready.
|
||||||
|
*/
|
||||||
|
export function useAutoInlineAiValidation() {
|
||||||
|
const initPhase = useInitPhase();
|
||||||
|
const hasRunRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Only run when ready phase is reached, and only once
|
||||||
|
if (initPhase !== 'ready' || hasRunRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRunRef.current = true;
|
||||||
|
|
||||||
|
const state = useValidationStore.getState();
|
||||||
|
const { rows, fields, inlineAi } = state;
|
||||||
|
|
||||||
|
console.log('[AutoInlineAI] Starting auto-validation for', rows.length, 'rows');
|
||||||
|
|
||||||
|
let nameCount = 0;
|
||||||
|
let descCount = 0;
|
||||||
|
|
||||||
|
// Process all rows - fire requests immediately (no batching)
|
||||||
|
for (const row of rows) {
|
||||||
|
const productIndex = row.__index;
|
||||||
|
if (!productIndex) continue;
|
||||||
|
|
||||||
|
// Check name context: company + line + name
|
||||||
|
const hasNameContext =
|
||||||
|
row.company &&
|
||||||
|
row.line &&
|
||||||
|
row.name &&
|
||||||
|
typeof row.name === 'string' &&
|
||||||
|
row.name.trim();
|
||||||
|
|
||||||
|
// Check description context: company + line + name + description
|
||||||
|
const hasDescContext =
|
||||||
|
hasNameContext &&
|
||||||
|
row.description &&
|
||||||
|
typeof row.description === 'string' &&
|
||||||
|
row.description.trim();
|
||||||
|
|
||||||
|
// Skip if already auto-validated (shouldn't happen on first run, but be safe)
|
||||||
|
const nameAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-name`);
|
||||||
|
const descAlreadyValidated = inlineAi.autoValidated.has(`${productIndex}-description`);
|
||||||
|
|
||||||
|
// Skip if currently validating (another process started validation)
|
||||||
|
const nameCurrentlyValidating = inlineAi.validating.has(`${productIndex}-name`);
|
||||||
|
const descCurrentlyValidating = inlineAi.validating.has(`${productIndex}-description`);
|
||||||
|
|
||||||
|
// Trigger name validation if context is sufficient
|
||||||
|
if (hasNameContext && !nameAlreadyValidated && !nameCurrentlyValidating) {
|
||||||
|
const payload = buildProductPayload(row, 'name', fields, rows);
|
||||||
|
triggerValidation(productIndex, 'name', payload);
|
||||||
|
nameCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger description validation if context is sufficient
|
||||||
|
if (hasDescContext && !descAlreadyValidated && !descCurrentlyValidating) {
|
||||||
|
const payload = buildProductPayload(row, 'description', fields, rows);
|
||||||
|
triggerValidation(productIndex, 'description', payload);
|
||||||
|
descCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[AutoInlineAI] Triggered ${nameCount} name validations, ${descCount} description validations`);
|
||||||
|
}, [initPhase]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useAutoInlineAiValidation;
|
||||||
@@ -1,54 +1,168 @@
|
|||||||
/**
|
/**
|
||||||
* useCopyDownValidation Hook
|
* useCopyDownValidation Hook
|
||||||
*
|
*
|
||||||
* Watches for copy-down operations on UPC-related fields (supplier, upc, barcode)
|
* Watches for copy-down operations and triggers appropriate validations:
|
||||||
* and triggers UPC validation for affected rows using the existing validateUpc function.
|
* - UPC-related fields (supplier, upc, barcode) -> UPC validation
|
||||||
|
* - Line field -> Inline AI validation for rows that gain sufficient context
|
||||||
*
|
*
|
||||||
* This avoids duplicating UPC validation logic - we reuse the same code path
|
* This avoids duplicating validation logic - we reuse the same code paths
|
||||||
* that handles individual cell blur events.
|
* that handle individual cell blur events.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useValidationStore } from '../store/validationStore';
|
import { useValidationStore } from '../store/validationStore';
|
||||||
import { useUpcValidation } from './useUpcValidation';
|
import { useUpcValidation } from './useUpcValidation';
|
||||||
|
import type { Field } from '../../../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook that handles UPC validation after copy-down operations.
|
* Helper to look up field option label
|
||||||
|
*/
|
||||||
|
function getFieldLabel(fields: Field<string>[], fieldKey: string, val: unknown): string | undefined {
|
||||||
|
const fieldDef = fields.find(f => f.key === fieldKey);
|
||||||
|
if (fieldDef && fieldDef.fieldType.type === 'select' && 'options' in fieldDef.fieldType) {
|
||||||
|
const option = fieldDef.fieldType.options?.find(o => o.value === String(val));
|
||||||
|
return option?.label;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger inline AI validation for a single row/field
|
||||||
|
*/
|
||||||
|
async function triggerInlineAiValidation(
|
||||||
|
rowIndex: number,
|
||||||
|
field: 'name' | 'description',
|
||||||
|
rows: ReturnType<typeof useValidationStore.getState>['rows'],
|
||||||
|
fields: Field<string>[],
|
||||||
|
setInlineAiValidating: (key: string, validating: boolean) => void,
|
||||||
|
setInlineAiSuggestion: (productIndex: string, field: 'name' | 'description', result: { isValid: boolean; suggestion?: string; issues: string[]; latencyMs?: number }) => void
|
||||||
|
) {
|
||||||
|
const row = rows[rowIndex];
|
||||||
|
if (!row?.__index) return;
|
||||||
|
|
||||||
|
const productIndex = row.__index;
|
||||||
|
const validationKey = `${productIndex}-${field}`;
|
||||||
|
|
||||||
|
setInlineAiValidating(validationKey, true);
|
||||||
|
|
||||||
|
// Compute sibling names for context
|
||||||
|
const siblingNames: string[] = [];
|
||||||
|
if (row.company && row.line) {
|
||||||
|
const companyId = String(row.company);
|
||||||
|
const lineId = String(row.line);
|
||||||
|
for (const otherRow of rows) {
|
||||||
|
if (otherRow.__index === productIndex) continue;
|
||||||
|
if (String(otherRow.company) !== companyId) continue;
|
||||||
|
if (String(otherRow.line) !== lineId) continue;
|
||||||
|
if (otherRow.name && typeof otherRow.name === 'string' && otherRow.name.trim()) {
|
||||||
|
siblingNames.push(otherRow.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const productPayload = {
|
||||||
|
name: String(row.name),
|
||||||
|
description: row.description ? String(row.description) : undefined,
|
||||||
|
company_name: row.company ? getFieldLabel(fields, 'company', row.company) : undefined,
|
||||||
|
company_id: row.company ? String(row.company) : undefined,
|
||||||
|
line_name: row.line ? getFieldLabel(fields, 'line', row.line) : undefined,
|
||||||
|
line_id: row.line ? String(row.line) : undefined,
|
||||||
|
siblingNames: siblingNames.length > 0 ? siblingNames : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const endpoint = field === 'name'
|
||||||
|
? '/api/ai/validate/inline/name'
|
||||||
|
: '/api/ai/validate/inline/description';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ product: productPayload }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success !== false) {
|
||||||
|
setInlineAiSuggestion(productIndex, field, {
|
||||||
|
isValid: result.isValid ?? true,
|
||||||
|
suggestion: result.suggestion,
|
||||||
|
issues: result.issues || [],
|
||||||
|
latencyMs: result.latencyMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[InlineAI] ${field} validation error on line copy-down:`, err);
|
||||||
|
} finally {
|
||||||
|
setInlineAiValidating(validationKey, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook that handles validation after copy-down operations.
|
||||||
* Should be called once in ValidationContainer to ensure validation runs.
|
* Should be called once in ValidationContainer to ensure validation runs.
|
||||||
*/
|
*/
|
||||||
export const useCopyDownValidation = () => {
|
export const useCopyDownValidation = () => {
|
||||||
const { validateUpc } = useUpcValidation();
|
const { validateUpc } = useUpcValidation();
|
||||||
|
|
||||||
// Subscribe to pending copy-down validation
|
// Subscribe to pending validations
|
||||||
const pendingValidation = useValidationStore((state) => state.pendingCopyDownValidation);
|
const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation);
|
||||||
|
const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation);
|
||||||
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
|
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
|
||||||
|
const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation);
|
||||||
|
|
||||||
|
// Handle UPC validation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pendingValidation) return;
|
if (!pendingUpcValidation) return;
|
||||||
|
|
||||||
const { fieldKey, affectedRows } = pendingValidation;
|
const { affectedRows } = pendingUpcValidation;
|
||||||
|
|
||||||
// Get current rows to check supplier and UPC values
|
|
||||||
const rows = useValidationStore.getState().rows;
|
const rows = useValidationStore.getState().rows;
|
||||||
|
|
||||||
// Process each affected row
|
|
||||||
const validationPromises = affectedRows.map(async (rowIndex) => {
|
const validationPromises = affectedRows.map(async (rowIndex) => {
|
||||||
const row = rows[rowIndex];
|
const row = rows[rowIndex];
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
|
|
||||||
// Get supplier and UPC values
|
|
||||||
const supplierId = row.supplier ? String(row.supplier) : '';
|
const supplierId = row.supplier ? String(row.supplier) : '';
|
||||||
const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : '');
|
const upcValue = row.upc ? String(row.upc) : (row.barcode ? String(row.barcode) : '');
|
||||||
|
|
||||||
// Only validate if we have both supplier and UPC
|
|
||||||
if (supplierId && upcValue) {
|
if (supplierId && upcValue) {
|
||||||
await validateUpc(rowIndex, supplierId, upcValue);
|
await validateUpc(rowIndex, supplierId, upcValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Run all validations and then clear the pending state
|
|
||||||
Promise.all(validationPromises).then(() => {
|
Promise.all(validationPromises).then(() => {
|
||||||
clearPendingCopyDownValidation();
|
clearPendingCopyDownValidation();
|
||||||
});
|
});
|
||||||
}, [pendingValidation, validateUpc, clearPendingCopyDownValidation]);
|
}, [pendingUpcValidation, validateUpc, clearPendingCopyDownValidation]);
|
||||||
|
|
||||||
|
// Handle inline AI validation (triggered by line copy-down)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingInlineAiValidation) return;
|
||||||
|
|
||||||
|
const { nameRows, descriptionRows } = pendingInlineAiValidation;
|
||||||
|
const state = useValidationStore.getState();
|
||||||
|
const { rows, fields, setInlineAiValidating, setInlineAiSuggestion } = state;
|
||||||
|
|
||||||
|
console.log(`[InlineAI] Line copy-down: validating ${nameRows.length} names, ${descriptionRows.length} descriptions`);
|
||||||
|
|
||||||
|
const validationPromises: Promise<void>[] = [];
|
||||||
|
|
||||||
|
// Trigger name validation for applicable rows
|
||||||
|
for (const rowIndex of nameRows) {
|
||||||
|
validationPromises.push(
|
||||||
|
triggerInlineAiValidation(rowIndex, 'name', rows, fields, setInlineAiValidating, setInlineAiSuggestion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger description validation for applicable rows
|
||||||
|
for (const rowIndex of descriptionRows) {
|
||||||
|
validationPromises.push(
|
||||||
|
triggerInlineAiValidation(rowIndex, 'description', rows, fields, setInlineAiValidating, setInlineAiSuggestion)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all(validationPromises).then(() => {
|
||||||
|
clearPendingInlineAiValidation();
|
||||||
|
});
|
||||||
|
}, [pendingInlineAiValidation, clearPendingInlineAiValidation]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { useTemplateManagement } from './hooks/useTemplateManagement';
|
|||||||
import { useUpcValidation } from './hooks/useUpcValidation';
|
import { useUpcValidation } from './hooks/useUpcValidation';
|
||||||
import { useValidationActions } from './hooks/useValidationActions';
|
import { useValidationActions } from './hooks/useValidationActions';
|
||||||
import { useProductLines } from './hooks/useProductLines';
|
import { useProductLines } from './hooks/useProductLines';
|
||||||
|
import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
|
||||||
import { BASE_IMPORT_FIELDS } from '../../config';
|
import { BASE_IMPORT_FIELDS } from '../../config';
|
||||||
import config from '@/config';
|
import config from '@/config';
|
||||||
import type { ValidationStepProps } from './store/types';
|
import type { ValidationStepProps } from './store/types';
|
||||||
@@ -120,6 +121,9 @@ export const ValidationStep = ({
|
|||||||
const { validateAllRows } = useValidationActions();
|
const { validateAllRows } = useValidationActions();
|
||||||
const { prefetchAllLines } = useProductLines();
|
const { prefetchAllLines } = useProductLines();
|
||||||
|
|
||||||
|
// Auto inline AI validation - triggers when ready phase is reached
|
||||||
|
useAutoInlineAiValidation();
|
||||||
|
|
||||||
// Fetch field options
|
// Fetch field options
|
||||||
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
|
const { data: fieldOptions, isLoading: optionsLoading, error: optionsError } = useQuery({
|
||||||
queryKey: ['field-options'],
|
queryKey: ['field-options'],
|
||||||
@@ -128,6 +132,9 @@ export const ValidationStep = ({
|
|||||||
retry: 2,
|
retry: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get current store state to check if we're returning to an already-initialized store
|
||||||
|
const storeRows = useValidationStore((state) => state.rows);
|
||||||
|
|
||||||
// Initialize store with data
|
// Initialize store with data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
||||||
@@ -140,6 +147,17 @@ export const ValidationStep = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
||||||
|
// This happens when navigating back from ImageUploadStep - the store still has
|
||||||
|
// all the validated data, so we don't need to re-run the initialization sequence.
|
||||||
|
// We check that the store is 'ready' and has matching row count to avoid
|
||||||
|
// false positives from stale store data.
|
||||||
|
if (initPhase === 'ready' && storeRows.length === initialData.length && storeRows.length > 0) {
|
||||||
|
console.log('[ValidationStep] Skipping init - returning to already-ready store with', storeRows.length, 'rows');
|
||||||
|
initStartedRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
initStartedRef.current = true;
|
initStartedRef.current = true;
|
||||||
|
|
||||||
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
||||||
@@ -154,7 +172,7 @@ export const ValidationStep = ({
|
|||||||
console.log('[ValidationStep] Calling initialize()');
|
console.log('[ValidationStep] Calling initialize()');
|
||||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||||
console.log('[ValidationStep] initialize() called');
|
console.log('[ValidationStep] initialize() called');
|
||||||
}, [initialData, file, initialize, initPhase]);
|
}, [initialData, file, initialize, initPhase, storeRows.length]);
|
||||||
|
|
||||||
// Update fields when options are loaded
|
// Update fields when options are loaded
|
||||||
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ export interface PendingCopyDownValidation {
|
|||||||
affectedRows: number[];
|
affectedRows: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks rows that need inline AI validation after line copy-down.
|
||||||
|
* When line is copied to rows that already have company + name/description,
|
||||||
|
* those rows now have sufficient context for validation.
|
||||||
|
*/
|
||||||
|
export interface PendingInlineAiValidation {
|
||||||
|
/** Row indices that need name validation */
|
||||||
|
nameRows: number[];
|
||||||
|
/** Row indices that need description validation */
|
||||||
|
descriptionRows: number[];
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dialog State Types
|
// Dialog State Types
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -291,8 +303,14 @@ export interface InlineAiSuggestion {
|
|||||||
export interface InlineAiState {
|
export interface InlineAiState {
|
||||||
/** Map of product __index to their inline suggestions */
|
/** Map of product __index to their inline suggestions */
|
||||||
suggestions: Map<string, InlineAiSuggestion>;
|
suggestions: Map<string, InlineAiSuggestion>;
|
||||||
/** Products currently being validated */
|
/** Products currently being validated (format: "productIndex-field") */
|
||||||
validating: Set<string>;
|
validating: Set<string>;
|
||||||
|
/**
|
||||||
|
* Fields that have been auto-validated on load (format: "productIndex-field")
|
||||||
|
* This prevents re-validation when blur fires for a field that was just auto-validated,
|
||||||
|
* and prevents auto-validation from firing for fields that were manually edited.
|
||||||
|
*/
|
||||||
|
autoValidated: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -370,6 +388,7 @@ export interface ValidationState {
|
|||||||
// === Copy-Down Mode ===
|
// === Copy-Down Mode ===
|
||||||
copyDownMode: CopyDownState;
|
copyDownMode: CopyDownState;
|
||||||
pendingCopyDownValidation: PendingCopyDownValidation | null;
|
pendingCopyDownValidation: PendingCopyDownValidation | null;
|
||||||
|
pendingInlineAiValidation: PendingInlineAiValidation | null;
|
||||||
|
|
||||||
// === Dialogs ===
|
// === Dialogs ===
|
||||||
dialogs: DialogState;
|
dialogs: DialogState;
|
||||||
@@ -458,6 +477,7 @@ export interface ValidationActions {
|
|||||||
completeCopyDown: (targetRowIndex: number) => void;
|
completeCopyDown: (targetRowIndex: number) => void;
|
||||||
setTargetRowHover: (rowIndex: number | null) => void;
|
setTargetRowHover: (rowIndex: number | null) => void;
|
||||||
clearPendingCopyDownValidation: () => void;
|
clearPendingCopyDownValidation: () => void;
|
||||||
|
clearPendingInlineAiValidation: () => void;
|
||||||
|
|
||||||
// === Dialogs ===
|
// === Dialogs ===
|
||||||
setDialogs: (updates: Partial<DialogState>) => void;
|
setDialogs: (updates: Partial<DialogState>) => void;
|
||||||
@@ -484,6 +504,8 @@ export interface ValidationActions {
|
|||||||
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
|
acceptInlineAiSuggestion: (productIndex: string, field: 'name' | 'description') => void;
|
||||||
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
|
clearInlineAiSuggestion: (productIndex: string, field?: 'name' | 'description') => void;
|
||||||
setInlineAiValidating: (productIndex: string, validating: boolean) => void;
|
setInlineAiValidating: (productIndex: string, validating: boolean) => void;
|
||||||
|
markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => void;
|
||||||
|
isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => boolean;
|
||||||
|
|
||||||
// === Output ===
|
// === Output ===
|
||||||
getCleanedData: () => CleanRowData[];
|
getCleanedData: () => CleanRowData[];
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const getInitialState = (): ValidationState => ({
|
|||||||
// Copy-Down Mode
|
// Copy-Down Mode
|
||||||
copyDownMode: { ...initialCopyDownState },
|
copyDownMode: { ...initialCopyDownState },
|
||||||
pendingCopyDownValidation: null,
|
pendingCopyDownValidation: null,
|
||||||
|
pendingInlineAiValidation: null,
|
||||||
|
|
||||||
// Dialogs
|
// Dialogs
|
||||||
dialogs: { ...initialDialogState },
|
dialogs: { ...initialDialogState },
|
||||||
@@ -129,6 +130,7 @@ const getInitialState = (): ValidationState => ({
|
|||||||
inlineAi: {
|
inlineAi: {
|
||||||
suggestions: new Map(),
|
suggestions: new Map(),
|
||||||
validating: new Set(),
|
validating: new Set(),
|
||||||
|
autoValidated: new Set(),
|
||||||
},
|
},
|
||||||
|
|
||||||
// File
|
// File
|
||||||
@@ -256,13 +258,45 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
|
|
||||||
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
|
copyDown: (fromRowIndex: number, fieldKey: string, toRowIndex?: number) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const sourceValue = state.rows[fromRowIndex]?.[fieldKey];
|
const sourceRow = state.rows[fromRowIndex];
|
||||||
|
const sourceValue = sourceRow?.[fieldKey];
|
||||||
if (sourceValue === undefined) return;
|
if (sourceValue === undefined) return;
|
||||||
|
|
||||||
const endIndex = toRowIndex ?? state.rows.length - 1;
|
const endIndex = toRowIndex ?? state.rows.length - 1;
|
||||||
|
const isInlineAiField = fieldKey === 'name' || fieldKey === 'description';
|
||||||
|
|
||||||
|
// For inline AI fields, check if source was validated/dismissed
|
||||||
|
// If so, we'll mark targets as autoValidated to skip re-validation
|
||||||
|
let sourceIsDismissed = false;
|
||||||
|
if (isInlineAiField && sourceRow?.__index) {
|
||||||
|
const sourceSuggestion = state.inlineAi.suggestions.get(sourceRow.__index);
|
||||||
|
sourceIsDismissed = sourceSuggestion?.dismissed?.[fieldKey as 'name' | 'description'] ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
|
for (let i = fromRowIndex + 1; i <= endIndex; i++) {
|
||||||
if (state.rows[i]) {
|
const targetRow = state.rows[i];
|
||||||
state.rows[i][fieldKey] = sourceValue;
|
if (targetRow) {
|
||||||
|
targetRow[fieldKey] = sourceValue;
|
||||||
|
|
||||||
|
// For name/description fields:
|
||||||
|
// 1. Mark as autoValidated so blur won't re-validate
|
||||||
|
// 2. Clear any existing suggestion for this field (value changed)
|
||||||
|
// 3. Set dismissed state based on source (if source was dismissed, targets are also valid)
|
||||||
|
if (isInlineAiField && targetRow.__index) {
|
||||||
|
const field = fieldKey as 'name' | 'description';
|
||||||
|
state.inlineAi.autoValidated.add(`${targetRow.__index}-${field}`);
|
||||||
|
|
||||||
|
// Clear existing suggestion and set dismissed state
|
||||||
|
const existing = state.inlineAi.suggestions.get(targetRow.__index) || {};
|
||||||
|
state.inlineAi.suggestions.set(targetRow.__index, {
|
||||||
|
...existing,
|
||||||
|
[field]: undefined, // Clear the suggestion for this field
|
||||||
|
dismissed: {
|
||||||
|
...existing.dismissed,
|
||||||
|
[field]: sourceIsDismissed, // Copy dismissed state from source
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -620,6 +654,43 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
affectedRows,
|
affectedRows,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If line is being copied, check which rows now have sufficient context
|
||||||
|
// for inline AI validation (company + line + name/description)
|
||||||
|
if (fieldKey === 'line' && affectedRows.length > 0) {
|
||||||
|
const nameRows: number[] = [];
|
||||||
|
const descriptionRows: number[] = [];
|
||||||
|
|
||||||
|
for (const rowIdx of affectedRows) {
|
||||||
|
const row = state.rows[rowIdx];
|
||||||
|
if (!row?.__index) continue;
|
||||||
|
|
||||||
|
// Check if row has company + line (just set) + name
|
||||||
|
const hasNameContext = row.company && sourceValue && row.name &&
|
||||||
|
typeof row.name === 'string' && row.name.trim();
|
||||||
|
|
||||||
|
if (hasNameContext) {
|
||||||
|
// Check if name hasn't been dismissed
|
||||||
|
const suggestion = state.inlineAi.suggestions.get(row.__index);
|
||||||
|
const nameIsDismissed = suggestion?.dismissed?.name;
|
||||||
|
if (!nameIsDismissed) {
|
||||||
|
nameRows.push(rowIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if description also has sufficient context
|
||||||
|
const hasDescContext = row.description &&
|
||||||
|
typeof row.description === 'string' && row.description.trim();
|
||||||
|
const descIsDismissed = suggestion?.dismissed?.description;
|
||||||
|
if (hasDescContext && !descIsDismissed) {
|
||||||
|
descriptionRows.push(rowIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameRows.length > 0 || descriptionRows.length > 0) {
|
||||||
|
state.pendingInlineAiValidation = { nameRows, descriptionRows };
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -637,6 +708,12 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
clearPendingInlineAiValidation: () => {
|
||||||
|
set((state) => {
|
||||||
|
state.pendingInlineAiValidation = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Dialogs
|
// Dialogs
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
@@ -853,6 +930,16 @@ export const useValidationStore = create<ValidationStore>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
markInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => {
|
||||||
|
set((state) => {
|
||||||
|
state.inlineAi.autoValidated.add(`${productIndex}-${field}`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
isInlineAiAutoValidated: (productIndex: string, field: 'name' | 'description') => {
|
||||||
|
return get().inlineAi.autoValidated.has(`${productIndex}-${field}`);
|
||||||
|
},
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Output
|
// Output
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user