Remove old validation step code
This commit is contained in:
-981
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
-42
@@ -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;
|
||||
-73
@@ -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
|
||||
-328
@@ -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;
|
||||
-158
@@ -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
|
||||
-661
@@ -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;
|
||||
-1078
File diff suppressed because it is too large
Load Diff
-657
@@ -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);
|
||||
-145
@@ -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;
|
||||
});
|
||||
-215
@@ -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;
|
||||
});
|
||||
-576
@@ -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;
|
||||
});
|
||||
-238
@@ -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;
|
||||
});
|
||||
-295
@@ -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;
|
||||
});
|
||||
-1088
File diff suppressed because it is too large
Load Diff
-171
@@ -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
|
||||
};
|
||||
};
|
||||
-119
@@ -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
|
||||
};
|
||||
};
|
||||
-268
@@ -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();
|
||||
});
|
||||
}
|
||||
-543
@@ -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
|
||||
};
|
||||
};
|
||||
-500
@@ -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
|
||||
};
|
||||
};
|
||||
-516
@@ -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
|
||||
};
|
||||
};
|
||||
-158
@@ -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
|
||||
};
|
||||
};
|
||||
-131
@@ -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
|
||||
};
|
||||
};
|
||||
-595
@@ -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
|
||||
};
|
||||
};
|
||||
-174
@@ -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
|
||||
};
|
||||
}
|
||||
-538
@@ -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,
|
||||
};
|
||||
};
|
||||
-100
@@ -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);
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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;
|
||||
}
|
||||
-111
@@ -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.+\)$/, '');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
-142
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user