Compare commits
2 Commits
441a2c74ad
...
54a87ca3dc
| Author | SHA1 | Date | |
|---|---|---|---|
| 54a87ca3dc | |||
| 6bf93d33ea |
@@ -24,12 +24,38 @@ import {
|
|||||||
AlertDialogOverlay,
|
AlertDialogOverlay,
|
||||||
} from "@/components/ui/alert-dialog"
|
} from "@/components/ui/alert-dialog"
|
||||||
import { DeepReadonly as TsDeepReadonly } from "ts-essentials"
|
import { DeepReadonly as TsDeepReadonly } from "ts-essentials"
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
import config from "@/config"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { CheckCircle2, AlertCircle, HelpCircle } from "lucide-react"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
|
||||||
export type MatchColumnsProps<T extends string> = {
|
export type MatchColumnsProps<T extends string> = {
|
||||||
data: RawData[]
|
data: RawData[]
|
||||||
headerValues: RawData
|
headerValues: RawData
|
||||||
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>) => void
|
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections) => void
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
initialGlobalSelections?: GlobalSelections
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GlobalSelections = {
|
||||||
|
supplier?: string
|
||||||
|
company?: string
|
||||||
|
line?: string
|
||||||
|
subline?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ColumnType {
|
export enum ColumnType {
|
||||||
@@ -98,15 +124,63 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
headerValues,
|
headerValues,
|
||||||
onContinue,
|
onContinue,
|
||||||
onBack,
|
onBack,
|
||||||
|
initialGlobalSelections
|
||||||
}: MatchColumnsProps<T>) => {
|
}: MatchColumnsProps<T>) => {
|
||||||
const dataExample = data.slice(0, 2)
|
const dataExample = data.slice(0, 2)
|
||||||
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations, allowInvalidSubmit } = useRsi<T>()
|
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [columns, setColumns] = useState<Columns<T>>(
|
const [columns, setColumns] = useState<Columns<T>>(
|
||||||
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
|
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
|
||||||
([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })),
|
([...headerValues] as string[]).map((value, index) => ({ type: ColumnType.empty, index, header: value ?? "" })),
|
||||||
)
|
)
|
||||||
const [showUnmatchedFieldsAlert, setShowUnmatchedFieldsAlert] = useState(false)
|
const [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {})
|
||||||
|
|
||||||
|
// Initialize with any provided global selections
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialGlobalSelections) {
|
||||||
|
setGlobalSelections(initialGlobalSelections)
|
||||||
|
}
|
||||||
|
}, [initialGlobalSelections])
|
||||||
|
|
||||||
|
// Fetch field options from the API
|
||||||
|
const { data: fieldOptions } = 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();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch product lines when company is selected
|
||||||
|
const { data: productLines } = useQuery({
|
||||||
|
queryKey: ["product-lines", globalSelections.company],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!globalSelections.company) return [];
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch product lines");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!globalSelections.company,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch sublines when line is selected
|
||||||
|
const { data: sublines } = useQuery({
|
||||||
|
queryKey: ["sublines", globalSelections.line],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!globalSelections.line) return [];
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch sublines");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!globalSelections.line,
|
||||||
|
});
|
||||||
|
|
||||||
const onChange = useCallback(
|
const onChange = useCallback(
|
||||||
(value: T, columnIndex: number) => {
|
(value: T, columnIndex: number) => {
|
||||||
@@ -169,6 +243,46 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
},
|
},
|
||||||
[columns, setColumns],
|
[columns, setColumns],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Get all required fields
|
||||||
|
const requiredFields = useMemo(() => {
|
||||||
|
// Convert the fields to the expected type
|
||||||
|
const fieldsArray = Array.isArray(fields) ? fields : [fields];
|
||||||
|
|
||||||
|
// Log the fields for debugging
|
||||||
|
console.log("All fields:", fieldsArray);
|
||||||
|
|
||||||
|
// Log validation rules for each field
|
||||||
|
fieldsArray.forEach(field => {
|
||||||
|
if (field.validations && Array.isArray(field.validations)) {
|
||||||
|
console.log(`Field ${field.key} validations:`, field.validations.map((v: any) => v.rule));
|
||||||
|
} else {
|
||||||
|
console.log(`Field ${field.key} has no validations`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check for required fields based on validations
|
||||||
|
const required = fieldsArray.filter(field => {
|
||||||
|
// Check if the field has validations
|
||||||
|
if (!field.validations || !Array.isArray(field.validations)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for any validation with rule: 'required' or required: true
|
||||||
|
const isRequired = field.validations.some(
|
||||||
|
(v: any) => v.rule === 'required' || v.required === true
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`Field ${field.key} required:`, isRequired, field.validations);
|
||||||
|
|
||||||
|
return isRequired;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Required fields:", required);
|
||||||
|
return required;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Get unmatched required fields
|
||||||
const unmatchedRequiredFields = useMemo(() => {
|
const unmatchedRequiredFields = useMemo(() => {
|
||||||
// Convert the fields to the expected type
|
// Convert the fields to the expected type
|
||||||
const fieldsArray = Array.isArray(fields) ? fields : [fields]
|
const fieldsArray = Array.isArray(fields) ? fields : [fields]
|
||||||
@@ -177,25 +291,47 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
key: field.key as TsDeepReadonly<T>
|
key: field.key as TsDeepReadonly<T>
|
||||||
})) as unknown as Fields<T>
|
})) as unknown as Fields<T>
|
||||||
|
|
||||||
return findUnmatchedRequiredFields(typedFields, columns)
|
const unmatched = findUnmatchedRequiredFields(typedFields, columns);
|
||||||
|
console.log("Unmatched required fields:", unmatched);
|
||||||
|
return unmatched;
|
||||||
}, [fields, columns])
|
}, [fields, columns])
|
||||||
|
|
||||||
const handleOnContinue = useCallback(async () => {
|
// Get matched required fields
|
||||||
if (unmatchedRequiredFields.length > 0) {
|
const matchedRequiredFields = useMemo(() => {
|
||||||
setShowUnmatchedFieldsAlert(true)
|
const matched = requiredFields
|
||||||
} else {
|
.map(field => field.key)
|
||||||
setIsLoading(true)
|
.filter(key => {
|
||||||
await onContinue(normalizeTableData(columns, data, fields), data, columns)
|
// Type assertion to handle the DeepReadonly<T> vs string type mismatch
|
||||||
setIsLoading(false)
|
return !unmatchedRequiredFields.includes(key as any);
|
||||||
}
|
});
|
||||||
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields])
|
console.log("Matched required fields:", matched);
|
||||||
|
return matched;
|
||||||
|
}, [requiredFields, unmatchedRequiredFields]);
|
||||||
|
|
||||||
const handleAlertOnContinue = useCallback(async () => {
|
// Get field label by key
|
||||||
setShowUnmatchedFieldsAlert(false)
|
const getFieldLabel = useCallback((key: string) => {
|
||||||
|
const fieldsArray = Array.isArray(fields) ? fields : [fields];
|
||||||
|
const field = fieldsArray.find(f => f.key === key);
|
||||||
|
return field?.label || key;
|
||||||
|
}, [fields]);
|
||||||
|
|
||||||
|
// Check if a field is covered by global selections
|
||||||
|
const isFieldCoveredByGlobalSelections = useCallback((key: string) => {
|
||||||
|
const isCovered = (key === 'supplier' && globalSelections.supplier) ||
|
||||||
|
(key === 'company' && globalSelections.company) ||
|
||||||
|
(key === 'line' && globalSelections.line) ||
|
||||||
|
(key === 'subline' && globalSelections.subline);
|
||||||
|
console.log(`Field ${key} covered by global selections:`, isCovered);
|
||||||
|
return isCovered;
|
||||||
|
}, [globalSelections]);
|
||||||
|
|
||||||
|
const handleOnContinue = useCallback(async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await onContinue(normalizeTableData(columns, data, fields), data, columns)
|
// Normalize the data with global selections before continuing
|
||||||
|
const normalizedData = normalizeTableData(columns, data, fields)
|
||||||
|
await onContinue(normalizedData, data, columns, globalSelections)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [onContinue, columns, data, fields])
|
}, [onContinue, columns, data, fields, globalSelections])
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
@@ -208,40 +344,113 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex h-[calc(100vh-10rem)] flex-col">
|
||||||
<AlertDialog open={showUnmatchedFieldsAlert} onOpenChange={setShowUnmatchedFieldsAlert}>
|
<div className="flex-1 overflow-hidden">
|
||||||
<AlertDialogPortal>
|
<div className="h-full flex flex-col">
|
||||||
<AlertDialogOverlay className="z-[1400]" />
|
<div className="px-8 py-6 flex-1 overflow-auto">
|
||||||
<AlertDialogContent className="z-[1500]">
|
<div className="space-y-8 pb-4">
|
||||||
<AlertDialogHeader>
|
{/* Global Selections Section */}
|
||||||
<AlertDialogTitle>
|
<Card>
|
||||||
{translations.alerts.unmatchedRequiredFields.headerTitle}
|
<CardHeader>
|
||||||
</AlertDialogTitle>
|
<CardTitle>Global Selections</CardTitle>
|
||||||
<div className="space-y-3">
|
</CardHeader>
|
||||||
<AlertDialogDescription>
|
<CardContent>
|
||||||
{translations.alerts.unmatchedRequiredFields.bodyText}
|
<div className="grid grid-cols-2 gap-4">
|
||||||
</AlertDialogDescription>
|
<div className="space-y-2">
|
||||||
<p className="text-sm text-muted-foreground">
|
<label className="text-sm font-medium">Supplier</label>
|
||||||
{translations.alerts.unmatchedRequiredFields.listTitle}{" "}
|
<Select
|
||||||
<span className="font-bold">
|
value={globalSelections.supplier}
|
||||||
{unmatchedRequiredFields.join(", ")}
|
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
|
||||||
</span>
|
>
|
||||||
</p>
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select supplier..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions?.suppliers?.map((supplier: any) => (
|
||||||
|
<SelectItem key={supplier.value} value={supplier.value}>
|
||||||
|
{supplier.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
<div className="space-y-2">
|
||||||
<AlertDialogCancel>
|
<label className="text-sm font-medium">Company</label>
|
||||||
{translations.alerts.unmatchedRequiredFields.cancelButtonTitle}
|
<Select
|
||||||
</AlertDialogCancel>
|
value={globalSelections.company}
|
||||||
{allowInvalidSubmit && (
|
onValueChange={(value) => {
|
||||||
<AlertDialogAction onClick={handleAlertOnContinue}>
|
setGlobalSelections(prev => ({
|
||||||
{translations.alerts.unmatchedRequiredFields.continueButtonTitle}
|
...prev,
|
||||||
</AlertDialogAction>
|
company: value,
|
||||||
)}
|
line: undefined,
|
||||||
</AlertDialogFooter>
|
subline: undefined
|
||||||
</AlertDialogContent>
|
}))
|
||||||
</AlertDialogPortal>
|
}}
|
||||||
</AlertDialog>
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select company..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{fieldOptions?.companies?.map((company: any) => (
|
||||||
|
<SelectItem key={company.value} value={company.value}>
|
||||||
|
{company.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Line</label>
|
||||||
|
<Select
|
||||||
|
value={globalSelections.line}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setGlobalSelections(prev => ({
|
||||||
|
...prev,
|
||||||
|
line: value,
|
||||||
|
subline: undefined
|
||||||
|
}))
|
||||||
|
}}
|
||||||
|
disabled={!globalSelections.company}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select line..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{productLines?.map((line: any) => (
|
||||||
|
<SelectItem key={line.value} value={line.value}>
|
||||||
|
{line.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Sub Line</label>
|
||||||
|
<Select
|
||||||
|
value={globalSelections.subline}
|
||||||
|
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, subline: value }))}
|
||||||
|
disabled={!globalSelections.line}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select sub line..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{sublines?.map((subline: any) => (
|
||||||
|
<SelectItem key={subline.value} value={subline.value}>
|
||||||
|
{subline.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
<ColumnGrid
|
<ColumnGrid
|
||||||
columns={columns}
|
columns={columns}
|
||||||
onContinue={handleOnContinue}
|
onContinue={handleOnContinue}
|
||||||
@@ -257,6 +466,87 @@ export const MatchColumnsStep = <T extends string>({
|
|||||||
)}
|
)}
|
||||||
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
|
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
|
||||||
|
{/* Required Fields Checklist */}
|
||||||
|
<Card className="mt-8">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
Required Fields
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<HelpCircle className="h-4 w-4 text-muted-foreground" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p className="max-w-xs">
|
||||||
|
These fields are required for product import. You can either map them from your spreadsheet or set them globally above.
|
||||||
|
{unmatchedRequiredFields.length > 0 && " Missing fields will need to be filled in during validation."}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{requiredFields.length > 0 ? (
|
||||||
|
requiredFields.map(field => {
|
||||||
|
const isMatched = matchedRequiredFields.includes(field.key);
|
||||||
|
const isCoveredByGlobal = isFieldCoveredByGlobalSelections(field.key);
|
||||||
|
const isAccountedFor = isMatched || isCoveredByGlobal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key} className="flex items-center gap-2">
|
||||||
|
{isAccountedFor ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className={isAccountedFor ? "text-green-700 dark:text-green-400" : "text-amber-700 dark:text-amber-400"}>
|
||||||
|
{getFieldLabel(field.key)}
|
||||||
|
</span>
|
||||||
|
{isCoveredByGlobal && (
|
||||||
|
<span className="text-xs text-muted-foreground">(set globally)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="col-span-2 text-muted-foreground">
|
||||||
|
No required fields found in the configuration.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="border-t bg-muted px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{onBack && (
|
||||||
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
{translations.matchColumnsStep.backButtonTitle}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{unmatchedRequiredFields.length > 0 && (
|
||||||
|
<span className="text-sm text-amber-600 dark:text-amber-400">
|
||||||
|
{unmatchedRequiredFields.length} required field(s) missing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
disabled={isLoading}
|
||||||
|
onClick={handleOnContinue}
|
||||||
|
>
|
||||||
|
{translations.matchColumnsStep.nextButtonTitle}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type React from "react"
|
|||||||
import type { Column, Columns } from "../MatchColumnsStep"
|
import type { Column, Columns } from "../MatchColumnsStep"
|
||||||
import { ColumnType } from "../MatchColumnsStep"
|
import { ColumnType } from "../MatchColumnsStep"
|
||||||
import { useRsi } from "../../../hooks/useRsi"
|
import { useRsi } from "../../../hooks/useRsi"
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||||
|
|
||||||
type ColumnGridProps<T extends string> = {
|
type ColumnGridProps<T extends string> = {
|
||||||
@@ -18,9 +17,6 @@ export const ColumnGrid = <T extends string>({
|
|||||||
columns,
|
columns,
|
||||||
userColumn,
|
userColumn,
|
||||||
templateColumn,
|
templateColumn,
|
||||||
onContinue,
|
|
||||||
onBack,
|
|
||||||
isLoading,
|
|
||||||
}: ColumnGridProps<T>) => {
|
}: ColumnGridProps<T>) => {
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
const normalColumnWidth = 250
|
const normalColumnWidth = 250
|
||||||
@@ -32,15 +28,13 @@ export const ColumnGrid = <T extends string>({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-10rem)] flex-col">
|
<div className="h-full">
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<div className="px-8 py-6">
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-3xl font-semibold text-foreground">
|
<h2 className="text-3xl font-semibold text-foreground">
|
||||||
{translations.matchColumnsStep.title}
|
{translations.matchColumnsStep.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<ScrollArea className="relative">
|
<ScrollArea className="relative" type="hover">
|
||||||
<div className="space-y-8" style={{ width: totalWidth }}>
|
<div className="space-y-8" style={{ width: totalWidth }}>
|
||||||
{/* Your table section */}
|
{/* Your table section */}
|
||||||
<div>
|
<div>
|
||||||
@@ -90,23 +84,5 @@ export const ColumnGrid = <T extends string>({
|
|||||||
<ScrollBar orientation="horizontal" />
|
<ScrollBar orientation="horizontal" />
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="border-t bg-muted px-8 py-4 -mb-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{onBack && (
|
|
||||||
<Button variant="outline" onClick={onBack}>
|
|
||||||
{translations.matchColumnsStep.backButtonTitle}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="ml-auto"
|
|
||||||
disabled={isLoading}
|
|
||||||
onClick={() => onContinue([])}
|
|
||||||
>
|
|
||||||
{translations.matchColumnsStep.nextButtonTitle}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import type { Fields } from "../../../types"
|
import type { Fields } from "../../../types"
|
||||||
import type { Columns } from "../MatchColumnsStep"
|
import type { Columns } from "../MatchColumnsStep"
|
||||||
|
|
||||||
export const findUnmatchedRequiredFields = <T extends string>(fields: Fields<T>, columns: Columns<T>) =>
|
export const findUnmatchedRequiredFields = <T extends string>(fields: Fields<T>, columns: Columns<T>) => {
|
||||||
fields
|
// Get all required fields
|
||||||
.filter((field) => field.validations?.some((validation) => validation.rule === "required"))
|
const requiredFields = fields
|
||||||
|
.filter((field) => field.validations?.some((validation: any) =>
|
||||||
|
validation.rule === "required" || validation.required === true
|
||||||
|
))
|
||||||
|
|
||||||
|
// Find which required fields are not matched in columns
|
||||||
|
return requiredFields
|
||||||
.filter((field) => columns.findIndex((column) => "value" in column && column.value === field.key) === -1)
|
.filter((field) => columns.findIndex((column) => "value" in column && column.value === field.key) === -1)
|
||||||
.map((field) => field.label) || []
|
.map((field) => field.key) || []
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
|||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { RawData } from "../../types"
|
import type { RawData } from "../../types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { useToast } from "@/hooks/use-toast"
|
||||||
|
|
||||||
type SelectHeaderProps = {
|
type SelectHeaderProps = {
|
||||||
data: RawData[]
|
data: RawData[]
|
||||||
@@ -12,32 +13,165 @@ type SelectHeaderProps = {
|
|||||||
|
|
||||||
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
|
const { toast } = useToast()
|
||||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [localData, setLocalData] = useState<RawData[]>(data)
|
||||||
|
|
||||||
const handleContinue = useCallback(async () => {
|
const handleContinue = useCallback(async () => {
|
||||||
const [selectedRowIndex] = selectedRows
|
const [selectedRowIndex] = selectedRows
|
||||||
// We consider data above header to be redundant
|
// We consider data above header to be redundant
|
||||||
const trimmedData = data.slice(selectedRowIndex + 1)
|
const trimmedData = localData.slice(selectedRowIndex + 1)
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
await onContinue(data[selectedRowIndex], trimmedData)
|
await onContinue(localData[selectedRowIndex], trimmedData)
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}, [onContinue, data, selectedRows])
|
}, [onContinue, localData, selectedRows])
|
||||||
|
|
||||||
|
const discardEmptyAndDuplicateRows = useCallback(() => {
|
||||||
|
// Helper function to count non-empty values in a row
|
||||||
|
const countNonEmptyValues = (values: Record<string, any>): number => {
|
||||||
|
return Object.values(values).filter(val =>
|
||||||
|
val !== undefined &&
|
||||||
|
val !== null &&
|
||||||
|
(typeof val === 'string' ? val.trim() !== '' : true)
|
||||||
|
).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to normalize row values for case-insensitive comparison
|
||||||
|
const normalizeRowForComparison = (row: Record<string, any>): Record<string, any> => {
|
||||||
|
return Object.entries(row).reduce((acc, [key, value]) => {
|
||||||
|
// Convert string values to lowercase for case-insensitive comparison
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
acc[key.toLowerCase()] = value.toLowerCase().trim();
|
||||||
|
} else {
|
||||||
|
acc[key.toLowerCase()] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, any>);
|
||||||
|
};
|
||||||
|
|
||||||
|
// First, analyze all rows to determine if we have rows with multiple values
|
||||||
|
const rowsWithValues = localData.map(row => {
|
||||||
|
return countNonEmptyValues(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we have any rows with more than one value
|
||||||
|
const hasMultiValueRows = rowsWithValues.some(count => count > 1);
|
||||||
|
|
||||||
|
// Get the selected header row
|
||||||
|
const [selectedRowIndex] = selectedRows;
|
||||||
|
const selectedHeaderRow = localData[selectedRowIndex];
|
||||||
|
|
||||||
|
// Debug: Log the selected header row
|
||||||
|
console.log("Selected header row:", selectedHeaderRow);
|
||||||
|
|
||||||
|
const normalizedHeaderRow = normalizeRowForComparison(selectedHeaderRow);
|
||||||
|
|
||||||
|
// Debug: Log the normalized header row
|
||||||
|
console.log("Normalized header row:", normalizedHeaderRow);
|
||||||
|
|
||||||
|
const selectedHeaderStr = JSON.stringify(Object.entries(normalizedHeaderRow).sort());
|
||||||
|
|
||||||
|
// Filter out empty rows, rows with single values (if we have multi-value rows),
|
||||||
|
// and duplicate rows (including duplicates of the header row)
|
||||||
|
const seen = new Set<string>();
|
||||||
|
// Add the selected header row to the seen set first
|
||||||
|
seen.add(selectedHeaderStr);
|
||||||
|
|
||||||
|
// Debug: Track which rows are being removed and why
|
||||||
|
const removedRows: { index: number; reason: string; row: any }[] = [];
|
||||||
|
|
||||||
|
const filteredRows = localData.filter((row, index) => {
|
||||||
|
// Always keep the selected header row
|
||||||
|
if (index === selectedRowIndex) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's empty or has only one value
|
||||||
|
const nonEmptyCount = rowsWithValues[index];
|
||||||
|
if (nonEmptyCount === 0 || (hasMultiValueRows && nonEmptyCount <= 1)) {
|
||||||
|
removedRows.push({ index, reason: "Empty or single value", row });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's a duplicate (case-insensitive)
|
||||||
|
const normalizedRow = normalizeRowForComparison(row);
|
||||||
|
|
||||||
|
// Debug: If this row might be a duplicate of the header, log it
|
||||||
|
if (index < 5 || index === selectedRowIndex + 1 || index === selectedRowIndex - 1) {
|
||||||
|
console.log(`Row ${index} normalized:`, normalizedRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowStr = JSON.stringify(Object.entries(normalizedRow).sort());
|
||||||
|
|
||||||
|
if (seen.has(rowStr)) {
|
||||||
|
removedRows.push({
|
||||||
|
index,
|
||||||
|
reason: "Duplicate",
|
||||||
|
row
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(rowStr);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Debug: Log removed rows
|
||||||
|
console.log("Removed rows:", removedRows);
|
||||||
|
|
||||||
|
// Only update if we actually removed any rows
|
||||||
|
if (filteredRows.length < localData.length) {
|
||||||
|
// Adjust the selected row index if needed
|
||||||
|
const newSelectedIndex = filteredRows.findIndex(row =>
|
||||||
|
JSON.stringify(Object.entries(normalizeRowForComparison(row)).sort()) === selectedHeaderStr
|
||||||
|
);
|
||||||
|
|
||||||
|
// Debug: Log the new selected index
|
||||||
|
console.log("New selected index:", newSelectedIndex);
|
||||||
|
|
||||||
|
setLocalData(filteredRows);
|
||||||
|
setSelectedRows(new Set([newSelectedIndex]));
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Rows removed",
|
||||||
|
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
|
||||||
|
variant: "default"
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: "No rows removed",
|
||||||
|
description: "No empty, single-value, or duplicate rows were found",
|
||||||
|
variant: "default"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [localData, selectedRows, toast]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)]">
|
||||||
<div className="px-8 py-6">
|
<div className="px-8 py-6 bg-background">
|
||||||
<h2 className="text-2xl font-semibold text-foreground">
|
<h2 className="text-2xl font-semibold text-foreground">
|
||||||
{translations.selectHeaderStep.title}
|
{translations.selectHeaderStep.title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 px-8 mb-12 overflow-auto">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
<div className="px-8 mb-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="sm"
|
||||||
|
onClick={discardEmptyAndDuplicateRows}
|
||||||
|
>
|
||||||
|
Remove Empty/Duplicates
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="px-8 flex-1 overflow-auto">
|
||||||
<SelectHeaderTable
|
<SelectHeaderTable
|
||||||
data={data}
|
data={localData}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
setSelectedRows={setSelectedRows}
|
setSelectedRows={setSelectedRows}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-2">
|
<div className="flex items-center justify-between border-t bg-muted px-8 py-4 mt-2">
|
||||||
{onBack && (
|
{onBack && (
|
||||||
<Button variant="outline" onClick={onBack}>
|
<Button variant="outline" onClick={onBack}>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
|||||||
import { mapWorkbook } from "../utils/mapWorkbook"
|
import { mapWorkbook } from "../utils/mapWorkbook"
|
||||||
import { ValidationStep } from "./ValidationStep/ValidationStep"
|
import { ValidationStep } from "./ValidationStep/ValidationStep"
|
||||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||||
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
|
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
|
||||||
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
||||||
import { useRsi } from "../hooks/useRsi"
|
import { useRsi } from "../hooks/useRsi"
|
||||||
import type { RawData } from "../types"
|
import type { RawData } from "../types"
|
||||||
@@ -20,6 +20,7 @@ export enum StepType {
|
|||||||
matchColumns = "matchColumns",
|
matchColumns = "matchColumns",
|
||||||
validateData = "validateData",
|
validateData = "validateData",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StepState =
|
export type StepState =
|
||||||
| {
|
| {
|
||||||
type: StepType.upload
|
type: StepType.upload
|
||||||
@@ -36,10 +37,12 @@ export type StepState =
|
|||||||
type: StepType.matchColumns
|
type: StepType.matchColumns
|
||||||
data: RawData[]
|
data: RawData[]
|
||||||
headerValues: RawData
|
headerValues: RawData
|
||||||
|
globalSelections?: GlobalSelections
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: StepType.validateData
|
type: StepType.validateData
|
||||||
data: any[]
|
data: any[]
|
||||||
|
globalSelections?: GlobalSelections
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -72,6 +75,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
[toast, translations],
|
[toast, translations],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Keep track of global selections across steps
|
||||||
|
const [persistedGlobalSelections, setPersistedGlobalSelections] = useState<GlobalSelections | undefined>(
|
||||||
|
state.type === StepType.validateData || state.type === StepType.matchColumns
|
||||||
|
? state.globalSelections
|
||||||
|
: undefined
|
||||||
|
)
|
||||||
|
|
||||||
switch (state.type) {
|
switch (state.type) {
|
||||||
case StepType.upload:
|
case StepType.upload:
|
||||||
return (
|
return (
|
||||||
@@ -132,6 +142,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
type: StepType.matchColumns,
|
type: StepType.matchColumns,
|
||||||
data,
|
data,
|
||||||
headerValues,
|
headerValues,
|
||||||
|
globalSelections: persistedGlobalSelections,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast((e as Error).message)
|
errorToast((e as Error).message)
|
||||||
@@ -145,13 +156,16 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
<MatchColumnsStep
|
<MatchColumnsStep
|
||||||
data={state.data}
|
data={state.data}
|
||||||
headerValues={state.headerValues}
|
headerValues={state.headerValues}
|
||||||
onContinue={async (values, rawData, columns) => {
|
initialGlobalSelections={persistedGlobalSelections}
|
||||||
|
onContinue={async (values, rawData, columns, globalSelections) => {
|
||||||
try {
|
try {
|
||||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
||||||
|
setPersistedGlobalSelections(globalSelections)
|
||||||
onNext({
|
onNext({
|
||||||
type: StepType.validateData,
|
type: StepType.validateData,
|
||||||
data: dataWithMeta,
|
data: dataWithMeta,
|
||||||
|
globalSelections,
|
||||||
})
|
})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorToast((e as Error).message)
|
errorToast((e as Error).message)
|
||||||
@@ -161,7 +175,20 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case StepType.validateData:
|
case StepType.validateData:
|
||||||
return <ValidationStep initialData={state.data} file={uploadedFile!} onBack={onBack} />
|
return (
|
||||||
|
<ValidationStep
|
||||||
|
initialData={state.data}
|
||||||
|
file={uploadedFile!}
|
||||||
|
onBack={() => {
|
||||||
|
if (onBack) {
|
||||||
|
// When going back, preserve the global selections
|
||||||
|
setPersistedGlobalSelections(state.globalSelections)
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
globalSelections={state.globalSelections}
|
||||||
|
/>
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return <Progress value={33} className="w-full" />
|
return <Progress value={33} className="w-full" />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useMemo, useState, useEffect, memo } from "react"
|
|||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { Meta, Error } from "./types"
|
import type { Meta, Error } from "./types"
|
||||||
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./utils/dataMutations"
|
||||||
import type { Data, SelectOption, Result } from "../../types"
|
import type { Data, SelectOption, Result, Fields, Field } from "../../types"
|
||||||
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
|
import { Check, ChevronsUpDown, ArrowDown, AlertCircle, Loader2 } from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import {
|
import {
|
||||||
@@ -74,6 +74,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
|
import type { GlobalSelections } from "../MatchColumnsStep/MatchColumnsStep"
|
||||||
|
import { useQuery } from "@tanstack/react-query"
|
||||||
|
|
||||||
// Template interface
|
// Template interface
|
||||||
interface Template {
|
interface Template {
|
||||||
@@ -103,8 +105,10 @@ type Props<T extends string> = {
|
|||||||
initialData: RowData<T>[]
|
initialData: RowData<T>[]
|
||||||
file: File
|
file: File
|
||||||
onBack?: () => void
|
onBack?: () => void
|
||||||
|
globalSelections?: GlobalSelections
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove the local Field type declaration since we're importing it
|
||||||
type BaseFieldType = {
|
type BaseFieldType = {
|
||||||
multiline?: boolean;
|
multiline?: boolean;
|
||||||
price?: boolean;
|
price?: boolean;
|
||||||
@@ -121,7 +125,7 @@ type MultiInputFieldType = BaseFieldType & {
|
|||||||
|
|
||||||
type SelectFieldType = {
|
type SelectFieldType = {
|
||||||
type: "select" | "multi-select";
|
type: "select" | "multi-select";
|
||||||
options: SelectOption[];
|
options: readonly SelectOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckboxFieldType = {
|
type CheckboxFieldType = {
|
||||||
@@ -131,23 +135,14 @@ type CheckboxFieldType = {
|
|||||||
|
|
||||||
type FieldType = InputFieldType | MultiInputFieldType | SelectFieldType | CheckboxFieldType;
|
type FieldType = InputFieldType | MultiInputFieldType | SelectFieldType | CheckboxFieldType;
|
||||||
|
|
||||||
type Field<T extends string> = {
|
|
||||||
label: string;
|
|
||||||
key: T;
|
|
||||||
description?: string;
|
|
||||||
alternateMatches?: string[];
|
|
||||||
validations?: ({ rule: string } & Record<string, any>)[];
|
|
||||||
fieldType: FieldType;
|
|
||||||
width?: number;
|
|
||||||
disabled?: boolean;
|
|
||||||
onChange?: (value: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type CellProps = {
|
type CellProps = {
|
||||||
value: any;
|
value: any;
|
||||||
onChange: (value: any) => void;
|
onChange: (value: any) => void;
|
||||||
error?: { level: string; message: string };
|
error?: { level: string; message: string };
|
||||||
field: Field<string>;
|
field: Field<string>;
|
||||||
|
productLines?: SelectOption[];
|
||||||
|
sublines?: SelectOption[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Define ValidationIcon before EditableCell
|
// Define ValidationIcon before EditableCell
|
||||||
@@ -167,12 +162,39 @@ const ValidationIcon = memo(({ error }: { error: { level: string, message: strin
|
|||||||
))
|
))
|
||||||
|
|
||||||
// Wrap EditableCell with memo to avoid unnecessary re-renders
|
// Wrap EditableCell with memo to avoid unnecessary re-renders
|
||||||
const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
const EditableCell = memo(({ value, onChange, error, field, productLines, sublines }: CellProps) => {
|
||||||
const [isEditing, setIsEditing] = useState(false)
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
const [inputValue, setInputValue] = useState(value ?? "")
|
const [inputValue, setInputValue] = useState(value ?? "")
|
||||||
const [searchQuery, setSearchQuery] = useState("")
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
const [localValues, setLocalValues] = useState<string[]>([])
|
const [localValues, setLocalValues] = useState<string[]>([])
|
||||||
|
|
||||||
|
// Determine if the field should be disabled based on its key and context
|
||||||
|
const isFieldDisabled = useMemo(() => {
|
||||||
|
if (field.key === 'line') {
|
||||||
|
// Enable the line field if we have product lines available
|
||||||
|
return !productLines || productLines.length === 0;
|
||||||
|
}
|
||||||
|
if (field.key === 'subline') {
|
||||||
|
// Enable subline field if we have sublines available
|
||||||
|
return !sublines || sublines.length === 0;
|
||||||
|
}
|
||||||
|
return field.disabled;
|
||||||
|
}, [field.key, field.disabled, productLines, sublines]);
|
||||||
|
|
||||||
|
// For debugging
|
||||||
|
useEffect(() => {
|
||||||
|
if (field.key === 'subline') {
|
||||||
|
console.log('Subline field state:', {
|
||||||
|
disabled: field.disabled,
|
||||||
|
isFieldDisabled,
|
||||||
|
value,
|
||||||
|
options: field.fieldType.type === 'select' ? field.fieldType.options : [],
|
||||||
|
sublines,
|
||||||
|
hasSublines: sublines && sublines.length > 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [field, value, sublines, isFieldDisabled]);
|
||||||
|
|
||||||
const handleWheel = useCallback((e: React.WheelEvent) => {
|
const handleWheel = useCallback((e: React.WheelEvent) => {
|
||||||
const commandList = e.currentTarget;
|
const commandList = e.currentTarget;
|
||||||
commandList.scrollTop += e.deltaY;
|
commandList.scrollTop += e.deltaY;
|
||||||
@@ -245,21 +267,33 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
||||||
if (fieldType.type === "select" || fieldType.type === "multi-select") {
|
if (fieldType.type === "select" || fieldType.type === "multi-select") {
|
||||||
if (fieldType.type === "select") {
|
if (fieldType.type === "select") {
|
||||||
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value
|
// For line and subline fields, ensure we're using the latest options
|
||||||
|
if (field.key === 'line' && productLines?.length) {
|
||||||
|
const option = productLines.find((opt: SelectOption) => opt.value === value);
|
||||||
|
return option?.label || value;
|
||||||
|
}
|
||||||
|
if (field.key === 'subline' && sublines?.length) {
|
||||||
|
const option = sublines.find((opt: SelectOption) => opt.value === value);
|
||||||
|
return option?.label || value;
|
||||||
|
}
|
||||||
|
return fieldType.options.find((opt: SelectOption) => opt.value === value)?.label || value;
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
return value.map(v => fieldType.options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ")
|
const options = field.key === 'line' && productLines?.length ? productLines :
|
||||||
|
field.key === 'subline' && sublines?.length ? sublines :
|
||||||
|
fieldType.options;
|
||||||
|
return value.map(v => options.find((opt: SelectOption) => opt.value === v)?.label || v).join(", ");
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
if (fieldType.type === "checkbox") {
|
if (fieldType.type === "checkbox") {
|
||||||
if (typeof value === "boolean") return value ? "Yes" : "No"
|
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
if (fieldType.type === "multi-input" && Array.isArray(value)) {
|
if (fieldType.type === "multi-input" && Array.isArray(value)) {
|
||||||
return value.join(", ")
|
return value.join(", ");
|
||||||
}
|
}
|
||||||
return value
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validateAndCommit = (newValue: string | boolean) => {
|
const validateAndCommit = (newValue: string | boolean) => {
|
||||||
@@ -329,7 +363,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
||||||
<CommandEmpty>No options found.</CommandEmpty>
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{field.fieldType.options
|
{(field.key === 'line' && productLines ? productLines :
|
||||||
|
field.key === 'subline' && sublines ? sublines :
|
||||||
|
field.fieldType.options)
|
||||||
.filter(option =>
|
.filter(option =>
|
||||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
@@ -412,7 +448,9 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
||||||
<CommandEmpty>No options found.</CommandEmpty>
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{field.fieldType.options
|
{(field.key === 'line' && productLines ? productLines :
|
||||||
|
field.key === 'subline' && sublines ? sublines :
|
||||||
|
field.fieldType.options)
|
||||||
.filter(option =>
|
.filter(option =>
|
||||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
)
|
)
|
||||||
@@ -578,7 +616,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
<div
|
<div
|
||||||
id={`cell-${field.key}`}
|
id={`cell-${field.key}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (field.fieldType.type !== "checkbox" && !field.disabled) {
|
if (field.fieldType.type !== "checkbox" && !isFieldDisabled) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setIsEditing(true)
|
setIsEditing(true)
|
||||||
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
|
setInputValue(Array.isArray(value) ? value.join(", ") : value ?? "")
|
||||||
@@ -590,7 +628,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
field.fieldType.multiline && "max-h-[100px] overflow-y-auto",
|
field.fieldType.multiline && "max-h-[100px] overflow-y-auto",
|
||||||
currentError ? "border-destructive" : "border-input",
|
currentError ? "border-destructive" : "border-input",
|
||||||
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
|
field.fieldType.type === "checkbox" ? "flex items-center" : "flex items-center justify-between",
|
||||||
field.disabled && "opacity-50 cursor-not-allowed bg-muted"
|
isFieldDisabled && "opacity-50 cursor-not-allowed bg-muted"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{((field.fieldType.type === "input" || field.fieldType.type === "multi-input") && field.fieldType.multiline) ? (
|
{((field.fieldType.type === "input" || field.fieldType.type === "multi-input") && field.fieldType.multiline) ? (
|
||||||
@@ -620,7 +658,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
|
{(field.fieldType.type === "select" || field.fieldType.type === "multi-select") && (
|
||||||
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", field.disabled ? "opacity-30" : "opacity-50")} />
|
<ChevronsUpDown className={cn("h-4 w-4 shrink-0", isFieldDisabled ? "opacity-30" : "opacity-50")} />
|
||||||
)}
|
)}
|
||||||
{currentError && <ValidationIcon error={currentError} />}
|
{currentError && <ValidationIcon error={currentError} />}
|
||||||
</div>
|
</div>
|
||||||
@@ -1013,11 +1051,152 @@ const SaveTemplateDialog = memo(({
|
|||||||
export const ValidationStep = <T extends string>({
|
export const ValidationStep = <T extends string>({
|
||||||
initialData,
|
initialData,
|
||||||
file,
|
file,
|
||||||
onBack}: Props<T>) => {
|
onBack,
|
||||||
|
globalSelections
|
||||||
|
}: Props<T>) => {
|
||||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
|
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [data, setData] = useState<(Data<T> & ExtendedMeta)[]>(initialData)
|
// Fetch product lines when company is selected
|
||||||
|
const { data: productLines } = useQuery({
|
||||||
|
queryKey: ["product-lines", globalSelections?.company],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!globalSelections?.company) return [];
|
||||||
|
console.log('Fetching product lines for company:', globalSelections.company);
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/product-lines/${globalSelections.company}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error('Failed to fetch product lines:', response.status, response.statusText);
|
||||||
|
throw new Error("Failed to fetch product lines");
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Received product lines:', data);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: !!globalSelections?.company,
|
||||||
|
staleTime: 30000, // Cache for 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch sublines when line is selected
|
||||||
|
const { data: sublines } = useQuery({
|
||||||
|
queryKey: ["sublines", globalSelections?.line],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!globalSelections?.line) return [];
|
||||||
|
const response = await fetch(`${config.apiUrl}/import/sublines/${globalSelections.line}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch sublines");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
enabled: !!globalSelections?.line,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply global selections to initial data and validate
|
||||||
|
const initialDataWithGlobals = useMemo(() => {
|
||||||
|
if (!globalSelections) return initialData;
|
||||||
|
|
||||||
|
// Find the field definitions for our global selection fields
|
||||||
|
const supplierField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'supplier');
|
||||||
|
const companyField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'company');
|
||||||
|
const lineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'line');
|
||||||
|
const sublineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'subline');
|
||||||
|
|
||||||
|
// Helper function to safely set a field value and update options if needed
|
||||||
|
const setFieldValue = (field: Field<T> | undefined, value: string | undefined, options?: SelectOption[]) => {
|
||||||
|
if (!field || !value) return undefined;
|
||||||
|
if (field.fieldType.type === 'select') {
|
||||||
|
// Use provided options if available, otherwise use field's default options
|
||||||
|
const fieldOptions = options || (field.fieldType as SelectFieldType).options;
|
||||||
|
// First try to find by value (ID)
|
||||||
|
const optionByValue = fieldOptions.find(opt => opt.value === value);
|
||||||
|
if (optionByValue) {
|
||||||
|
return optionByValue.value;
|
||||||
|
}
|
||||||
|
// Then try to find by label (name)
|
||||||
|
const optionByLabel = fieldOptions.find(opt => opt.label.toLowerCase() === value.toLowerCase());
|
||||||
|
if (optionByLabel) {
|
||||||
|
return optionByLabel.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Apply global selections to each row
|
||||||
|
const newData = initialData.map(row => {
|
||||||
|
const newRow = { ...row };
|
||||||
|
|
||||||
|
// Apply each global selection if it exists
|
||||||
|
if (globalSelections.supplier) {
|
||||||
|
const supplierValue = setFieldValue(supplierField as Field<T>, globalSelections.supplier);
|
||||||
|
if (supplierValue) newRow.supplier = supplierValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalSelections.company) {
|
||||||
|
const companyValue = setFieldValue(companyField as Field<T>, globalSelections.company);
|
||||||
|
if (companyValue) newRow.company = companyValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalSelections.line && productLines) {
|
||||||
|
const lineValue = setFieldValue(lineField as Field<T>, globalSelections.line, productLines);
|
||||||
|
if (lineValue) newRow.line = lineValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalSelections.subline && sublines) {
|
||||||
|
const sublineValue = setFieldValue(sublineField as Field<T>, globalSelections.subline, sublines);
|
||||||
|
if (sublineValue) newRow.subline = sublineValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newData;
|
||||||
|
}, [initialData, globalSelections, fields, productLines, sublines]);
|
||||||
|
|
||||||
|
// Update field options with fetched data
|
||||||
|
const fieldsWithUpdatedOptions = useMemo(() => {
|
||||||
|
return Array.from(fields as ReadonlyFields<T>).map(field => {
|
||||||
|
if (field.key === 'line') {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
...field.fieldType,
|
||||||
|
options: productLines || (field.fieldType.type === 'select' ? field.fieldType.options : []),
|
||||||
|
},
|
||||||
|
disabled: (!productLines || productLines.length === 0) && !globalSelections?.line
|
||||||
|
} as Field<T>;
|
||||||
|
}
|
||||||
|
if (field.key === 'subline') {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
fieldType: {
|
||||||
|
...field.fieldType,
|
||||||
|
options: sublines || (field.fieldType.type === 'select' ? field.fieldType.options : []),
|
||||||
|
},
|
||||||
|
// Enable subline field if we have a global line selection or if we have sublines available
|
||||||
|
disabled: !globalSelections?.line && (!sublines || sublines.length === 0)
|
||||||
|
} as Field<T>;
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
}, [fields, productLines, sublines, globalSelections?.line]);
|
||||||
|
|
||||||
|
const [data, setData] = useState<RowData<T>[]>(initialDataWithGlobals);
|
||||||
|
|
||||||
|
// Run validation when component mounts or when global selections change
|
||||||
|
useEffect(() => {
|
||||||
|
const validateData = async () => {
|
||||||
|
// Cast the fields to the expected type for validation
|
||||||
|
const validationFields = fieldsWithUpdatedOptions as unknown as Fields<T>;
|
||||||
|
const validatedData = await addErrorsAndRunHooks(
|
||||||
|
initialDataWithGlobals,
|
||||||
|
validationFields,
|
||||||
|
rowHook,
|
||||||
|
tableHook
|
||||||
|
);
|
||||||
|
setData(validatedData as RowData<T>[]);
|
||||||
|
};
|
||||||
|
validateData();
|
||||||
|
}, [initialDataWithGlobals, fieldsWithUpdatedOptions, rowHook, tableHook]);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
const [filterByErrors, setFilterByErrors] = useState(false)
|
const [filterByErrors, setFilterByErrors] = useState(false)
|
||||||
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
||||||
@@ -1211,6 +1390,8 @@ export const ValidationStep = <T extends string>({
|
|||||||
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
|
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
|
||||||
error={error}
|
error={error}
|
||||||
field={field as Field<string>}
|
field={field as Field<string>}
|
||||||
|
productLines={productLines}
|
||||||
|
sublines={sublines}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -1222,7 +1403,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
})))
|
})))
|
||||||
]
|
]
|
||||||
return baseColumns
|
return baseColumns
|
||||||
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate])
|
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: filteredData,
|
data: filteredData,
|
||||||
@@ -1244,70 +1425,17 @@ export const ValidationStep = <T extends string>({
|
|||||||
}
|
}
|
||||||
}, [rowSelection, data, updateData]);
|
}, [rowSelection, data, updateData]);
|
||||||
|
|
||||||
const discardEmptyAndDuplicateRows = useCallback(() => {
|
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>): string | undefined => {
|
||||||
// Helper function to count non-empty values in a row
|
|
||||||
const countNonEmptyValues = (values: Record<string, any>): number => {
|
|
||||||
return Object.values(values).filter(val =>
|
|
||||||
val !== undefined &&
|
|
||||||
val !== null &&
|
|
||||||
(typeof val === 'string' ? val.trim() !== '' : true)
|
|
||||||
).length;
|
|
||||||
};
|
|
||||||
|
|
||||||
// First, analyze all rows to determine if we have rows with multiple values
|
|
||||||
const rowsWithValues = data.map(row => {
|
|
||||||
const { __index, __errors, ...values } = row;
|
|
||||||
return countNonEmptyValues(values);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if we have any rows with more than one value
|
|
||||||
const hasMultiValueRows = rowsWithValues.some(count => count > 1);
|
|
||||||
|
|
||||||
// Filter out empty rows and rows with single values (if we have multi-value rows)
|
|
||||||
const nonEmptyRows = data.filter((_row, index) => {
|
|
||||||
const nonEmptyCount = rowsWithValues[index];
|
|
||||||
|
|
||||||
// Keep the row if:
|
|
||||||
// 1. It has more than one value, OR
|
|
||||||
// 2. It has exactly one value AND we don't have any rows with multiple values
|
|
||||||
return nonEmptyCount > 0 && (!hasMultiValueRows || nonEmptyCount > 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then, remove duplicates by creating a unique string representation of each row
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const uniqueRows = nonEmptyRows.filter(row => {
|
|
||||||
const { __index, __errors, ...values } = row;
|
|
||||||
const rowStr = JSON.stringify(Object.entries(values).sort());
|
|
||||||
if (seen.has(rowStr)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
seen.add(rowStr);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only update if we actually removed any rows
|
|
||||||
if (uniqueRows.length < data.length) {
|
|
||||||
updateData(uniqueRows);
|
|
||||||
setRowSelection({});
|
|
||||||
toast({
|
|
||||||
title: "Rows removed",
|
|
||||||
description: `Removed ${data.length - uniqueRows.length} empty, single-value, or duplicate rows`,
|
|
||||||
variant: "default"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [data, updateData, toast]);
|
|
||||||
|
|
||||||
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>) => {
|
|
||||||
if (field.fieldType.type === "checkbox") {
|
if (field.fieldType.type === "checkbox") {
|
||||||
if (typeof value === "boolean") return value
|
if (typeof value === "boolean") return value ? "true" : "false"
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
const normalizedValue = value.toLowerCase().trim()
|
const normalizedValue = value.toLowerCase().trim()
|
||||||
if (field.fieldType.booleanMatches) {
|
if (field.fieldType.booleanMatches) {
|
||||||
return !!field.fieldType.booleanMatches[normalizedValue]
|
return !!field.fieldType.booleanMatches[normalizedValue] ? "true" : "false"
|
||||||
}
|
}
|
||||||
return ["yes", "true", "1"].includes(normalizedValue)
|
return ["yes", "true", "1"].includes(normalizedValue) ? "true" : "false"
|
||||||
}
|
}
|
||||||
return false
|
return "false"
|
||||||
}
|
}
|
||||||
if (field.fieldType.type === "select" && field.fieldType.options) {
|
if (field.fieldType.type === "select" && field.fieldType.options) {
|
||||||
// Ensure the value matches one of the options
|
// Ensure the value matches one of the options
|
||||||
@@ -1320,61 +1448,85 @@ export const ValidationStep = <T extends string>({
|
|||||||
)
|
)
|
||||||
return matchByLabel ? matchByLabel.value : value
|
return matchByLabel ? matchByLabel.value : value
|
||||||
}
|
}
|
||||||
return value
|
return value?.toString()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const submitData = useCallback(async () => {
|
const submitData = useCallback(async () => {
|
||||||
const calculatedData: Result<T> = data.reduce(
|
const result: Result<T> = {
|
||||||
(acc, value) => {
|
validData: [],
|
||||||
const { __index, __errors, __template, ...values } = value
|
invalidData: [],
|
||||||
|
all: data
|
||||||
|
};
|
||||||
|
|
||||||
|
data.forEach((value) => {
|
||||||
|
const { __index, __errors, __template, ...values } = value;
|
||||||
|
|
||||||
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
||||||
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
|
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key);
|
||||||
if (field) {
|
if (field) {
|
||||||
obj[key as keyof Data<T>] = normalizeValue(val, field as Field<T>)
|
const normalizedVal = normalizeValue(val, field as Field<T>);
|
||||||
} else {
|
if (normalizedVal !== undefined) {
|
||||||
obj[key as keyof Data<T>] = val as string | boolean | undefined
|
obj[key as keyof Data<T>] = normalizedVal as Data<T>[keyof Data<T>];
|
||||||
|
}
|
||||||
|
} else if (val !== undefined) {
|
||||||
|
obj[key as keyof Data<T>] = String(val) as Data<T>[keyof Data<T>];
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}, {} as Data<T>);
|
||||||
|
|
||||||
|
// Apply global selections with proper normalization
|
||||||
|
if (globalSelections) {
|
||||||
|
const supplierField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'supplier');
|
||||||
|
const companyField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'company');
|
||||||
|
const lineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'line');
|
||||||
|
const sublineField = Array.from(fields as ReadonlyFields<T>).find(f => f.key === 'subline');
|
||||||
|
|
||||||
|
const supplier = normalizeValue(normalizedValues.supplier, supplierField as Field<T>);
|
||||||
|
const company = normalizeValue(normalizedValues.company, companyField as Field<T>);
|
||||||
|
const line = normalizeValue(normalizedValues.line, lineField as Field<T>);
|
||||||
|
const subline = normalizeValue(normalizedValues.subline, sublineField as Field<T>);
|
||||||
|
|
||||||
|
if (supplier) normalizedValues.supplier = supplier;
|
||||||
|
if (company) normalizedValues.company = company;
|
||||||
|
if (line) normalizedValues.line = line;
|
||||||
|
if (subline) normalizedValues.subline = subline;
|
||||||
}
|
}
|
||||||
return obj
|
|
||||||
}, {} as Data<T>)
|
|
||||||
|
|
||||||
if (__errors) {
|
if (__errors) {
|
||||||
for (const key in __errors) {
|
for (const key in __errors) {
|
||||||
if (__errors[key].level === "error") {
|
if (__errors[key].level === "error") {
|
||||||
acc.invalidData.push(normalizedValues)
|
result.invalidData.push(normalizedValues);
|
||||||
return acc
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
acc.validData.push(normalizedValues)
|
result.validData.push(normalizedValues);
|
||||||
return acc
|
});
|
||||||
},
|
|
||||||
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
|
setShowSubmitAlert(false);
|
||||||
)
|
setSubmitting(true);
|
||||||
setShowSubmitAlert(false)
|
const response = onSubmit(result, file);
|
||||||
setSubmitting(true)
|
|
||||||
const response = onSubmit(calculatedData, file)
|
|
||||||
if (response?.then) {
|
if (response?.then) {
|
||||||
response
|
response
|
||||||
.then(() => {
|
.then(() => {
|
||||||
onClose()
|
onClose();
|
||||||
})
|
})
|
||||||
.catch((err: Error) => {
|
.catch((err: Error) => {
|
||||||
const defaultMessage = translations.alerts.submitError.defaultMessage
|
const defaultMessage = translations.alerts.submitError.defaultMessage;
|
||||||
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'
|
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred';
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: translations.alerts.submitError.title,
|
title: translations.alerts.submitError.title,
|
||||||
description: String(err?.message || errorMessage),
|
description: String(err?.message || errorMessage),
|
||||||
})
|
});
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
setSubmitting(false)
|
setSubmitting(false);
|
||||||
})
|
});
|
||||||
} else {
|
} else {
|
||||||
onClose()
|
onClose();
|
||||||
}
|
}
|
||||||
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]);
|
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections]);
|
||||||
|
|
||||||
const onContinue = useCallback(() => {
|
const onContinue = useCallback(() => {
|
||||||
const invalidData = data.find((value) => {
|
const invalidData = data.find((value) => {
|
||||||
@@ -1743,6 +1895,7 @@ export const ValidationStep = <T extends string>({
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1761,7 +1914,6 @@ export const ValidationStep = <T extends string>({
|
|||||||
>
|
>
|
||||||
Save Selected as Template
|
Save Selected as Template
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1770,13 +1922,6 @@ export const ValidationStep = <T extends string>({
|
|||||||
>
|
>
|
||||||
{translations.validationStep.discardButtonTitle}
|
{translations.validationStep.discardButtonTitle}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={discardEmptyAndDuplicateRows}
|
|
||||||
>
|
|
||||||
Remove Empty/Duplicates
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export type RsiProps<T extends string> = {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
// Field description for requested data
|
// Field description for requested data
|
||||||
fields: Fields<T>
|
fields: Fields<T>
|
||||||
|
// Initial state of component that will be rendered on load
|
||||||
|
initialStepState?: StepState
|
||||||
// Runs after file upload step, receives and returns raw sheet data
|
// Runs after file upload step, receives and returns raw sheet data
|
||||||
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>
|
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>
|
||||||
// Runs after header selection step, receives and returns raw sheet data
|
// Runs after header selection step, receives and returns raw sheet data
|
||||||
@@ -37,23 +39,28 @@ export type RsiProps<T extends string> = {
|
|||||||
maxFileSize?: number
|
maxFileSize?: number
|
||||||
// Automatically map imported headers to specified fields if possible. Default: true
|
// Automatically map imported headers to specified fields if possible. Default: true
|
||||||
autoMapHeaders?: boolean
|
autoMapHeaders?: boolean
|
||||||
// When field type is "select", automatically match values if possible. Default: false
|
// Automatically map select values to options if possible. Default: false
|
||||||
autoMapSelectValues?: boolean
|
autoMapSelectValues?: boolean
|
||||||
// Headers matching accuracy: 1 for strict and up for more flexible matching
|
// Maximum distance for fuzzy string matching. Default: 2
|
||||||
autoMapDistance?: number
|
autoMapDistance?: number
|
||||||
// Initial Step state to be rendered on load
|
// Date format for parsing dates. Default: "yyyy-mm-dd"
|
||||||
initialStepState?: StepState
|
|
||||||
// Sets SheetJS dateNF option. If date parsing is applied, date will be formatted e.g. "yyyy-mm-dd hh:mm:ss", "m/d/yy h:mm", 'mmm-yy', etc.
|
|
||||||
dateFormat?: string
|
dateFormat?: string
|
||||||
// Sets SheetJS "raw" option. If true, parsing will only be applied to xlsx date fields.
|
// Whether to parse raw values. Default: true
|
||||||
parseRaw?: boolean
|
parseRaw?: boolean
|
||||||
// Use for right-to-left (RTL) support
|
// Use for right-to-left (RTL) support
|
||||||
rtl?: boolean
|
rtl?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RawData = Array<string | undefined>
|
export type RawData = (string | undefined)[]
|
||||||
|
|
||||||
export type Data<T extends string> = { [key in T]: string | boolean | undefined }
|
export type Data<T extends string> = {
|
||||||
|
[key in T]?: string | boolean | undefined
|
||||||
|
} & {
|
||||||
|
supplier?: string
|
||||||
|
company?: string
|
||||||
|
line?: string
|
||||||
|
subline?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Data model RSI uses for spreadsheet imports
|
// Data model RSI uses for spreadsheet imports
|
||||||
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
|
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
|
||||||
@@ -142,13 +149,11 @@ export type RegexValidation = {
|
|||||||
|
|
||||||
export type RowHook<T extends string> = (
|
export type RowHook<T extends string> = (
|
||||||
row: Data<T>,
|
row: Data<T>,
|
||||||
addError: (fieldKey: T, error: Info) => void,
|
rowIndex: number,
|
||||||
table: Data<T>[],
|
allRows: Data<T>[],
|
||||||
) => Data<T> | Promise<Data<T>>
|
) => Promise<Meta> | Meta
|
||||||
export type TableHook<T extends string> = (
|
|
||||||
table: Data<T>[],
|
export type TableHook<T extends string> = (rows: Data<T>[]) => Promise<Meta[]> | Meta[]
|
||||||
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
|
|
||||||
) => Data<T>[] | Promise<Data<T>[]>
|
|
||||||
|
|
||||||
export type ErrorLevel = "info" | "warning" | "error"
|
export type ErrorLevel = "info" | "warning" | "error"
|
||||||
|
|
||||||
@@ -175,5 +180,5 @@ export type InfoWithSource = Info & {
|
|||||||
export type Result<T extends string> = {
|
export type Result<T extends string> = {
|
||||||
validData: Data<T>[]
|
validData: Data<T>[]
|
||||||
invalidData: Data<T>[]
|
invalidData: Data<T>[]
|
||||||
all: (Data<T> & Meta)[]
|
all: Data<T>[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ export function Import() {
|
|||||||
}}
|
}}
|
||||||
onSubmit={handleData}
|
onSubmit={handleData}
|
||||||
fields={importFields}
|
fields={importFields}
|
||||||
|
isNavigationEnabled={true}
|
||||||
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
||||||
/>
|
/>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user