Remove old validation step code

This commit is contained in:
2026-04-01 12:27:57 -04:00
parent 407731e17d
commit b95bd4a4a0
34 changed files with 0 additions and 10849 deletions

View File

@@ -1,981 +0,0 @@
import React, { useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { Loader2, CheckIcon, XIcon } from "lucide-react";
import { Code } from "@/components/ui/code";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
AiValidationDetails,
AiValidationProgress,
CurrentPrompt,
} from "../hooks/useAiValidation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Protected } from "@/components/auth/Protected";
interface TaxonomyStats {
categories: number;
themes: number;
colors: number;
taxCodes: number;
sizeCategories: number;
suppliers: number;
companies: number;
artists: number;
}
interface DebugData {
taxonomyStats: TaxonomyStats | null;
basePrompt: string;
sampleFullPrompt: string;
promptLength: number;
apiFormat?: Array<{
role: string;
content: string;
}>;
promptSources?: {
systemPrompt?: { id: number; prompt_text: string };
generalPrompt?: { id: number; prompt_text: string };
companyPrompts?: Array<{
id: number;
company: string;
companyName?: string;
prompt_text: string;
}>;
};
estimatedProcessingTime?: {
seconds: number | null;
sampleCount: number;
};
}
interface AiValidationDialogsProps {
aiValidationProgress: AiValidationProgress;
aiValidationDetails: AiValidationDetails;
currentPrompt: CurrentPrompt;
setAiValidationProgress: React.Dispatch<
React.SetStateAction<AiValidationProgress>
>;
setAiValidationDetails: React.Dispatch<
React.SetStateAction<AiValidationDetails>
>;
setCurrentPrompt: React.Dispatch<React.SetStateAction<CurrentPrompt>>;
revertAiChange: (productIndex: number, fieldKey: string) => void;
isChangeReverted: (productIndex: number, fieldKey: string) => boolean;
getFieldDisplayValueWithHighlight: (
fieldKey: string,
originalValue: any,
correctedValue: any
) => { originalHtml: string; correctedHtml: string };
fields: readonly any[];
debugData?: DebugData;
}
export const AiValidationDialogs: React.FC<AiValidationDialogsProps> = ({
aiValidationProgress,
aiValidationDetails,
currentPrompt,
setAiValidationProgress,
setAiValidationDetails,
setCurrentPrompt,
revertAiChange,
isChangeReverted,
getFieldDisplayValueWithHighlight,
fields,
}) => {
const [costPerMillionTokens, setCostPerMillionTokens] = useState(1.25); // Default cost
// Create our own state to track changes
const [localReversionState, setLocalReversionState] = useState<
Record<string, boolean>
>({});
// Initialize local state from the isChangeReverted function when component mounts
// or when aiValidationDetails changes
React.useEffect(() => {
if (
aiValidationDetails.changeDetails &&
aiValidationDetails.changeDetails.length > 0
) {
const initialState: Record<string, boolean> = {};
aiValidationDetails.changeDetails.forEach((product) => {
product.changes.forEach((change) => {
const key = `${product.productIndex}-${change.field}`;
initialState[key] = isChangeReverted(
product.productIndex,
change.field
);
});
});
setLocalReversionState(initialState);
}
}, [aiValidationDetails.changeDetails, isChangeReverted]);
// This function will toggle the local state for a given change
const toggleChangeAcceptance = (productIndex: number, fieldKey: string) => {
const key = `${productIndex}-${fieldKey}`;
const currentlyRejected = !!localReversionState[key];
// Toggle the local state
setLocalReversionState((prev) => ({
...prev,
[key]: !prev[key],
}));
// Only call revertAiChange when toggling to rejected state
// Since revertAiChange is specifically for rejecting changes
if (!currentlyRejected) {
revertAiChange(productIndex, fieldKey);
}
};
// Function to check local reversion state
const isChangeLocallyReverted = (
productIndex: number,
fieldKey: string
): boolean => {
const key = `${productIndex}-${fieldKey}`;
return !!localReversionState[key];
};
// Format time helper
const formatTime = (seconds: number): string => {
if (seconds < 60) {
return `${Math.round(seconds)} seconds`;
} else {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.round(seconds % 60);
return `${minutes}m ${remainingSeconds}s`;
}
};
// Calculate token costs
const calculateTokenCost = (promptLength: number): number => {
const estimatedTokens = Math.round(promptLength / 4);
return (estimatedTokens / 1_000_000) * costPerMillionTokens * 100; // In cents
};
const formatNumber = (value: number | null | undefined): string => {
if (value === null || value === undefined) {
return "—";
}
return value.toLocaleString();
};
// Use the prompt length from the current prompt
const promptLength = currentPrompt.prompt ? currentPrompt.prompt.length : 0;
const tokenUsage = aiValidationDetails.tokenUsage;
const formattedReasoningEffort = aiValidationDetails.reasoningEffort
? aiValidationDetails.reasoningEffort.charAt(0).toUpperCase() +
aiValidationDetails.reasoningEffort.slice(1)
: null;
return (
<>
{/* Current Prompt Dialog with Debug Info */}
<Dialog
open={currentPrompt.isOpen}
onOpenChange={(open) =>
setCurrentPrompt((prev) => ({ ...prev, isOpen: open }))
}
>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden">
<DialogHeader>
<DialogTitle>Current AI Prompt</DialogTitle>
<DialogDescription>
This is the current prompt that would be sent to the AI for
validation
</DialogDescription>
</DialogHeader>
<div className="flex flex-col h-[calc(90vh-120px)] overflow-hidden">
{/* Debug Information Section - Fixed at the top */}
<div className="flex-shrink-0">
{currentPrompt.isLoading ? (
<div className="flex justify-center items-center h-[100px]"></div>
) : (
<>
<div className="grid grid-cols-3 gap-4 mb-4">
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">
Prompt Length
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
<div className="text-sm">
<span className="text-muted-foreground">
Characters:
</span>{" "}
<span className="font-semibold">
{promptLength}
</span>
</div>
<div className="text-sm">
<span className="text-muted-foreground">
Tokens:
</span>{" "}
<span className="font-semibold">
~{Math.round(promptLength / 4)}
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">
Cost Estimate
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
<div className="flex flex-row items-center">
<label
htmlFor="costPerMillion"
className="text-sm text-muted-foreground"
>
$
</label>
<input
id="costPerMillion"
className="w-[40px] px-1 border rounded-md text-sm"
defaultValue={costPerMillionTokens.toFixed(2)}
onChange={(e) => {
const value = parseFloat(e.target.value);
if (!isNaN(value)) {
setCostPerMillionTokens(value);
}
}}
/>
<label
htmlFor="costPerMillion"
className="text-sm text-muted-foreground ml-1"
>
per million input tokens
</label>
</div>
<div className="text-sm">
<span className="text-muted-foreground">Cost:</span>{" "}
<span className="font-semibold">
{calculateTokenCost(promptLength).toFixed(1)}¢
</span>
</div>
</div>
</CardContent>
</Card>
<Card className="py-2">
<CardHeader className="py-2">
<CardTitle className="text-base">
Processing Time
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-col space-y-2">
{currentPrompt.debugData?.estimatedProcessingTime ? (
currentPrompt.debugData.estimatedProcessingTime
.seconds ? (
<>
<div className="text-sm">
<span className="text-muted-foreground">
Estimated time:
</span>{" "}
<span className="font-semibold">
{formatTime(
currentPrompt.debugData
.estimatedProcessingTime.seconds
)}
</span>
</div>
<div className="text-xs text-muted-foreground">
Based on{" "}
{
currentPrompt.debugData
.estimatedProcessingTime.sampleCount
}{" "}
similar validation
{currentPrompt.debugData
.estimatedProcessingTime.sampleCount !== 1
? "s"
: ""}
</div>
</>
) : (
<div className="text-sm text-muted-foreground">
No historical data available for this prompt
size
</div>
)
) : (
<div className="text-sm text-muted-foreground">
No processing time data available
</div>
)}
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
{/* Prompt Section - Scrollable content */}
<div className="flex-1 min-h-0">
{currentPrompt.isLoading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : (
<>
{currentPrompt.debugData?.apiFormat ? (
<div className="flex flex-col h-full">
{/* Prompt Sources Card - Fixed at the top of the content area */}
<Card className="py-2 mb-4 flex-shrink-0">
<CardHeader className="py-2">
<CardTitle className="text-base">
Prompt Sources
</CardTitle>
</CardHeader>
<CardContent className="py-2">
<div className="flex flex-wrap gap-2">
<Badge
variant="outline"
className="bg-purple-100 hover:bg-purple-200 cursor-pointer"
onClick={() =>
document
.getElementById("system-message")
?.scrollIntoView({ behavior: "smooth" })
}
>
System
</Badge>
<Badge
variant="outline"
className="bg-green-100 hover:bg-green-200 cursor-pointer"
onClick={() =>
document
.getElementById("general-section")
?.scrollIntoView({ behavior: "smooth" })
}
>
General
</Badge>
{currentPrompt.debugData.promptSources?.companyPrompts?.map(
(company, idx) => (
<Badge
key={idx}
variant="outline"
className="bg-blue-100 hover:bg-blue-200 cursor-pointer"
onClick={() =>
document
.getElementById("company-section")
?.scrollIntoView({ behavior: "smooth" })
}
>
{company.companyName ||
`Company ${company.company}`}
</Badge>
)
)}
<Badge
variant="outline"
className="bg-amber-100 hover:bg-amber-200 cursor-pointer"
onClick={() =>
document
.getElementById("taxonomy-section")
?.scrollIntoView({ behavior: "smooth" })
}
>
Taxonomy
</Badge>
<Badge
variant="outline"
className="bg-pink-100 hover:bg-pink-200 cursor-pointer"
onClick={() =>
document
.getElementById("product-section")
?.scrollIntoView({ behavior: "smooth" })
}
>
Products
</Badge>
</div>
</CardContent>
</Card>
<ScrollArea className="flex-1 w-full overflow-y-auto">
{currentPrompt.debugData.apiFormat.map(
(message, idx: number) => (
<div
key={idx}
className="border rounded-md p-2 mb-4"
>
<div
id={
message.role === "system"
? "system-message"
: ""
}
className={`p-2 mb-2 rounded-sm font-medium ${
message.role === "system"
? "bg-purple-50 text-purple-800"
: "bg-green-50 text-green-800"
}`}
>
Role: {message.role}
</div>
<Code
className={`whitespace-pre-wrap p-4 break-normal max-w-full ${
message.role === "system"
? "bg-purple-50/30"
: "bg-green-50/30"
}`}
>
{message.role === "user" ? (
<div className="text-wrapper">
{(() => {
const content = message.content;
// Find section boundaries by looking for specific markers
const companySpecificStartIndex =
content.indexOf(
"--- COMPANY-SPECIFIC INSTRUCTIONS ---"
);
const companySpecificEndIndex =
content.indexOf(
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
);
const taxonomyStartIndex =
content.indexOf(
"All Available Categories:"
);
const taxonomyFallbackStartIndex =
content.indexOf(
"Available Categories:"
);
const actualTaxonomyStartIndex =
taxonomyStartIndex >= 0
? taxonomyStartIndex
: taxonomyFallbackStartIndex;
const productDataStartIndex =
content.indexOf(
"----------Here is the product data to validate----------"
);
// If we can't find any markers, just return the content as-is
if (
actualTaxonomyStartIndex < 0 &&
productDataStartIndex < 0 &&
companySpecificStartIndex < 0
) {
return content;
}
// Determine section indices
let generalEndIndex = content.length;
if (companySpecificStartIndex >= 0) {
generalEndIndex =
companySpecificStartIndex;
} else if (
actualTaxonomyStartIndex >= 0
) {
generalEndIndex =
actualTaxonomyStartIndex;
} else if (productDataStartIndex >= 0) {
generalEndIndex = productDataStartIndex;
}
// Determine where taxonomy starts
let taxonomyEndIndex = content.length;
if (productDataStartIndex >= 0) {
taxonomyEndIndex =
productDataStartIndex;
}
// Segments to render with appropriate styling
const segments = [];
// General section (beginning to company/taxonomy/product)
if (generalEndIndex > 0) {
segments.push(
<div
id="general-section"
key="general"
className="border-l-4 border-green-500 pl-4 py-0 my-1"
>
<div className="text-xs font-semibold text-green-700 mb-2">
General Prompt
</div>
<pre className="whitespace-pre-wrap">
{content.substring(
0,
generalEndIndex
)}
</pre>
</div>
);
}
// Company-specific section if present
if (
companySpecificStartIndex >= 0 &&
companySpecificEndIndex >= 0
) {
segments.push(
<div
id="company-section"
key="company"
className="border-l-4 border-blue-500 pl-4 py-0 my-1"
>
<div className="text-xs font-semibold text-blue-700 mb-2">
Company-Specific Instructions
</div>
<pre className="whitespace-pre-wrap break-words break-all">
{content.substring(
companySpecificStartIndex,
companySpecificEndIndex +
"--- END COMPANY-SPECIFIC INSTRUCTIONS ---"
.length
)}
</pre>
</div>
);
}
// Taxonomy section
if (actualTaxonomyStartIndex >= 0) {
const taxEnd = taxonomyEndIndex;
segments.push(
<div
id="taxonomy-section"
key="taxonomy"
className="border-l-4 border-amber-500 pl-4 py-0 my-1"
>
<div className="text-xs font-semibold text-amber-700 mb-2">
Taxonomy Data
</div>
<pre className="whitespace-pre-wrap break-words break-all">
{content.substring(
actualTaxonomyStartIndex,
taxEnd
)}
</pre>
</div>
);
}
// Product data section
if (productDataStartIndex >= 0) {
segments.push(
<div
id="product-section"
key="product"
className="border-l-4 border-pink-500 pl-4 py-0 my-1"
>
<div className="text-xs font-semibold text-pink-700 mb-2">
Product Data
</div>
<pre className="whitespace-pre-wrap break-words break-all">
{content.substring(
productDataStartIndex
)}
</pre>
</div>
);
}
return <>{segments}</>;
})()}
</div>
) : (
<pre className="whitespace-pre-wrap break-words break-all">
{message.content}
</pre>
)}
</Code>
</div>
)
)}
</ScrollArea>
</div>
) : (
<ScrollArea className="h-full w-full">
<Code className="whitespace-pre-wrap break-words break-all p-4 max-w-full overflow-x-hidden">
{currentPrompt.prompt}
</Code>
</ScrollArea>
)}
</>
)}
</div>
</div>
</DialogContent>
</Dialog>
{/* AI Validation Progress Dialog */}
<Dialog
open={aiValidationProgress.isOpen}
onOpenChange={(open) => {
// Only allow closing if validation failed
if (!open && aiValidationProgress.step === -1) {
setAiValidationProgress((prev) => ({ ...prev, isOpen: false }));
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>AI Validation Progress</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-4">
<div className="flex-1">
<div className="h-2 w-full bg-secondary rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-500"
style={{
width: `${
aiValidationProgress.progressPercent !== undefined
? Math.round(aiValidationProgress.progressPercent)
: Math.round((aiValidationProgress.step / 5) * 100)
}%`,
backgroundColor:
aiValidationProgress.step === -1
? "var(--destructive)"
: undefined,
}}
/>
</div>
</div>
<div className="text-sm text-muted-foreground w-12 text-right">
{aiValidationProgress.step === -1
? "❌"
: `${
aiValidationProgress.progressPercent !== undefined
? Math.round(aiValidationProgress.progressPercent)
: Math.round((aiValidationProgress.step / 5) * 100)
}%`}
</div>
</div>
<p className="text-center text-sm text-muted-foreground">
{aiValidationProgress.status}
</p>
{(() => {
// Only show time remaining if we have an estimate and are in progress
return (
aiValidationProgress.estimatedSeconds &&
aiValidationProgress.elapsedSeconds !== undefined &&
aiValidationProgress.step > 0 &&
aiValidationProgress.step < 5 && (() => {
const elapsedSeconds = aiValidationProgress.elapsedSeconds;
const totalEstimatedSeconds = aiValidationProgress.estimatedSeconds;
const remainingSeconds = Math.max(
0,
totalEstimatedSeconds - elapsedSeconds
);
if (remainingSeconds <= 5) {
return null;
}
const message = remainingSeconds < 60
? `Approximately ${Math.round(remainingSeconds)} seconds remaining`
: (() => {
const minutes = Math.floor(remainingSeconds / 60);
const seconds = Math.round(remainingSeconds % 60);
return `Approximately ${minutes}m ${seconds}s remaining`;
})();
return (
<div className="text-center text-sm">
{message}
{aiValidationProgress.promptLength && (
<p className="mt-1 text-xs text-muted-foreground">
Prompt length:{" "}
{aiValidationProgress.promptLength.toLocaleString()}{" "}
characters
</p>
)}
</div>
);
})()
);
})()}
</div>
</DialogContent>
</Dialog>
{/* AI Validation Results Dialog */}
<Dialog
open={aiValidationDetails.isOpen}
onOpenChange={(open) =>
setAiValidationDetails((prev) => ({ ...prev, isOpen: open }))
}
>
<DialogContent className="max-w-6xl w-[90vw] max-h-[90vh] overflow-auto">
<DialogHeader>
<DialogTitle>AI Validation Results</DialogTitle>
<DialogDescription>
Review the changes and warnings suggested by the AI
</DialogDescription>
</DialogHeader>
<Protected permission="admin:debug">
{(aiValidationDetails.model || tokenUsage || formattedReasoningEffort) && (
<div className="mb-4 rounded-md border bg-muted/40 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
{aiValidationDetails.model && (
<Badge variant="outline">
Model · {aiValidationDetails.model}
</Badge>
)}
{formattedReasoningEffort && (
<Badge variant="secondary">
Reasoning {formattedReasoningEffort}
</Badge>
)}
</div>
{tokenUsage && (
<div className="mt-3 grid gap-4 text-xs sm:grid-cols-2 lg:grid-cols-5">
<div>
<span className="block text-muted-foreground">
Prompt tokens
</span>
<span className="font-medium">
{formatNumber(tokenUsage.prompt)}
</span>
</div>
<div>
<span className="block text-muted-foreground">
Completion tokens
</span>
<span className="font-medium">
{formatNumber(tokenUsage.completion)}
</span>
</div>
<div>
<span className="block text-muted-foreground">
Total tokens
</span>
<span className="font-medium">
{formatNumber(tokenUsage.total)}
</span>
</div>
<div>
<span className="block text-muted-foreground">
Reasoning tokens
</span>
<span className="font-medium">
{formatNumber(tokenUsage.reasoning)}
</span>
</div>
<div>
<span className="block text-muted-foreground">
Cached prompt tokens
</span>
<span className="font-medium">
{formatNumber(tokenUsage.cachedPrompt)}
</span>
</div>
</div>
)}
</div>
)}
</Protected>
{(aiValidationDetails.summary ||
(aiValidationDetails.changes && aiValidationDetails.changes.length > 0) ||
(aiValidationDetails.warnings && aiValidationDetails.warnings.length > 0)) && (
<Card className="mb-4 max-h-[25vh] overflow-auto">
<CardHeader className="pb-2">
<CardTitle className="text-base">Overall Assessment</CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
{aiValidationDetails.changes &&
aiValidationDetails.changes.length > 0 && (
<div>
<h4 className="font-medium text-foreground mb-1">
Key Changes
</h4>
<ul className="list-disc pl-5 space-y-1">
{aiValidationDetails.changes.map((change, idx) => (
<li key={`change-${idx}`}>{change}</li>
))}
</ul>
</div>
)}
{aiValidationDetails.warnings &&
aiValidationDetails.warnings.length > 0 && (
<div>
<h4 className="font-medium text-foreground mb-1">
Warnings
</h4>
<ul className="list-disc pl-5 space-y-1">
{aiValidationDetails.warnings.map((warning, idx) => (
<li key={`warning-${idx}`}>{warning}</li>
))}
</ul>
</div>
)}
{aiValidationDetails.summary && (
<p className="leading-relaxed">{aiValidationDetails.summary}</p>
)}
</CardContent>
</Card>
)}
<ScrollArea className="max-h-[70vh]">
{aiValidationDetails.changeDetails &&
aiValidationDetails.changeDetails.length > 0 ? (
<div className="mb-6 space-y-6">
<h3 className="font-semibold text-lg">Detailed Changes:</h3>
{aiValidationDetails.changeDetails.map((product, i) => {
// Find the title change if it exists
const titleChange = product.changes.find(
(c) => c.field === "title"
);
const titleValue = titleChange
? titleChange.corrected
: product.title;
return (
<div key={`product-${i}`} className="border rounded-md p-4">
<h4 className="font-medium text-base mb-3">
{titleValue || `Product ${product.productIndex + 1}`}
</h4>
<Table>
<TableHeader>
<TableRow>
<TableHead className="">Field</TableHead>
<TableHead className="w-[35%]">
Original Value
</TableHead>
<TableHead className="w-[35%]">
Corrected Value
</TableHead>
<TableHead className="text-right">
Accept Changes?
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{product.changes.map((change, j) => {
const field = fields.find(
(f) => f.key === change.field
);
const fieldLabel = field
? field.label
: change.field;
const isReverted = isChangeLocallyReverted(
product.productIndex,
change.field
);
// Get highlighted differences
const { originalHtml, correctedHtml } =
getFieldDisplayValueWithHighlight(
change.field,
change.original,
change.corrected
);
return (
<TableRow key={`change-${j}`}>
<TableCell className="font-medium">
{fieldLabel}
</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{
__html: originalHtml,
}}
/>
</TableCell>
<TableCell>
<div
dangerouslySetInnerHTML={{
__html: correctedHtml,
}}
/>
</TableCell>
<TableCell className="text-right align-top">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
// Toggle to Accepted state if currently rejected
toggleChangeAcceptance(
product.productIndex,
change.field
);
}}
className={
!isReverted
? "bg-green-100 text-green-600 border-green-300 flex items-center"
: "border-gray-200 text-gray-600 hover:bg-green-50 hover:text-green-600 hover:border-green-200 flex items-center"
}
>
<CheckIcon className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
// Toggle to Rejected state if currently accepted
toggleChangeAcceptance(
product.productIndex,
change.field
);
}}
className={
isReverted
? "bg-red-100 text-red-600 border-red-300 flex items-center"
: "border-gray-200 text-gray-600 hover:bg-red-50 hover:text-red-600 hover:border-red-200 flex items-center"
}
>
<XIcon className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
})}
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
<p>No field-level changes were suggested by the AI.</p>
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -1,42 +0,0 @@
// Define MultiSelectCell component to fix the import issue
type MultiSelectCellProps = {
field: string;
value: any;
onChange: (value: any) => void;
options: any[];
hasErrors: boolean;
className?: string;
};
// Using _ to indicate intentionally unused parameters
const MultiSelectCell = (_: MultiSelectCellProps) => {
// This is a placeholder implementation
return null;
};
const BaseCellContent = ({ fieldType, field, value, onChange, options, hasErrors, className }: {
fieldType: string;
field: string;
value: any;
onChange: (value: any) => void;
options: any[];
hasErrors: boolean;
className?: string;
}) => {
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return (
<MultiSelectCell
field={field}
value={value}
onChange={onChange}
options={options}
hasErrors={hasErrors}
className={className}
/>
);
}
return null;
};
export default BaseCellContent;

View File

@@ -1,73 +0,0 @@
import React from 'react'
import { Loader2, Check, X } from 'lucide-react'
import { cn } from '@/lib/utils'
interface InitializationTask {
label: string
status: 'pending' | 'in_progress' | 'completed' | 'failed'
}
interface InitializingValidationProps {
totalRows: number
tasks: InitializationTask[]
}
export const InitializingValidation: React.FC<InitializingValidationProps> = ({
totalRows,
tasks
}) => {
return (
<div className="flex flex-col items-center justify-center h-[calc(100vh-10rem)]">
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
<h3 className="text-lg font-medium text-foreground mb-2">Initializing Validation</h3>
<p className="text-sm text-muted-foreground mb-6">Processing {totalRows} rows...</p>
{/* Task checklist */}
<div className="w-80 space-y-2">
{tasks.map((task, index) => (
<div
key={index}
className={cn(
"flex items-center gap-3 px-4 py-2 rounded-md transition-colors",
task.status === 'completed' && "bg-green-50 border border-green-200",
task.status === 'failed' && "bg-red-50 border border-red-200",
task.status === 'in_progress' && "bg-blue-50 border border-blue-200",
task.status === 'pending' && "bg-blue-50 border border-blue-200"
)}
>
{/* Status icon */}
<div className="flex-shrink-0">
{task.status === 'completed' && (
<Check className="h-4 w-4 text-green-600" />
)}
{task.status === 'failed' && (
<X className="h-4 w-4 text-red-600" />
)}
{task.status === 'in_progress' && (
<Loader2 className="h-4 w-4 text-blue-600 animate-spin" />
)}
{task.status === 'pending' && (
<Loader2 className="h-4 w-4 text-blue-600 animate-spin" />
)}
</div>
{/* Task label */}
<span
className={cn(
"text-sm font-medium",
task.status === 'completed' && "text-green-700",
task.status === 'failed' && "text-red-700",
task.status === 'in_progress' && "text-blue-700",
task.status === 'pending' && "text-blue-700"
)}
>
{task.label}
</span>
</div>
))}
</div>
</div>
)
}
export default InitializingValidation

View File

@@ -1,328 +0,0 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { Template } from '../hooks/validationTypes'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { cn } from '@/lib/utils'
import { Check, ChevronsUpDown } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
interface SearchableTemplateSelectProps {
templates: Template[] | undefined;
value: string;
onValueChange: (value: string) => void;
getTemplateDisplayText: (templateId: string | null) => string;
placeholder?: string;
className?: string;
triggerClassName?: string;
defaultBrand?: string;
}
const SearchableTemplateSelect: React.FC<SearchableTemplateSelectProps> = ({
templates = [],
value,
onValueChange,
getTemplateDisplayText,
placeholder = "Select template",
className,
triggerClassName,
defaultBrand,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [selectedBrand, setSelectedBrand] = useState<string | null>(null);
const [open, setOpen] = useState(false);
// Set default brand when component mounts or defaultBrand changes
useEffect(() => {
if (defaultBrand) {
setSelectedBrand(defaultBrand);
}
}, [defaultBrand]);
// Force a re-render when templates change from empty to non-empty
useEffect(() => {
if (templates && templates.length > 0) {
// Force a re-render by updating state
setSearchTerm("");
}
}, [templates]);
// Handle wheel events for scrolling
const handleWheel = (e: React.WheelEvent) => {
const scrollArea = e.currentTarget;
scrollArea.scrollTop += e.deltaY;
};
// Extract unique brands from templates
const brands = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) {
return [];
}
const brandSet = new Set<string>();
const brandNames: {id: string, name: string}[] = [];
templates.forEach(template => {
if (!template?.company) return;
const companyId = template.company;
if (!brandSet.has(companyId)) {
brandSet.add(companyId);
// Try to get the company name from the template display text
try {
const displayText = getTemplateDisplayText(template.id.toString());
const companyName = displayText.split(' - ')[0];
brandNames.push({ id: companyId, name: companyName || companyId });
} catch (err) {
brandNames.push({ id: companyId, name: companyId });
}
}
});
return brandNames.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
return [];
}
}, [templates, getTemplateDisplayText]);
// Group templates by company for better organization
const groupedTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return {};
const groups: Record<string, Template[]> = {};
templates.forEach(template => {
if (!template?.company) return;
const companyId = template.company;
if (!groups[companyId]) {
groups[companyId] = [];
}
groups[companyId].push(template);
});
return groups;
} catch (err) {
return {};
}
}, [templates]);
// Filter templates based on selected brand and search term
const filteredTemplates = useMemo(() => {
try {
if (!Array.isArray(templates) || templates.length === 0) return [];
// First filter by brand if selected
let brandFiltered = templates;
if (selectedBrand) {
// Check if the selected brand has any templates
const brandTemplates = templates.filter(t => t?.company === selectedBrand);
// If the selected brand has templates, use them; otherwise, show all templates
brandFiltered = brandTemplates.length > 0 ? brandTemplates : templates;
}
// Then filter by search term if provided
if (!searchTerm.trim()) return brandFiltered;
const lowerSearchTerm = searchTerm.toLowerCase();
return brandFiltered.filter(template => {
if (!template?.id) return false;
try {
const displayText = getTemplateDisplayText(template.id.toString());
const productType = template.product_type?.toLowerCase() || '';
return displayText.toLowerCase().includes(lowerSearchTerm) ||
productType.includes(lowerSearchTerm);
} catch (error) {
return false;
}
});
} catch (err) {
return [];
}
}, [templates, selectedBrand, searchTerm, getTemplateDisplayText]);
// Handle errors gracefully
const getDisplayText = useCallback(() => {
try {
if (!value) return placeholder;
const template = templates.find(t => t.id.toString() === value);
if (!template) return placeholder;
// Get the original display text
const originalText = getTemplateDisplayText(value);
// Check if it has the expected format "Brand - Product Type"
if (originalText.includes(' - ')) {
const [brand, productType] = originalText.split(' - ', 2);
// Reverse the order to "Product Type - Brand"
return `${productType} - ${brand}`;
}
// If it doesn't match the expected format, return the original text
return originalText;
} catch (err) {
console.error('Error getting display text:', err);
return placeholder;
}
}, [getTemplateDisplayText, placeholder, value, templates]);
// Safe render function for CommandItem
const renderCommandItem = useCallback((template: Template) => {
if (!template?.id) return null;
try {
const displayText = getTemplateDisplayText(template.id.toString());
return (
<CommandItem
key={template.id}
value={template.id.toString()}
onSelect={(currentValue) => {
try {
onValueChange(currentValue);
setOpen(false);
setSearchTerm("");
} catch (err) {
console.error('Error selecting template:', err);
}
}}
className="flex items-center justify-between"
>
<span>{displayText}</span>
{value === template.id.toString() && <Check className="h-4 w-4 ml-2" />}
</CommandItem>
);
} catch (err) {
console.error('Error rendering template item:', err);
return null;
}
}, [onValueChange, value, getTemplateDisplayText]);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn("w-full justify-between overflow-hidden", triggerClassName)}
>
<span className="truncate overflow-hidden mr-1">{getDisplayText()}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50 flex-none" />
</Button>
</PopoverTrigger>
<PopoverContent className={cn("w-[300px] p-0", className)}>
<Command>
<div className="flex flex-col p-2 gap-2">
{brands.length > 0 && (
<div className="flex items-center gap-2">
<Select
value={selectedBrand || "all"}
onValueChange={(value) => {
setSelectedBrand(value === "all" ? null : value);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="All Brands" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Brands</SelectItem>
{brands.map(brand => (
<SelectItem key={brand.id} value={brand.id}>
{brand.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<CommandSeparator />
<div className="flex items-center gap-2">
<CommandInput
placeholder="Search by product type..."
value={searchTerm}
onValueChange={setSearchTerm}
className="h-8 flex-1"
/>
</div>
</div>
<CommandEmpty>
<div className="py-6 text-center">
<p className="text-sm text-muted-foreground">No templates found.</p>
</div>
</CommandEmpty>
<CommandList>
<ScrollArea className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
{!searchTerm ? (
selectedBrand ? (
groupedTemplates[selectedBrand]?.length > 0 ? (
<CommandGroup heading={brands.find(b => b.id === selectedBrand)?.name || selectedBrand}>
{groupedTemplates[selectedBrand]?.map(template => renderCommandItem(template))}
</CommandGroup>
) : (
// If selected brand has no templates, show all brands
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find(b => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => {
const brand = brands.find(b => b.id === companyId);
const companyName = brand ? brand.name : companyId;
return (
<CommandGroup key={companyId} heading={companyName}>
{companyTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
);
})
)
) : (
<CommandGroup>
{filteredTemplates.map(template => renderCommandItem(template))}
</CommandGroup>
)}
</ScrollArea>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
export default SearchableTemplateSelect;

View File

@@ -1,158 +0,0 @@
import React, { useMemo } from 'react'
import ValidationTable from './ValidationTable'
import { RowSelectionState } from '@tanstack/react-table'
import { Fields } from '../../../types'
import { Template } from '../hooks/validationTypes'
interface UpcValidationTableAdapterProps<T extends string> {
data: any[]
fields: Fields<string>
validationErrors: Map<number, Record<string, any[]>>
rowSelection: RowSelectionState
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
filters: any
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
copyDown: (rowIndex: number, fieldKey: string, endRowIndex?: number) => void
validatingCells: Set<string>
isLoadingTemplates: boolean
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
rowProductLines: Record<string, any[]>
rowSublines: Record<string, any[]>
isLoadingLines: Record<string, boolean>
isLoadingSublines: Record<string, boolean>
upcValidation: {
validatingRows: Set<number>
getItemNumber: (rowIndex: number) => string | undefined
}
itemNumbers?: Map<number, string>
}
/**
* UpcValidationTableAdapter component - connects UPC validation data to ValidationTable
*
* This component adapts UPC validation data and functionality to work with the core ValidationTable,
* transforming item numbers and validation states into a format the table component can render.
*/
function UpcValidationTableAdapter<T extends string>({
data,
fields,
validationErrors,
rowSelection,
setRowSelection,
updateRow,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
isValidatingUpc,
validatingUpcRows,
copyDown,
validatingCells: externalValidatingCells,
isLoadingTemplates,
editingCells,
setEditingCells,
rowProductLines,
rowSublines,
isLoadingLines,
isLoadingSublines,
upcValidation,
itemNumbers
}: UpcValidationTableAdapterProps<T>) {
// Prepare the validation table with UPC data
// Create combined validatingCells set from validating rows and external cells
const combinedValidatingCells = useMemo(() => {
const combined = new Set<string>();
// Add UPC validation cells
upcValidation.validatingRows.forEach(rowIndex => {
// Only mark the item_number cells as validating, NOT the UPC or supplier
combined.add(`${rowIndex}-item_number`);
});
// Add any other validating cells from state
externalValidatingCells.forEach(cellKey => {
combined.add(cellKey);
});
return combined;
}, [upcValidation.validatingRows, externalValidatingCells]);
// Create a consolidated item numbers map from all sources
const consolidatedItemNumbers = useMemo(() => {
const result = new Map<number, string>();
// First add from itemNumbers directly - this is the source of truth for template applications
if (itemNumbers) {
itemNumbers.forEach((itemNumber, rowIndex) => {
result.set(rowIndex, itemNumber);
});
}
// For each row, ensure we have the most up-to-date item number
data.forEach((_, index) => {
// Check if upcValidation has an item number for this row
const itemNumber = upcValidation.getItemNumber(index);
if (itemNumber) {
result.set(index, itemNumber);
}
// Also check if it's directly in the data
const dataItemNumber = data[index].item_number;
if (dataItemNumber && !result.has(index)) {
result.set(index, dataItemNumber);
}
});
return result;
}, [data, itemNumbers, upcValidation]);
// Create upcValidationResults map using the consolidated item numbers
const upcValidationResults = useMemo(() => {
const results = new Map<number, { itemNumber: string }>();
// Populate with our consolidated item numbers
consolidatedItemNumbers.forEach((itemNumber, rowIndex) => {
results.set(rowIndex, { itemNumber });
});
return results;
}, [consolidatedItemNumbers]);
// Render the validation table with the provided props and UPC data
return (
<ValidationTable
data={data}
fields={fields}
rowSelection={rowSelection}
setRowSelection={setRowSelection}
updateRow={updateRow as unknown as (rowIndex: number, key: string, value: any) => void}
validationErrors={validationErrors}
isValidatingUpc={isValidatingUpc}
validatingUpcRows={validatingUpcRows}
filters={filters}
templates={templates}
applyTemplate={applyTemplate}
getTemplateDisplayText={getTemplateDisplayText}
validatingCells={combinedValidatingCells}
itemNumbers={consolidatedItemNumbers}
isLoadingTemplates={isLoadingTemplates}
copyDown={copyDown}
upcValidationResults={upcValidationResults}
rowProductLines={rowProductLines}
rowSublines={rowSublines}
isLoadingLines={isLoadingLines}
isLoadingSublines={isLoadingSublines}
editingCells={editingCells}
setEditingCells={setEditingCells}
/>
)
}
export default UpcValidationTableAdapter

View File

@@ -1,661 +0,0 @@
import React from 'react'
import { Field, ErrorType } from '../../../types'
import { AlertCircle, ArrowDown, Wand2, X } from 'lucide-react'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip'
import InputCell from './cells/InputCell'
import SelectCell from './cells/SelectCell'
import MultiSelectCell from './cells/MultiSelectCell'
import { TableCell } from '@/components/ui/table'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/hooks/use-toast'
import config from '@/config'
// Context for copy down selection mode
export const CopyDownContext = React.createContext<{
isInCopyDownMode: boolean;
sourceRowIndex: number | null;
sourceFieldKey: string | null;
targetRowIndex: number | null;
setIsInCopyDownMode: (value: boolean) => void;
setSourceRowIndex: (value: number | null) => void;
setSourceFieldKey: (value: string | null) => void;
setTargetRowIndex: (value: number | null) => void;
handleCopyDownComplete: (sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => void;
}>({
isInCopyDownMode: false,
sourceRowIndex: null,
sourceFieldKey: null,
targetRowIndex: null,
setIsInCopyDownMode: () => {},
setSourceRowIndex: () => {},
setSourceFieldKey: () => {},
setTargetRowIndex: () => {},
handleCopyDownComplete: () => {},
});
// Define error object type
type ErrorObject = {
message: string;
level: string;
source?: string;
type?: ErrorType;
}
// Helper function to check if a value is empty - utility function shared by all components
const isEmpty = (val: any): boolean =>
val === undefined ||
val === null ||
val === '' ||
(Array.isArray(val) && val.length === 0) ||
(typeof val === 'object' && !Array.isArray(val) && Object.keys(val).length === 0);
// Memoized validation icon component
const ValidationIcon = React.memo(({ error }: { error: ErrorObject }) => (
<TooltipProvider>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="cursor-help">
<AlertCircle className="h-4 w-4 text-red-500" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px] text-wrap break-words">
<p>{error.message}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
));
ValidationIcon.displayName = 'ValidationIcon';
// Memoized base cell content component
const BaseCellContent = React.memo(({
field,
value,
onChange,
hasErrors,
options = [],
className = '',
fieldKey = '',
onStartEdit,
onEndEdit
}: {
field: Field<string>;
value: any;
onChange: (value: any) => void;
hasErrors: boolean;
options?: readonly any[];
className?: string;
fieldKey?: string;
onStartEdit?: () => void;
onEndEdit?: () => void;
}) => {
// Get field type information
const fieldType = fieldKey === 'line' || fieldKey === 'subline'
? 'select'
: typeof field.fieldType === 'string'
? field.fieldType
: field.fieldType?.type || 'input';
// Check for multiline input
const isMultiline = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.multiline === true;
// Check for price field
const isPrice = typeof field.fieldType === 'object' &&
(field.fieldType.type === 'input' || field.fieldType.type === 'multi-input') &&
field.fieldType.price === true;
// Special case for line and subline - check this first, before any other field type checks
if (fieldKey === 'line' || fieldKey === 'subline') {
// Force these fields to always use SelectCell regardless of fieldType
return (
<SelectCell
field={{...field, fieldType: { type: 'select', options }}}
value={value}
onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
if (fieldType === 'select') {
return (
<SelectCell
field={field}
value={value}
onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
if (fieldType === 'multi-select' || fieldType === 'multi-input') {
return (
<MultiSelectCell
field={field}
value={value}
onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
options={options}
hasErrors={hasErrors}
className={className}
disabled={field.disabled}
/>
);
}
return (
<InputCell
field={field}
value={value}
onChange={onChange}
onStartEdit={onStartEdit}
onEndEdit={onEndEdit}
hasErrors={hasErrors}
isMultiline={isMultiline}
isPrice={isPrice}
disabled={field.disabled}
/>
);
}, (prev, next) => {
// Shallow array comparison for options if arrays
const optionsEqual = prev.options === next.options ||
(Array.isArray(prev.options) && Array.isArray(next.options) &&
prev.options.length === next.options.length &&
prev.options.every((opt, idx) => opt === (next.options as any[])[idx]));
return (
prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.field === next.field &&
prev.className === next.className &&
optionsEqual
);
});
BaseCellContent.displayName = 'BaseCellContent';
export interface ValidationCellProps {
field: Field<string>
value: any
onChange: (value: any) => void
errors: ErrorObject[]
isValidating?: boolean
fieldKey: string
options?: readonly any[]
itemNumber?: string
width: number
rowIndex: number
copyDown?: (endRowIndex?: number) => void
totalRows?: number
rowData: Record<string, any>
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
}
// Add efficient error message extraction function
// Highly optimized error processing function with fast paths for common cases
function processErrors(value: any, errors: ErrorObject[]): {
hasError: boolean;
isRequiredButEmpty: boolean;
shouldShowErrorIcon: boolean;
errorMessages: string;
} {
// Fast path - if no errors or empty error array, return immediately
if (!errors || errors.length === 0) {
return {
hasError: false,
isRequiredButEmpty: false,
shouldShowErrorIcon: false,
errorMessages: ''
};
}
// Use the shared isEmpty function for value checking
const valueIsEmpty = isEmpty(value);
// Fast path for the most common case - required field with empty value
if (valueIsEmpty && errors.length === 1 && errors[0].type === ErrorType.Required) {
return {
hasError: true,
isRequiredButEmpty: true,
shouldShowErrorIcon: false,
errorMessages: ''
};
}
// For non-empty values with errors, we need to show error icons
const hasError = errors.some(error => error.level === 'error' || error.level === 'warning');
// For empty values with required errors, show only a border
const isRequiredButEmpty = valueIsEmpty && errors.some(error => error.type === ErrorType.Required);
// Show error icons for non-empty fields with errors, or for empty fields with non-required errors
const shouldShowErrorIcon = hasError && (!valueIsEmpty || !errors.every(error => error.type === ErrorType.Required));
// Only compute error messages if we're going to show an icon
const errorMessages = shouldShowErrorIcon
? errors
.filter(e => e.level === 'error' || e.level === 'warning')
.map(e => e.message)
.join('\n')
: '';
return {
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
};
}
// Helper function to compare error arrays efficiently with a hash-based approach
function compareErrorArrays(prevErrors: ErrorObject[], nextErrors: ErrorObject[]): boolean {
// Fast path for referential equality
if (prevErrors === nextErrors) return true;
// Fast path for length check
if (!prevErrors || !nextErrors) return prevErrors === nextErrors;
if (prevErrors.length !== nextErrors.length) return false;
// Generate simple hash from error properties
const getErrorHash = (error: ErrorObject): string => {
return `${error.message}|${error.level}|${error.type || ''}`;
};
// Compare using hashes
const prevHashes = prevErrors.map(getErrorHash);
const nextHashes = nextErrors.map(getErrorHash);
// Sort hashes to ensure consistent order
prevHashes.sort();
nextHashes.sort();
// Compare sorted hash arrays
return prevHashes.join(',') === nextHashes.join(',');
}
const ValidationCell = React.memo(({
field,
value,
onChange,
errors,
isValidating,
fieldKey,
options = [],
itemNumber,
width,
copyDown,
rowIndex,
totalRows = 0,
rowData,
editingCells,
setEditingCells
}: ValidationCellProps) => {
// Use the CopyDown context
const copyDownContext = React.useContext(CopyDownContext);
const { toast } = useToast();
const [isGeneratingUpc, setIsGeneratingUpc] = React.useState(false);
// CRITICAL FIX: For item_number fields, always prioritize the itemNumber prop over the value
// This ensures that when the itemNumber changes, the display value changes
let displayValue;
if (fieldKey === 'item_number' && itemNumber) {
// Prioritize itemNumber prop for item_number fields
displayValue = itemNumber;
} else {
displayValue = value;
}
// Use the optimized processErrors function to avoid redundant filtering
const {
hasError,
isRequiredButEmpty,
shouldShowErrorIcon,
errorMessages
} = React.useMemo(() => processErrors(displayValue, errors), [displayValue, errors]);
// Track whether this cell is the source of a copy-down operation
const isSourceCell = copyDownContext.isInCopyDownMode &&
rowIndex === copyDownContext.sourceRowIndex &&
fieldKey === copyDownContext.sourceFieldKey;
// Add state for hover on copy down button
const [isCopyDownHovered, setIsCopyDownHovered] = React.useState(false);
// Add state for hover on target row
const [isTargetRowHovered, setIsTargetRowHovered] = React.useState(false);
// PERFORMANCE FIX: Create cell key for editing state management
const cellKey = `${rowIndex}-${fieldKey}`;
const isEditingCell = editingCells.has(cellKey);
// SINGLE-CLICK EDITING FIX: Create editing state management functions
const handleStartEdit = React.useCallback(() => {
setEditingCells(prev => new Set([...prev, cellKey]));
}, [setEditingCells, cellKey]);
const handleEndEdit = React.useCallback(() => {
setEditingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}, [setEditingCells, cellKey]);
// Handle copy down button click
const handleCopyDownClick = React.useCallback(() => {
if (copyDown && totalRows > rowIndex + 1) {
// Enter copy down mode
copyDownContext.setIsInCopyDownMode(true);
copyDownContext.setSourceRowIndex(rowIndex);
copyDownContext.setSourceFieldKey(fieldKey);
}
}, [copyDown, copyDownContext, fieldKey, rowIndex, totalRows]);
// Check if this cell is in a row that can be a target for copy down
const isInTargetRow = copyDownContext.isInCopyDownMode &&
copyDownContext.sourceFieldKey === fieldKey &&
rowIndex > (copyDownContext.sourceRowIndex || 0);
// Check if this row is the currently selected target row
const isSelectedTarget = isInTargetRow && rowIndex <= (copyDownContext.targetRowIndex || 0);
// Handle click on a potential target cell
const handleTargetCellClick = React.useCallback(() => {
if (isInTargetRow && copyDownContext.sourceRowIndex !== null && copyDownContext.sourceFieldKey !== null) {
copyDownContext.handleCopyDownComplete(
copyDownContext.sourceRowIndex,
copyDownContext.sourceFieldKey,
rowIndex
);
}
}, [copyDownContext, isInTargetRow, rowIndex]);
// Memoize the cell style objects to avoid recreating them on every render
const cellStyle = React.useMemo(() => ({
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box' as const,
cursor: isInTargetRow ? 'pointer' : undefined
}), [width, isInTargetRow]);
// Memoize the cell class name to prevent re-calculating on every render
const cellClassName = React.useMemo(() => {
if (isSourceCell || isSelectedTarget || isInTargetRow) {
return isSourceCell ? '!bg-blue-100 !border-blue-500 !rounded-md' :
isSelectedTarget ? '!bg-blue-200 !border-blue-200 !rounded-md' :
isInTargetRow ? 'hover:!bg-blue-100 !border-blue-200 !rounded-md' : '';
}
return '';
}, [isSourceCell, isSelectedTarget, isInTargetRow]);
const isUpcField = fieldKey === 'upc';
const baseIsLoading = isValidating === true;
const showGeneratingSkeleton = isUpcField && isGeneratingUpc;
const isLoading = baseIsLoading || showGeneratingSkeleton;
const supplierRaw = rowData?.supplier ?? rowData?.supplier_id ?? rowData?.supplierId;
const supplierIdString = supplierRaw !== undefined && supplierRaw !== null
? String(supplierRaw).trim()
: '';
const normalizedSupplierId = /^\d+$/.test(supplierIdString) ? supplierIdString : '';
const canGenerateUpc = normalizedSupplierId !== '';
const upcValueEmpty = isUpcField && isEmpty(displayValue);
const showGenerateButton = upcValueEmpty && !isEditingCell && !copyDownContext.isInCopyDownMode && !isInTargetRow && !isLoading;
const cellClassNameWithPadding = showGenerateButton ? `${cellClassName} pr-10`.trim() : cellClassName;
const buttonDisabled = !canGenerateUpc || isGeneratingUpc;
const tooltipMessage = canGenerateUpc ? 'Generate UPC' : 'Select a supplier before generating a UPC';
const handleGenerateUpc = React.useCallback(async () => {
if (!normalizedSupplierId) {
toast({
variant: 'destructive',
description: 'Select a supplier before generating a UPC.',
});
return;
}
if (isGeneratingUpc) {
return;
}
setIsGeneratingUpc(true);
try {
const response = await fetch(`${config.apiUrl}/import/generate-upc`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ supplierId: normalizedSupplierId })
});
let payload = null;
try {
payload = await response.json();
} catch (parseError) {
// Ignore JSON parse errors and handle via status code
}
if (!response.ok) {
const message = payload?.error || `Request failed (${response.status})`;
throw new Error(message);
}
if (!payload || !payload.success || !payload.upc) {
throw new Error(payload?.error || 'Unexpected response while generating UPC');
}
onChange(payload.upc);
} catch (error) {
console.error('Error generating UPC:', error);
const errorMessage =
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof (error as { message?: unknown }).message === 'string'
? (error as { message: string }).message
: 'Failed to generate UPC';
toast({
variant: 'destructive',
description: errorMessage,
});
} finally {
setIsGeneratingUpc(false);
}
}, [normalizedSupplierId, isGeneratingUpc, onChange, toast]);
const handleGenerateButtonClick = React.useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
if (!buttonDisabled) {
handleGenerateUpc();
}
}, [buttonDisabled, handleGenerateUpc]);
const containerClassName = `truncate overflow-hidden${isCopyDownHovered && !copyDownContext.isInCopyDownMode ? ' bg-blue-50/50' : ''}${showGenerateButton ? ' relative group/upc' : ''}`.trim();
return (
<TableCell
className="p-1 group relative"
style={cellStyle}
onClick={isInTargetRow ? handleTargetCellClick : undefined}
onMouseEnter={isInTargetRow ? () => setIsTargetRowHovered(true) : undefined}
onMouseLeave={isInTargetRow ? () => setIsTargetRowHovered(false) : undefined}
>
<div className={`relative ${hasError || isRequiredButEmpty ? 'border-red-500' : ''}`}>
{shouldShowErrorIcon && !isInTargetRow && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
<ValidationIcon error={{
message: errorMessages,
level: 'error',
type: ErrorType.Custom
}} />
</div>
)}
{!shouldShowErrorIcon && copyDown && !isEmpty(displayValue) && !copyDownContext.isInCopyDownMode && (
<div className="absolute right-0.5 top-1/2 -translate-y-1/2 z-20 opacity-0 group-hover:opacity-100 transition-opacity">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleCopyDownClick}
onMouseEnter={() => setIsCopyDownHovered(true)}
onMouseLeave={() => setIsCopyDownHovered(false)}
className="p-1 rounded-full hover:bg-blue-100 text-blue-500/70 hover:text-blue-600 transition-colors"
aria-label="Copy value to rows below"
>
<ArrowDown className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<div className="flex flex-col">
<p className="font-medium">Copy value to rows below</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isSourceCell && (
<div className="absolute right-1.5 top-1/2 -translate-y-1/2 z-20">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => copyDownContext.setIsInCopyDownMode(false)}
className="p-1 rounded-full bg-blue-100 text-blue-600 hover:bg-blue-200 transition-colors"
aria-label="Cancel copy down"
>
<X className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Cancel copy down</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{isLoading ? (
<div className={`flex items-center justify-center gap-2 border ${hasError || isRequiredButEmpty ? 'border-red-500' : 'border-input'} rounded-md px-2 py-2`}>
<Skeleton className="w-full h-4" />
</div>
) : (
<div
className={containerClassName}
style={{
backgroundColor: isSourceCell ? '#dbeafe' :
isSelectedTarget ? '#bfdbfe' :
isInTargetRow && isTargetRowHovered ? '#dbeafe' :
undefined,
borderRadius: (isSourceCell || isSelectedTarget || isInTargetRow) ? '0.375rem' : undefined,
boxShadow: isSourceCell ? '0 0 0 2px #3b82f6' : undefined
}}
>
<BaseCellContent
field={field}
value={displayValue}
onChange={onChange}
hasErrors={hasError || isRequiredButEmpty}
options={options}
className={cellClassNameWithPadding}
fieldKey={fieldKey}
onStartEdit={handleStartEdit}
onEndEdit={handleEndEdit}
/>
{showGenerateButton && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={handleGenerateButtonClick}
onMouseDown={(event) => event.stopPropagation()}
className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1 rounded-md border border-input bg-background px-2 py-1 text-xs text-muted-foreground shadow-sm transition-opacity opacity-0 group-hover/upc:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-60"
disabled={buttonDisabled}
aria-label="Generate UPC"
>
<Wand2 className="h-3 w-3" />
</button>
</TooltipTrigger>
<TooltipContent side="top">
<p>{tooltipMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
)}
</div>
</TableCell>
);
}, (prevProps, nextProps) => {
// Fast path: if all props are the same object
if (prevProps === nextProps) return true;
// Optimize the memo comparison function, checking most impactful props first
// Check isValidating first as it's most likely to change frequently
if (prevProps.isValidating !== nextProps.isValidating) return false;
// Then check value changes
if (prevProps.value !== nextProps.value) return false;
// Item number is related to validation state
if (prevProps.itemNumber !== nextProps.itemNumber) return false;
// Check errors with our optimized comparison function
if (!compareErrorArrays(prevProps.errors, nextProps.errors)) return false;
// Check field identity
if (prevProps.field !== nextProps.field) return false;
if (prevProps.rowData !== nextProps.rowData) return false;
if (prevProps.editingCells !== nextProps.editingCells) return false;
// Shallow options comparison - only if field type is select or multi-select
if (prevProps.field.fieldType?.type === 'select' || prevProps.field.fieldType?.type === 'multi-select') {
const optionsEqual = prevProps.options === nextProps.options ||
(Array.isArray(prevProps.options) &&
Array.isArray(nextProps.options) &&
prevProps.options.length === nextProps.options.length &&
prevProps.options.every((opt, idx) => {
const nextOptions = nextProps.options || [];
return opt === nextOptions[idx];
}));
if (!optionsEqual) return false;
}
// Check copy down context changes
const copyDownContextChanged =
prevProps.rowIndex !== nextProps.rowIndex ||
prevProps.fieldKey !== nextProps.fieldKey;
if (copyDownContextChanged) return false;
// All essential props are the same - we can skip re-rendering
return true;
});
ValidationCell.displayName = 'ValidationCell';
export default ValidationCell;

View File

@@ -1,657 +0,0 @@
import React, { useMemo, useCallback, useState } from 'react'
import {
useReactTable,
getCoreRowModel,
flexRender,
RowSelectionState,
ColumnDef
} from '@tanstack/react-table'
import { Fields, Field } from '../../../types'
import { RowData, Template } from '../hooks/validationTypes'
import ValidationCell, { CopyDownContext } from './ValidationCell'
import { useRsi } from '../../../hooks/useRsi'
import SearchableTemplateSelect from './SearchableTemplateSelect'
import { Table, TableBody, TableRow, TableCell } from '@/components/ui/table'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
// Define a simple Error type locally to avoid import issues
type ErrorType = {
message: string;
level: string;
source?: string;
}
// Stable empty errors array to prevent unnecessary re-renders
// Use a mutable empty array to satisfy the ErrorType[] type
const EMPTY_ERRORS: ErrorType[] = [];
interface ValidationTableProps<T extends string> {
data: RowData<T>[]
fields: Fields<T>
rowSelection: RowSelectionState
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>
updateRow: (rowIndex: number, key: T, value: any) => void
validationErrors: Map<number, Record<string, ErrorType[]>>
isValidatingUpc: (rowIndex: number) => boolean
validatingUpcRows: number[]
filters?: { showErrorsOnly?: boolean }
templates: Template[]
applyTemplate: (templateId: string, rowIndexes: number[]) => void
getTemplateDisplayText: (templateId: string | null) => string
rowProductLines?: Record<string, any[]>
rowSublines?: Record<string, any[]>
isLoadingLines?: Record<string, boolean>
isLoadingSublines?: Record<string, boolean>
upcValidationResults: Map<number, { itemNumber: string }>
validatingCells: Set<string>
itemNumbers: Map<number, string>
isLoadingTemplates?: boolean
copyDown: (rowIndex: number, key: string, endRowIndex?: number) => void
editingCells: Set<string>
setEditingCells: React.Dispatch<React.SetStateAction<Set<string>>>
[key: string]: any
}
// Simple template select component - let React handle optimization
const TemplateSelectWrapper = ({
templates,
value,
onValueChange,
getTemplateDisplayText,
defaultBrand,
isLoading
}: {
templates: Template[],
value: string,
onValueChange: (value: string) => void,
getTemplateDisplayText: (value: string | null) => string,
defaultBrand?: string,
isLoading?: boolean
}) => {
if (isLoading) {
return (
<div className="flex items-center justify-center gap-2 border border-input rounded-md px-2 py-2">
<Skeleton className="h-4 w-full" />
</div>
);
}
return (
<SearchableTemplateSelect
templates={templates}
value={value}
onValueChange={onValueChange}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
/>
);
};
const ValidationTable = <T extends string>({
data,
fields,
rowSelection,
setRowSelection,
updateRow,
validationErrors,
filters,
templates,
applyTemplate,
getTemplateDisplayText,
validatingCells,
itemNumbers,
isLoadingTemplates = false,
copyDown,
editingCells,
setEditingCells,
rowProductLines = {},
rowSublines = {},
isLoadingLines = {},
isLoadingSublines = {},
isValidatingUpc,
validatingUpcRows = [],
upcValidationResults
}: ValidationTableProps<T>) => {
const { translations } = useRsi<T>();
// Copy-down state combined into single object
type CopyDownState = {
sourceRowIndex: number;
sourceFieldKey: string;
targetRowIndex: number | null;
};
const [copyDownState, setCopyDownState] = useState<CopyDownState | null>(null);
// Handle copy down completion
const handleCopyDownComplete = useCallback((sourceRowIndex: number, fieldKey: string, targetRowIndex: number) => {
copyDown(sourceRowIndex, fieldKey, targetRowIndex);
setCopyDownState(null);
}, [copyDown]);
// Create copy down context value
// Use a ref to track partial state during initialization
const partialCopyDownRef = React.useRef<{ rowIndex?: number; fieldKey?: string }>({});
const copyDownContextValue = useMemo(() => ({
isInCopyDownMode: copyDownState !== null,
sourceRowIndex: copyDownState?.sourceRowIndex ?? null,
sourceFieldKey: copyDownState?.sourceFieldKey ?? null,
targetRowIndex: copyDownState?.targetRowIndex ?? null,
setIsInCopyDownMode: (value: boolean) => {
if (!value) {
setCopyDownState(null);
partialCopyDownRef.current = {};
}
},
setSourceRowIndex: (rowIndex: number | null) => {
if (rowIndex !== null) {
partialCopyDownRef.current.rowIndex = rowIndex;
// If we have both values, set the full state
if (partialCopyDownRef.current.fieldKey !== undefined) {
setCopyDownState({
sourceRowIndex: rowIndex,
sourceFieldKey: partialCopyDownRef.current.fieldKey,
targetRowIndex: null
});
partialCopyDownRef.current = {};
}
}
},
setSourceFieldKey: (fieldKey: string | null) => {
if (fieldKey !== null) {
partialCopyDownRef.current.fieldKey = fieldKey;
// If we have both values, set the full state
if (partialCopyDownRef.current.rowIndex !== undefined) {
setCopyDownState({
sourceRowIndex: partialCopyDownRef.current.rowIndex,
sourceFieldKey: fieldKey,
targetRowIndex: null
});
partialCopyDownRef.current = {};
}
}
},
setTargetRowIndex: (rowIndex: number | null) => {
if (copyDownState) {
setCopyDownState({
...copyDownState,
targetRowIndex: rowIndex
});
}
},
handleCopyDownComplete
}), [copyDownState, handleCopyDownComplete]);
// Update targetRowIndex when hovering over rows in copy down mode
const handleRowMouseEnter = useCallback((rowIndex: number) => {
if (copyDownState && copyDownState.sourceRowIndex < rowIndex) {
setCopyDownState({
...copyDownState,
targetRowIndex: rowIndex
});
}
}, [copyDownState]);
// Memoize the selection column with stable callback
const handleSelectAll = useCallback((value: boolean, table: any) => {
table.toggleAllPageRowsSelected(!!value);
}, []);
const handleRowSelect = useCallback((value: boolean, row: any) => {
row.toggleSelected(!!value);
}, []);
const selectionColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
id: 'select',
header: ({ table }) => (
<div className="flex h-full items-center justify-center py-2">
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => handleSelectAll(!!value, table)}
aria-label="Select all"
/>
</div>
),
cell: ({ row }) => (
<div className="flex items-center justify-center py-9">
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => handleRowSelect(!!value, row)}
aria-label="Select row"
/>
</div>
),
enableSorting: false,
enableHiding: false,
size: 50,
}), [handleSelectAll, handleRowSelect]);
// Memoize template selection handler
const handleTemplateChange = useCallback((value: string, rowIndex: number) => {
applyTemplate(value, [rowIndex]);
}, [applyTemplate]);
// Memoize the template column with stable callback
const templateColumn = useMemo((): ColumnDef<RowData<T>, any> => ({
accessorKey: '__template',
header: 'Template',
size: 200,
cell: ({ row }) => {
const templateValue = row.original.__template || null;
const defaultBrand = row.original.company || undefined;
const rowIndex = data.findIndex(r => r === row.original);
return (
<TableCell className="p-1" style={{ width: '200px', minWidth: '200px', maxWidth: '200px', overflow: 'hidden' }}>
<div className="w-full overflow-hidden">
<TemplateSelectWrapper
templates={templates}
value={templateValue || ''}
onValueChange={(value) => handleTemplateChange(value, rowIndex)}
getTemplateDisplayText={getTemplateDisplayText}
defaultBrand={defaultBrand}
isLoading={isLoadingTemplates}
/>
</div>
</TableCell>
);
}
}), [templates, handleTemplateChange, getTemplateDisplayText, isLoadingTemplates, data]);
// Cache options by field key to avoid recreating arrays
const optionsCache = useMemo(() => {
const cache = new Map<string, readonly any[]>();
fields.forEach((field) => {
// Get the field key
const fieldKey = String(field.key);
// Handle all select and multi-select fields the same way
if (field.fieldType &&
(typeof field.fieldType === 'object') &&
(field.fieldType.type === 'select' || field.fieldType.type === 'multi-select')) {
cache.set(fieldKey, (field.fieldType as any).options || []);
}
});
return cache;
}, [fields]);
// Memoize the field update handler
const handleFieldUpdate = useCallback((rowIndex: number, fieldKey: T, value: any) => {
updateRow(rowIndex, fieldKey, value);
}, [updateRow]);
// Memoize the copyDown handler
const handleCopyDown = useCallback((rowIndex: number, fieldKey: string, endRowIndex?: number) => {
copyDown(rowIndex, fieldKey, endRowIndex);
}, [copyDown]);
// Use validatingUpcRows for calculation
const isRowValidatingUpc = useCallback((rowIndex: number) => {
return isValidatingUpc?.(rowIndex) || validatingUpcRows.includes(rowIndex);
}, [isValidatingUpc, validatingUpcRows]);
// Use upcValidationResults for display, prioritizing the most recent values
const getRowUpcResult = useCallback((rowIndex: number) => {
// ALWAYS get from the data array directly - most authoritative source
const rowData = data[rowIndex];
if (rowData && rowData.item_number) {
return rowData.item_number;
}
// Maps are only backup sources when data doesn't have a value
const itemNumberFromMap = itemNumbers.get(rowIndex);
if (itemNumberFromMap) {
return itemNumberFromMap;
}
// Last resort - upcValidationResults
const upcResult = upcValidationResults?.get(rowIndex)?.itemNumber;
if (upcResult) {
return upcResult;
}
return undefined;
}, [data, itemNumbers, upcValidationResults]);
// Memoize field columns with stable handlers
const fieldColumns = useMemo(() => fields.map((field): ColumnDef<RowData<T>, any> | null => {
// Don't filter out disabled fields, just pass the disabled state to the cell component
const fieldWidth = field.width || (
field.fieldType.type === "checkbox" ? 80 :
field.fieldType.type === "select" ? 150 :
field.fieldType.type === "multi-select" ? 200 :
(field.fieldType.type === "input" || field.fieldType.type === "multi-input") &&
(field.fieldType as any).multiline ? 300 :
150
);
const fieldKey = String(field.key);
// Get cached options for this field
const fieldOptions = optionsCache.get(fieldKey) || [];
return {
accessorKey: fieldKey,
header: field.label || fieldKey,
size: fieldWidth,
cell: ({ row }) => {
// Get row-specific options for line and subline fields
let options = fieldOptions;
const rowId = (row.original as any).__index;
const lookupKey = (rowId !== undefined && rowId !== null) ? rowId : row.index;
if (fieldKey === 'line' && lookupKey !== undefined && rowProductLines[lookupKey]) {
options = rowProductLines[lookupKey];
} else if (fieldKey === 'subline' && lookupKey !== undefined && rowSublines[lookupKey]) {
options = rowSublines[lookupKey];
}
// Get the current cell value first
const currentValue = fieldKey === 'item_number' && row.original[field.key]
? row.original[field.key]
: row.original[field.key as keyof typeof row.original];
// Determine if this cell is in loading state
let isLoading = false;
const cellLoadingKey = `${row.index}-${fieldKey}`;
const isEmpty = currentValue === undefined || currentValue === null || currentValue === '' ||
(Array.isArray(currentValue) && currentValue.length === 0);
// CRITICAL: Check validatingCells FIRST - this shows loading for item_number during UPC validation
// even if the field already has a value (because we're fetching a new one)
if (validatingCells.has(cellLoadingKey)) {
isLoading = true;
}
// Only show loading for empty fields for these other cases
else if (isEmpty) {
// Check if UPC is validating for this row and field is item_number
if (fieldKey === 'item_number' && isRowValidatingUpc(row.index)) {
isLoading = true;
}
// Add loading state for line/subline fields
else if (fieldKey === 'line' && lookupKey !== undefined && isLoadingLines[lookupKey]) {
isLoading = true;
}
else if (fieldKey === 'subline' && lookupKey !== undefined && isLoadingSublines[lookupKey]) {
isLoading = true;
}
}
// Get validation errors for this cell
// Use stable EMPTY_ERRORS to avoid new array creation on every render
const cellErrors = validationErrors.get(row.index)?.[fieldKey] || EMPTY_ERRORS;
// Create a copy of the field with guaranteed field type for line and subline fields
let fieldWithType = field;
// Ensure line and subline fields always have the correct fieldType
if (fieldKey === 'line' || fieldKey === 'subline') {
// Create a deep clone of the field to prevent any reference issues
fieldWithType = {
...JSON.parse(JSON.stringify(field)), // Ensure deep clone
fieldType: {
type: 'select',
options: options
},
// Explicitly mark as not disabled to ensure dropdown works
disabled: false
};
}
// CRITICAL CHANGE: Get item number directly from row data first for item_number fields
let itemNumber;
if (fieldKey === 'item_number') {
// Check directly in row data first - this is the most accurate source
const directValue = row.original[fieldKey];
if (directValue) {
itemNumber = directValue;
} else {
// Fall back to centralized getter that checks all sources
itemNumber = getRowUpcResult(row.index);
}
}
// Create stable keys that only change when actual content changes
const cellKey = fieldKey === 'item_number'
? `cell-${row.index}-${fieldKey}-${itemNumber || 'empty'}` // Only change when itemNumber actually changes
: `cell-${row.index}-${fieldKey}`;
return (
<ValidationCell
key={cellKey}
field={fieldWithType as Field<string>}
value={currentValue}
onChange={(value) => handleFieldUpdate(row.index, field.key as T, value)}
errors={cellErrors}
isValidating={isLoading}
fieldKey={fieldKey}
options={options}
itemNumber={itemNumber}
width={fieldWidth}
rowIndex={row.index}
copyDown={(endRowIndex?: number) => handleCopyDown(row.index, field.key as string, endRowIndex)}
totalRows={data.length}
rowData={row.original as Record<string, any>}
editingCells={editingCells}
setEditingCells={setEditingCells}
/>
);
}
};
}).filter((col): col is ColumnDef<RowData<T>, any> => col !== null),
[fields, validationErrors, validatingCells, itemNumbers, handleFieldUpdate, handleCopyDown, optionsCache,
data.length, rowProductLines, rowSublines, isLoadingLines, isLoadingSublines,
isRowValidatingUpc, getRowUpcResult]);
// Combine columns
const columns = useMemo(() => [selectionColumn, templateColumn, ...fieldColumns], [selectionColumn, templateColumn, fieldColumns]);
const table = useReactTable({
data,
columns,
state: {
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getRowId: useCallback((_row: RowData<T>, index: number) => String(index), []),
});
// Calculate total table width for stable horizontal scrolling
const totalWidth = useMemo(() => {
return columns.reduce((total, col) => total + (col.size || 0), 0);
}, [columns]);
// Don't render if no data
if (data.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">
{filters?.showErrorsOnly
? translations.validationStep.noRowsMessageWhenFiltered || "No rows with errors"
: translations.validationStep.noRowsMessage || "No data to display"}
</p>
</div>
);
}
return (
<CopyDownContext.Provider value={copyDownContextValue}>
<div className="min-w-max relative">
{/* Add global styles for copy down mode */}
{copyDownState && (
<style>
{`
.copy-down-target-row,
.copy-down-target-row *,
.copy-down-target-row input,
.copy-down-target-row textarea,
.copy-down-target-row div,
.copy-down-target-row button,
.target-row-cell,
.target-row-cell * {
cursor: pointer !important;
}
`}
</style>
)}
{copyDownState && (
<div className="sticky top-0 z-30 h-0 overflow-visible">
<div
className="absolute w-[240px] top-16 bg-blue-50 border rounded-2xl shadow-lg border-blue-200 p-3 text-sm text-blue-700 flex items-center justify-between"
style={{
left: (() => {
// Find the column index
const colIndex = columns.findIndex(col =>
'accessorKey' in col && col.accessorKey === copyDownState.sourceFieldKey
);
// If column not found, position at a default location
if (colIndex === -1) return '50px';
// Calculate position based on column widths
let position = 0;
for (let i = 0; i < colIndex; i++) {
position += columns[i].size || 0;
}
// Add half of the current column width to center it
position += (columns[colIndex].size || 0) / 2;
// Adjust to center the notification
position -= 120; // Half of the notification width
return `${Math.max(50, position)}px`;
})()
}}
>
<div>
<span className="font-medium">Click on the last row you want to copy to</span>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setCopyDownState(null)}
className="text-xs h-7 border-blue-200 text-blue-700 hover:bg-blue-100"
>
Cancel
</Button>
</div>
</div>
)}
<div className="relative">
{/* Custom Table Header - Always Visible with GPU acceleration */}
<div
className="sticky top-0 z-20 bg-muted border-b shadow-sm will-change-transform"
style={{
width: `${totalWidth}px`,
transform: 'translateZ(0)', // Force GPU acceleration
}}
>
<div className="flex">
{table.getFlatHeaders().map((header) => {
const width = header.getSize();
return (
<div
key={header.id}
className="py-2 px-2 font-bold text-sm text-muted-foreground bg-muted flex items-center justify-center"
style={{
width: `${width}px`,
minWidth: `${width}px`,
maxWidth: `${width}px`,
boxSizing: 'border-box',
height: '40px'
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
})}
</div>
</div>
{/* Table Body - With optimized rendering */}
<Table style={{
width: `${totalWidth}px`,
tableLayout: 'fixed',
borderCollapse: 'separate',
borderSpacing: 0,
marginTop: '-1px',
willChange: 'transform', // Help browser optimize
contain: 'content', // Contain paint operations
transform: 'translateZ(0)' // Force GPU acceleration
}}>
<TableBody>
{table.getRowModel().rows.map((row) => {
// Precompute validation error status for this row
const hasErrors = validationErrors.has(parseInt(row.id)) &&
Object.keys(validationErrors.get(parseInt(row.id)) || {}).length > 0;
// Precompute copy down target status
const isCopyDownTarget = copyDownState !== null &&
parseInt(row.id) > copyDownState.sourceRowIndex;
// Using CSS variables for better performance on hover/state changes
const rowStyle = {
cursor: isCopyDownTarget ? 'pointer' : undefined,
position: 'relative' as const,
willChange: copyDownState ? 'background-color' : 'auto',
contain: 'layout',
transition: 'background-color 100ms ease-in-out'
};
return (
<TableRow
key={row.id}
className={cn(
"hover:bg-muted/50",
row.getIsSelected() ? "!bg-blue-50/50" : "",
hasErrors ? "bg-red-50/40" : "",
isCopyDownTarget ? "cursor-pointer copy-down-target-row" : ""
)}
style={rowStyle}
onMouseEnter={() => handleRowMouseEnter(parseInt(row.id))}
>
{row.getVisibleCells().map((cell: any) => (
<React.Fragment key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</React.Fragment>
))}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</CopyDownContext.Provider>
);
};
// Memo comparator: re-render when any prop affecting visible state changes.
// Keep this conservative to avoid skipping updates for loading/options states.
const areEqual = (prev: ValidationTableProps<any>, next: ValidationTableProps<any>) => {
return (
// Core props
prev.data === next.data &&
prev.validationErrors === next.validationErrors &&
prev.rowSelection === next.rowSelection &&
// Loading + validation state that affects cell skeletons
prev.validatingCells === next.validatingCells &&
prev.isLoadingLines === next.isLoadingLines &&
prev.isLoadingSublines === next.isLoadingSublines &&
// Options sources used for line/subline selects
prev.rowProductLines === next.rowProductLines &&
prev.rowSublines === next.rowSublines
);
};
export default React.memo(ValidationTable, areEqual);

View File

@@ -1,145 +0,0 @@
import { useState, useEffect, useCallback } from 'react'
import { Field } from '../../../../types'
import { Checkbox } from '@/components/ui/checkbox'
import { cn } from '@/lib/utils'
import React from 'react'
interface CheckboxCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
hasErrors?: boolean
booleanMatches?: Record<string, boolean>
className?: string
}
const CheckboxCell = <T extends string>({
field,
value,
onChange,
hasErrors,
booleanMatches = {},
className = ''
}: CheckboxCellProps<T>) => {
const [checked, setChecked] = useState(false)
// Add state for hover
const [isHovered, setIsHovered] = useState(false)
// Initialize checkbox state
useEffect(() => {
if (value === undefined || value === null) {
setChecked(false)
return
}
if (typeof value === 'boolean') {
setChecked(value)
return
}
// Handle string values using booleanMatches
if (typeof value === 'string') {
// First try the field's booleanMatches
const fieldBooleanMatches = field.fieldType.type === 'checkbox'
? field.fieldType.booleanMatches || {}
: {}
// Merge with the provided booleanMatches, with the provided ones taking precedence
const allMatches = { ...fieldBooleanMatches, ...booleanMatches }
// Try to find the value in the matches
const matchEntry = Object.entries(allMatches).find(([k]) =>
k.toLowerCase() === value.toLowerCase())
if (matchEntry) {
setChecked(matchEntry[1])
return
}
// If no match found, use common true/false strings
const trueStrings = ['yes', 'true', '1', 'y']
const falseStrings = ['no', 'false', '0', 'n']
if (trueStrings.includes(value.toLowerCase())) {
setChecked(true)
return
}
if (falseStrings.includes(value.toLowerCase())) {
setChecked(false)
return
}
}
// For any other values, try to convert to boolean
setChecked(!!value)
}, [value, field.fieldType, booleanMatches])
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = (className || '').split(' ');
return classNames.includes(cls);
};
// Handle checkbox change
const handleChange = useCallback((checked: boolean) => {
setChecked(checked)
onChange(checked)
}, [onChange])
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0"
return (
<div
className={cn(
"flex items-center justify-center h-10 px-2 py-1 rounded-md",
outlineClass,
hasErrors ? "bg-red-50 border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Checkbox
checked={checked}
onCheckedChange={handleChange}
className={cn(
hasErrors ? "border-destructive" : ""
)}
/>
</div>
)
}
export default React.memo(CheckboxCell, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.field !== next.field) return false;
if (prev.value !== next.value) return false;
if (prev.className !== next.className) return false;
// Compare booleanMatches objects
const prevMatches = prev.booleanMatches || {};
const nextMatches = next.booleanMatches || {};
const prevKeys = Object.keys(prevMatches);
const nextKeys = Object.keys(nextMatches);
if (prevKeys.length !== nextKeys.length) return false;
for (const key of prevKeys) {
if (prevMatches[key] !== nextMatches[key]) return false;
}
return true;
});

View File

@@ -1,215 +0,0 @@
import React, { useState, useCallback, useMemo } from 'react'
import { Field } from '../../../../types'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import MultilineInput from './MultilineInput'
interface InputCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
isMultiline?: boolean
isPrice?: boolean
disabled?: boolean
className?: string
}
// (removed unused formatPrice helper)
const InputCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
isMultiline = false,
isPrice = false,
disabled = false,
className = ''
}: InputCellProps<T>) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const [isHovered, setIsHovered] = useState(false);
// Remove optimistic updates and rely on parent state
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = className.split(' ');
return classNames.includes(cls);
};
// No complex initialization needed
// Handle focus event
const handleFocus = useCallback(() => {
setIsEditing(true);
if (value !== undefined && value !== null) {
if (isPrice) {
// Remove any non-numeric characters except decimal point for editing
const numericValue = String(value).replace(/[^\d.]/g, '');
setEditValue(numericValue);
} else {
setEditValue(String(value));
}
} else {
setEditValue('');
}
onStartEdit?.();
}, [value, onStartEdit, isPrice]);
// Handle blur event - save to parent only
const handleBlur = useCallback(() => {
const finalValue = editValue.trim();
// Save to parent - parent must update immediately for this to work
onChange(finalValue);
// Exit editing mode
setIsEditing(false);
onEndEdit?.();
}, [editValue, onChange, onEndEdit]);
// Handle direct input change - optimized to be synchronous for typing
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const newValue = isPrice ? e.target.value.replace(/[$,]/g, '') : e.target.value;
setEditValue(newValue);
}, [isPrice]);
// Get the display value - use parent value directly
const displayValue = useMemo(() => {
const currentValue = value ?? '';
// Handle price formatting for display
if (isPrice && currentValue !== '' && currentValue !== undefined && currentValue !== null) {
if (typeof currentValue === 'number') {
return currentValue.toFixed(2);
} else if (typeof currentValue === 'string' && /^-?\d+(\.\d+)?$/.test(currentValue)) {
return parseFloat(currentValue).toFixed(2);
}
}
// For non-price or invalid price values, return as-is
return String(currentValue);
}, [isPrice, value]);
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
// If disabled, just render the value without any interactivity
if (disabled) {
return (
<div
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
);
}
// Render multiline fields using the dedicated MultilineInput component
if (isMultiline) {
return (
<MultilineInput
field={field}
value={value}
onChange={onChange}
hasErrors={hasErrors}
disabled={disabled}
className={className}
/>
);
}
// Original component for non-multiline fields
return (
<div className="w-full">
{isEditing ? (
<Input
type="text"
value={editValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus
className={cn(
outlineClass,
hasErrors ? "border-destructive" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
/>
) : (
<div
onClick={handleFocus}
className={cn(
"px-3 py-2 h-10 rounded-md text-sm w-full cursor-text flex items-center",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'text'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
)}
</div>
)
}
// Simplified memo comparison
export default React.memo(InputCell, (prev, next) => {
// Only re-render if essential props change
return prev.value === next.value &&
prev.hasErrors === next.hasErrors &&
prev.disabled === next.disabled &&
prev.field === next.field;
});

View File

@@ -1,576 +0,0 @@
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { Field } from '../../../../types'
import { cn } from '@/lib/utils'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
// Define a type for field options
interface FieldOption {
label: string;
value: string;
hex?: string; // optional hex color for colors field
}
interface MultiSelectCellProps<T extends string> {
field: Field<T>
value: string[]
onChange: (value: string[]) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
options?: readonly FieldOption[]
disabled?: boolean
className?: string
}
// Memoized option item to prevent unnecessary renders for large option lists
const OptionItem = React.memo(({
option,
isSelected,
onSelect
}: {
option: FieldOption,
isSelected: boolean,
onSelect: (value: string) => void
}) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => onSelect(option.value)}
className="flex w-full"
>
<div className="flex items-center w-full overflow-hidden">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
isSelected ? "opacity-100" : "opacity-0"
)}
/>
<span className="truncate w-full">{option.label}</span>
</div>
</CommandItem>
), (prev, next) => {
return prev.option.value === next.option.value &&
prev.isSelected === next.isSelected;
});
OptionItem.displayName = 'OptionItem';
// Create a virtualized list component for large option lists
const VirtualizedOptions = React.memo(({
options,
selectedValues,
onSelect,
maxHeight = 200
}: {
options: FieldOption[],
selectedValues: Set<string>,
onSelect: (value: string) => void,
maxHeight?: number
}) => {
const listRef = useRef<HTMLDivElement>(null);
// Only render visible options for better performance with large lists
const [visibleOptions, setVisibleOptions] = useState<FieldOption[]>([]);
const [scrollPosition, setScrollPosition] = useState(0);
// Constants for virtualization
const itemHeight = 32; // Height of each option item in pixels
const visibleCount = Math.ceil(maxHeight / itemHeight) + 2; // Number of visible items + buffer
// Handle scroll events
const handleScroll = useCallback(() => {
if (listRef.current) {
setScrollPosition(listRef.current.scrollTop);
}
}, []);
// Update visible options based on scroll position
useEffect(() => {
if (options.length <= visibleCount) {
// If fewer options than visible count, just show all
setVisibleOptions(options);
return;
}
// Calculate start and end indices
const startIndex = Math.floor(scrollPosition / itemHeight);
const endIndex = Math.min(startIndex + visibleCount, options.length);
// Update visible options
setVisibleOptions(options.slice(Math.max(0, startIndex), endIndex));
}, [options, scrollPosition, visibleCount, itemHeight]);
// If fewer than the threshold, render all directly
if (options.length <= 100) {
return (
<div ref={listRef} className="max-h-[200px] overflow-y-auto" onScroll={handleScroll}>
{options.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
);
}
return (
<div
ref={listRef}
className="max-h-[200px] overflow-y-auto"
onScroll={handleScroll}
style={{ height: `${Math.min(maxHeight, options.length * itemHeight)}px` }}
>
<div style={{ height: `${options.length * itemHeight}px`, position: 'relative' }}>
<div style={{
position: 'absolute',
top: `${Math.floor(scrollPosition / itemHeight) * itemHeight}px`,
width: '100%'
}}>
{visibleOptions.map(option => (
<OptionItem
key={option.value}
option={option}
isSelected={selectedValues.has(option.value)}
onSelect={onSelect}
/>
))}
</div>
</div>
</div>
);
});
VirtualizedOptions.displayName = 'VirtualizedOptions';
const MultiSelectCell = <T extends string>({
field,
value = [],
onChange,
onStartEdit,
onEndEdit,
hasErrors,
options: providedOptions,
disabled = false,
className = ''
}: MultiSelectCellProps<T>) => {
const [open, setOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState("")
// Add internal state for tracking selections - ensure value is always an array
const [internalValue, setInternalValue] = useState<string[]>(Array.isArray(value) ? value : [])
// Ref for the command list to enable scrolling
const commandListRef = useRef<HTMLDivElement>(null)
// Add state for hover
const [isHovered, setIsHovered] = useState(false)
// Add ref to track if we need to sync internal state with external value
const shouldSyncWithExternalValue = useRef(true)
// Create a memoized Set for fast lookups of selected values
const selectedValueSet = useMemo(() => new Set(internalValue), [internalValue]);
// Sync internalValue with external value when component mounts or value changes externally
// Modified to prevent infinite loop by checking if values are different before updating
useEffect(() => {
// Only sync if we should (not during internal edits) and if not open
if (shouldSyncWithExternalValue.current && !open) {
const externalValue = Array.isArray(value) ? value : [];
// Only update if values are actually different to prevent infinite loops
if (internalValue.length !== externalValue.length ||
!internalValue.every(v => externalValue.includes(v)) ||
!externalValue.every(v => internalValue.includes(v))) {
setInternalValue(externalValue);
}
}
}, [value, open, internalValue]);
// Handle open state changes with improved responsiveness
const handleOpenChange = useCallback((newOpen: boolean) => {
if (open && !newOpen) {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
// Only update parent state when dropdown closes
// Make a defensive copy to avoid mutations
const valuesToCommit = [...internalValue];
// Immediate UI update
setOpen(false);
// Update parent with the value immediately
onChange(valuesToCommit);
if (onEndEdit) onEndEdit();
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
} else if (newOpen && !open) {
// When opening the dropdown, sync with external value
const externalValue = Array.isArray(value) ? value : [];
setInternalValue(externalValue);
setSearchQuery(""); // Reset search query on open
setOpen(true);
if (onStartEdit) onStartEdit();
} else if (!newOpen) {
// Handle case when dropdown is already closed but handleOpenChange is called
setOpen(false);
}
}, [open, internalValue, value, onChange, onStartEdit, onEndEdit]);
// Memoize field options to prevent unnecessary recalculations
const selectOptions = useMemo(() => {
const fieldType = field.fieldType;
const fieldOptions = fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
fieldType.options ?
fieldType.options :
[];
// Use provided options or field options, ensuring they have the correct shape
// Skip this work if we have a large number of options and they didn't change
if (providedOptions && providedOptions.length > 0) {
// Check if options are already in the right format
if (typeof providedOptions[0] === 'object' && 'label' in providedOptions[0] && 'value' in providedOptions[0]) {
// Preserve optional hex if present (hex or hex_color without #)
return (providedOptions as any[]).map(opt => ({
label: opt.label,
value: String(opt.value),
hex: opt.hex
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
})) as FieldOption[];
}
return (providedOptions as any[]).map(option => ({
label: option.label || String(option.value),
value: String(option.value),
hex: option.hex
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
}));
}
// Check field options format
if (fieldOptions.length > 0) {
if (typeof fieldOptions[0] === 'object' && 'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
return (fieldOptions as any[]).map(opt => ({
label: opt.label,
value: String(opt.value),
hex: opt.hex
|| (opt.hexColor ? `#${String(opt.hexColor).replace(/^#/, '')}` : undefined)
|| (opt.hex_color ? `#${String(opt.hex_color).replace(/^#/, '')}` : undefined)
})) as FieldOption[];
}
return (fieldOptions as any[]).map(option => ({
label: option.label || String(option.value),
value: String(option.value),
hex: option.hex
|| (option.hexColor ? `#${String(option.hexColor).replace(/^#/, '')}` : undefined)
|| (option.hex_color ? `#${String(option.hex_color).replace(/^#/, '')}` : undefined)
}));
}
// Add default option if no options available
return [{ label: 'No options available', value: '' }];
}, [field.fieldType, providedOptions]);
// Use deferredValue for search to prevent UI blocking with large lists
const deferredSearchQuery = React.useDeferredValue(searchQuery);
// Memoize filtered options based on search query - efficient filtering algorithm
const filteredOptions = useMemo(() => {
// If no search query, return all options
if (!deferredSearchQuery.trim()) return selectOptions;
const query = deferredSearchQuery.toLowerCase();
// Use faster algorithm for large option lists
if (selectOptions.length > 100) {
return selectOptions.filter(option => {
// First check starting with the query (most relevant)
if (option.label.toLowerCase().startsWith(query)) return true;
// Then check includes for more general matches
return option.label.toLowerCase().includes(query);
});
}
// For smaller lists, do full text search
return selectOptions.filter(option =>
option.label.toLowerCase().includes(query)
);
}, [selectOptions, deferredSearchQuery]);
// Sort options with selected items at the top for the dropdown - only for smaller lists
const sortedOptions = useMemo(() => {
// Skip expensive sorting for large lists
if (selectOptions.length > 100) return filteredOptions;
return [...filteredOptions].sort((a, b) => {
const aSelected = selectedValueSet.has(a.value);
const bSelected = selectedValueSet.has(b.value);
if (aSelected && !bSelected) return -1;
if (!aSelected && bSelected) return 1;
return a.label.localeCompare(b.label);
});
}, [filteredOptions, selectedValueSet, selectOptions.length]);
// Memoize selected values display
const selectedValues = useMemo(() => {
// Use a map for looking up options by value for better performance
const optionsMap = new Map(selectOptions.map(opt => [opt.value, opt]));
return internalValue.map(v => {
const option = optionsMap.get(v);
return {
value: v,
label: option ? option.label : String(v)
};
});
}, [internalValue, selectOptions]);
// Update the handleSelect to operate on internalValue instead of directly calling onChange
const handleSelect = useCallback((selectedValue: string) => {
// Prevent syncing with external value during our internal update
shouldSyncWithExternalValue.current = false;
setInternalValue(prev => {
let newValue;
if (prev.includes(selectedValue)) {
// Remove the value
newValue = prev.filter(v => v !== selectedValue);
} else {
// Add the value - make a new array to avoid mutations
newValue = [...prev, selectedValue];
}
return newValue;
});
// Allow syncing with external value again after a short delay
setTimeout(() => {
shouldSyncWithExternalValue.current = true;
}, 0);
}, []);
// Handle wheel scroll in dropdown
const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) {
e.stopPropagation();
commandListRef.current.scrollTop += e.deltaY;
}
}, []);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = className.split(' ');
return classNames.includes(cls);
};
// If disabled, just render the value without any interactivity
if (disabled) {
const displayValue = internalValue.length > 0
? internalValue.map(val => {
const option = selectOptions.find(opt => opt.value === val);
return option ? option.label : val;
}).join(', ')
: '';
return (
<div
className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue || ""}
</div>
);
}
return (
<Popover open={open} onOpenChange={(o) => {
// Only open the popover if we're not in copy down mode
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
setOpen(o);
handleOpenChange(o);
}
}}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue.length && "text-muted-foreground",
hasErrors ? "border-destructive" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onClick={(e) => {
// Don't open the dropdown if we're in copy down mode
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
// Let the parent cell handle the click by NOT preventing default or stopping propagation
return;
}
// Only prevent default and stop propagation if not in copy down mode
e.preventDefault();
e.stopPropagation();
// Toggle the open state and call handleOpenChange to ensure values are saved
const newOpenState = !open;
setOpen(newOpenState);
handleOpenChange(newOpenState);
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="flex items-center w-full justify-between">
<div className="flex items-center gap-2 overflow-hidden">
{internalValue.length === 0 ? (
<span className="text-muted-foreground truncate w-full">Select...</span>
) : internalValue.length === 1 ? (
<span className="truncate w-full">{selectedValues[0].label}</span>
) : (
<>
<Badge variant="secondary" className="shrink-0 whitespace-nowrap">
{internalValue.length} selected
</Badge>
<span className="truncate">
{selectedValues.map(v => v.label).join(', ')}
</span>
</>
)}
</div>
<ChevronsUpDown className="mx-2 h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={false}>
<CommandInput
placeholder="Search..."
className="h-9"
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{sortedOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
<div className="flex items-center gap-2">
{field.key === 'colors' && option.hex && (
<span
className={`inline-block h-3.5 w-3.5 rounded-full ${option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? 'border' : ''}`}
style={{
backgroundColor: option.hex,
...(option.hex.toLowerCase() === '#ffffff' || option.hex.toLowerCase() === '#fff' ? { borderColor: '#000' } : {})
}}
/>
)}
<span>{option.label}</span>
</div>
{selectedValueSet.has(option.value) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
MultiSelectCell.displayName = 'MultiSelectCell';
export default React.memo(MultiSelectCell, (prev, next) => {
// Check primitive props first (cheap comparisons)
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.className !== next.className) return false;
// Check field reference
if (prev.field !== next.field) return false;
// Check value arrays (potentially expensive for large arrays)
// Handle undefined or null values safely
const prevValue = prev.value || [];
const nextValue = next.value || [];
if (prevValue.length !== nextValue.length) return false;
for (let i = 0; i < prevValue.length; i++) {
if (prevValue[i] !== nextValue[i]) return false;
}
// Check options (potentially expensive for large option lists)
const prevOptions = prev.options || [];
const nextOptions = next.options || [];
if (prevOptions.length !== nextOptions.length) return false;
// For large option lists, just compare references
if (prevOptions.length > 100) {
return prevOptions === nextOptions;
}
// For smaller lists, do a shallow comparison
for (let i = 0; i < prevOptions.length; i++) {
if (prevOptions[i] !== nextOptions[i]) return false;
}
return true;
});

View File

@@ -1,238 +0,0 @@
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { Field } from '../../../../types'
import { Textarea } from '@/components/ui/textarea'
import { cn } from '@/lib/utils'
import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface MultilineInputProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
hasErrors?: boolean
disabled?: boolean
className?: string
}
const MultilineInput = <T extends string>({
field,
value,
onChange,
hasErrors = false,
disabled = false,
className = ''
}: MultilineInputProps<T>) => {
const [popoverOpen, setPopoverOpen] = useState(false);
const [editValue, setEditValue] = useState('');
const [localDisplayValue, setLocalDisplayValue] = useState<string | null>(null);
const cellRef = useRef<HTMLDivElement>(null);
const preventReopenRef = useRef(false);
const pendingChangeRef = useRef<string | null>(null);
// Add state for hover
const [isHovered, setIsHovered] = useState(false);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = (className || '').split(' ');
return classNames.includes(cls);
};
// Initialize localDisplayValue on mount and when value changes externally
useEffect(() => {
if (localDisplayValue === null ||
(typeof value === 'string' && typeof localDisplayValue === 'string' &&
value.trim() !== localDisplayValue.trim())) {
setLocalDisplayValue(value);
}
}, [value, localDisplayValue]);
// Process any pending changes in the background
useEffect(() => {
if (pendingChangeRef.current !== null && !popoverOpen) {
const newValue = pendingChangeRef.current;
pendingChangeRef.current = null;
// Apply changes after the popover is closed
if (newValue !== value) {
onChange(newValue);
}
}
}, [popoverOpen, onChange, value]);
// Handle trigger click to toggle the popover
const handleTriggerClick = useCallback((e: React.MouseEvent) => {
if (preventReopenRef.current) {
e.preventDefault();
e.stopPropagation();
preventReopenRef.current = false;
return;
}
// Only process if not already open
if (!popoverOpen) {
setPopoverOpen(true);
// Initialize edit value from the current display
setEditValue(localDisplayValue || value || '');
}
}, [popoverOpen, value, localDisplayValue]);
// Handle immediate close of popover
const handleClosePopover = useCallback(() => {
// Only process if we have changes
if (editValue !== value || editValue !== localDisplayValue) {
// Store pending changes for async processing
pendingChangeRef.current = editValue;
// Update local display immediately
setLocalDisplayValue(editValue);
// Queue up the change to be processed in the background
setTimeout(() => {
onChange(editValue);
}, 0);
}
// Immediately close popover
setPopoverOpen(false);
// Prevent reopening
preventReopenRef.current = true;
setTimeout(() => {
preventReopenRef.current = false;
}, 100);
}, [editValue, value, localDisplayValue, onChange]);
// Handle clicking outside the popover
const handleInteractOutside = useCallback(() => {
handleClosePopover();
}, [handleClosePopover]);
// Handle popover open/close
const handlePopoverOpenChange = useCallback((open: boolean) => {
if (!open && popoverOpen) {
// Just call the close handler
handleClosePopover();
} else if (open && !popoverOpen) {
// When opening, set edit value from current display
setEditValue(localDisplayValue || value || '');
setPopoverOpen(true);
}
}, [value, popoverOpen, handleClosePopover, localDisplayValue]);
// Handle direct input change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
setEditValue(e.target.value);
}, []);
// Calculate display value
const displayValue = localDisplayValue !== null ? localDisplayValue : (value ?? '');
// Add outline even when not in focus
const outlineClass = "border focus-visible:ring-0 focus-visible:ring-offset-0";
// If disabled, just render the value without any interactivity
if (disabled) {
return (
<div
className={cn(
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full",
outlineClass,
hasErrors ? "border-destructive" : "border-input"
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
);
}
return (
<div className="w-full" ref={cellRef}>
<Popover open={popoverOpen} onOpenChange={handlePopoverOpenChange}>
<PopoverTrigger asChild>
<div
onClick={handleTriggerClick}
className={cn(
"px-3 py-2 min-h-[80px] max-h-[80px] rounded-md text-sm w-full cursor-pointer",
"overflow-hidden whitespace-pre-wrap",
outlineClass,
hasErrors ? "border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : 'pointer'
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayValue}
</div>
</PopoverTrigger>
<PopoverContent
className="p-0 shadow-lg rounded-md"
style={{ width: cellRef.current?.offsetWidth || 'auto' }}
align="start"
side="bottom"
alignOffset={0}
sideOffset={-80}
avoidCollisions={false}
onInteractOutside={handleInteractOutside}
forceMount
>
<div className="flex flex-col">
<Button
size="icon"
variant="ghost"
onClick={handleClosePopover}
className="h-6 w-6 text-muted-foreground absolute top-0.5 right-0.5"
>
<X className="h-3 w-3" />
</Button>
<Textarea
value={editValue}
onChange={handleChange}
className="min-h-[200px] border-none focus-visible:ring-0 rounded-none p-2"
placeholder={`Enter ${field.label || 'text'}...`}
autoFocus
/>
</div>
</PopoverContent>
</Popover>
</div>
);
};
export default React.memo(MultilineInput, (prev, next) => {
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.field !== next.field) return false;
if (prev.value !== next.value) return false;
if (prev.className !== next.className) return false;
return true;
});

View File

@@ -1,295 +0,0 @@
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'
import { Field } from '../../../../types'
import { Check, ChevronsUpDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import React from 'react'
export type SelectOption = {
label: string;
value: string;
};
interface SelectCellProps<T extends string> {
field: Field<T>
value: any
onChange: (value: any) => void
onStartEdit?: () => void
onEndEdit?: () => void
hasErrors?: boolean
options: readonly any[]
disabled?: boolean
className?: string
}
// Lightweight version of the select cell with minimal dependencies
const SelectCell = <T extends string>({
field,
value,
onChange,
onStartEdit,
onEndEdit,
hasErrors,
options = [],
disabled = false,
className = ''
}: SelectCellProps<T>) => {
// State for the open/closed state of the dropdown
const [open, setOpen] = useState(false);
// Ref for the command list
const commandListRef = useRef<HTMLDivElement>(null);
// Controlled state for the internal value - this is key to prevent reopening
const [internalValue, setInternalValue] = useState(value);
// State to track if the value is being processed/validated
const [isProcessing, setIsProcessing] = useState(false);
// Add state for hover
const [isHovered, setIsHovered] = useState(false);
// Helper function to check if a class is present in the className string
const hasClass = (cls: string): boolean => {
const classNames = className.split(' ');
return classNames.includes(cls);
};
// Update internal value when prop value changes
useEffect(() => {
setInternalValue(value);
// When the value prop changes, it means validation is complete
setIsProcessing(false);
}, [value]);
// Memoize options processing to avoid recalculation on every render
const selectOptions = useMemo(() => {
// Fast path check - if we have raw options, just use those
if (options && options.length > 0) {
// Check if options already have the correct structure to avoid mapping
if (typeof options[0] === 'object' && 'label' in options[0] && 'value' in options[0]) {
return options as SelectOption[];
}
// Optimize mapping to only convert what's needed
return options.map((option: any) => ({
label: option.label || String(option.value || option),
value: String(option.value || option)
}));
}
// Fall back to field options if no direct options provided
const fieldType = field.fieldType;
if (fieldType &&
(fieldType.type === 'select' || fieldType.type === 'multi-select') &&
(fieldType as any).options) {
const fieldOptions = (fieldType as any).options;
// Check if fieldOptions already have the correct structure
if (fieldOptions.length > 0 && typeof fieldOptions[0] === 'object' &&
'label' in fieldOptions[0] && 'value' in fieldOptions[0]) {
return fieldOptions as SelectOption[];
}
return fieldOptions.map((option: any) => ({
label: option.label || String(option.value || option),
value: String(option.value || option)
}));
}
// Return default empty option if no options available
return [{ label: 'No options available', value: '' }];
}, [field.fieldType, options]);
// Memoize display value to avoid recalculation on every render
const displayValue = useMemo(() => {
if (!internalValue) return 'Select...';
// Fast path: direct lookup by value using find
const stringValue = String(internalValue);
const found = selectOptions.find((option: SelectOption) => String(option.value) === stringValue);
return found ? found.label : stringValue;
}, [internalValue, selectOptions]);
// Handle wheel scroll in dropdown - optimized with passive event
const handleWheel = useCallback((e: React.WheelEvent) => {
if (commandListRef.current) {
e.stopPropagation();
commandListRef.current.scrollTop += e.deltaY;
}
}, []);
// Handle selection - UPDATE INTERNAL VALUE FIRST
const handleSelect = useCallback((selectedValue: string) => {
// Store the selected value to prevent it being lost in async operations
const valueToCommit = selectedValue;
// 1. Update internal value immediately to prevent UI flicker
setInternalValue(valueToCommit);
// 2. Close the dropdown immediately
setOpen(false);
// 3. Set processing state to show visual indicator
setIsProcessing(true);
// 4. Only then call the onChange callback
// This prevents the parent component from re-rendering and causing dropdown to reopen
if (onEndEdit) onEndEdit();
// 5. Call onChange synchronously to avoid race conditions with other cells
onChange(valueToCommit);
// 6. Clear processing state after a short delay - reduced for responsiveness
setTimeout(() => {
setIsProcessing(false);
}, 50);
}, [onChange, onEndEdit]);
// If disabled, render a static view
if (disabled && field.key !== 'line' && field.key !== 'subline') {
const displayText = displayValue;
return (
<div
className={cn(
"w-full px-3 py-2 h-10 rounded-md text-sm flex items-center",
"border",
hasErrors ? "border-destructive" : "border-input",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{displayText || ""}
</div>
);
}
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
// Only open the popover if we're not in copy down mode
if (!hasClass('!bg-blue-100') && !hasClass('!bg-blue-200') && !hasClass('hover:!bg-blue-100')) {
setOpen(isOpen);
if (isOpen && onStartEdit) onStartEdit();
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
"w-full justify-between font-normal",
"border",
!internalValue && "text-muted-foreground",
isProcessing && "text-muted-foreground",
hasErrors ? "border-destructive" : "",
className
)}
style={{
backgroundColor: hasClass('!bg-blue-100') ? '#dbeafe' :
hasClass('!bg-blue-200') ? '#bfdbfe' :
hasClass('hover:!bg-blue-100') && isHovered ? '#dbeafe' :
undefined,
borderColor: hasClass('!border-blue-500') ? '#3b82f6' :
hasClass('!border-blue-200') ? '#bfdbfe' :
hasClass('!border-blue-200') && isHovered ? '#bfdbfe' :
undefined,
borderRadius: hasClass('!rounded-md') ? '0.375rem' : undefined,
borderWidth: hasClass('!border-blue-500') || hasClass('!border-blue-200') ? '0px' : undefined,
cursor: hasClass('hover:!bg-blue-100') ? 'pointer' : undefined
}}
onClick={(e) => {
// Don't open the dropdown if we're in copy down mode
if (hasClass('!bg-blue-100') || hasClass('!bg-blue-200') || hasClass('hover:!bg-blue-100')) {
// Let the parent cell handle the click by NOT preventing default or stopping propagation
return;
}
// Only prevent default and stop propagation if not in copy down mode
e.preventDefault();
e.stopPropagation();
setOpen(!open);
if (!open && onStartEdit) onStartEdit();
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<span className={isProcessing ? "opacity-70" : ""}>
{displayValue}
</span>
<ChevronsUpDown className="mr-1.5 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[var(--radix-popover-trigger-width)]"
align="start"
sideOffset={4}
>
<Command shouldFilter={true}>
<CommandInput
placeholder="Search..."
className="h-9"
/>
<CommandList
ref={commandListRef}
onWheel={handleWheel}
className="max-h-[200px]"
>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{selectOptions.map((option: SelectOption) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => handleSelect(option.value)}
className="cursor-pointer"
>
{option.label}
{String(option.value) === String(internalValue) && (
<Check className="ml-auto h-4 w-4" />
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)
}
// Optimize memo comparison to avoid unnecessary re-renders
export default React.memo(SelectCell, (prev, next) => {
// Only rerender when these critical props change
if (prev.value !== next.value) return false;
if (prev.hasErrors !== next.hasErrors) return false;
if (prev.disabled !== next.disabled) return false;
if (prev.className !== next.className) return false;
// Only check options array for reference equality - we're handling deep comparison internally
if (prev.options !== next.options &&
(prev.options.length !== next.options.length)) {
return false;
}
return true;
});

View File

@@ -1,171 +0,0 @@
import { useCallback } from 'react';
import type { Field, Fields, RowHook } from '../../../types';
import type { Meta } from '../types';
import { ErrorType, ValidationError } from '../../../types';
import { RowData, isEmpty } from './validationTypes';
// Create a cache for validation results to avoid repeated validation of the same data
const validationResultCache = new Map();
// Optimize cache clearing - only clear when necessary
export const clearValidationCacheForField = (fieldKey: string, specificValue?: any) => {
if (specificValue !== undefined) {
// Only clear specific field-value combinations
const specificKey = `${fieldKey}-${String(specificValue)}`;
validationResultCache.forEach((_, key) => {
if (key.startsWith(specificKey)) {
validationResultCache.delete(key);
}
});
} else {
// Clear all entries for the field
validationResultCache.forEach((_, key) => {
if (key.startsWith(`${fieldKey}-`)) {
validationResultCache.delete(key);
}
});
}
};
// Add a special function to clear all uniqueness validation caches
export const clearAllUniquenessCaches = () => {
// Clear cache for common unique fields
['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'].forEach(fieldKey => {
clearValidationCacheForField(fieldKey);
});
// Also clear any cache entries that might involve uniqueness validation
validationResultCache.forEach((_, key) => {
if (key.includes('unique')) {
validationResultCache.delete(key);
}
});
};
export const useFieldValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>
) => {
// Validate a single field
const validateField = useCallback((
value: any,
field: Field<T>
): ValidationError[] => {
const errors: ValidationError[] = [];
if (!field.validations) return errors;
// Create a cache key using field key, value, and validation rules
const cacheKey = `${field.key}-${String(value)}-${JSON.stringify(field.validations)}`;
// Check cache first to avoid redundant validation
if (validationResultCache.has(cacheKey)) {
return validationResultCache.get(cacheKey) || [];
}
field.validations.forEach(validation => {
switch (validation.rule) {
case 'required':
// Use the shared isEmpty function
if (isEmpty(value)) {
errors.push({
message: validation.errorMessage || 'This field is required',
level: validation.level || 'error',
type: ErrorType.Required
});
}
break;
case 'unique':
// Unique validation happens at table level, not here
break;
case 'regex':
if (value !== undefined && value !== null && value !== '') {
try {
const regex = new RegExp(validation.value, validation.flags);
if (!regex.test(String(value))) {
errors.push({
message: validation.errorMessage,
level: validation.level || 'error',
type: ErrorType.Regex
});
}
} catch (error) {
console.error('Invalid regex in validation:', error);
}
}
break;
}
});
// Store results in cache to speed up future validations
validationResultCache.set(cacheKey, errors);
return errors;
}, []);
// Validate a single row
const validateRow = useCallback(async (
row: RowData<T>,
rowIndex: number,
allRows: RowData<T>[]
): Promise<Meta> => {
// Run field-level validations
const fieldErrors: Record<string, ValidationError[]> = {};
fields.forEach(field => {
const value = row[String(field.key) as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
fieldErrors[String(field.key)] = errors;
}
});
// Special validation for supplier and company fields - only apply if the field exists in fields
if (fields.some(field => String(field.key) === 'supplier') && isEmpty(row.supplier)) {
fieldErrors['supplier'] = [{
message: 'Supplier is required',
level: 'error',
type: ErrorType.Required
}];
}
if (fields.some(field => String(field.key) === 'company') && isEmpty(row.company)) {
fieldErrors['company'] = [{
message: 'Company is required',
level: 'error',
type: ErrorType.Required
}];
}
// Run row hook if provided
let rowHookResult: Meta = {
__index: row.__index || String(rowIndex)
};
if (rowHook) {
try {
// Call the row hook and extract only the __index property
const result = await rowHook(row, rowIndex, allRows);
rowHookResult.__index = result.__index || rowHookResult.__index;
} catch (error) {
console.error('Error in row hook:', error);
}
}
// We no longer need to merge errors since we're not storing them in the row data
// The calling code should handle storing errors in the validationErrors Map
return {
__index: row.__index || String(rowIndex)
};
}, [fields, validateField, rowHook]);
return {
validateField,
validateRow,
clearValidationCacheForField,
clearAllUniquenessCaches
};
};

View File

@@ -1,119 +0,0 @@
import { useState, useCallback, useMemo } from 'react';
import { FilterState, RowData } from './validationTypes';
import type { Fields } from '../../../types';
import { ValidationError } from '../../../types';
export const useFilterManagement = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
validationErrors: Map<number, Record<string, ValidationError[]>>
) => {
// Filter state
const [filters, setFilters] = useState<FilterState>({
searchText: "",
showErrorsOnly: false,
filterField: null,
filterValue: null,
});
// Filter data based on current filter state
const filteredData = useMemo(() => {
// Fast path: no filters active, return original data reference to avoid re-renders
const noSearch = !filters.searchText || filters.searchText.trim() === '';
const noErrorsOnly = !filters.showErrorsOnly;
const noFieldFilter = !filters.filterField || !filters.filterValue || filters.filterValue.trim() === '';
if (noSearch && noErrorsOnly && noFieldFilter) {
return data; // preserve reference; prevents full table rerender on error map changes
}
return data.filter((row, index) => {
// Filter by search text
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const matchesSearch = fields.some((field) => {
const value = row[field.key as keyof typeof row];
if (value === undefined || value === null) return false;
return String(value).toLowerCase().includes(searchLower);
});
if (!matchesSearch) return false;
}
// Filter by errors
if (filters.showErrorsOnly) {
const hasErrors =
validationErrors.has(index) &&
Object.keys(validationErrors.get(index) || {}).length > 0;
if (!hasErrors) return false;
}
// Filter by field value
if (filters.filterField && filters.filterValue) {
const fieldValue = row[filters.filterField as keyof typeof row];
if (fieldValue === undefined) return false;
const valueStr = String(fieldValue).toLowerCase();
const filterStr = filters.filterValue.toLowerCase();
if (!valueStr.includes(filterStr)) return false;
}
return true;
});
}, [data, fields, filters, validationErrors]);
// Get filter fields
const filterFields = useMemo(() => {
return fields.map((field) => ({
key: String(field.key),
label: field.label,
}));
}, [fields]);
// Get filter values for the selected field
const filterValues = useMemo(() => {
if (!filters.filterField) return [];
// Get unique values for the selected field
const uniqueValues = new Set<string>();
data.forEach((row) => {
const value = row[filters.filterField as keyof typeof row];
if (value !== undefined && value !== null) {
uniqueValues.add(String(value));
}
});
return Array.from(uniqueValues).map((value) => ({
value,
label: value,
}));
}, [data, filters.filterField]);
// Update filters
const updateFilters = useCallback((newFilters: Partial<FilterState>) => {
setFilters((prev) => ({
...prev,
...newFilters,
}));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
setFilters({
searchText: "",
showErrorsOnly: false,
filterField: null,
filterValue: null,
});
}, []);
return {
filters,
filteredData,
filterFields,
filterValues,
updateFilters,
resetFilters
};
};

View File

@@ -1,268 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { Fields } from '@/components/product-import/types';
import type { RowData } from './validationTypes';
import { cleanPriceField } from '../utils/priceUtils';
/**
* Custom hook for handling initial data validation
*
* Performs comprehensive validation on import data including:
* - Price field cleaning and formatting
* - Required field validation
* - Regex pattern validation
* - Batch processing for performance
*
* @param data - Array of row data to validate
* @param fields - Field configuration with validation rules
* @param setData - Function to update data after cleaning
* @param setValidationErrors - Function to set validation errors
* @param validateUniqueItemNumbers - Async function to validate uniqueness
* @param upcValidationComplete - Flag indicating UPC validation is done
* @param onComplete - Callback when validation is complete
*/
export function useInitialValidation<T extends string>({
data,
fields,
setData,
setValidationErrors,
validateUniqueItemNumbers,
upcValidationComplete,
onComplete,
}: {
data: RowData<T>[];
fields: Fields<T>;
setData: (data: RowData<T>[]) => void;
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, any[]>>>>;
validateUniqueItemNumbers: () => Promise<void>;
upcValidationComplete: boolean;
onComplete?: () => void;
}) {
const hasRunRef = useRef(false);
const [isValidating, setIsValidating] = useState(false);
useEffect(() => {
// Only run once
if (hasRunRef.current) return;
// Wait for UPC validation to complete first
if (!upcValidationComplete) return;
// Handle empty dataset immediately
if (!data || data.length === 0) {
hasRunRef.current = true;
if (onComplete) {
onComplete();
}
return;
}
hasRunRef.current = true;
setIsValidating(true);
const runValidation = async () => {
console.log('Running initial validation...');
// Extract field groups for validation
const requiredFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === 'required')
);
const regexFields = fields.filter((field) =>
field.validations?.some((v) => v.rule === 'regex')
);
console.log(`Validating ${requiredFields.length} required fields, ${regexFields.length} regex fields`);
// Dynamic batch size based on dataset size
const BATCH_SIZE = data.length <= 50 ? data.length : 25;
const totalBatches = Math.ceil(data.length / BATCH_SIZE);
// Initialize containers
const newData = [...data];
const validationErrorsTemp = new Map<number, Record<string, any[]>>();
// Process batches
for (let batchNum = 0; batchNum < totalBatches; batchNum++) {
const startIdx = batchNum * BATCH_SIZE;
const endIdx = Math.min(startIdx + BATCH_SIZE, data.length);
console.log(`Processing batch ${batchNum + 1}/${totalBatches} (rows ${startIdx}-${endIdx - 1})`);
// Process all rows in this batch
const batchPromises = [];
for (let rowIndex = startIdx; rowIndex < endIdx; rowIndex++) {
batchPromises.push(
processRow(
rowIndex,
data[rowIndex],
newData,
requiredFields,
regexFields,
validationErrorsTemp
)
);
}
await Promise.all(batchPromises);
// Yield to UI thread for large datasets
if (batchNum % 2 === 1 || data.length > 500) {
await new Promise((resolve) => setTimeout(resolve, data.length > 1000 ? 10 : 5));
}
}
console.log('Batch validation complete, applying results...');
// Apply validation errors
setValidationErrors(validationErrorsTemp);
// Apply data changes (price formatting)
if (JSON.stringify(data) !== JSON.stringify(newData)) {
setData(newData);
}
// Run uniqueness validation
console.log('Running uniqueness validation...');
await validateUniqueItemNumbers();
console.log('Initial validation complete');
setIsValidating(false);
// Notify completion
if (onComplete) {
onComplete();
}
};
runValidation().catch((error) => {
console.error('Error during initial validation:', error);
setIsValidating(false);
if (onComplete) {
onComplete();
}
});
}, [data, fields, setData, setValidationErrors, validateUniqueItemNumbers, upcValidationComplete, onComplete]);
return { isValidating };
}
/**
* Process a single row: clean data and validate
*/
function processRow<T extends string>(
rowIndex: number,
row: RowData<T>,
newData: RowData<T>[],
requiredFields: Fields<T>,
regexFields: Fields<T>,
validationErrorsTemp: Map<number, Record<string, any[]>>
): Promise<void> {
return new Promise((resolve) => {
// Skip empty rows
if (!row) {
resolve();
return;
}
const fieldErrors: Record<string, any[]> = {};
let hasErrors = false;
const rowAsRecord = row as Record<string, any>;
// Clean price fields if needed
let needsUpdate = false;
let cleanedRow = row;
if (
rowAsRecord.msrp &&
typeof rowAsRecord.msrp === 'string' &&
(rowAsRecord.msrp.includes('$') || rowAsRecord.msrp.includes(','))
) {
if (!needsUpdate) {
cleanedRow = { ...row } as RowData<T>;
needsUpdate = true;
}
(cleanedRow as Record<string, any>).msrp = cleanPriceField(rowAsRecord.msrp);
}
if (
rowAsRecord.cost_each &&
typeof rowAsRecord.cost_each === 'string' &&
(rowAsRecord.cost_each.includes('$') || rowAsRecord.cost_each.includes(','))
) {
if (!needsUpdate) {
cleanedRow = { ...row } as RowData<T>;
needsUpdate = true;
}
(cleanedRow as Record<string, any>).cost_each = cleanPriceField(rowAsRecord.cost_each);
}
if (needsUpdate) {
newData[rowIndex] = cleanedRow;
}
// Validate required fields
for (const field of requiredFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
if (
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && value !== null && Object.keys(value).length === 0)
) {
fieldErrors[key] = [
{
message:
field.validations?.find((v) => v.rule === 'required')?.errorMessage ||
'This field is required',
level: 'error',
source: 'row',
type: 'required',
},
];
hasErrors = true;
}
}
// Validate regex fields
for (const field of regexFields) {
const key = String(field.key);
const value = row[key as keyof typeof row];
// Skip empty values (handled by required validation)
if (value === undefined || value === null || value === '') {
continue;
}
const regexValidation = field.validations?.find((v) => v.rule === 'regex');
if (regexValidation) {
try {
const regex = new RegExp(regexValidation.value, regexValidation.flags);
if (!regex.test(String(value))) {
fieldErrors[key] = [
{
message: regexValidation.errorMessage,
level: regexValidation.level || 'error',
source: 'row',
type: 'regex',
},
];
hasErrors = true;
}
} catch (error) {
console.error('Invalid regex in validation:', error);
}
}
}
// Store errors if any
if (hasErrors) {
validationErrorsTemp.set(rowIndex, fieldErrors);
}
resolve();
});
}

View File

@@ -1,543 +0,0 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import axios from 'axios'
import { toast } from 'sonner'
/**
* Custom hook for managing product lines and sublines fetching with caching
*/
export const useProductLinesFetching = (data: Record<string, any>[]) => {
// State for tracking product lines and sublines per row
const [rowProductLines, setRowProductLines] = useState<Record<string, any[]>>({});
const [rowSublines, setRowSublines] = useState<Record<string, any[]>>({});
// State for tracking loading states
const [isLoadingLines, setIsLoadingLines] = useState<Record<string, boolean>>({});
const [isLoadingSublines, setIsLoadingSublines] = useState<Record<string, boolean>>({});
// Add caches for product lines and sublines by company/line ID
const [companyLinesCache, setCompanyLinesCache] = useState<Record<string, any[]>>({});
const [lineSublineCache, setLineSublineCache] = useState<Record<string, any[]>>({});
// Track in-flight requests to prevent duplicate fetches (especially in StrictMode/dev)
const pendingCompanyRequests = useRef<Set<string>>(new Set());
const pendingLineRequests = useRef<Set<string>>(new Set());
// Function to fetch product lines for a specific company - memoized
const fetchProductLines = useCallback(async (rowIndex: string | number | undefined | null, companyId: string) => {
try {
// Only fetch if we have a valid company ID
if (!companyId) return;
const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows';
console.log(`Fetching product lines for row ${logRowKey}, company ${companyId}`);
// Check if we already have this company's lines in the cache
if (companyLinesCache[companyId]) {
console.log(`Using cached product lines for company ${companyId}`);
// Use cached data
const cached = companyLinesCache[companyId];
// Update the specific row if provided
if (rowIndex !== undefined && rowIndex !== null) {
setRowProductLines(prev => ({ ...prev, [rowIndex]: cached }));
}
// Also update all rows that currently have this company set
const updates: Record<string, any[]> = {};
data.forEach((row, idx) => {
if (row.company && String(row.company) === String(companyId)) {
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
updates[key] = cached;
}
});
if (Object.keys(updates).length > 0) {
setRowProductLines(prev => ({ ...prev, ...updates }));
}
return cached;
}
// If a request for this company is already in flight, skip duplicate fetch
if (pendingCompanyRequests.current.has(companyId)) {
console.log(`Skipping fetch for company ${companyId} - request already pending`);
return;
}
pendingCompanyRequests.current.add(companyId);
// Set loading state for this row
if (rowIndex !== undefined && rowIndex !== null) {
setIsLoadingLines(prev => ({ ...prev, [rowIndex]: true }));
}
// Fetch product lines from API
const productLinesUrl = `/api/import/product-lines/${companyId}`;
const response = await axios.get(productLinesUrl);
const lines = response.data;
console.log(`Received ${lines.length} product lines for company ${companyId}`);
// Format the data properly for dropdown display
const formattedLines = lines.map((line: any) => ({
label: line.name || line.label || String(line.value || line.id),
value: String(line.value || line.id)
}));
// Store in company cache
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
// Update specific row if provided
if (rowIndex !== undefined && rowIndex !== null) {
setRowProductLines(prev => ({ ...prev, [rowIndex]: formattedLines }));
}
// Also update all rows that currently have this company set
const updates: Record<string, any[]> = {};
data.forEach((row, idx) => {
if (row.company && String(row.company) === String(companyId)) {
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
updates[key] = formattedLines;
}
});
if (Object.keys(updates).length > 0) {
setRowProductLines(prev => ({ ...prev, ...updates }));
}
return formattedLines;
} catch (error) {
console.error(`Error fetching product lines for company ${companyId}:`, error);
toast.error(`Failed to load product lines for company ${companyId}`);
// Set empty array for this company to prevent repeated failed requests
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
// Store empty array for this specific row
if (rowIndex !== undefined && rowIndex !== null) {
setRowProductLines(prev => ({ ...prev, [rowIndex]: [] }));
}
return [];
} finally {
// Clear pending flag
pendingCompanyRequests.current.delete(companyId);
// Clear loading state
if (rowIndex !== undefined && rowIndex !== null) {
setIsLoadingLines(prev => ({ ...prev, [rowIndex]: false }));
}
}
}, [companyLinesCache, data]);
// Function to fetch sublines for a specific line - memoized
const fetchSublines = useCallback(async (rowIndex: string | number | undefined | null, lineId: string) => {
try {
// Only fetch if we have a valid line ID
if (!lineId) return;
const logRowKey = (rowIndex !== undefined && rowIndex !== null) ? rowIndex : 'all-matching-rows';
console.log(`Fetching sublines for row ${logRowKey}, line ${lineId}`);
// Check if we already have this line's sublines in the cache
if (lineSublineCache[lineId]) {
console.log(`Using cached sublines for line ${lineId}`);
// Use cached data
const cached = lineSublineCache[lineId];
if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: cached }));
}
// Also update all rows with this line
const updates: Record<string, any[]> = {};
data.forEach((row, idx) => {
if (row.line && String(row.line) === String(lineId)) {
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
updates[key] = cached;
}
});
if (Object.keys(updates).length > 0) {
setRowSublines(prev => ({ ...prev, ...updates }));
}
return cached;
}
// If a request for this line is already in flight, skip duplicate fetch
if (pendingLineRequests.current.has(lineId)) {
console.log(`Skipping fetch for line ${lineId} - request already pending`);
return;
}
pendingLineRequests.current.add(lineId);
// Set loading state for this row
if (rowIndex !== undefined && rowIndex !== null) {
setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: true }));
}
// Fetch sublines from API
const sublinesUrl = `/api/import/sublines/${lineId}`;
const response = await axios.get(sublinesUrl);
const sublines = response.data;
console.log(`Received ${sublines.length} sublines for line ${lineId}`);
// Format the data properly for dropdown display
const formattedSublines = sublines.map((subline: any) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id)
}));
// Store in line cache
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
// Update specific row if provided
if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: formattedSublines }));
}
// Also update all rows with this line
const updates: Record<string, any[]> = {};
data.forEach((row, idx) => {
if (row.line && String(row.line) === String(lineId)) {
const key = (row.__index !== undefined && row.__index !== null) ? row.__index : idx;
updates[key] = formattedSublines;
}
});
if (Object.keys(updates).length > 0) {
setRowSublines(prev => ({ ...prev, ...updates }));
}
return formattedSublines;
} catch (error) {
console.error(`Error fetching sublines for line ${lineId}:`, error);
// Set empty array for this line to prevent repeated failed requests
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
// Store empty array for this specific row
if (rowIndex !== undefined && rowIndex !== null) {
setRowSublines(prev => ({ ...prev, [rowIndex]: [] }));
}
return [];
} finally {
// Clear pending flag
pendingLineRequests.current.delete(lineId);
// Clear loading state
if (rowIndex !== undefined && rowIndex !== null) {
setIsLoadingSublines(prev => ({ ...prev, [rowIndex]: false }));
}
}
}, [lineSublineCache, data]);
// When data changes, fetch product lines and sublines for rows that have company/line values
useEffect(() => {
// Skip if there's no data
if (!data.length) return;
// First check if we need to do anything at all
let needsFetching = false;
// Quick check for any rows that would need fetching
for (const row of data) {
const rowId = row.__index;
if (!rowId) continue;
if ((row.company && !rowProductLines[rowId]) || (row.line && !rowSublines[rowId])) {
needsFetching = true;
break;
}
}
// If nothing needs fetching, exit early
if (!needsFetching) {
return;
}
console.log("Starting to fetch product lines and sublines");
// Group rows by company and line to minimize API calls
const companiesNeeded = new Map<string, string[]>(); // company ID -> row IDs
const linesNeeded = new Map<string, string[]>(); // line ID -> row IDs
data.forEach(row => {
const rowId = row.__index;
if (!rowId) return; // Skip rows without an index
// If row has company but no product lines fetched yet
if (row.company && !rowProductLines[rowId]) {
const companyId = row.company.toString();
if (!companiesNeeded.has(companyId)) {
companiesNeeded.set(companyId, []);
}
companiesNeeded.get(companyId)?.push(rowId);
}
// If row has line but no sublines fetched yet
if (row.line && !rowSublines[rowId]) {
const lineId = row.line.toString();
if (!linesNeeded.has(lineId)) {
linesNeeded.set(lineId, []);
}
linesNeeded.get(lineId)?.push(rowId);
}
});
console.log(`Need to fetch product lines for ${companiesNeeded.size} companies and sublines for ${linesNeeded.size} lines`);
// If nothing to fetch, exit early to prevent unnecessary processing
if (companiesNeeded.size === 0 && linesNeeded.size === 0) {
return;
}
// Create arrays to hold all fetch promises
const fetchPromises: Promise<void>[] = [];
// Set initial loading states for all affected rows
const lineLoadingUpdates: Record<string, boolean> = {};
const sublineLoadingUpdates: Record<string, boolean> = {};
// Process companies that need product lines
companiesNeeded.forEach((rowIds, companyId) => {
// If this company is already being fetched, skip creating another request
if (pendingCompanyRequests.current.has(companyId)) {
console.log(`Skipping batch fetch for company ${companyId} - request already pending`);
return;
}
// Skip if already in cache
if (companyLinesCache[companyId]) {
console.log(`Using cached product lines for company ${companyId}`);
// Use cached data for all rows with this company
const lines = companyLinesCache[companyId];
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = lines;
});
setRowProductLines(prev => ({ ...prev, ...updates }));
return;
}
// Set loading state for all affected rows
rowIds.forEach(rowId => {
lineLoadingUpdates[rowId] = true;
});
// Create fetch promise
const fetchPromise = (async () => {
// Mark this company as pending
pendingCompanyRequests.current.add(companyId);
// Safety timeout to ensure loading state is cleared after 10 seconds
const timeoutId = setTimeout(() => {
console.log(`Safety timeout triggered for company ${companyId}`);
const clearLoadingUpdates: Record<string, boolean> = {};
rowIds.forEach(rowId => {
clearLoadingUpdates[rowId] = false;
});
setIsLoadingLines(prev => ({ ...prev, ...clearLoadingUpdates }));
// Set empty cache to prevent repeated requests
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
// Update rows with empty array
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = [];
});
setRowProductLines(prev => ({ ...prev, ...updates }));
toast.error(`Timeout loading product lines for company ${companyId}`);
}, 10000);
try {
console.log(`Fetching product lines for company ${companyId} (affecting ${rowIds.length} rows)`);
// Fetch product lines from API
const productLinesUrl = `/api/import/product-lines/${companyId}`;
console.log(`Fetching from URL: ${productLinesUrl}`);
const response = await axios.get(productLinesUrl);
console.log(`Product lines API response status for company ${companyId}:`, response.status);
const productLines = response.data;
console.log(`Received ${productLines.length} product lines for company ${companyId}`);
// Format the data for dropdown display (consistent with single-row fetch)
const formattedLines = productLines.map((line: any) => ({
label: line.name || line.label || String(line.value || line.id),
value: String(line.value || line.id)
}));
// Store in company cache
setCompanyLinesCache(prev => ({ ...prev, [companyId]: formattedLines }));
// Update all rows with this company
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = formattedLines;
});
setRowProductLines(prev => ({ ...prev, ...updates }));
} catch (error) {
console.error(`Error fetching product lines for company ${companyId}:`, error);
// Set empty array for this company to prevent repeated failed requests
setCompanyLinesCache(prev => ({ ...prev, [companyId]: [] }));
// Update rows with empty array
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = [];
});
setRowProductLines(prev => ({ ...prev, ...updates }));
// Show error toast
toast.error(`Failed to load product lines for company ${companyId}`);
} finally {
// Clear pending flag for company
pendingCompanyRequests.current.delete(companyId);
// Clear the safety timeout
clearTimeout(timeoutId);
// Clear loading state for all affected rows
const clearLoadingUpdates: Record<string, boolean> = {};
rowIds.forEach(rowId => {
clearLoadingUpdates[rowId] = false;
});
setIsLoadingLines(prev => ({ ...prev, ...clearLoadingUpdates }));
}
})();
fetchPromises.push(fetchPromise);
});
// Process lines that need sublines
linesNeeded.forEach((rowIds, lineId) => {
// If this line is already being fetched, skip creating another request
if (pendingLineRequests.current.has(lineId)) {
console.log(`Skipping batch fetch for line ${lineId} - request already pending`);
return;
}
// Skip if already in cache
if (lineSublineCache[lineId]) {
console.log(`Using cached sublines for line ${lineId}`);
// Use cached data for all rows with this line
const sublines = lineSublineCache[lineId];
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = sublines;
});
setRowSublines(prev => ({ ...prev, ...updates }));
return;
}
// Set loading state for all affected rows
rowIds.forEach(rowId => {
sublineLoadingUpdates[rowId] = true;
});
// Create fetch promise
const fetchPromise = (async () => {
// Mark this line as pending
pendingLineRequests.current.add(lineId);
// Safety timeout to ensure loading state is cleared after 10 seconds
const timeoutId = setTimeout(() => {
console.log(`Safety timeout triggered for line ${lineId}`);
const clearLoadingUpdates: Record<string, boolean> = {};
rowIds.forEach(rowId => {
clearLoadingUpdates[rowId] = false;
});
setIsLoadingSublines(prev => ({ ...prev, ...clearLoadingUpdates }));
// Set empty cache to prevent repeated requests
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
// Update rows with empty array
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = [];
});
setRowSublines(prev => ({ ...prev, ...updates }));
toast.error(`Timeout loading sublines for line ${lineId}`);
}, 10000);
try {
console.log(`Fetching sublines for line ${lineId} (affecting ${rowIds.length} rows)`);
// Fetch sublines from API
const sublinesUrl = `/api/import/sublines/${lineId}`;
console.log(`Fetching from URL: ${sublinesUrl}`);
const response = await axios.get(sublinesUrl);
console.log(`Sublines API response status for line ${lineId}:`, response.status);
const sublines = response.data;
console.log(`Received ${sublines.length} sublines for line ${lineId}`);
// Format the data for dropdown display (consistent with single-row fetch)
const formattedSublines = sublines.map((subline: any) => ({
label: subline.name || subline.label || String(subline.value || subline.id),
value: String(subline.value || subline.id)
}));
// Store in line cache
setLineSublineCache(prev => ({ ...prev, [lineId]: formattedSublines }));
// Update all rows with this line
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = formattedSublines;
});
setRowSublines(prev => ({ ...prev, ...updates }));
} catch (error) {
console.error(`Error fetching sublines for line ${lineId}:`, error);
// Set empty array for this line to prevent repeated failed requests
setLineSublineCache(prev => ({ ...prev, [lineId]: [] }));
// Update rows with empty array
const updates: Record<string, any[]> = {};
rowIds.forEach(rowId => {
updates[rowId] = [];
});
setRowSublines(prev => ({ ...prev, ...updates }));
// Show error toast
toast.error(`Failed to load sublines for line ${lineId}`);
} finally {
// Clear pending flag for line
pendingLineRequests.current.delete(lineId);
// Clear the safety timeout
clearTimeout(timeoutId);
// Clear loading state for all affected rows
const clearLoadingUpdates: Record<string, boolean> = {};
rowIds.forEach(rowId => {
clearLoadingUpdates[rowId] = false;
});
setIsLoadingSublines(prev => ({ ...prev, ...clearLoadingUpdates }));
}
})();
fetchPromises.push(fetchPromise);
});
// Set initial loading states
if (Object.keys(lineLoadingUpdates).length > 0) {
console.log(`Setting loading state for ${Object.keys(lineLoadingUpdates).length} rows (product lines)`);
setIsLoadingLines(prev => ({ ...prev, ...lineLoadingUpdates }));
}
if (Object.keys(sublineLoadingUpdates).length > 0) {
console.log(`Setting loading state for ${Object.keys(sublineLoadingUpdates).length} rows (sublines)`);
setIsLoadingSublines(prev => ({ ...prev, ...sublineLoadingUpdates }));
}
// Run all fetch operations in parallel
Promise.all(fetchPromises).then(() => {
console.log("All product lines and sublines fetch operations completed");
}).catch(error => {
console.error('Error in fetch operations:', error);
});
}, [data, rowProductLines, rowSublines, companyLinesCache, lineSublineCache]);
return {
rowProductLines,
rowSublines,
isLoadingLines,
isLoadingSublines,
fetchProductLines,
fetchSublines
};
};

