Add global options to pass in to validate step, move remove empty/duplicate button to select header row step
This commit is contained in:
@@ -24,12 +24,31 @@ import {
|
||||
AlertDialogOverlay,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
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"
|
||||
|
||||
export type MatchColumnsProps<T extends string> = {
|
||||
data: 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
|
||||
initialGlobalSelections?: GlobalSelections
|
||||
}
|
||||
|
||||
export type GlobalSelections = {
|
||||
supplier?: string
|
||||
company?: string
|
||||
line?: string
|
||||
subline?: string
|
||||
}
|
||||
|
||||
export enum ColumnType {
|
||||
@@ -98,6 +117,7 @@ export const MatchColumnsStep = <T extends string>({
|
||||
headerValues,
|
||||
onContinue,
|
||||
onBack,
|
||||
initialGlobalSelections
|
||||
}: MatchColumnsProps<T>) => {
|
||||
const dataExample = data.slice(0, 2)
|
||||
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations, allowInvalidSubmit } = useRsi<T>()
|
||||
@@ -107,6 +127,54 @@ export const MatchColumnsStep = <T extends string>({
|
||||
([...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(
|
||||
(value: T, columnIndex: number) => {
|
||||
@@ -185,17 +253,19 @@ export const MatchColumnsStep = <T extends string>({
|
||||
setShowUnmatchedFieldsAlert(true)
|
||||
} else {
|
||||
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)
|
||||
}
|
||||
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields])
|
||||
}, [unmatchedRequiredFields.length, onContinue, columns, data, fields, globalSelections])
|
||||
|
||||
const handleAlertOnContinue = useCallback(async () => {
|
||||
setShowUnmatchedFieldsAlert(false)
|
||||
setIsLoading(true)
|
||||
await onContinue(normalizeTableData(columns, data, fields), data, columns)
|
||||
await onContinue(normalizeTableData(columns, data, fields), data, columns, globalSelections)
|
||||
setIsLoading(false)
|
||||
}, [onContinue, columns, data, fields])
|
||||
}, [onContinue, columns, data, fields, globalSelections])
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
@@ -242,21 +312,150 @@ export const MatchColumnsStep = <T extends string>({
|
||||
</AlertDialogContent>
|
||||
</AlertDialogPortal>
|
||||
</AlertDialog>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
onContinue={handleOnContinue}
|
||||
onBack={onBack}
|
||||
isLoading={isLoading}
|
||||
userColumn={(column) => (
|
||||
<UserTableColumn
|
||||
column={column}
|
||||
onIgnore={onIgnore}
|
||||
onRevertIgnore={onRevertIgnore}
|
||||
entries={dataExample.map((row) => row[column.index])}
|
||||
/>
|
||||
)}
|
||||
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
|
||||
/>
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="px-8 py-6 flex-1 overflow-auto">
|
||||
<div className="space-y-8">
|
||||
{/* Global Selections Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Global Selections</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Supplier</label>
|
||||
<Select
|
||||
value={globalSelections.supplier}
|
||||
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
|
||||
>
|
||||
<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 className="space-y-2">
|
||||
<label className="text-sm font-medium">Company</label>
|
||||
<Select
|
||||
value={globalSelections.company}
|
||||
onValueChange={(value) => {
|
||||
setGlobalSelections(prev => ({
|
||||
...prev,
|
||||
company: value,
|
||||
line: undefined,
|
||||
subline: undefined
|
||||
}))
|
||||
}}
|
||||
>
|
||||
<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
|
||||
columns={columns}
|
||||
onContinue={handleOnContinue}
|
||||
onBack={onBack}
|
||||
isLoading={isLoading}
|
||||
userColumn={(column) => (
|
||||
<UserTableColumn
|
||||
column={column}
|
||||
onIgnore={onIgnore}
|
||||
onRevertIgnore={onRevertIgnore}
|
||||
entries={dataExample.map((row) => row[column.index])}
|
||||
/>
|
||||
)}
|
||||
templateColumn={(column) => <TemplateColumn column={column} onChange={onChange} onSubChange={onSubChange} />}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
disabled={isLoading}
|
||||
onClick={handleOnContinue}
|
||||
>
|
||||
{translations.matchColumnsStep.nextButtonTitle}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import type React from "react"
|
||||
import type { Column, Columns } from "../MatchColumnsStep"
|
||||
import { ColumnType } from "../MatchColumnsStep"
|
||||
import { useRsi } from "../../../hooks/useRsi"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"
|
||||
|
||||
type ColumnGridProps<T extends string> = {
|
||||
@@ -18,9 +17,6 @@ export const ColumnGrid = <T extends string>({
|
||||
columns,
|
||||
userColumn,
|
||||
templateColumn,
|
||||
onContinue,
|
||||
onBack,
|
||||
isLoading,
|
||||
}: ColumnGridProps<T>) => {
|
||||
const { translations } = useRsi()
|
||||
const normalColumnWidth = 250
|
||||
@@ -32,81 +28,61 @@ export const ColumnGrid = <T extends string>({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-10rem)] flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="px-8 py-6">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-semibold text-foreground">
|
||||
{translations.matchColumnsStep.title}
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea className="relative">
|
||||
<div className="space-y-8" style={{ width: totalWidth }}>
|
||||
{/* Your table section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium text-foreground">
|
||||
{translations.matchColumnsStep.userTableTitle}
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="grid auto-cols-fr gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: columns.map(col =>
|
||||
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
|
||||
).join(" "),
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={column.header + index}>
|
||||
{userColumn(column)}
|
||||
</div>
|
||||
))}
|
||||
<div className="h-full">
|
||||
<div className="mb-8">
|
||||
<h2 className="text-3xl font-semibold text-foreground">
|
||||
{translations.matchColumnsStep.title}
|
||||
</h2>
|
||||
</div>
|
||||
<ScrollArea className="relative" type="hover">
|
||||
<div className="space-y-8" style={{ width: totalWidth }}>
|
||||
{/* Your table section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium text-foreground">
|
||||
{translations.matchColumnsStep.userTableTitle}
|
||||
</h3>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="grid auto-cols-fr gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: columns.map(col =>
|
||||
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
|
||||
).join(" "),
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={column.header + index}>
|
||||
{userColumn(column)}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/50 to-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Will become section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium text-foreground">
|
||||
{translations.matchColumnsStep.templateTitle}
|
||||
</h3>
|
||||
<div
|
||||
className="grid auto-cols-fr gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: columns.map(col =>
|
||||
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
|
||||
).join(" "),
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={column.header + index}>
|
||||
{templateColumn(column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-16 bg-gradient-to-b from-transparent via-background/50 to-background" />
|
||||
</div>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Will become section */}
|
||||
<div>
|
||||
<h3 className="mb-4 text-lg font-medium text-foreground">
|
||||
{translations.matchColumnsStep.templateTitle}
|
||||
</h3>
|
||||
<div
|
||||
className="grid auto-cols-fr gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: columns.map(col =>
|
||||
`${col.type === ColumnType.ignored ? ignoredColumnWidth : normalColumnWidth}px`
|
||||
).join(" "),
|
||||
}}
|
||||
>
|
||||
{columns.map((column, index) => (
|
||||
<div key={column.header + index}>
|
||||
{templateColumn(column)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
<ScrollBar orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
||||
import { useRsi } from "../../hooks/useRsi"
|
||||
import type { RawData } from "../../types"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
|
||||
type SelectHeaderProps = {
|
||||
data: RawData[]
|
||||
@@ -12,17 +13,142 @@ type SelectHeaderProps = {
|
||||
|
||||
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
||||
const { translations } = useRsi()
|
||||
const { toast } = useToast()
|
||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [localData, setLocalData] = useState<RawData[]>(data)
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
const [selectedRowIndex] = selectedRows
|
||||
// We consider data above header to be redundant
|
||||
const trimmedData = data.slice(selectedRowIndex + 1)
|
||||
const trimmedData = localData.slice(selectedRowIndex + 1)
|
||||
setIsLoading(true)
|
||||
await onContinue(data[selectedRowIndex], trimmedData)
|
||||
await onContinue(localData[selectedRowIndex], trimmedData)
|
||||
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,
|
||||
normalizedRow,
|
||||
rowStr,
|
||||
headerStr: selectedHeaderStr
|
||||
});
|
||||
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 (
|
||||
<div className="flex flex-col">
|
||||
@@ -32,8 +158,17 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex-1 px-8 mb-12 overflow-auto">
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={discardEmptyAndDuplicateRows}
|
||||
>
|
||||
Remove Empty/Duplicates
|
||||
</Button>
|
||||
</div>
|
||||
<SelectHeaderTable
|
||||
data={data}
|
||||
data={localData}
|
||||
selectedRows={selectedRows}
|
||||
setSelectedRows={setSelectedRows}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { SelectSheetStep } from "./SelectSheetStep/SelectSheetStep"
|
||||
import { mapWorkbook } from "../utils/mapWorkbook"
|
||||
import { ValidationStep } from "./ValidationStep/ValidationStep"
|
||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
|
||||
import { MatchColumnsStep, type GlobalSelections } from "./MatchColumnsStep/MatchColumnsStep"
|
||||
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
||||
import { useRsi } from "../hooks/useRsi"
|
||||
import type { RawData } from "../types"
|
||||
@@ -20,6 +20,7 @@ export enum StepType {
|
||||
matchColumns = "matchColumns",
|
||||
validateData = "validateData",
|
||||
}
|
||||
|
||||
export type StepState =
|
||||
| {
|
||||
type: StepType.upload
|
||||
@@ -36,10 +37,12 @@ export type StepState =
|
||||
type: StepType.matchColumns
|
||||
data: RawData[]
|
||||
headerValues: RawData
|
||||
globalSelections?: GlobalSelections
|
||||
}
|
||||
| {
|
||||
type: StepType.validateData
|
||||
data: any[]
|
||||
globalSelections?: GlobalSelections
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -72,6 +75,13 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
[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) {
|
||||
case StepType.upload:
|
||||
return (
|
||||
@@ -132,6 +142,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
type: StepType.matchColumns,
|
||||
data,
|
||||
headerValues,
|
||||
globalSelections: persistedGlobalSelections,
|
||||
})
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message)
|
||||
@@ -145,13 +156,16 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
<MatchColumnsStep
|
||||
data={state.data}
|
||||
headerValues={state.headerValues}
|
||||
onContinue={async (values, rawData, columns) => {
|
||||
initialGlobalSelections={persistedGlobalSelections}
|
||||
onContinue={async (values, rawData, columns, globalSelections) => {
|
||||
try {
|
||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook)
|
||||
setPersistedGlobalSelections(globalSelections)
|
||||
onNext({
|
||||
type: StepType.validateData,
|
||||
data: dataWithMeta,
|
||||
globalSelections,
|
||||
})
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message)
|
||||
@@ -161,7 +175,20 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
/>
|
||||
)
|
||||
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:
|
||||
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 type { Meta, Error } from "./types"
|
||||
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 { cn } from "@/lib/utils"
|
||||
import {
|
||||
@@ -74,6 +74,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import type { GlobalSelections } from "../MatchColumnsStep/MatchColumnsStep"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
|
||||
// Template interface
|
||||
interface Template {
|
||||
@@ -103,8 +105,10 @@ type Props<T extends string> = {
|
||||
initialData: RowData<T>[]
|
||||
file: File
|
||||
onBack?: () => void
|
||||
globalSelections?: GlobalSelections
|
||||
}
|
||||
|
||||
// Remove the local Field type declaration since we're importing it
|
||||
type BaseFieldType = {
|
||||
multiline?: boolean;
|
||||
price?: boolean;
|
||||
@@ -121,7 +125,7 @@ type MultiInputFieldType = BaseFieldType & {
|
||||
|
||||
type SelectFieldType = {
|
||||
type: "select" | "multi-select";
|
||||
options: SelectOption[];
|
||||
options: readonly SelectOption[];
|
||||
}
|
||||
|
||||
type CheckboxFieldType = {
|
||||
@@ -131,23 +135,14 @@ type 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 = {
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
error?: { level: string; message: string };
|
||||
field: Field<string>;
|
||||
productLines?: SelectOption[];
|
||||
sublines?: SelectOption[];
|
||||
}
|
||||
|
||||
// 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
|
||||
const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
||||
const EditableCell = memo(({ value, onChange, error, field, productLines, sublines }: CellProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value ?? "")
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
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 commandList = e.currentTarget;
|
||||
commandList.scrollTop += e.deltaY;
|
||||
@@ -245,21 +267,33 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
||||
const getDisplayValue = (value: any, fieldType: Field<string>["fieldType"]) => {
|
||||
if (fieldType.type === "select" || fieldType.type === "multi-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)) {
|
||||
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 (typeof value === "boolean") return value ? "Yes" : "No"
|
||||
return value
|
||||
if (typeof value === "boolean") return value ? "Yes" : "No";
|
||||
return value;
|
||||
}
|
||||
if (fieldType.type === "multi-input" && Array.isArray(value)) {
|
||||
return value.join(", ")
|
||||
return value.join(", ");
|
||||
}
|
||||
return value
|
||||
return value;
|
||||
}
|
||||
|
||||
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}>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{field.fieldType.options
|
||||
{(field.key === 'line' && productLines ? productLines :
|
||||
field.key === 'subline' && sublines ? sublines :
|
||||
field.fieldType.options)
|
||||
.filter(option =>
|
||||
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}>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{field.fieldType.options
|
||||
{(field.key === 'line' && productLines ? productLines :
|
||||
field.key === 'subline' && sublines ? sublines :
|
||||
field.fieldType.options)
|
||||
.filter(option =>
|
||||
option.label.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
@@ -578,7 +616,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
||||
<div
|
||||
id={`cell-${field.key}`}
|
||||
onClick={(e) => {
|
||||
if (field.fieldType.type !== "checkbox" && !field.disabled) {
|
||||
if (field.fieldType.type !== "checkbox" && !isFieldDisabled) {
|
||||
e.stopPropagation()
|
||||
setIsEditing(true)
|
||||
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",
|
||||
currentError ? "border-destructive" : "border-input",
|
||||
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) ? (
|
||||
@@ -620,7 +658,7 @@ const EditableCell = memo(({ value, onChange, error, field }: CellProps) => {
|
||||
</div>
|
||||
)}
|
||||
{(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} />}
|
||||
</div>
|
||||
@@ -1013,11 +1051,152 @@ const SaveTemplateDialog = memo(({
|
||||
export const ValidationStep = <T extends string>({
|
||||
initialData,
|
||||
file,
|
||||
onBack}: Props<T>) => {
|
||||
onBack,
|
||||
globalSelections
|
||||
}: Props<T>) => {
|
||||
const { translations, fields, onClose, onSubmit, rowHook, tableHook, allowInvalidSubmit } = useRsi<T>();
|
||||
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 [filterByErrors, setFilterByErrors] = useState(false)
|
||||
const [showSubmitAlert, setShowSubmitAlert] = useState(false)
|
||||
@@ -1211,6 +1390,8 @@ export const ValidationStep = <T extends string>({
|
||||
onChange={(newValue) => updateRows(rowIndex, column.id, newValue)}
|
||||
error={error}
|
||||
field={field as Field<string>}
|
||||
productLines={productLines}
|
||||
sublines={sublines}
|
||||
/>
|
||||
)
|
||||
},
|
||||
@@ -1222,7 +1403,7 @@ export const ValidationStep = <T extends string>({
|
||||
})))
|
||||
]
|
||||
return baseColumns
|
||||
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate])
|
||||
}, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines])
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredData,
|
||||
@@ -1244,70 +1425,17 @@ export const ValidationStep = <T extends string>({
|
||||
}
|
||||
}, [rowSelection, data, updateData]);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// 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>) => {
|
||||
const normalizeValue = useCallback((value: any, field: DeepReadonlyField<T>): string | undefined => {
|
||||
if (field.fieldType.type === "checkbox") {
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "boolean") return value ? "true" : "false"
|
||||
if (typeof value === "string") {
|
||||
const normalizedValue = value.toLowerCase().trim()
|
||||
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) {
|
||||
// Ensure the value matches one of the options
|
||||
@@ -1320,61 +1448,85 @@ export const ValidationStep = <T extends string>({
|
||||
)
|
||||
return matchByLabel ? matchByLabel.value : value
|
||||
}
|
||||
return value
|
||||
return value?.toString()
|
||||
}, [])
|
||||
|
||||
const submitData = useCallback(async () => {
|
||||
const calculatedData: Result<T> = data.reduce(
|
||||
(acc, value) => {
|
||||
const { __index, __errors, __template, ...values } = value
|
||||
const result: Result<T> = {
|
||||
validData: [],
|
||||
invalidData: [],
|
||||
all: data
|
||||
};
|
||||
|
||||
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
||||
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key)
|
||||
if (field) {
|
||||
obj[key as keyof Data<T>] = normalizeValue(val, field as Field<T>)
|
||||
} else {
|
||||
obj[key as keyof Data<T>] = val as string | boolean | undefined
|
||||
data.forEach((value) => {
|
||||
const { __index, __errors, __template, ...values } = value;
|
||||
|
||||
const normalizedValues = Object.entries(values).reduce((obj, [key, val]) => {
|
||||
const field = Array.from(fields as ReadonlyFields<T>).find((f) => f.key === key);
|
||||
if (field) {
|
||||
const normalizedVal = normalizeValue(val, field as Field<T>);
|
||||
if (normalizedVal !== undefined) {
|
||||
obj[key as keyof Data<T>] = normalizedVal as Data<T>[keyof Data<T>];
|
||||
}
|
||||
return obj
|
||||
}, {} as 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>);
|
||||
|
||||
if (__errors) {
|
||||
for (const key in __errors) {
|
||||
if (__errors[key].level === "error") {
|
||||
acc.invalidData.push(normalizedValues)
|
||||
return acc
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
|
||||
if (__errors) {
|
||||
for (const key in __errors) {
|
||||
if (__errors[key].level === "error") {
|
||||
result.invalidData.push(normalizedValues);
|
||||
return;
|
||||
}
|
||||
}
|
||||
acc.validData.push(normalizedValues)
|
||||
return acc
|
||||
},
|
||||
{ validData: [] as Data<T>[], invalidData: [] as Data<T>[], all: data },
|
||||
)
|
||||
setShowSubmitAlert(false)
|
||||
setSubmitting(true)
|
||||
const response = onSubmit(calculatedData, file)
|
||||
}
|
||||
result.validData.push(normalizedValues);
|
||||
});
|
||||
|
||||
setShowSubmitAlert(false);
|
||||
setSubmitting(true);
|
||||
const response = onSubmit(result, file);
|
||||
if (response?.then) {
|
||||
response
|
||||
.then(() => {
|
||||
onClose()
|
||||
onClose();
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
const defaultMessage = translations.alerts.submitError.defaultMessage
|
||||
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred'
|
||||
const defaultMessage = translations.alerts.submitError.defaultMessage;
|
||||
const errorMessage = typeof defaultMessage === 'string' ? defaultMessage : 'An error occurred';
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: translations.alerts.submitError.title,
|
||||
description: String(err?.message || errorMessage),
|
||||
})
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setSubmitting(false)
|
||||
})
|
||||
setSubmitting(false);
|
||||
});
|
||||
} else {
|
||||
onClose()
|
||||
onClose();
|
||||
}
|
||||
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations]);
|
||||
}, [data, fields, file, onClose, onSubmit, normalizeValue, toast, translations, globalSelections]);
|
||||
|
||||
const onContinue = useCallback(() => {
|
||||
const invalidData = data.find((value) => {
|
||||
@@ -1743,25 +1895,25 @@ export const ValidationStep = <T extends string>({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const selectedRows = Object.keys(rowSelection);
|
||||
if (selectedRows.length !== 1) {
|
||||
toast({
|
||||
title: "Invalid selection",
|
||||
description: "Please select exactly one row to save as a template",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowSaveTemplateDialog(true);
|
||||
}}
|
||||
>
|
||||
Save Selected as Template
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const selectedRows = Object.keys(rowSelection);
|
||||
if (selectedRows.length !== 1) {
|
||||
toast({
|
||||
title: "Invalid selection",
|
||||
description: "Please select exactly one row to save as a template",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setShowSaveTemplateDialog(true);
|
||||
}}
|
||||
>
|
||||
Save Selected as Template
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -1770,13 +1922,6 @@ export const ValidationStep = <T extends string>({
|
||||
>
|
||||
{translations.validationStep.discardButtonTitle}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={discardEmptyAndDuplicateRows}
|
||||
>
|
||||
Remove Empty/Duplicates
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
||||
@@ -11,6 +11,8 @@ export type RsiProps<T extends string> = {
|
||||
onClose: () => void
|
||||
// Field description for requested data
|
||||
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
|
||||
uploadStepHook?: (data: RawData[]) => Promise<RawData[]>
|
||||
// Runs after header selection step, receives and returns raw sheet data
|
||||
@@ -37,23 +39,28 @@ export type RsiProps<T extends string> = {
|
||||
maxFileSize?: number
|
||||
// Automatically map imported headers to specified fields if possible. Default: true
|
||||
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
|
||||
// Headers matching accuracy: 1 for strict and up for more flexible matching
|
||||
// Maximum distance for fuzzy string matching. Default: 2
|
||||
autoMapDistance?: number
|
||||
// Initial Step state to be rendered on load
|
||||
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.
|
||||
// Date format for parsing dates. Default: "yyyy-mm-dd"
|
||||
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
|
||||
// Use for right-to-left (RTL) support
|
||||
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
|
||||
export type Fields<T extends string> = DeepReadonly<Field<T>[]>
|
||||
@@ -142,13 +149,11 @@ export type RegexValidation = {
|
||||
|
||||
export type RowHook<T extends string> = (
|
||||
row: Data<T>,
|
||||
addError: (fieldKey: T, error: Info) => void,
|
||||
table: Data<T>[],
|
||||
) => Data<T> | Promise<Data<T>>
|
||||
export type TableHook<T extends string> = (
|
||||
table: Data<T>[],
|
||||
addError: (rowIndex: number, fieldKey: T, error: Info) => void,
|
||||
) => Data<T>[] | Promise<Data<T>[]>
|
||||
rowIndex: number,
|
||||
allRows: Data<T>[],
|
||||
) => Promise<Meta> | Meta
|
||||
|
||||
export type TableHook<T extends string> = (rows: Data<T>[]) => Promise<Meta[]> | Meta[]
|
||||
|
||||
export type ErrorLevel = "info" | "warning" | "error"
|
||||
|
||||
@@ -175,5 +180,5 @@ export type InfoWithSource = Info & {
|
||||
export type Result<T extends string> = {
|
||||
validData: Data<T>[]
|
||||
invalidData: Data<T>[]
|
||||
all: (Data<T> & Meta)[]
|
||||
all: Data<T>[]
|
||||
}
|
||||
|
||||
@@ -597,6 +597,7 @@ export function Import() {
|
||||
}}
|
||||
onSubmit={handleData}
|
||||
fields={importFields}
|
||||
isNavigationEnabled={true}
|
||||
initialStepState={startFromScratch ? { type: StepType.validateData, data: [{}] } : undefined}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user