Match columns tweaks

This commit is contained in:
2025-02-27 00:50:31 -05:00
parent ca35a67e9f
commit bb455b3c37
2 changed files with 427 additions and 124 deletions

View File

@@ -19,13 +19,21 @@ import {
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import config from "@/config" import config from "@/config"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, FileIcon } from "lucide-react" import { CheckCircle2, AlertCircle, EyeIcon, EyeOffIcon, ArrowRightIcon, XIcon, FileSpreadsheetIcon, LinkIcon, FileIcon, CheckIcon, ChevronsUpDown } from "lucide-react"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { useVirtualizer } from '@tanstack/react-virtual'; import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { cn } from "@/lib/utils"
export type MatchColumnsProps<T extends string> = { export type MatchColumnsProps<T extends string> = {
data: RawData[] data: RawData[]
@@ -166,15 +174,12 @@ const MemoizedColumnSamplePreview = React.memo(({ samples }: { samples: any[] })
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent side="right" align="start" className="w-[250px] p-0"> <PopoverContent side="right" align="start" className="w-[250px] p-0">
<div className="p-3 border-b"> <ScrollArea className="h-[200px] overflow-y-auto">
<h4 className="text-sm font-medium">Sample Data</h4>
</div>
<ScrollArea className="h-[200px] overflow-auto">
<div className="p-3 space-y-2"> <div className="p-3 space-y-2">
{samples.map((sample, i) => ( {samples.map((sample, i) => (
<div key={i} className="text-sm"> <div key={i} className="text-sm">
<span className="text-muted-foreground text-xs mr-2">{i + 1}:</span>
<span className="font-medium">{String(sample || '(empty)')}</span> <span className="font-medium">{String(sample || '(empty)')}</span>
{i < samples.length - 1 && <Separator className="w-full my-2" />}
</div> </div>
))} ))}
</div> </div>
@@ -264,6 +269,353 @@ const ValueMappings = memo(({
); );
}); });
// Add these new components before the MatchColumnsStep component
const SupplierSelector = React.memo(({
value,
onChange,
suppliers
}: {
value?: string;
onChange: (value: string) => void;
suppliers: any[]
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{value
? suppliers?.find((supplier: any) =>
supplier.value === value)?.label || "Select supplier..."
: "Select supplier..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search suppliers..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No suppliers found.</CommandEmpty>
<CommandGroup>
{suppliers?.map((supplier: any) => (
<CommandItem
key={supplier.value}
value={supplier.label}
onSelect={() => {
onChange(supplier.value);
setOpen(false); // Close popover after selection
}}
>
{supplier.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === supplier.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const CompanySelector = React.memo(({
value,
onChange,
companies
}: {
value?: string;
onChange: (value: string) => void;
companies: any[]
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{value
? companies?.find((company: any) =>
company.value === value)?.label || "Select company..."
: "Select company..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No companies found.</CommandEmpty>
<CommandGroup>
{companies?.map((company: any) => (
<CommandItem
key={company.value}
value={company.label}
onSelect={() => {
onChange(company.value);
setOpen(false); // Close popover after selection
}}
>
{company.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === company.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const LineSelector = React.memo(({
value,
onChange,
lines,
disabled
}: {
value?: string;
onChange: (value: string) => void;
lines: any[];
disabled: boolean;
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
disabled={disabled}
>
{value
? lines?.find((line: any) =>
line.value === value)?.label || "Select line..."
: "Select line..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search lines..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No lines found.</CommandEmpty>
<CommandGroup>
{lines?.map((line: any) => (
<CommandItem
key={line.value}
value={line.label}
onSelect={() => {
onChange(line.value);
setOpen(false); // Close popover after selection
}}
>
{line.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === line.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
const SubLineSelector = React.memo(({
value,
onChange,
sublines,
disabled
}: {
value?: string;
onChange: (value: string) => void;
sublines: any[];
disabled: boolean;
}) => {
const [open, setOpen] = useState(false);
const handleCommandListWheel = (e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
disabled={disabled}
>
{value
? sublines?.find((subline: any) =>
subline.value === value)?.label || "Select sub line..."
: "Select sub line..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search sub lines..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No sub lines found.</CommandEmpty>
<CommandGroup>
{sublines?.map((subline: any) => (
<CommandItem
key={subline.value}
value={subline.label}
onSelect={() => {
onChange(subline.value);
setOpen(false); // Close popover after selection
}}
>
{subline.label}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
value === subline.value
? "opacity-100"
: "opacity-0"
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
// Add this new component before the MatchColumnsStep component
const FieldSelector = React.memo(({
column,
isUnmapped = false,
fieldCategories,
allFields,
onChange,
isFieldMappedToOtherColumn,
handleCommandListWheel
}: {
column: any;
isUnmapped?: boolean;
fieldCategories: any[];
allFields: any[];
onChange: (value: string) => void;
isFieldMappedToOtherColumn: (fieldKey: string, currentColumnIndex: number) => { isMapped: boolean, columnHeader?: string };
handleCommandListWheel: (e: React.WheelEvent) => void;
}) => {
const [open, setOpen] = useState(false);
// For ignored columns, show a badge
if (column.type === ColumnType.ignored) {
return <Badge variant="outline">Ignored</Badge>;
}
// Get the current value if this is a mapped column
const currentValue = "value" in column ? column.value as string : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{currentValue
? allFields.find(f => f.key === currentValue)?.label || "Select field..."
: "Select field..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList className="max-h-[300px] overflow-y-auto" onWheel={handleCommandListWheel}>
<CommandEmpty>No fields found.</CommandEmpty>
{fieldCategories.map(category => (
<CommandGroup key={category.name} heading={category.name}>
{category.fields.map((field: { key: string; label: string }) => {
const { isMapped, columnHeader } = isFieldMappedToOtherColumn(field.key as string, column.index);
return (
<CommandItem
key={field.key as string}
value={field.key as string}
onSelect={(value: string) => {
onChange(value);
setOpen(false); // Close the popover after selection
}}
className={isMapped ? "opacity-70" : ""}
>
<div className="flex-1 flex items-center justify-between">
<span>{field.label}</span>
{isMapped ? (
<span className="text-xs text-muted-foreground ml-2">
(mapped to "{columnHeader}")
</span>
) : null}
</div>
{currentValue === field.key && (
<CheckIcon className="ml-2 h-4 w-4" />
)}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
});
export const MatchColumnsStep = React.memo(<T extends string>({ export const MatchColumnsStep = React.memo(<T extends string>({
data, data,
headerValues, headerValues,
@@ -278,7 +630,7 @@ export const MatchColumnsStep = React.memo(<T extends string>({
([...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 [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {}) const [globalSelections, setGlobalSelections] = useState<GlobalSelections>(initialGlobalSelections || {})
const [showAllColumns, setShowAllColumns] = useState(false) const [showAllColumns, setShowAllColumns] = useState(true)
const [expandedValueMappings, setExpandedValueMappings] = useState<number[]>([]) const [expandedValueMappings, setExpandedValueMappings] = useState<number[]>([])
// Use debounce for expensive operations // Use debounce for expensive operations
@@ -601,18 +953,14 @@ export const MatchColumnsStep = React.memo(<T extends string>({
return mappings; return mappings;
}, [columns, fields, isFieldCoveredByGlobalSelections]); }, [columns, fields, isFieldCoveredByGlobalSelections]);
// Available fields for mapping (excluding already mapped fields) // Available fields for mapping (including already mapped fields)
const availableFields = useMemo(() => { const availableFields = useMemo(() => {
const fieldsArray = Array.isArray(fields) ? fields : [fields]; const fieldsArray = Array.isArray(fields) ? fields : [fields];
const mappedFieldKeys = matchedColumns // Don't filter out mapped fields, only filter global selections
.filter(col => "value" in col)
.map(col => (col as any).value);
return fieldsArray.filter(field => return fieldsArray.filter(field =>
!mappedFieldKeys.includes(field.key) &&
!isFieldCoveredByGlobalSelections(field.key) !isFieldCoveredByGlobalSelections(field.key)
); );
}, [fields, matchedColumns, isFieldCoveredByGlobalSelections]); }, [fields, isFieldCoveredByGlobalSelections]);
// All available fields including already mapped ones (for editing mapped columns) // All available fields including already mapped ones (for editing mapped columns)
const allFields = useMemo(() => { const allFields = useMemo(() => {
@@ -963,48 +1311,48 @@ export const MatchColumnsStep = React.memo(<T extends string>({
return handlers; return handlers;
}, [columns, onChange]); }, [columns, onChange]);
// Render the field selector for a column // Add a function to check if a field is already mapped to another column
const isFieldMappedToOtherColumn = useCallback((fieldKey: string, currentColumnIndex: number) => {
const matchedColumnForField = columns.find(col =>
col.type !== ColumnType.empty &&
col.type !== ColumnType.ignored &&
"value" in col &&
col.value === fieldKey &&
col.index !== currentColumnIndex
);
return matchedColumnForField ? { isMapped: true, columnHeader: matchedColumnForField.header } : { isMapped: false };
}, [columns]);
// Add a wheel handler function for command lists
const handleCommandListWheel = useCallback((e: React.WheelEvent) => {
e.currentTarget.scrollTop += e.deltaY;
e.stopPropagation();
}, []);
// Replace the renderFieldSelector function with a more stable version
const renderFieldSelector = useCallback((column: Column<T>, isUnmapped: boolean = false) => { const renderFieldSelector = useCallback((column: Column<T>, isUnmapped: boolean = false) => {
// For ignored columns, show a badge // For ignored columns, show a badge
if (column.type === ColumnType.ignored) { if (column.type === ColumnType.ignored) {
return <Badge variant="outline">Ignored</Badge>; return <Badge variant="outline">Ignored</Badge>;
} }
// Get the current value if this is a mapped column
const currentValue = "value" in column ? column.value as string : undefined;
// Use all fields for mapped columns, and only available fields for unmapped columns
const fieldCategoriesForSelector = isUnmapped ? availableFieldCategories : allFieldCategories;
// Get the pre-created onChange handler for this column // Get the pre-created onChange handler for this column
const handleChange = columnChangeHandlers.get(column.index); const handleChange = columnChangeHandlers.get(column.index);
return ( return (
<Select <FieldSelector
value={currentValue} column={column}
onValueChange={handleChange} isUnmapped={isUnmapped}
> fieldCategories={availableFieldCategories}
<SelectTrigger> allFields={allFields}
<SelectValue placeholder="Select field..." /> onChange={(value: string) => {
</SelectTrigger> if (handleChange) handleChange(value);
<SelectContent> }}
{fieldCategoriesForSelector.map(category => ( isFieldMappedToOtherColumn={isFieldMappedToOtherColumn}
<div key={category.name}> handleCommandListWheel={handleCommandListWheel}
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground"> />
{category.name}
</div>
{category.fields.map(field => (
<SelectItem key={field.key as string} value={field.key as string}>
{field.label}
</SelectItem>
))}
<Separator className="my-1" />
</div>
))}
</SelectContent>
</Select>
); );
}, [availableFieldCategories, allFieldCategories, columnChangeHandlers]); }, [availableFieldCategories, allFields, columnChangeHandlers, isFieldMappedToOtherColumn, handleCommandListWheel]);
// Replace the renderValueMappings function with a memoized version // Replace the renderValueMappings function with a memoized version
const renderValueMappings = useCallback((column: Column<T>) => { const renderValueMappings = useCallback((column: Column<T>) => {
@@ -1023,8 +1371,8 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<Table> <Table>
<TableHeader className="sticky top-0 bg-muted z-10"> <TableHeader className="sticky top-0 bg-muted z-10">
<TableRow> <TableRow>
<TableHead className="w-1/3">Spreadsheet Column</TableHead> <TableHead className="w-1/4">Imported Spreadsheet Column</TableHead>
<TableHead className="w-12 text-center">Data</TableHead> <TableHead className="w-15 text-center">Sample Data</TableHead>
<TableHead className="w-12"></TableHead> <TableHead className="w-12"></TableHead>
<TableHead>Map To Field</TableHead> <TableHead>Map To Field</TableHead>
<TableHead className="w-24 text-right">Ignore</TableHead> <TableHead className="w-24 text-right">Ignore</TableHead>
@@ -1216,97 +1564,52 @@ export const MatchColumnsStep = React.memo(<T extends string>({
<div className="space-y-2"> <div className="space-y-2">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">Supplier</label> <label className="text-sm font-medium">Supplier</label>
<Select <SupplierSelector
value={globalSelections.supplier} value={globalSelections.supplier}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))} onChange={(value) => setGlobalSelections(prev => ({ ...prev, supplier: value }))}
> suppliers={fieldOptions?.suppliers || []}
<SelectTrigger> />
<SelectValue placeholder="Select supplier..." />
</SelectTrigger>
<SelectContent>
{hasSuppliers(stableFieldOptions) ?
stableFieldOptions.suppliers.map((supplier: any) => (
<SelectItem key={supplier.value} value={supplier.value}>
{supplier.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">Company</label> <label className="text-sm font-medium">Company</label>
<Select <CompanySelector
value={globalSelections.company} value={globalSelections.company}
onValueChange={(value) => { onChange={(value) => setGlobalSelections(prev => ({
setGlobalSelections(prev => ({ ...prev,
...prev, company: value,
company: value, line: undefined,
line: undefined, subline: undefined
subline: undefined }))}
})) companies={fieldOptions?.companies || []}
}} />
>
<SelectTrigger>
<SelectValue placeholder="Select company..." />
</SelectTrigger>
<SelectContent>
{hasCompanies(stableFieldOptions) ?
stableFieldOptions.companies.map((company: any) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">Line</label> <label className="text-sm font-medium">Line</label>
<Select <LineSelector
value={globalSelections.line} value={globalSelections.line}
onValueChange={(value) => { onChange={(value) => setGlobalSelections(prev => ({
setGlobalSelections(prev => ({ ...prev,
...prev, line: value,
line: value, subline: undefined
subline: undefined }))}
})) lines={productLines || []}
}}
disabled={!globalSelections.company} disabled={!globalSelections.company}
> />
<SelectTrigger>
<SelectValue placeholder="Select line..." />
</SelectTrigger>
<SelectContent>
{Array.isArray(stableProductLines) ?
stableProductLines.map((line: any) => (
<SelectItem key={line.value} value={line.value}>
{line.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-sm font-medium">Sub Line</label> <label className="text-sm font-medium">Sub Line</label>
<Select <SubLineSelector
value={globalSelections.subline} value={globalSelections.subline}
onValueChange={(value) => setGlobalSelections(prev => ({ ...prev, subline: value }))} onChange={(value) => setGlobalSelections(prev => ({
...prev,
subline: value
}))}
sublines={sublines || []}
disabled={!globalSelections.line} disabled={!globalSelections.line}
> />
<SelectTrigger>
<SelectValue placeholder="Select sub line..." />
</SelectTrigger>
<SelectContent>
{Array.isArray(stableSublines) ?
stableSublines.map((subline: any) => (
<SelectItem key={subline.value} value={subline.value}>
{subline.label}
</SelectItem>
)) : null}
</SelectContent>
</Select>
</div> </div>
</div> </div>
</div> </div>
@@ -1378,8 +1681,8 @@ export const MatchColumnsStep = React.memo(<T extends string>({
size="sm" size="sm"
onClick={() => setShowAllColumns(!showAllColumns)} onClick={() => setShowAllColumns(!showAllColumns)}
> >
{showAllColumns ? <EyeOffIcon className="h-3.5 w-3.5 mr-1" /> : <EyeIcon className="h-3.5 w-3.5 mr-1" />} {!showAllColumns ? <EyeIcon className="h-3.5 w-3.5 mr-1" /> : <EyeOffIcon className="h-3.5 w-3.5 mr-1" />}
{showAllColumns ? "Hide mapped" : "Show all"} {!showAllColumns ? "Show all" : "Hide mapped"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -48,7 +48,7 @@ export const translations = {
filterSwitchTitle: "Show only rows with errors", filterSwitchTitle: "Show only rows with errors",
}, },
imageUploadStep: { imageUploadStep: {
title: "Add Product Images", title: "Add Images",
nextButtonTitle: "Submit", nextButtonTitle: "Submit",
backButtonTitle: "Back", backButtonTitle: "Back",
}, },