View File

@@ -1,500 +0,0 @@
import { useCallback, useMemo } from 'react';
import { RowData } from './validationTypes';
import type { Field, Fields } from '../../../types';
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
import { useUniqueValidation } from './useUniqueValidation';
import { isEmpty } from './validationTypes';
export const useRowOperations = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
validateFieldFromHook: (value: any, field: Field<T>) => ValidationError[]
) => {
// Uniqueness validation utilities
const { validateUniqueField } = useUniqueValidation<T>(fields);
// Determine which field keys are considered uniqueness-constrained
const uniquenessFieldKeys = useMemo(() => {
const keys = new Set<string>([
'item_number',
'upc',
'barcode',
'supplier_no',
'notions_no',
'name'
]);
fields.forEach((f) => {
if (f.validations?.some((v) => v.rule === 'unique')) {
keys.add(String(f.key));
}
});
return keys;
}, [fields]);
// Merge per-field uniqueness errors into the validation error map
const mergeUniqueErrorsForFields = useCallback(
(
baseErrors: Map<number, Record<string, ValidationError[]>>,
dataForCalc: RowData<T>[],
fieldKeysToCheck: string[]
) => {
if (!fieldKeysToCheck.length) return baseErrors;
const newErrors = new Map(baseErrors);
// For each field, compute duplicates and merge
fieldKeysToCheck.forEach((fieldKey) => {
if (!uniquenessFieldKeys.has(fieldKey)) return;
// Compute unique errors for this single field
const uniqueMap = validateUniqueField(dataForCalc, fieldKey);
// Rows that currently have uniqueness errors for this field
const rowsWithUniqueErrors = new Set<number>();
uniqueMap.forEach((_, rowIdx) => rowsWithUniqueErrors.add(rowIdx));
// First, apply/overwrite unique errors for rows that have duplicates
uniqueMap.forEach((errorsForRow, rowIdx) => {
const existing = { ...(newErrors.get(rowIdx) || {}) };
// Convert InfoWithSource to ValidationError[] for this field
const info = errorsForRow[fieldKey];
// Only apply uniqueness error when the value is non-empty
const currentValue = (dataForCalc[rowIdx] as any)?.[fieldKey];
if (info && !isEmpty(currentValue)) {
existing[fieldKey] = [
{
message: info.message,
level: info.level,
source: info.source ?? ErrorSources.Table,
type: info.type ?? ErrorType.Unique
}
];
}
if (Object.keys(existing).length > 0) newErrors.set(rowIdx, existing);
else newErrors.delete(rowIdx);
});
// Then, remove any stale unique errors for this field where duplicates are resolved
newErrors.forEach((rowErrs, rowIdx) => {
// Skip rows that still have unique errors for this field
if (rowsWithUniqueErrors.has(rowIdx)) return;
if ((rowErrs as any)[fieldKey]) {
const filtered = (rowErrs as any)[fieldKey].filter((e: ValidationError) => e.type !== ErrorType.Unique);
if (filtered.length > 0) (rowErrs as any)[fieldKey] = filtered;
else delete (rowErrs as any)[fieldKey];
if (Object.keys(rowErrs).length > 0) newErrors.set(rowIdx, rowErrs);
else newErrors.delete(rowIdx);
}
});
});
return newErrors;
},
[uniquenessFieldKeys, validateUniqueField]
);
// Helper function to validate a field value
const fieldValidationHelper = useCallback(
(rowIndex: number, specificField?: string) => {
// Skip validation if row doesn't exist
if (rowIndex < 0 || rowIndex >= data.length) return;
// Get the row data
const row = data[rowIndex];
// If validating a specific field, only check that field
if (specificField) {
const field = fields.find((f) => String(f.key) === specificField);
if (field) {
const value = row[specificField as keyof typeof row];
// Use state setter instead of direct mutation
setValidationErrors((prev) => {
let newErrors = new Map(prev);
const existingErrors = { ...(newErrors.get(rowIndex) || {}) };
// Quick check for required fields - this prevents flashing errors
const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
value === undefined ||
value === null ||
value === "" ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === "object" &&
value !== null &&
Object.keys(value).length === 0);
// For non-empty values, remove required errors immediately
if (isRequired && !isEmpty && existingErrors[specificField]) {
const nonRequiredErrors = existingErrors[specificField].filter(
(e) => e.type !== ErrorType.Required
);
if (nonRequiredErrors.length === 0) {
// If no other errors, remove the field entirely from errors
delete existingErrors[specificField];
} else {
existingErrors[specificField] = nonRequiredErrors;
}
}
// Run full validation for the field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update validation errors for this field
if (errors.length > 0) {
existingErrors[specificField] = errors;
} else {
delete existingErrors[specificField];
}
// Update validation errors map
if (Object.keys(existingErrors).length > 0) {
newErrors.set(rowIndex, existingErrors);
} else {
newErrors.delete(rowIndex);
}
// If field is uniqueness-constrained, also re-validate uniqueness for the column
if (uniquenessFieldKeys.has(specificField)) {
const dataForCalc = data; // latest data
newErrors = mergeUniqueErrorsForFields(newErrors, dataForCalc, [specificField]);
}
return newErrors;
});
}
} else {
// Validate all fields in the row
setValidationErrors((prev) => {
const newErrors = new Map(prev);
const rowErrors: Record<string, ValidationError[]> = {};
fields.forEach((field) => {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
});
// Update validation errors map
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
}
},
[data, fields, validateFieldFromHook, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// Use validateRow as an alias for fieldValidationHelper for compatibility
const validateRow = fieldValidationHelper;
// Modified updateRow function that properly handles field-specific validation
const updateRow = useCallback(
(rowIndex: number, key: T, value: any) => {
// Process value before updating data
let processedValue = value;
// Strip dollar signs from price fields
if (
(key === "msrp" || key === "cost_each") &&
typeof value === "string"
) {
processedValue = value.replace(/[$,]/g, "");
// Also ensure it's a valid number
const numValue = parseFloat(processedValue);
if (!isNaN(numValue)) {
processedValue = numValue.toFixed(2);
}
}
// Find the row data first
const rowData = data[rowIndex];
if (!rowData) {
console.error(`No row data found for index ${rowIndex}`);
return;
}
// Create a copy of the row to avoid mutation
const updatedRow = { ...rowData, [key]: processedValue };
// Update the data immediately - this sets the value
setData((prevData) => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = updatedRow;
}
return newData;
});
// Find the field definition
const field = fields.find((f) => String(f.key) === key);
if (!field) return;
// CRITICAL FIX: Combine both validation operations into a single state update
// to prevent intermediate rendering that causes error icon flashing
setValidationErrors((prev) => {
// Start with previous errors
let newMap = new Map(prev);
const existingErrors = newMap.get(rowIndex) || {};
const newRowErrors = { ...existingErrors };
// Check for required field first
const isRequired = field.validations?.some(
(v) => v.rule === "required"
);
const isEmpty =
processedValue === undefined ||
processedValue === null ||
processedValue === "" ||
(Array.isArray(processedValue) && processedValue.length === 0) ||
(typeof processedValue === "object" &&
processedValue !== null &&
Object.keys(processedValue).length === 0);
// For required fields with values, remove required errors
if (isRequired && !isEmpty && newRowErrors[key as string]) {
const hasRequiredError = newRowErrors[key as string].some(
(e) => e.type === ErrorType.Required
);
if (hasRequiredError) {
// Remove required errors but keep other types of errors
const nonRequiredErrors = newRowErrors[key as string].filter(
(e) => e.type !== ErrorType.Required
);
if (nonRequiredErrors.length === 0) {
// If no other errors, delete the field's errors entirely
delete newRowErrors[key as string];
} else {
// Otherwise keep non-required errors
newRowErrors[key as string] = nonRequiredErrors;
}
}
}
// Now run full validation for the field (except for required which we already handled)
const errors = validateFieldFromHook(
processedValue,
field as unknown as Field<T>
).filter((e) => e.type !== ErrorType.Required || isEmpty);
// Update with new validation results
if (errors.length > 0) {
newRowErrors[key as string] = errors;
} else {
// Clear any existing errors for this field
delete newRowErrors[key as string];
}
// Update the map
if (Object.keys(newRowErrors).length > 0) {
newMap.set(rowIndex, newRowErrors);
} else {
newMap.delete(rowIndex);
}
// If uniqueness applies, validate affected columns
const fieldsToCheck: string[] = [];
if (uniquenessFieldKeys.has(String(key))) fieldsToCheck.push(String(key));
if (key === ('upc' as T) || key === ('barcode' as T) || key === ('supplier' as T)) {
if (uniquenessFieldKeys.has('item_number')) fieldsToCheck.push('item_number');
}
if (fieldsToCheck.length > 0) {
const dataForCalc = (() => {
const copy = [...data];
if (rowIndex >= 0 && rowIndex < copy.length) {
copy[rowIndex] = { ...(copy[rowIndex] || {}), [key]: processedValue } as RowData<T>;
}
return copy;
})();
newMap = mergeUniqueErrorsForFields(newMap, dataForCalc, fieldsToCheck);
}
return newMap;
});
// Handle simple secondary effects here
setTimeout(() => {
// Use __index to find the actual row in the full data array
const rowId = rowData.__index;
// Handle company change - clear line/subline
if (key === "company" && processedValue) {
// Clear any existing line/subline values
setData((prevData) => {
const newData = [...prevData];
const idx = newData.findIndex((item) => item.__index === rowId);
if (idx >= 0) {
newData[idx] = {
...newData[idx],
line: undefined,
subline: undefined,
};
}
return newData;
});
}
// Handle line change - clear subline
if (key === "line" && processedValue) {
// Clear any existing subline value
setData((prevData) => {
const newData = [...prevData];
const idx = newData.findIndex((item) => item.__index === rowId);
if (idx >= 0) {
newData[idx] = {
...newData[idx],
subline: undefined,
};
}
return newData;
});
}
}, 5); // Reduced delay for faster secondary effects
},
[data, fields, validateFieldFromHook, setData, setValidationErrors, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// Improved revalidateRows function
const revalidateRows = useCallback(
async (
rowIndexes: number[],
updatedFields?: { [rowIndex: number]: string[] },
dataOverride?: RowData<T>[]
) => {
// Process all specified rows using a single state update to avoid race conditions
setValidationErrors((prev) => {
let newErrors = new Map(prev);
const workingData = dataOverride ?? data;
// Track which uniqueness fields need to be revalidated across the dataset
const uniqueFieldsToCheck = new Set<string>();
// Process each row
for (const rowIndex of rowIndexes) {
if (rowIndex < 0 || rowIndex >= workingData.length) continue;
const row = workingData[rowIndex];
if (!row) continue;
// If we have specific fields to update for this row
const fieldsToValidate = updatedFields?.[rowIndex] || [];
if (fieldsToValidate.length > 0) {
// Get existing errors for this row
const existingRowErrors = { ...(newErrors.get(rowIndex) || {}) };
// Validate each specified field
for (const fieldKey of fieldsToValidate) {
const field = fields.find((f) => String(f.key) === fieldKey);
if (!field) continue;
const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) {
existingRowErrors[fieldKey] = errors;
} else {
delete existingRowErrors[fieldKey];
}
// If field is uniqueness-constrained, mark for uniqueness pass
if (uniquenessFieldKeys.has(fieldKey)) {
uniqueFieldsToCheck.add(fieldKey);
}
}
// Update the row's errors
if (Object.keys(existingRowErrors).length > 0) {
newErrors.set(rowIndex, existingRowErrors);
} else {
newErrors.delete(rowIndex);
}
} else {
// No specific fields provided - validate the entire row
const rowErrors: Record<string, ValidationError[]> = {};
// Validate all fields in the row
for (const field of fields) {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
// Run validation for this field
const errors = validateFieldFromHook(value, field as unknown as Field<T>);
// Update errors for this field
if (errors.length > 0) {
rowErrors[fieldKey] = errors;
}
// If field is uniqueness-constrained and we validated it, include for uniqueness pass
if (uniquenessFieldKeys.has(fieldKey)) {
uniqueFieldsToCheck.add(fieldKey);
}
}
// Update the row's errors
if (Object.keys(rowErrors).length > 0) {
newErrors.set(rowIndex, rowErrors);
} else {
newErrors.delete(rowIndex);
}
}
}
// Run per-field uniqueness checks and merge results
if (uniqueFieldsToCheck.size > 0) {
newErrors = mergeUniqueErrorsForFields(newErrors, workingData, Array.from(uniqueFieldsToCheck));
}
return newErrors;
});
},
[data, fields, validateFieldFromHook, mergeUniqueErrorsForFields, uniquenessFieldKeys]
);
// Copy a cell value to all cells below it in the same column
const copyDown = useCallback(
(rowIndex: number, key: T) => {
// Get the source value to copy
const sourceValue = data[rowIndex][key];
// Update all rows below with the same value using the existing updateRow function
// This ensures all validation logic runs consistently
for (let i = rowIndex + 1; i < data.length; i++) {
// Just use updateRow which will handle validation with proper timing
updateRow(i, key, sourceValue);
}
},
[data, updateRow]
);
return {
validateRow,
updateRow,
revalidateRows,
copyDown
};
};

View File

@@ -1,516 +0,0 @@
import { useState, useCallback } from 'react';
import { toast } from 'sonner';
import { Template, RowData, TemplateState, getApiUrl } from './validationTypes';
import { RowSelectionState } from '@tanstack/react-table';
import { ValidationError } from '../../../types';
export const useTemplateManagement = <T extends string>(
data: RowData<T>[],
setData: React.Dispatch<React.SetStateAction<RowData<T>[]>>,
rowSelection: RowSelectionState,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>,
setRowValidationStatus: React.Dispatch<React.SetStateAction<Map<number, "pending" | "validating" | "validated" | "error">>>,
validateRow: (rowIndex: number, specificField?: string) => void,
isApplyingTemplateRef: React.MutableRefObject<boolean>,
upcValidation: {
validateUpc: (rowIndex: number, supplierId: string, upcValue: string) => Promise<{success: boolean, itemNumber?: string}>,
applyItemNumbersToData: (onApplied?: (updatedRowIds: number[]) => void) => void
},
setValidatingCells?: React.Dispatch<React.SetStateAction<Set<string>>>
) => {
// Template state
const [templates, setTemplates] = useState<Template[]>([]);
const [isLoadingTemplates, setIsLoadingTemplates] = useState(true);
const [templateState, setTemplateState] = useState<TemplateState>({
selectedTemplateId: null,
showSaveTemplateDialog: false,
newTemplateName: "",
newTemplateType: "",
});
// Load templates
const loadTemplates = useCallback(async () => {
try {
setIsLoadingTemplates(true);
console.log("Fetching templates from:", `${getApiUrl()}/templates`);
const response = await fetch(`${getApiUrl()}/templates`);
if (!response.ok) throw new Error("Failed to fetch templates");
const templateData = await response.json();
const validTemplates = templateData.filter(
(t: any) =>
t && typeof t === "object" && t.id && t.company && t.product_type
);
setTemplates(validTemplates);
} catch (error) {
console.error("Error fetching templates:", error);
toast.error("Failed to load templates");
} finally {
setIsLoadingTemplates(false);
}
}, []);
// Refresh templates
const refreshTemplates = useCallback(() => {
loadTemplates();
}, [loadTemplates]);
// Save a new template
const saveTemplate = useCallback(
async (name: string, type: string) => {
try {
// Get selected rows
const selectedRowIndex = Number(Object.keys(rowSelection)[0]);
const selectedRow = data[selectedRowIndex];
if (!selectedRow) {
toast.error("Please select a row to create a template");
return;
}
// Extract data for template, removing metadata fields
const {
__index,
__template,
__original,
__corrected,
__changes,
...templateData
} = selectedRow as any;
// Clean numeric values (remove $ from price fields)
const cleanedData: Record<string, any> = {};
// Process each key-value pair
Object.entries(templateData).forEach(([key, value]) => {
// Handle numeric values with dollar signs
if (typeof value === "string" && value.includes("$")) {
cleanedData[key] = value.replace(/[$,\s]/g, "").trim();
}
// Handle array values (like categories or ship_restrictions)
else if (Array.isArray(value)) {
cleanedData[key] = value;
}
// Handle other values
else {
cleanedData[key] = value;
}
});
// Send the template to the API
const response = await fetch(`${getApiUrl()}/templates`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
...cleanedData,
company: name,
product_type: type,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(
errorData.error || errorData.details || "Failed to save template"
);
}
// Get the new template from the response
const newTemplate = await response.json();
// Update the templates list with the new template
setTemplates((prev) => [...prev, newTemplate]);
// Update the row to show it's using this template
setData((prev) => {
const newData = [...prev];
if (newData[selectedRowIndex]) {
newData[selectedRowIndex] = {
...newData[selectedRowIndex],
__template: newTemplate.id.toString(),
};
}
return newData;
});
toast.success(`Template "${name}" saved successfully`);
// Reset dialog state
setTemplateState((prev) => ({
...prev,
showSaveTemplateDialog: false,
newTemplateName: "",
newTemplateType: "",
}));
} catch (error) {
console.error("Error saving template:", error);
toast.error(
error instanceof Error ? error.message : "Failed to save template"
);
}
},
[data, rowSelection, setData]
);
// Apply template to rows - optimized version
const applyTemplate = useCallback(
(templateId: string, rowIndexes: number[]) => {
const template = templates.find((t) => t.id.toString() === templateId);
if (!template) {
toast.error("Template not found");
return;
}
console.log(`Applying template ${templateId} to rows:`, rowIndexes);
// Validate row indexes
const validRowIndexes = rowIndexes.filter(
(index) => index >= 0 && index < data.length && Number.isInteger(index)
);
if (validRowIndexes.length === 0) {
toast.error("No valid rows to update");
console.error("Invalid row indexes:", rowIndexes);
return;
}
// Set the template application flag
isApplyingTemplateRef.current = true;
// Save scroll position
const scrollPosition = {
left: window.scrollX,
top: window.scrollY,
};
// Create a copy of data and process all rows at once to minimize state updates
const newData = [...data];
const batchErrors = new Map<number, Record<string, ValidationError[]>>();
const batchStatuses = new Map<
number,
"pending" | "validating" | "validated" | "error"
>();
// Extract template fields once outside the loop
const templateFields = Object.entries(template).filter(
([key]) =>
![
"id",
"__meta",
"__template",
"__original",
"__corrected",
"__changes",
].includes(key)
);
// Apply template to each valid row
validRowIndexes.forEach((index) => {
// Create a new row with template values
const originalRow = newData[index];
const updatedRow = { ...originalRow } as Record<string, any>;
// Apply template fields (excluding metadata fields)
for (const [key, value] of templateFields) {
updatedRow[key] = value;
}
// Mark the row as using this template
updatedRow.__template = templateId;
// Update the row in the data array
newData[index] = updatedRow as RowData<T>;
// Clear validation errors and mark as validated
batchErrors.set(index, {});
batchStatuses.set(index, "validated");
});
// Check which rows need UPC validation
const upcValidationRows = validRowIndexes.filter((rowIndex) => {
const row = newData[rowIndex];
return row && row.upc && row.supplier;
});
// Perform a single update for all rows
setData(newData);
// Update all validation errors and statuses at once
setValidationErrors((prev) => {
const newErrors = new Map(prev);
for (const [rowIndex, errors] of batchErrors.entries()) {
newErrors.set(rowIndex, errors);
}
return newErrors;
});
setRowValidationStatus((prev) => {
const newStatus = new Map(prev);
for (const [rowIndex, status] of batchStatuses.entries()) {
newStatus.set(rowIndex, status);
}
return newStatus;
});
// Restore scroll position
requestAnimationFrame(() => {
window.scrollTo(scrollPosition.left, scrollPosition.top);
});
// Show success toast
if (validRowIndexes.length === 1) {
toast.success("Template applied");
} else if (validRowIndexes.length > 1) {
toast.success(`Template applied to ${validRowIndexes.length} rows`);
}
// Reset template application flag to allow validation
isApplyingTemplateRef.current = false;
// If there are rows with both UPC and supplier, validate them
if (upcValidationRows.length > 0) {
console.log(`Validating UPCs for ${upcValidationRows.length} rows after template application`);
// Process each row sequentially - this mimics the exact manual edit behavior
const processNextValidation = (index = 0) => {
if (index >= upcValidationRows.length) {
return; // All rows processed
}
const rowIndex = upcValidationRows[index];
const row = newData[rowIndex];
if (row && row.supplier && row.upc) {
// The EXACT implementation from handleUpdateRow when supplier is edited manually:
// 1. Mark the item_number cell as being validated - THIS IS CRITICAL FOR LOADING STATE
const cellKey = `${rowIndex}-item_number`;
// Clear validation errors for this field
setValidationErrors(prev => {
const newErrors = new Map(prev);
if (newErrors.has(rowIndex)) {
const rowErrors = { ...newErrors.get(rowIndex) };
if (rowErrors.item_number) {
delete rowErrors.item_number;
}
newErrors.set(rowIndex, rowErrors);
}
return newErrors;
});
// Set loading state - using setValidatingCells from props
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.add(cellKey);
return newSet;
});
}
// Validate UPC for this row
upcValidation.validateUpc(rowIndex, row.supplier.toString(), row.upc.toString())
.then(result => {
if (result.success && result.itemNumber) {
// CRITICAL FIX: Directly update data with the item number to ensure immediate UI update
setData(prevData => {
const newData = [...prevData];
// Update this specific row with the item number
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: result.itemNumber
};
}
return newData;
});
// Also trigger other relevant updates
upcValidation.applyItemNumbersToData();
// Mark for revalidation after item numbers are updated
setTimeout(() => {
// Validate the row EXACTLY like in manual edit
validateRow(rowIndex, 'item_number');
// CRITICAL FIX: Make one final check to ensure data is correct
setTimeout(() => {
// Get the current item number from the data
const currentItemNumber = (() => {
try {
const dataAtThisPointInTime = data[rowIndex];
return dataAtThisPointInTime?.item_number;
} catch (e) {
return undefined;
}
})();
// If the data is wrong at this point, fix it directly
if (currentItemNumber !== result.itemNumber) {
// Directly update the data to fix the issue
setData(dataRightNow => {
const fixedData = [...dataRightNow];
if (rowIndex >= 0 && rowIndex < fixedData.length) {
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
}
return fixedData;
});
// Then do a force update after a brief delay
setTimeout(() => {
setData(currentData => {
// Critical fix: ensure the item number is correct
if (currentData[rowIndex] && currentData[rowIndex].item_number !== result.itemNumber) {
// Create a completely new array with the correct item number
const fixedData = [...currentData];
fixedData[rowIndex] = {
...fixedData[rowIndex],
item_number: result.itemNumber
};
return fixedData;
}
// Create a completely new array
return [...currentData];
});
}, 20);
} else {
// Item number is already correct, just do the force update
setData(currentData => {
// Create a completely new array
return [...currentData];
});
}
}, 50);
// Clear loading state
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row after validation is complete
setTimeout(() => processNextValidation(index + 1), 100);
}, 100);
} else {
// Clear loading state on failure
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row if validation fails
setTimeout(() => processNextValidation(index + 1), 100);
}
})
.catch(err => {
console.error(`Error validating UPC for row ${rowIndex}:`, err);
// Clear loading state on error
if (setValidatingCells) {
setValidatingCells(prev => {
const newSet = new Set(prev);
newSet.delete(cellKey);
return newSet;
});
}
// Continue to next row despite error
setTimeout(() => processNextValidation(index + 1), 100);
});
} else {
// Skip this row and continue to the next
processNextValidation(index + 1);
}
};
// Start processing validations
processNextValidation();
}
},
[
data,
templates,
setData,
setValidationErrors,
setRowValidationStatus,
validateRow,
upcValidation,
setValidatingCells
]
);
// Apply template to selected rows
const applyTemplateToSelected = useCallback(
(templateId: string) => {
if (!templateId) return;
// Update the selected template ID
setTemplateState((prev) => ({
...prev,
selectedTemplateId: templateId,
}));
// Get selected row keys (which may be UUIDs)
const selectedKeys = Object.entries(rowSelection)
.filter(([_, selected]) => selected === true)
.map(([key, _]) => key);
console.log("Selected row keys:", selectedKeys);
if (selectedKeys.length === 0) {
toast.error("No rows selected");
return;
}
// Map UUID keys to array indices
const selectedIndexes = selectedKeys
.map((key) => {
// Find the matching row index in the data array
const index = data.findIndex(
(row) =>
(row.__index && row.__index === key) || // Match by __index
String(data.indexOf(row)) === key // Or by numeric index
);
return index;
})
.filter((index) => index !== -1); // Filter out any not found
console.log("Mapped row indices:", selectedIndexes);
if (selectedIndexes.length === 0) {
toast.error("Could not find selected rows");
return;
}
// Apply template to selected rows
applyTemplate(templateId, selectedIndexes);
},
[rowSelection, applyTemplate, setTemplateState, data]
);
return {
templates,
isLoadingTemplates,
templateState,
setTemplateState,
loadTemplates,
refreshTemplates,
saveTemplate,
applyTemplate,
applyTemplateToSelected
};
};

View File

@@ -1,158 +0,0 @@
import { useCallback } from 'react';
import { RowData } from './validationTypes';
import type { Fields } from '../../../types';
import { ErrorSources, ErrorType, ValidationError } from '../../../types';
export const useUniqueItemNumbersValidation = <T extends string>(
data: RowData<T>[],
fields: Fields<T>,
setValidationErrors: React.Dispatch<React.SetStateAction<Map<number, Record<string, ValidationError[]>>>>
) => {
// Update validateUniqueItemNumbers to also check for uniqueness of UPC/barcode
const validateUniqueItemNumbers = useCallback(async () => {
// Skip if no data
if (!data.length) return;
// Track unique identifiers in maps
const uniqueFieldsMap = new Map<string, Map<string, number[]>>();
// Find fields that need uniqueness validation
const uniqueFields = fields
.filter((field) => field.validations?.some((v) => v.rule === "unique"))
.map((field) => String(field.key));
// Always check item_number uniqueness even if not explicitly defined
if (!uniqueFields.includes("item_number")) {
uniqueFields.push("item_number");
}
// Initialize maps for each unique field
uniqueFields.forEach((fieldKey) => {
uniqueFieldsMap.set(fieldKey, new Map<string, number[]>());
});
// Initialize batch updates
const errors = new Map<number, Record<string, ValidationError[]>>();
// ASYNC: Single pass through data to identify all unique values in batches
const BATCH_SIZE = 20;
for (let batchStart = 0; batchStart < data.length; batchStart += BATCH_SIZE) {
const batchEnd = Math.min(batchStart + BATCH_SIZE, data.length);
for (let index = batchStart; index < batchEnd; index++) {
const row = data[index];
uniqueFields.forEach((fieldKey) => {
const value = row[fieldKey as keyof typeof row];
// Skip empty values
if (value === undefined || value === null || value === "") {
return;
}
const valueStr = String(value);
const fieldMap = uniqueFieldsMap.get(fieldKey);
if (fieldMap) {
// Get or initialize the array of indices for this value
const indices = fieldMap.get(valueStr) || [];
indices.push(index);
fieldMap.set(valueStr, indices);
}
});
}
// Yield control back to UI thread after each batch
if (batchEnd < data.length) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// ASYNC: Process duplicates in batches to prevent UI blocking
let processedFields = 0;
for (const fieldKey of uniqueFields) {
const fieldMap = uniqueFieldsMap.get(fieldKey);
if (!fieldMap) continue;
fieldMap.forEach((indices, value) => {
// Only process if there are duplicates
if (indices.length > 1) {
// Get the validation rule for this field
const field = fields.find((f) => String(f.key) === fieldKey);
const validationRule = field?.validations?.find(
(v) => v.rule === "unique"
);
const errorObj = {
message:
validationRule?.errorMessage || `Duplicate ${fieldKey}: ${value}`,
level: validationRule?.level || ("error" as "error"),
source: ErrorSources.Table,
type: ErrorType.Unique,
};
// Add error to each row with this value
indices.forEach((rowIndex) => {
const rowErrors = errors.get(rowIndex) || {};
rowErrors[fieldKey] = [errorObj];
errors.set(rowIndex, rowErrors);
});
}
});
processedFields++;
// Yield control after every few fields to prevent UI blocking
if (processedFields % 2 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
// Merge uniqueness errors with existing validation errors
setValidationErrors((prev) => {
const newMap = new Map(prev);
// Add uniqueness errors
errors.forEach((rowErrors, rowIndex) => {
const existingErrors = newMap.get(rowIndex) || {};
const updatedErrors = { ...existingErrors };
// Add uniqueness errors to existing errors
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
updatedErrors[fieldKey] = fieldErrors;
});
newMap.set(rowIndex, updatedErrors);
});
// Clean up rows that have no uniqueness errors anymore
// by removing only uniqueness error types from rows not in the errors map
newMap.forEach((rowErrors, rowIndex) => {
if (!errors.has(rowIndex)) {
// Remove uniqueness errors from this row
const cleanedErrors: Record<string, ValidationError[]> = {};
Object.entries(rowErrors).forEach(([fieldKey, fieldErrors]) => {
// Keep non-uniqueness errors
const nonUniqueErrors = fieldErrors.filter(error => error.type !== ErrorType.Unique);
if (nonUniqueErrors.length > 0) {
cleanedErrors[fieldKey] = nonUniqueErrors;
}
});
// Update the row or remove it if no errors remain
if (Object.keys(cleanedErrors).length > 0) {
newMap.set(rowIndex, cleanedErrors);
} else {
newMap.delete(rowIndex);
}
}
});
return newMap;
});
console.log("Uniqueness validation complete");
}, [data, fields, setValidationErrors]);
return {
validateUniqueItemNumbers
};
};

View File

@@ -1,131 +0,0 @@
import { useCallback } from 'react';
import type { Fields } from '../../../types';
import { ErrorSources, ErrorType } from '../../../types';
import { RowData, InfoWithSource, isEmpty } from './validationTypes';
export const useUniqueValidation = <T extends string>(
fields: Fields<T>
) => {
// Additional function to explicitly validate uniqueness for specified fields
const validateUniqueField = useCallback((data: RowData<T>[], fieldKey: string) => {
// Field keys that need special handling for uniqueness
const uniquenessFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// If the field doesn't need uniqueness validation, return empty errors
if (!uniquenessFields.includes(fieldKey)) {
const field = fields.find(f => String(f.key) === fieldKey);
if (!field || !field.validations?.some(v => v.rule === 'unique')) {
return new Map<number, Record<string, InfoWithSource>>();
}
}
// Create map to track errors
const uniqueErrors = new Map<number, Record<string, InfoWithSource>>();
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (!field) return uniqueErrors;
// Get validation properties
const validation = field.validations?.find(v => v.rule === 'unique');
const allowEmpty = validation?.allowEmpty ?? false;
const errorMessage = validation?.errorMessage || `${field.label} must be unique`;
const level = validation?.level || 'error';
// Track values for uniqueness check
const valueMap = new Map<string, number[]>();
// Build value map
data.forEach((row, rowIndex) => {
const value = String(row[fieldKey as keyof typeof row] || '');
// Skip empty values if allowed
if (allowEmpty && isEmpty(value)) {
return;
}
if (!valueMap.has(value)) {
valueMap.set(value, [rowIndex]);
} else {
valueMap.get(value)?.push(rowIndex);
}
});
// Add errors for duplicate values
valueMap.forEach((rowIndexes, value) => {
if (rowIndexes.length > 1) {
// Skip empty values
if (!value || value.trim() === '') return;
// Add error to all duplicate rows
rowIndexes.forEach(rowIndex => {
// Create errors object if needed
if (!uniqueErrors.has(rowIndex)) {
uniqueErrors.set(rowIndex, {});
}
// Add error for this field
uniqueErrors.get(rowIndex)![fieldKey] = {
message: errorMessage,
level: level as 'info' | 'warning' | 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
});
}
});
return uniqueErrors;
}, [fields]);
// Validate uniqueness for multiple fields
const validateUniqueFields = useCallback((data: RowData<T>[], fieldKeys: string[]) => {
// Process each field and merge results
const allErrors = new Map<number, Record<string, InfoWithSource>>();
fieldKeys.forEach(fieldKey => {
const fieldErrors = validateUniqueField(data, fieldKey);
// Merge errors
fieldErrors.forEach((errors, rowIdx) => {
if (!allErrors.has(rowIdx)) {
allErrors.set(rowIdx, {});
}
Object.assign(allErrors.get(rowIdx)!, errors);
});
});
return allErrors;
}, [validateUniqueField]);
// Run complete validation for uniqueness
const validateAllUniqueFields = useCallback((data: RowData<T>[]) => {
// Get fields requiring uniqueness validation
const uniqueFields = fields
.filter(field => field.validations?.some(v => v.rule === 'unique'))
.map(field => String(field.key));
// Also add standard unique fields that might not be explicitly marked as unique
const standardUniqueFields = ['item_number', 'upc', 'barcode', 'supplier_no', 'notions_no'];
// Combine all fields that need uniqueness validation
const allUniqueFieldKeys = [...new Set([
...uniqueFields,
...standardUniqueFields
])];
// Filter to only fields that exist in the data
const existingFields = allUniqueFieldKeys.filter(fieldKey =>
data.some(row => fieldKey in row)
);
// Validate all fields at once
return validateUniqueFields(data, existingFields);
}, [fields, validateUniqueFields]);
return {
validateUniqueField,
validateUniqueFields,
validateAllUniqueFields
};
};

View File

@@ -1,595 +0,0 @@
import { useState, useCallback, useRef, useEffect, Dispatch, SetStateAction } from 'react'
import config from '@/config'
import { ErrorSources, ErrorType, ValidationError } from '../../../types'
interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key
validatingRows: Set<number>; // Rows currently being validated
activeValidations: Set<string>; // Active validations
}
export const useUpcValidation = (
data: any[],
setData: (updater: any[] | ((prevData: any[]) => any[])) => void,
setValidationErrors: Dispatch<SetStateAction<Map<number, Record<string, ValidationError[]>>>>
) => {
// Use a ref for validation state to avoid triggering re-renders
const validationStateRef = useRef<ValidationState>({
validatingCells: new Set(),
itemNumbers: new Map(),
validatingRows: new Set(),
activeValidations: new Set()
});
// Use state only for forcing re-renders of specific cells
const [, setValidatingCellKeys] = useState<Set<string>>(new Set());
const [, setItemNumberUpdates] = useState<Map<number, string>>(new Map());
const [validatingRows, setValidatingRows] = useState<Set<number>>(new Set());
const [, setIsValidatingUpc] = useState(false);
// Cache for UPC validation results
const processedUpcMapRef = useRef(new Map<string, string>());
const initialUpcValidationStartedRef = useRef(false);
const initialUpcValidationDoneRef = useRef(false);
// Helper to create cell key
const getCellKey = (rowIndex: number, fieldKey: string) => `${rowIndex}-${fieldKey}`;
// Start validating a cell
const startValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
const cellKey = getCellKey(rowIndex, fieldKey);
validationStateRef.current.validatingCells.add(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
}, []);
// Stop validating a cell
const stopValidatingCell = useCallback((rowIndex: number, fieldKey: string) => {
const cellKey = getCellKey(rowIndex, fieldKey);
validationStateRef.current.validatingCells.delete(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
}, []);
// Update item number
const updateItemNumber = useCallback((rowIndex: number, itemNumber: string) => {
// CRITICAL: Update BOTH the data state and the ref
// First, update the data directly to ensure UI consistency
setData(prevData => {
// Create a new copy of the data
const newData = [...prevData];
// Only update if the row exists
if (rowIndex >= 0 && rowIndex < newData.length) {
// First, we need a new object reference for the row to force a re-render
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
}
return newData;
});
// Also update the itemNumbers map AFTER the data is updated
// This ensures the map reflects the current state of the data
setTimeout(() => {
// Update the ref with the same value
validationStateRef.current.itemNumbers.set(rowIndex, itemNumber);
// CRITICAL: Force a React state update to ensure all components re-render
// Created a brand new Map object to ensure React detects the change
const newItemNumbersMap = new Map(validationStateRef.current.itemNumbers);
setItemNumberUpdates(newItemNumbersMap);
// Force an immediate React render cycle by triggering state updates
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
setValidatingRows(new Set(validationStateRef.current.validatingRows));
}, 0);
}, [setData]);
const applyUpcUniqueError = useCallback((rowIndex: number, message?: string) => {
const error: ValidationError = {
message: message || 'Must be unique',
level: 'error',
source: ErrorSources.Table,
type: ErrorType.Unique
};
setValidationErrors(prev => {
const newErrors = new Map(prev);
const existing = { ...(newErrors.get(rowIndex) || {}) };
existing.upc = [error];
newErrors.set(rowIndex, existing);
return newErrors;
});
}, [setValidationErrors]);
const clearUpcUniqueError = useCallback((rowIndex: number) => {
setValidationErrors(prev => {
const existing = prev.get(rowIndex);
if (!existing || !existing.upc) {
return prev;
}
const filtered = existing.upc.filter(err => err.type !== ErrorType.Unique);
if (filtered.length === existing.upc.length) {
return prev;
}
const newErrors = new Map(prev);
const updated = { ...existing } as Record<string, ValidationError[]>;
if (filtered.length > 0) {
updated.upc = filtered;
} else {
delete updated.upc;
}
if (Object.keys(updated).length > 0) {
newErrors.set(rowIndex, updated);
} else {
newErrors.delete(rowIndex);
}
return newErrors;
});
}, [setValidationErrors]);
// Mark a row as no longer being validated
const stopValidatingRow = useCallback((rowIndex: number) => {
validationStateRef.current.validatingRows.delete(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
// If no more rows are being validated, set global validation state to false
if (validationStateRef.current.validatingRows.size === 0) {
setIsValidatingUpc(false);
}
}, []);
// Check if a specific cell is being validated
const isValidatingCell = useCallback((rowIndex: number, fieldKey: string): boolean => {
return validationStateRef.current.validatingCells.has(getCellKey(rowIndex, fieldKey));
}, []);
// Check if a specific row is being validated
const isRowValidatingUpc = useCallback((rowIndex: number): boolean => {
return validationStateRef.current.validatingRows.has(rowIndex);
}, []);
// Get item number for a row
const getItemNumber = useCallback((rowIndex: number): string | undefined => {
return validationStateRef.current.itemNumbers.get(rowIndex);
}, []);
// Fetch product by UPC from API
const fetchProductByUpc = useCallback(async (supplierId: string, upcValue: string) => {
try {
console.log(`Fetching product for UPC ${upcValue} with supplier ${supplierId}`);
const response = await fetch(`${config.apiUrl}/import/check-upc-and-generate-sku?upc=${encodeURIComponent(upcValue)}&supplierId=${encodeURIComponent(supplierId)}`);
let payload: any = null;
try {
payload = await response.json();
} catch (parseError) {
// Non-JSON responses are treated generically below
}
if (response.status === 409) {
console.log(`UPC ${upcValue} already exists`);
return {
error: true,
code: 'conflict',
message: payload?.error || 'UPC already exists',
data: payload || undefined
};
}
if (!response.ok) {
console.error(`API error: ${response.status}`);
return {
error: true,
code: 'http_error',
message: payload?.error || `API error (${response.status})`,
data: payload || undefined
};
}
const data = payload;
if (!data?.success) {
return {
error: true,
code: 'invalid_response',
message: data?.message || 'Unknown error'
};
}
return {
error: false,
data: {
itemNumber: data.itemNumber || '',
...data
}
};
} catch (error) {
console.error('Network error:', error);
return { error: true, message: 'Network error' };
}
}, []);
// Validate a UPC for a row - returns a promise that resolves when complete
const validateUpc = useCallback(async (rowIndex: number, supplierId: string, upcValue: string) => {
// Clear any previous validation keys for this row to avoid cancellations
const previousKeys = Array.from(validationStateRef.current.activeValidations).filter(key =>
key.startsWith(`${rowIndex}-`)
);
previousKeys.forEach(key => validationStateRef.current.activeValidations.delete(key));
// Log validation start to help debug template issues
console.log(`[UPC-DEBUG] Starting UPC validation for row ${rowIndex} with supplier ${supplierId}, upc ${upcValue}`);
// IMPORTANT: Set validation state using setState to FORCE UI updates
validationStateRef.current.validatingRows.add(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
setIsValidatingUpc(true);
// Start cell validation and explicitly update UI via setState
const cellKey = getCellKey(rowIndex, 'item_number');
validationStateRef.current.validatingCells.add(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Set loading state for row ${rowIndex}, cell key ${cellKey}`);
console.log(`[UPC-DEBUG] Current validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Current validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
try {
// Create a unique key for this validation to track it
const validationKey = `${rowIndex}-${supplierId}-${upcValue}`;
validationStateRef.current.activeValidations.add(validationKey);
// IMPORTANT: First update the data with the new UPC value to prevent UI flicker
// This ensures the UPC field keeps showing the new value while validation runs
setData(prevData => {
const newData = [...prevData];
if (newData[rowIndex]) {
newData[rowIndex] = {
...newData[rowIndex],
upc: upcValue
};
}
return newData;
});
// Fetch the product by UPC
console.log(`[UPC-DEBUG] Fetching product data for UPC ${upcValue} with supplier ${supplierId}`);
const product = await fetchProductByUpc(supplierId, upcValue);
console.log(`[UPC-DEBUG] Fetch complete for row ${rowIndex}, success: ${!product.error}`);
// Check if this validation is still relevant (hasn't been superseded by another)
if (!validationStateRef.current.activeValidations.has(validationKey)) {
console.log(`[UPC-DEBUG] Validation ${validationKey} was cancelled`);
return { success: false };
}
// Extract the item number from the API response - check for !error since API returns { error: boolean, data: any }
if (product && !product.error && product.data?.itemNumber) {
console.log(`[UPC-DEBUG] Got item number for row ${rowIndex}: ${product.data.itemNumber}`);
updateItemNumber(rowIndex, product.data.itemNumber);
clearUpcUniqueError(rowIndex);
return {
success: true,
itemNumber: product.data.itemNumber
};
} else if (product && product.error) {
console.log(`[UPC-DEBUG] UPC validation error for row ${rowIndex}: ${product.message}`);
// Clear any existing item number value in data and internal state
setData(prevData => {
const newData = [...prevData];
if (rowIndex >= 0 && rowIndex < newData.length) {
newData[rowIndex] = {
...newData[rowIndex],
item_number: ''
};
}
return newData;
});
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
validationStateRef.current.itemNumbers.delete(rowIndex);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
if (product.code === 'conflict') {
applyUpcUniqueError(rowIndex, 'Must be unique');
}
return { success: false };
} else {
// No item number found but validation was still attempted
console.log(`[UPC-DEBUG] No item number found for UPC ${upcValue}`);
// Clear any existing item number to show validation was attempted and failed
if (validationStateRef.current.itemNumbers.has(rowIndex)) {
validationStateRef.current.itemNumbers.delete(rowIndex);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
return { success: false };
}
} catch (error) {
console.error('[UPC-DEBUG] Error validating UPC:', error);
return { success: false };
} finally {
// End validation - FORCE UI update by using setState directly
console.log(`[UPC-DEBUG] Ending validation for row ${rowIndex}`);
validationStateRef.current.validatingRows.delete(rowIndex);
setValidatingRows(new Set(validationStateRef.current.validatingRows));
if (validationStateRef.current.validatingRows.size === 0) {
setIsValidatingUpc(false);
}
validationStateRef.current.validatingCells.delete(cellKey);
setValidatingCellKeys(new Set(validationStateRef.current.validatingCells));
console.log(`[UPC-DEBUG] Cleared loading state for row ${rowIndex}`);
console.log(`[UPC-DEBUG] Updated validating rows: ${Array.from(validationStateRef.current.validatingRows).join(', ')}`);
console.log(`[UPC-DEBUG] Updated validating cells: ${Array.from(validationStateRef.current.validatingCells).join(', ')}`);
}
}, [fetchProductByUpc, updateItemNumber, setData, applyUpcUniqueError, clearUpcUniqueError]);
// Apply all pending item numbers to the data state
const applyItemNumbersToData = useCallback((callback?: (updatedRows: number[]) => void) => {
// Skip if we have nothing to apply
if (validationStateRef.current.itemNumbers.size === 0) {
if (callback) callback([]);
return;
}
// Gather all row IDs that will be updated
const rowIds: number[] = [];
// Update the data state with all item numbers
setData(prevData => {
const newData = [...prevData];
// Apply each item number to the data
validationStateRef.current.itemNumbers.forEach((itemNumber, rowIndex) => {
// Ensure row exists and value has actually changed
if (rowIndex >= 0 && rowIndex < newData.length &&
newData[rowIndex]?.item_number !== itemNumber) {
// Create a new row object to force re-rendering
newData[rowIndex] = {
...newData[rowIndex],
item_number: itemNumber
};
// Track which row was updated for the callback
rowIds.push(rowIndex);
}
});
return newData;
});
// Force a re-render by updating React state
setTimeout(() => {
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
}, 0);
// Call the callback with the updated row IDs
if (callback) {
callback(rowIds);
}
}, [setData]);
// Batch validate all UPCs in the data
const validateAllUPCs = useCallback(async () => {
// Skip if we've already done the initial validation
if (initialUpcValidationStartedRef.current) {
console.log('Initial UPC validation already in progress or complete, skipping');
return;
}
// Mark that we've started the initial validation
initialUpcValidationStartedRef.current = true;
console.log('Starting initial UPC validation...');
// Set validation state
setIsValidatingUpc(true);
// Find all rows that have both supplier and UPC/barcode
const rowsToValidate = data
.map((row, index) => ({ row, index }))
.filter(({ row }) => {
const rowAny = row as Record<string, any>;
const hasSupplier = rowAny.supplier;
const hasUpc = rowAny.upc || rowAny.barcode;
return hasSupplier && hasUpc;
});
const totalRows = rowsToValidate.length;
console.log(`Found ${totalRows} rows with both supplier and UPC for initial validation`);
if (totalRows === 0) {
setIsValidatingUpc(false);
initialUpcValidationDoneRef.current = true;
return;
}
// Mark all rows as being validated
const newValidatingRows = new Set(rowsToValidate.map(({ index }) => index));
validationStateRef.current.validatingRows = newValidatingRows;
setValidatingRows(newValidatingRows);
try {
// Process rows in batches for better UX
const BATCH_SIZE = 100;
const batches = [];
// Split rows into batches
for (let i = 0; i < rowsToValidate.length; i += BATCH_SIZE) {
batches.push(rowsToValidate.slice(i, i + BATCH_SIZE));
}
console.log(`Processing ${batches.length} batches for ${totalRows} rows`);
// Process each batch sequentially
for (let batchIndex = 0; batchIndex < batches.length; batchIndex++) {
const batch = batches[batchIndex];
console.log(`Processing batch ${batchIndex + 1}/${batches.length} with ${batch.length} rows`);
// Track updated rows in this batch
const batchUpdatedRows: number[] = [];
// Process all rows in current batch in parallel
await Promise.all(
batch.map(async ({ row, index }) => {
try {
const rowAny = row as Record<string, any>;
const supplierId = rowAny.supplier.toString();
const upcValue = (rowAny.upc || rowAny.barcode).toString();
console.log(`Validating UPC in initial batch: row=${index}, supplier=${supplierId}, upc=${upcValue}`);
// Mark the item_number cell as validating
startValidatingCell(index, 'item_number');
// Validate the UPC directly (don't use validateUpc to avoid duplicate UI updates)
const cacheKey = `${supplierId}-${upcValue}`;
// Check cache first
if (processedUpcMapRef.current.has(cacheKey)) {
const cachedItemNumber = processedUpcMapRef.current.get(cacheKey);
if (cachedItemNumber) {
console.log(`Using cached item number for row ${index}: ${cachedItemNumber}`);
updateItemNumber(index, cachedItemNumber);
batchUpdatedRows.push(index);
return;
}
}
// Make API call
const result = await fetchProductByUpc(supplierId, upcValue);
if (!result.error && result.data?.itemNumber) {
const itemNumber = result.data.itemNumber;
console.log(`Got item number from API for row ${index}: ${itemNumber}`);
// Cache the result
processedUpcMapRef.current.set(cacheKey, itemNumber);
// Update item number
updateItemNumber(index, itemNumber);
batchUpdatedRows.push(index);
clearUpcUniqueError(index);
} else {
console.warn(`No item number found for row ${index} UPC ${upcValue}`);
// Clear any previous item numbers for the row
setData(prevData => {
const newData = [...prevData];
if (index >= 0 && index < newData.length) {
newData[index] = {
...newData[index],
item_number: ''
};
}
return newData;
});
if (validationStateRef.current.itemNumbers.has(index)) {
validationStateRef.current.itemNumbers.delete(index);
setItemNumberUpdates(new Map(validationStateRef.current.itemNumbers));
validationStateRef.current.itemNumbers = new Map(validationStateRef.current.itemNumbers);
}
if (result.error && result.code === 'conflict') {
applyUpcUniqueError(index, 'Must be unique');
}
}
} catch (error) {
console.error(`Error validating row ${index}:`, error);
} finally {
// Clear validation state
stopValidatingCell(index, 'item_number');
stopValidatingRow(index);
}
})
);
// Apply updates for this batch
if (validationStateRef.current.itemNumbers.size > 0) {
console.log(`Applying item numbers after batch ${batchIndex + 1}`);
applyItemNumbersToData(updatedRowIds => {
console.log(`Processed initial UPC validation batch ${batchIndex + 1} for rows: ${updatedRowIds.join(', ')}`);
});
}
// Small delay between batches to update UI
if (batchIndex < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
} catch (error) {
console.error('Error in batch validation:', error);
} finally {
initialUpcValidationDoneRef.current = true;
// Make sure all validation states are cleared
validationStateRef.current.validatingRows.clear();
setValidatingRows(new Set());
setIsValidatingUpc(false);
console.log('Completed initial UPC validation');
}
}, [
data,
fetchProductByUpc,
updateItemNumber,
startValidatingCell,
stopValidatingCell,
stopValidatingRow,
applyItemNumbersToData,
setData,
applyUpcUniqueError,
clearUpcUniqueError
]);
// Run initial UPC validation when data changes
useEffect(() => {
if (initialUpcValidationStartedRef.current) return;
validateAllUPCs();
}, [data, validateAllUPCs]);
// Return public API
return {
// Validation methods
validateUpc,
validateAllUPCs,
// Cell state
isValidatingCell,
isRowValidatingUpc,
// Row state
validatingRows: validatingRows, // Expose as a Set to components
// Item number management
getItemNumber,
applyItemNumbersToData,
// CRITICAL: Expose the itemNumbers map directly
itemNumbers: validationStateRef.current.itemNumbers,
// Initialization state
initialValidationDone: initialUpcValidationDoneRef.current
};
};

View File

@@ -1,174 +0,0 @@
import { useCallback } from 'react'
import type { Field, Fields, RowHook } from '../../../types'
import { ErrorSources } from '../../../types'
import { RowData, InfoWithSource } from './validationTypes'
import { useFieldValidation, clearValidationCacheForField, clearAllUniquenessCaches } from './useFieldValidation'
import { useUniqueValidation } from './useUniqueValidation'
// Main validation hook that brings together field and uniqueness validation
export const useValidation = <T extends string>(
fields: Fields<T>,
rowHook?: RowHook<T>
) => {
// Use the field validation hook
const { validateField, validateRow } = useFieldValidation(fields, rowHook);
// Use the uniqueness validation hook
const {
validateUniqueField,
validateAllUniqueFields
} = useUniqueValidation(fields);
// Run complete validation
const validateData = useCallback(async (data: RowData<T>[], fieldToUpdate?: { rowIndex: number, fieldKey: string }) => {
const validationErrors = new Map<number, Record<string, InfoWithSource>>();
// If we're updating a specific field, only validate that field for that row
if (fieldToUpdate) {
const { rowIndex, fieldKey } = fieldToUpdate;
// Special handling for fields that often update item_number
const triggersItemNumberValidation = fieldKey === 'upc' || fieldKey === 'barcode' || fieldKey === 'supplier';
// If updating a uniqueness field or field that affects item_number, clear ALL related validation caches
const isUniqueField = fieldKey === 'upc' || fieldKey === 'item_number' ||
fieldKey === 'supplier_no' || fieldKey === 'notions_no' ||
fieldKey === 'name' || triggersItemNumberValidation;
// Force cache clearing for uniqueness-validated fields to ensure fresh validation
if (isUniqueField) {
console.log(`Clearing validation cache for uniqueness field: ${fieldKey}`);
clearValidationCacheForField(fieldKey);
// If a field that might affect item_number, also clear item_number cache
if (triggersItemNumberValidation) {
console.log('Also clearing item_number validation cache');
clearValidationCacheForField('item_number');
}
}
if (rowIndex >= 0 && rowIndex < data.length) {
const row = data[rowIndex];
// Find the field definition
const field = fields.find(f => String(f.key) === fieldKey);
if (field) {
// Validate just this field for this row
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
// Store the validation error
validationErrors.set(rowIndex, {
[fieldKey]: {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
}
});
}
// Check if the field requires uniqueness validation or if it's item_number after UPC/Supplier change
const needsUniquenessCheck = isUniqueField ||
field.validations?.some(v => v.rule === 'unique');
if (needsUniquenessCheck) {
console.log(`Running immediate uniqueness validation for field ${fieldKey}`);
// For item_number updated via UPC validation, or direct UPC update, check both fields
if (fieldKey === 'item_number' || fieldKey === 'upc' || fieldKey === 'barcode') {
// Validate both item_number and UPC/barcode fields for uniqueness
const itemNumberUniqueErrors = validateUniqueField(data, 'item_number');
const upcUniqueErrors = validateUniqueField(data, fieldKey === 'item_number' ? 'upc' : fieldKey);
// Combine the errors
itemNumberUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
upcUniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
} else {
// Normal uniqueness validation for other fields
const uniqueErrors = validateUniqueField(data, fieldKey);
// Add unique errors to validation errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
}
}
}
}
} else {
// Full validation - all fields for all rows
console.log('Running full validation for all fields and rows');
// Process each row for field-level validations
for (let rowIndex = 0; rowIndex < data.length; rowIndex++) {
const row = data[rowIndex];
let rowErrors: Record<string, InfoWithSource> = {};
// Validate all fields for this row
fields.forEach(field => {
const fieldKey = String(field.key);
const value = row[fieldKey as keyof typeof row];
const errors = validateField(value, field as Field<T>);
if (errors.length > 0) {
rowErrors[fieldKey] = {
message: errors[0].message,
level: errors[0].level as 'info' | 'warning' | 'error',
source: ErrorSources.Row,
type: errors[0].type
};
}
});
// Add row to validationErrors if it has any errors
if (Object.keys(rowErrors).length > 0) {
validationErrors.set(rowIndex, rowErrors);
}
}
// Validate all unique fields
const uniqueErrors = validateAllUniqueFields(data);
// Merge in unique errors
uniqueErrors.forEach((errors, rowIdx) => {
if (!validationErrors.has(rowIdx)) {
validationErrors.set(rowIdx, {});
}
Object.assign(validationErrors.get(rowIdx)!, errors);
});
console.log('Uniqueness validation complete');
}
return {
data,
validationErrors
};
}, [fields, validateField, validateUniqueField, validateAllUniqueFields]);
return {
validateData,
validateField,
validateRow,
validateUniqueField,
clearValidationCacheForField,
clearAllUniquenessCaches
};
}

View File

@@ -1,538 +0,0 @@
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { useRsi } from "../../../hooks/useRsi";
import { ErrorType } from "../../../types";
import { RowSelectionState } from "@tanstack/react-table";
import { toast } from "sonner";
import { useQuery } from "@tanstack/react-query";
import config from "@/config";
import { useValidation } from "./useValidation";
import { useRowOperations } from "./useRowOperations";
import { useTemplateManagement } from "./useTemplateManagement";
import { useFilterManagement } from "./useFilterManagement";
import { useUniqueItemNumbersValidation } from "./useUniqueItemNumbersValidation";
import { useUpcValidation } from "./useUpcValidation";
import { useInitialValidation } from "./useInitialValidation";
import { Props, RowData } from "./validationTypes";
import { normalizeCountryCode } from "../utils/countryUtils";
import { cleanPriceField } from "../utils/priceUtils";
import { correctUpcValue } from "../utils/upcUtils";
export const useValidationState = <T extends string>({
initialData,
onBack,
onNext,
}: Props<T>) => {
const { fields, rowHook, tableHook } = useRsi<T>();
// Import validateField and validateUniqueField from useValidation
const { validateField: validateFieldFromHook, validateUniqueField } = useValidation<T>(
fields,
rowHook
);
// Add ref to track template application state
const isApplyingTemplateRef = useRef(false);
// Core data state
const [data, setData] = useState<RowData<T>[]>(() => {
// Clean price fields in initial data before setting state
return initialData.map((row, index) => {
const updatedRow = { ...row } as Record<string, any>;
// Ensure each row has a stable __index key for downstream lookups
if (updatedRow.__index === undefined || updatedRow.__index === null || updatedRow.__index === '') {
updatedRow.__index = String(index);
}
// Clean price fields using utility
if (updatedRow.msrp !== undefined) {
updatedRow.msrp = cleanPriceField(updatedRow.msrp);
}
if (updatedRow.cost_each !== undefined) {
updatedRow.cost_each = cleanPriceField(updatedRow.cost_each);
}
// Set default tax category if not already set
if (
updatedRow.tax_cat === undefined ||
updatedRow.tax_cat === null ||
updatedRow.tax_cat === ""
) {
updatedRow.tax_cat = "0";
}
// Set default shipping restrictions if not already set
if (
updatedRow.ship_restrictions === undefined ||
updatedRow.ship_restrictions === null ||
updatedRow.ship_restrictions === ""
) {
updatedRow.ship_restrictions = "0";
}
// Normalize country code (COO) to 2-letter ISO if possible
if (typeof updatedRow.coo === "string") {
const raw = updatedRow.coo.trim();
const normalized = normalizeCountryCode(raw);
if (normalized) {
updatedRow.coo = normalized;
} else {
// Uppercase 2-letter values as fallback
if (raw.length === 2) updatedRow.coo = raw.toUpperCase();
}
}
if (updatedRow.upc !== undefined && updatedRow.upc !== null) {
const { corrected, changed } = correctUpcValue(updatedRow.upc);
if (changed) {
updatedRow.upc = corrected;
}
}
if (updatedRow.barcode !== undefined && updatedRow.barcode !== null) {
const { corrected, changed } = correctUpcValue(updatedRow.barcode);
if (changed) {
updatedRow.barcode = corrected;
}
}
return updatedRow as RowData<T>;
});
});
// Row selection state
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
// Validation state
const [validationErrors, setValidationErrors] = useState<
Map<number, Record<string, any[]>>
>(new Map());
const [rowValidationStatus, setRowValidationStatus] = useState<
Map<number, "pending" | "validating" | "validated" | "error">
>(new Map());
// Add state for tracking cells in loading state
const [validatingCells, setValidatingCells] = useState<Set<string>>(new Set());
// Add global editing state to prevent validation during editing
const [editingCells, setEditingCells] = useState<Set<string>>(new Set());
const hasEditingCells = editingCells.size > 0;
// Track initial validation lifecycle
const [initialValidationComplete, setInitialValidationComplete] = useState(false);
// Track last seen item_number signature to drive targeted uniqueness checks
const lastItemNumberSigRef = useRef<string | null>(null);
// Use row operations hook
const { validateRow, updateRow, revalidateRows, copyDown } = useRowOperations<T>(
data,
fields,
setData,
setValidationErrors,
validateFieldFromHook
);
// Use UPC validation hook - MUST be initialized before template management
const upcValidation = useUpcValidation(data, setData, setValidationErrors);
// Use unique item numbers validation hook
const { validateUniqueItemNumbers } = useUniqueItemNumbersValidation<T>(
data,
fields,
setValidationErrors
);
// Use template management hook
const templateManagement = useTemplateManagement<T>(
data,
setData,
rowSelection,
setValidationErrors,
setRowValidationStatus,
validateRow,
isApplyingTemplateRef,
upcValidation,
setValidatingCells
);
// Use filter management hook
const filterManagement = useFilterManagement<T>(data, fields, validationErrors);
// Disable global full-table revalidation on any data change.
// Field-level validation now runs inside updateRow/validateRow, and per-column
// uniqueness is handled surgically where needed.
// Intentionally left blank to avoid UI lock-ups on small edits.
useEffect(() => {
return; // no-op
}, [data, fields, hasEditingCells]);
// Add field options query
const { data: fieldOptionsData } = useQuery({
queryKey: ["import-field-options"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/import/field-options`);
if (!response.ok) {
throw new Error("Failed to fetch field options");
}
return response.json();
},
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
gcTime: 30 * 60 * 1000, // Keep in cache for 30 minutes
});
// Get display text for a template
const getTemplateDisplayText = useCallback(
(templateId: string | null) => {
if (!templateId) return "Select a template";
const template = templateManagement.templates.find((t) => t.id.toString() === templateId);
if (!template) return "Unknown template";
try {
const companyId = template.company || "";
const productType = template.product_type || "Unknown Type";
// Find company name from field options
const companyName =
fieldOptionsData?.companies?.find(
(c: { value: string; label: string }) => c.value === companyId
)?.label || companyId;
return `${companyName} - ${productType}`;
} catch (error) {
console.error(
"Error formatting template display text:",
error,
template
);
return "Error displaying template";
}
},
[templateManagement.templates, fieldOptionsData]
);
// Check if there are any errors
const hasErrors = useMemo(() => {
for (const [_, status] of rowValidationStatus.entries()) {
if (status === "error") return true;
}
return false;
}, [rowValidationStatus]);
// Create a function to handle button clicks (continue or back)
const handleButtonClick = useCallback(
async (direction: "next" | "back") => {
if (direction === "back" && onBack) {
// If a specific action is defined for back, use it
onBack();
return;
}
if (direction === "next") {
// When proceeding to the next screen, check for unvalidated rows first
const hasErrors = [...validationErrors.entries()].some(
([_, errors]) => {
return Object.values(errors).some((errorSet) =>
errorSet.some((error) => error.type !== ErrorType.Required)
);
}
);
if (hasErrors) {
// We have validation errors - ask the user to fix them first or continue anyway
const shouldContinue = window.confirm(
"There are validation errors in your data. Do you want to continue anyway?"
);
if (!shouldContinue) {
// User chose to fix errors
return;
}
}
// Prepare the data for the next step
try {
// No toast here - unnecessary and distracting
// Call onNext with the cleaned data
if (onNext) {
// Remove metadata fields before passing to onNext
const cleanedData = data.map((row) => {
const {
__index,
__template,
__original,
__corrected,
__changes,
__aiSupplemental: _aiSupplemental,
...cleanRow
} = row;
return cleanRow as any;
});
onNext(cleanedData);
}
} catch (error) {
console.error("Error proceeding to next step:", error);
toast.error("Error saving data");
}
}
},
[data, onBack, onNext, validationErrors]
);
const { isValidating: isInitialValidationRunning } = useInitialValidation<T>({
data,
fields,
setData,
setValidationErrors,
validateUniqueItemNumbers,
upcValidationComplete: upcValidation.initialValidationDone,
onComplete: () => {
setInitialValidationComplete(true);
},
});
useEffect(() => {
if (initialValidationComplete) return;
if ((!data || data.length === 0) && upcValidation.initialValidationDone) {
setInitialValidationComplete(true);
}
}, [data, initialValidationComplete, upcValidation.initialValidationDone]);
const hasPendingUpcValidation = upcValidation.validatingRows.size > 0;
// Separate initial validation from subsequent validations
// isInitializing should ONLY be true during the first load, never again
const isInitializing =
!initialValidationComplete ||
isInitialValidationRunning ||
templateManagement.isLoadingTemplates ||
(hasPendingUpcValidation && !upcValidation.initialValidationDone);
const isValidating = isInitialValidationRunning;
// Track initialization task statuses for the progress UI
const initializationTasks = {
upcValidation: {
label: 'Generating item numbers',
status: upcValidation.initialValidationDone ? 'completed' :
hasPendingUpcValidation ? 'in_progress' : 'pending'
},
fieldValidation: {
label: 'Checking for field errors',
status: initialValidationComplete ? 'completed' :
isInitialValidationRunning ? 'in_progress' : 'pending'
},
templateLoading: {
label: 'Loading product templates',
status: !templateManagement.isLoadingTemplates ? 'completed' :
'in_progress'
}
};
// Targeted uniqueness revalidation: run only when item_number values change
useEffect(() => {
if (!data || data.length === 0) return;
// Build a simple signature of the item_number column
const sig = data.map((r) => String((r as Record<string, any>).item_number ?? '')).join('|');
if (lastItemNumberSigRef.current === sig) return;
lastItemNumberSigRef.current = sig;
// Compute unique errors for item_number only and merge
const uniqueMap = validateUniqueField(data, 'item_number');
const rowsWithUnique = new Set<number>();
uniqueMap.forEach((_, idx) => rowsWithUnique.add(idx));
setValidationErrors((prev) => {
const newMap = new Map(prev);
// Apply unique errors
uniqueMap.forEach((errorsForRow, rowIdx) => {
const existing = { ...(newMap.get(rowIdx) || {}) } as Record<string, any[]>;
const info = (errorsForRow as any)['item_number'];
const currentValue = (data[rowIdx] as any)?.['item_number'];
// Only apply uniqueness error when the value is non-empty
if (info && currentValue !== undefined && currentValue !== null && String(currentValue) !== '') {
existing['item_number'] = [
{
message: info.message,
level: info.level,
source: info.source,
type: info.type,
},
];
}
// If value is now present, make sure to clear any lingering Required error
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && existing['item_number']) {
existing['item_number'] = (existing['item_number'] as any[]).filter((e) => e.type !== ErrorType.Required);
if ((existing['item_number'] as any[]).length === 0) delete existing['item_number'];
}
if (Object.keys(existing).length > 0) newMap.set(rowIdx, existing);
else newMap.delete(rowIdx);
});
// Remove stale unique errors for rows no longer duplicated
newMap.forEach((rowErrs, rowIdx) => {
const currentValue = (data[rowIdx] as any)?.['item_number'];
const shouldRemoveUnique = !rowsWithUnique.has(rowIdx) || currentValue === undefined || currentValue === null || String(currentValue) === '';
if (shouldRemoveUnique && (rowErrs as any)['item_number']) {
const filtered = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Unique);
if (filtered.length > 0) (rowErrs as any)['item_number'] = filtered;
else delete (rowErrs as any)['item_number'];
}
// If value now present, also clear any lingering Required error for this field
if (currentValue !== undefined && currentValue !== null && String(currentValue) !== '' && (rowErrs as any)['item_number']) {
const nonRequired = (rowErrs as any)['item_number'].filter((e: any) => e.type !== ErrorType.Required);
if (nonRequired.length > 0) (rowErrs as any)['item_number'] = nonRequired;
else delete (rowErrs as any)['item_number'];
}
if (Object.keys(rowErrs).length > 0) newMap.set(rowIdx, rowErrs);
else newMap.delete(rowIdx);
});
return newMap;
});
}, [data, validateUniqueField, setValidationErrors]);
// Update fields with latest options
const fieldsWithOptions = useMemo(() => {
if (!fieldOptionsData) return fields;
return fields.map((field) => {
// Skip fields that aren't select or multi-select
if (
typeof field.fieldType !== "object" ||
(field.fieldType.type !== "select" &&
field.fieldType.type !== "multi-select")
) {
return field;
}
// Get the correct options based on field key
let options = [];
switch (field.key) {
case "company":
options = [...(fieldOptionsData.companies || [])];
break;
case "supplier":
options = [...(fieldOptionsData.suppliers || [])];
break;
case "categories":
options = [...(fieldOptionsData.categories || [])];
break;
case "themes":
options = [...(fieldOptionsData.themes || [])];
break;
case "colors":
options = [...(fieldOptionsData.colors || [])];
break;
case "tax_cat":
options = [...(fieldOptionsData.taxCategories || [])];
// Ensure tax_cat is always a select, not multi-select
return {
...field,
fieldType: {
type: "select",
options,
},
};
case "ship_restrictions":
options = [...(fieldOptionsData.shippingRestrictions || [])];
break;
case "artist":
options = [...(fieldOptionsData.artists || [])];
break;
case "size_cat":
options = [...(fieldOptionsData.sizes || [])];
break;
default:
options = [...(field.fieldType.options || [])];
}
return {
...field,
fieldType: {
...field.fieldType,
options,
},
};
});
}, [fields, fieldOptionsData]);
// Load templates on mount
useEffect(() => {
templateManagement.loadTemplates();
}, [templateManagement.loadTemplates]);
return {
// Data
data,
setData,
filteredData: filterManagement.filteredData,
// Validation
isValidating,
isInitializing,
initializationTasks,
validationErrors,
rowValidationStatus,
validateRow,
hasErrors,
// CRITICAL: Export validatingCells to make it available to ValidationContainer
validatingCells,
setValidatingCells,
// PERFORMANCE FIX: Export editing state management
editingCells,
setEditingCells,
// Row selection
rowSelection,
setRowSelection,
// Row manipulation
updateRow,
copyDown,
// Templates
templates: templateManagement.templates,
isLoadingTemplates: templateManagement.isLoadingTemplates,
selectedTemplateId: templateManagement.templateState.selectedTemplateId,
showSaveTemplateDialog: templateManagement.templateState.showSaveTemplateDialog,
newTemplateName: templateManagement.templateState.newTemplateName,
newTemplateType: templateManagement.templateState.newTemplateType,
setTemplateState: templateManagement.setTemplateState,
templateState: templateManagement.templateState,
loadTemplates: templateManagement.loadTemplates,
saveTemplate: templateManagement.saveTemplate,
applyTemplate: templateManagement.applyTemplate,
applyTemplateToSelected: templateManagement.applyTemplateToSelected,
getTemplateDisplayText,
refreshTemplates: templateManagement.refreshTemplates,
// UPC validation
upcValidation,
// Filters
filters: filterManagement.filters,
filterFields: filterManagement.filterFields,
filterValues: filterManagement.filterValues,
updateFilters: filterManagement.updateFilters,
resetFilters: filterManagement.resetFilters,
// Fields reference
fields: fieldsWithOptions, // Return updated fields with options
// Hooks
rowHook,
tableHook,
// Button handling
handleButtonClick,
revalidateRows,
};
};

View File

@@ -1,100 +0,0 @@
import type { Data } from "../../../types";
import { ErrorSources, ErrorType } from "../../../types";
import config from "@/config";
// Define the Props interface for ValidationStepNew
export interface Props<T extends string> {
initialData: RowData<T>[];
file?: File;
onBack?: () => void;
onNext?: (data: RowData<T>[]) => void;
isFromScratch?: boolean;
}
// Extended Data type with meta information
export type RowData<T extends string> = Data<T> & {
__index?: string;
__template?: string;
__original?: Record<string, any>;
__corrected?: Record<string, any>;
__changes?: Record<string, boolean>;
upc?: string;
barcode?: string;
supplier?: string;
company?: string;
item_number?: string;
[key: string]: any; // Allow any string key for dynamic fields
};
// Template interface
export interface Template {
id: number;
company: string;
product_type: string;
[key: string]: string | number | boolean | undefined;
}
// Props for the useValidationState hook
export interface ValidationStateProps<T extends string> extends Props<T> {}
// Interface for validation results
export interface ValidationResult {
error?: boolean;
message?: string;
data?: Record<string, any>;
type?: ErrorType;
source?: ErrorSources;
}
// Filter state interface
export interface FilterState {
searchText: string;
showErrorsOnly: boolean;
filterField: string | null;
filterValue: string | null;
}
// UI validation state interface for useUpcValidation
export interface ValidationState {
validatingCells: Set<string>; // Using rowIndex-fieldKey as identifier
itemNumbers: Map<number, string>; // Using rowIndex as key
validatingRows: Set<number>; // Rows currently being validated
activeValidations: Set<string>; // Active validations
}
// InfoWithSource interface for validation errors
export interface InfoWithSource {
message: string;
level: 'info' | 'warning' | 'error';
source: ErrorSources;
type: ErrorType;
}
// Template state interface
export interface TemplateState {
selectedTemplateId: string | null;
showSaveTemplateDialog: boolean;
newTemplateName: string;
newTemplateType: string;
}
// Add config at the top of the file
// Import the config or access it through window
declare global {
interface Window {
config?: {
apiUrl: string;
};
}
}
// Use a helper to get API URL consistently
export const getApiUrl = () => config.apiUrl;
// Shared utility function for checking empty values
export const isEmpty = (value: any): boolean =>
value === undefined ||
value === null ||
value === '' ||
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === 0);

View File

@@ -1,28 +0,0 @@
import ValidationContainer from './components/ValidationContainer'
import { Props } from './hooks/validationTypes'
/**
* ValidationStepNew component - modern implementation of the validation step
*
* This component is a refactored version of the original ValidationStep component
* with improved architecture, performance and maintainability.
*/
export const ValidationStepNew = <T extends string>({
initialData,
file,
onBack,
onNext,
isFromScratch
}: Props<T>) => {
return (
<ValidationContainer<T>
initialData={initialData}
file={file}
onBack={onBack}
onNext={onNext}
isFromScratch={isFromScratch}
/>
)
}
export default ValidationStepNew

View File

@@ -1,5 +0,0 @@
import { InfoWithSource } from "../../types"
export type Meta = { __index: string }
export type Error = { [key: string]: InfoWithSource }
export type Errors = { [id: string]: Error }

View File

@@ -1,24 +0,0 @@
import { ErrorLevel, ErrorSources, ErrorType as ValidationErrorType } from "../../../types"
// Define our own Error type that's compatible with the original
export interface ErrorType {
message: string;
level: ErrorLevel;
source?: ErrorSources;
type: ValidationErrorType;
}
// Export a namespace to make it accessible at runtime
export const ErrorTypes = {
createError: (message: string, level: ErrorLevel = 'error', source: ErrorSources = ErrorSources.Row, type: ValidationErrorType = ValidationErrorType.Custom): ErrorType => {
return { message, level, source, type };
}
};
// Type for a collection of errors
export interface Errors { [id: string]: ErrorType[] }
// Make our Meta type match the original for compatibility
export interface Meta {
__index?: string;
}

View File

@@ -1,111 +0,0 @@
/**
* AI Validation utility functions
*
* Helper functions for processing AI validation data and managing progress
*/
import type { Fields } from '@/components/product-import/types';
/**
* Clean data for AI validation by including all fields
*
* Ensures every field is present in the data sent to the API,
* converting undefined values to empty strings
*/
export function prepareDataForAiValidation<T extends string>(
data: any[],
fields: Fields<T>
): Record<string, any>[] {
return data.map(item => {
const { __index, __aiSupplemental, ...rest } = item as Record<string, any>;
const withAllKeys: Record<string, any> = {};
fields.forEach((f) => {
const k = String(f.key);
if (Array.isArray(rest[k])) {
withAllKeys[k] = rest[k];
} else if (rest[k] === undefined) {
withAllKeys[k] = "";
} else {
withAllKeys[k] = rest[k];
}
});
if (typeof __aiSupplemental === 'object' && __aiSupplemental !== null) {
withAllKeys.aiSupplementalInfo = __aiSupplemental;
}
return withAllKeys;
});
}
/**
* Process AI-corrected data to handle multi-select and select fields
*
* Converts comma-separated strings to arrays for multi-select fields
* and handles label-to-value conversions for select fields
*/
export function processAiCorrectedData<T extends string>(
correctedData: any[],
originalData: any[],
fields: Fields<T>
): any[] {
return correctedData.map((corrected: any, index: number) => {
// Start with original data to preserve metadata like __index
const original = originalData[index] || {};
const processed = { ...original, ...corrected };
// Process each field according to its type
Object.keys(processed).forEach(key => {
if (key.startsWith('__')) return; // Skip metadata fields
const fieldConfig = fields.find(f => String(f.key) === key);
if (!fieldConfig) return;
// Handle multi-select fields (comma-separated values → array)
if (fieldConfig?.fieldType.type === 'multi-select' && typeof processed[key] === 'string') {
processed[key] = processed[key]
.split(',')
.map((v: string) => v.trim())
.filter(Boolean);
}
});
return processed;
});
}
/**
* Calculate progress percentage based on elapsed time and estimates
*
* @param step - Current step number (1-5)
* @param elapsedSeconds - Time elapsed since start
* @param estimatedSeconds - Estimated total time (optional)
* @returns Progress percentage (0-95, never reaches 100 until complete)
*/
export function calculateProgressPercent(
step: number,
elapsedSeconds: number,
estimatedSeconds?: number
): number {
if (estimatedSeconds && estimatedSeconds > 0) {
// Time-based progress
return Math.min(95, (elapsedSeconds / estimatedSeconds) * 100);
}
// Step-based progress with time adjustment
const baseProgress = (step / 5) * 100;
const timeAdjustment = step === 1 ? Math.min(20, elapsedSeconds * 0.5) : 0;
return Math.min(95, baseProgress + timeAdjustment);
}
/**
* Extract base status message by removing time information
*
* Removes patterns like "(5s remaining)" or "(1m 30s elapsed)"
*/
export function extractBaseStatus(status: string): string {
return status
.replace(/\s\(\d+[ms].+\)$/, '')
.replace(/\s\(\d+m \d+s.+\)$/, '');
}

View File

@@ -1,66 +0,0 @@
/**
* Country code normalization utilities
*
* Converts various country code formats and country names to ISO 3166-1 alpha-2 codes
*/
/**
* Normalizes country codes and names to ISO 3166-1 alpha-2 format (2-letter codes)
*
* Supports:
* - ISO 3166-1 alpha-2 codes (e.g., "US", "GB")
* - ISO 3166-1 alpha-3 codes (e.g., "USA", "GBR")
* - Common country names (e.g., "United States", "China")
*
* @param input - Country code or name to normalize
* @returns ISO 3166-1 alpha-2 code or null if not recognized
*
* @example
* normalizeCountryCode("USA") // "US"
* normalizeCountryCode("United States") // "US"
* normalizeCountryCode("US") // "US"
* normalizeCountryCode("invalid") // null
*/
export function normalizeCountryCode(input: string): string | null {
if (!input) return null;
const s = input.trim();
const upper = s.toUpperCase();
// Already in ISO 3166-1 alpha-2 format
if (/^[A-Z]{2}$/.test(upper)) return upper;
// ISO 3166-1 alpha-3 to alpha-2 mapping
const iso3to2: Record<string, string> = {
USA: "US", GBR: "GB", UK: "GB", CHN: "CN", DEU: "DE", FRA: "FR", ITA: "IT", ESP: "ES",
CAN: "CA", MEX: "MX", AUS: "AU", NZL: "NZ", JPN: "JP", KOR: "KR", PRK: "KP", TWN: "TW",
VNM: "VN", THA: "TH", IDN: "ID", IND: "IN", BRA: "BR", ARG: "AR", CHL: "CL", PER: "PE",
ZAF: "ZA", RUS: "RU", UKR: "UA", NLD: "NL", BEL: "BE", CHE: "CH", SWE: "SE", NOR: "NO",
DNK: "DK", POL: "PL", AUT: "AT", PRT: "PT", GRC: "GR", CZE: "CZ", HUN: "HU", IRL: "IE",
ISR: "IL", PAK: "PK", BGD: "BD", PHL: "PH", MYS: "MY", SGP: "SG", HKG: "HK", MAC: "MO"
};
if (iso3to2[upper]) return iso3to2[upper];
// Country name to ISO 3166-1 alpha-2 mapping
const nameMap: Record<string, string> = {
"UNITED STATES": "US", "UNITED STATES OF AMERICA": "US", "AMERICA": "US", "U.S.": "US", "U.S.A": "US", "USA": "US",
"UNITED KINGDOM": "GB", "UK": "GB", "GREAT BRITAIN": "GB", "ENGLAND": "GB",
"CHINA": "CN", "PEOPLE'S REPUBLIC OF CHINA": "CN", "PRC": "CN",
"CANADA": "CA", "MEXICO": "MX", "JAPAN": "JP", "SOUTH KOREA": "KR", "KOREA, REPUBLIC OF": "KR",
"TAIWAN": "TW", "VIETNAM": "VN", "THAILAND": "TH", "INDONESIA": "ID", "INDIA": "IN",
"GERMANY": "DE", "FRANCE": "FR", "ITALY": "IT", "SPAIN": "ES", "NETHERLANDS": "NL", "BELGIUM": "BE",
"SWITZERLAND": "CH", "SWEDEN": "SE", "NORWAY": "NO", "DENMARK": "DK", "POLAND": "PL", "AUSTRIA": "AT",
"PORTUGAL": "PT", "GREECE": "GR", "CZECH REPUBLIC": "CZ", "CZECHIA": "CZ", "HUNGARY": "HU", "IRELAND": "IE",
"RUSSIA": "RU", "UKRAINE": "UA", "AUSTRALIA": "AU", "NEW ZEALAND": "NZ",
"BRAZIL": "BR", "ARGENTINA": "AR", "CHILE": "CL", "PERU": "PE", "SOUTH AFRICA": "ZA",
"ISRAEL": "IL", "PAKISTAN": "PK", "BANGLADESH": "BD", "PHILIPPINES": "PH", "MALAYSIA": "MY", "SINGAPORE": "SG",
"HONG KONG": "HK", "MACAU": "MO"
};
// Normalize input: remove dots, trim, uppercase
const normalizedName = s.replace(/\./g, "").trim().toUpperCase();
if (nameMap[normalizedName]) return nameMap[normalizedName];
return null;
}

View File

@@ -1,142 +0,0 @@
import type { Data, Fields, Info, RowHook, TableHook } from "../../../types"
import type { Meta, Errors } from "../types"
import { v4 } from "uuid"
import { ErrorSources, ErrorType } from "../../../types"
type DataWithMeta<T extends string> = Data<T> & Meta & {
__index?: string;
}
export const addErrorsAndRunHooks = async <T extends string>(
data: (Data<T> & Partial<Meta>)[],
fields: Fields<T>,
rowHook?: RowHook<T>,
tableHook?: TableHook<T>,
changedRowIndexes?: number[],
): Promise<DataWithMeta<T>[]> => {
const errors: Errors = {}
const addError = (source: ErrorSources, rowIndex: number, fieldKey: string, error: Info, type: ErrorType = ErrorType.Custom) => {
errors[rowIndex] = {
...errors[rowIndex],
[fieldKey]: { ...error, source, type },
}
}
let processedData = [...data] as DataWithMeta<T>[]
if (tableHook) {
const tableResults = await tableHook(processedData)
processedData = tableResults.map((result, index) => ({
...processedData[index],
...result
}))
}
if (rowHook) {
if (changedRowIndexes) {
for (const index of changedRowIndexes) {
const rowResult = await rowHook(processedData[index], index, processedData)
processedData[index] = {
...processedData[index],
...rowResult
}
}
} else {
const rowResults = await Promise.all(
processedData.map(async (value, index) => {
const result = await rowHook(value, index, processedData)
return {
...value,
...result
}
})
)
processedData = rowResults
}
}
fields.forEach((field) => {
const fieldKey = field.key as string
field.validations?.forEach((validation) => {
switch (validation.rule) {
case "unique": {
const values = processedData.map((entry) => {
const value = entry[fieldKey as keyof typeof entry]
return value
})
const taken = new Set() // Set of items used at least once
const duplicates = new Set() // Set of items used multiple times
values.forEach((value) => {
if (validation.allowEmpty && !value) {
// If allowEmpty is set, we will not validate falsy fields such as undefined or empty string.
return
}
if (taken.has(value)) {
duplicates.add(value)
} else {
taken.add(value)
}
})
values.forEach((value, index) => {
if (duplicates.has(value)) {
addError(ErrorSources.Table, index, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field must be unique",
}, ErrorType.Unique)
}
})
break
}
case "required": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
if (value === null || value === undefined || value === "") {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message: validation.errorMessage || "Field is required",
}, ErrorType.Required)
}
})
break
}
case "regex": {
const dataToValidate = changedRowIndexes ? changedRowIndexes.map((index) => processedData[index]) : processedData
const regex = new RegExp(validation.value, validation.flags)
dataToValidate.forEach((entry, index) => {
const realIndex = changedRowIndexes ? changedRowIndexes[index] : index
const value = entry[fieldKey as keyof typeof entry]
const stringValue = value?.toString() ?? ""
if (!stringValue.match(regex)) {
addError(ErrorSources.Row, realIndex, fieldKey, {
level: validation.level || "error",
message:
validation.errorMessage || `Field did not match the regex /${validation.value}/${validation.flags} `,
}, ErrorType.Regex)
}
})
break
}
}
})
})
return processedData.map((value) => {
// This is required only for table. Mutates to prevent needless rerenders
const result: DataWithMeta<T> = { ...value }
if (!result.__index) {
result.__index = v4()
}
// We no longer store errors in the row data
// The errors are now only stored in the validationErrors Map
return result
})
}

View File

@@ -1,62 +0,0 @@
/**
* Price field cleaning and formatting utilities
*/
/**
* Cleans a price field by removing currency symbols and formatting to 2 decimal places
*
* - Removes dollar signs ($) and commas (,)
* - Converts to number and formats with 2 decimal places
* - Returns original value if conversion fails
*
* @param value - Price value to clean (string or number)
* @returns Cleaned price string formatted to 2 decimals, or original value if invalid
*
* @example
* cleanPriceField("$1,234.56") // "1234.56"
* cleanPriceField("$99.9") // "99.90"
* cleanPriceField(123.456) // "123.46"
* cleanPriceField("invalid") // "invalid"
*/
export function cleanPriceField(value: string | number): string {
if (typeof value === "string") {
const cleaned = value.replace(/[\s$,]/g, "");
const numValue = parseFloat(cleaned);
if (!isNaN(numValue)) {
return numValue.toFixed(2);
}
return value;
}
if (typeof value === "number") {
return value.toFixed(2);
}
return String(value);
}
/**
* Cleans multiple price fields in a data object
*
* @param data - Object containing price fields
* @param priceFields - Array of field keys to clean
* @returns New object with cleaned price fields
*
* @example
* cleanPriceFields({ msrp: "$99.99", cost_each: "$50.00" }, ["msrp", "cost_each"])
* // { msrp: "99.99", cost_each: "50.00" }
*/
export function cleanPriceFields<T extends Record<string, any>>(
data: T,
priceFields: (keyof T)[]
): T {
const cleaned = { ...data };
for (const field of priceFields) {
if (cleaned[field] !== undefined && cleaned[field] !== null) {
cleaned[field] = cleanPriceField(cleaned[field]) as any;
}
}
return cleaned;
}

View File

@@ -1,63 +0,0 @@
const NUMERIC_REGEX = /^\d+$/;
export function calculateUpcCheckDigit(upcBody: string): number {
if (!NUMERIC_REGEX.test(upcBody) || upcBody.length !== 11) {
throw new Error('UPC body must be 11 numeric characters');
}
const digits = upcBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] * 3 : digits[i]);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function calculateEanCheckDigit(eanBody: string): number {
if (!NUMERIC_REGEX.test(eanBody) || eanBody.length !== 12) {
throw new Error('EAN body must be 12 numeric characters');
}
const digits = eanBody.split('').map((d) => Number.parseInt(d, 10));
let sum = 0;
for (let i = 0; i < digits.length; i += 1) {
sum += (i % 2 === 0 ? digits[i] : digits[i] * 3);
}
const mod = sum % 10;
return mod === 0 ? 0 : 10 - mod;
}
export function correctUpcValue(rawValue: unknown): { corrected: string; changed: boolean } {
const value = rawValue ?? '';
const str = typeof value === 'string' ? value.trim() : String(value);
if (str === '' || !NUMERIC_REGEX.test(str)) {
return { corrected: str, changed: false };
}
if (str.length === 11) {
const check = calculateUpcCheckDigit(str);
return { corrected: `${str}${check}`, changed: true };
}
if (str.length === 12) {
const body = str.slice(0, 11);
const check = calculateUpcCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
if (str.length === 13) {
const body = str.slice(0, 12);
const check = calculateEanCheckDigit(body);
const corrected = `${body}${check}`;
return { corrected, changed: corrected !== str };
}
return { corrected: str, changed: false };
}