467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
flexRender,
|
|
getCoreRowModel,
|
|
useReactTable,
|
|
} from "@tanstack/react-table";
|
|
import { Check, ChevronRight, ChevronsUpDown, Plus } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
CommandSeparator,
|
|
} from "@/components/ui/command";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@/components/ui/popover";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface InputData {
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
mobile: string;
|
|
country: string;
|
|
city: string;
|
|
}
|
|
|
|
const SOURCE_FIELD_LABELS: Record<keyof InputData, string> = {
|
|
firstName: "نام",
|
|
lastName: "نام خانوادگی",
|
|
email: "ایمیل",
|
|
mobile: "موبایل",
|
|
country: "کشور",
|
|
city: "شهر",
|
|
};
|
|
|
|
const DUMMY_INPUT_DATA: InputData[] = [
|
|
{
|
|
firstName: "علی",
|
|
lastName: "محمدی",
|
|
email: "ali.mohammadi@acme.co",
|
|
mobile: "5551234567",
|
|
country: "آلمان",
|
|
city: "برلین",
|
|
},
|
|
{
|
|
firstName: "سارا",
|
|
lastName: "احمدی",
|
|
email: "sara.ahmadi@acme.co",
|
|
mobile: "5559876543",
|
|
country: "ژاپن",
|
|
city: "توکیو",
|
|
},
|
|
{
|
|
firstName: "رضا",
|
|
lastName: "کریمی",
|
|
email: "reza.karimi@acme.co",
|
|
mobile: "5552468135",
|
|
country: "برزیل",
|
|
city: "سائوپائولو",
|
|
},
|
|
{
|
|
firstName: "مینا",
|
|
lastName: "حسینی",
|
|
email: "mina.hosseini@acme.co",
|
|
mobile: "5551357924",
|
|
country: "فرانسه",
|
|
city: "پاریس",
|
|
},
|
|
{
|
|
firstName: "امیر",
|
|
lastName: "رضایی",
|
|
email: "amir.rezaei@acme.co",
|
|
mobile: "5558642097",
|
|
country: "اسپانیا",
|
|
city: "بارسلونا",
|
|
},
|
|
];
|
|
|
|
interface Column {
|
|
header: string;
|
|
accessorKey: string;
|
|
}
|
|
|
|
const DEFAULT_COLUMNS: Column[] = [
|
|
{
|
|
header: "نام",
|
|
accessorKey: "firstName",
|
|
},
|
|
{
|
|
header: "نام خانوادگی",
|
|
accessorKey: "lastName",
|
|
},
|
|
{
|
|
header: "ایمیل",
|
|
accessorKey: "email",
|
|
},
|
|
{
|
|
header: "تلفن",
|
|
accessorKey: "phone",
|
|
},
|
|
{
|
|
header: "کشور",
|
|
accessorKey: "country",
|
|
},
|
|
];
|
|
|
|
interface FieldComboboxProps {
|
|
columns: Column[];
|
|
value: string;
|
|
onValueChange: (value: string) => void;
|
|
onCreateField: (fieldName: string) => void;
|
|
onFocus: () => void;
|
|
}
|
|
|
|
const FieldCombobox = ({
|
|
columns,
|
|
value,
|
|
onValueChange,
|
|
onCreateField,
|
|
onFocus,
|
|
}: FieldComboboxProps) => {
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState("");
|
|
|
|
const handleCreate = () => {
|
|
if (
|
|
search &&
|
|
!columns.find((col) => col.header.toLowerCase() === search.toLowerCase())
|
|
) {
|
|
onCreateField(search);
|
|
onValueChange(search.toLowerCase().replace(/\s+/g, ""));
|
|
setOpen(false);
|
|
setSearch("");
|
|
}
|
|
};
|
|
|
|
const selectedColumn = columns.find((col) => col.accessorKey === value);
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild onClick={onFocus}>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className="w-full justify-between"
|
|
>
|
|
{selectedColumn ? selectedColumn.header : "انتخاب فیلد"}
|
|
<ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="p-0">
|
|
<Command>
|
|
<CommandInput
|
|
placeholder="جستجو یا افزودن فیلد..."
|
|
value={search}
|
|
onValueChange={setSearch}
|
|
/>
|
|
<CommandList>
|
|
<CommandEmpty>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full justify-start"
|
|
onClick={handleCreate}
|
|
>
|
|
<Plus className="size-4" />
|
|
افزودن «{search}»
|
|
</Button>
|
|
</CommandEmpty>
|
|
<CommandGroup>
|
|
{columns.map((column) => (
|
|
<CommandItem
|
|
key={column.accessorKey}
|
|
value={column.accessorKey}
|
|
onSelect={(currentValue) => {
|
|
onValueChange(currentValue === value ? "" : currentValue);
|
|
setOpen(false);
|
|
}}
|
|
>
|
|
<Check
|
|
className={cn(
|
|
"size-4",
|
|
value !== column.accessorKey && "opacity-0",
|
|
)}
|
|
/>
|
|
{column.header}
|
|
</CommandItem>
|
|
))}
|
|
</CommandGroup>
|
|
{search &&
|
|
!columns.find(
|
|
(col) => col.header.toLowerCase() === search.toLowerCase(),
|
|
) && (
|
|
<>
|
|
<CommandSeparator />
|
|
<CommandGroup>
|
|
<CommandItem onSelect={handleCreate}>
|
|
<Plus className="size-4" />
|
|
افزودن «{search}»
|
|
</CommandItem>
|
|
</CommandGroup>
|
|
</>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
);
|
|
};
|
|
|
|
interface DataPreviewListProps {
|
|
currentFocusedField: string | null;
|
|
}
|
|
|
|
const DataPreviewList = ({ currentFocusedField }: DataPreviewListProps) => {
|
|
if (!currentFocusedField) return null;
|
|
return (
|
|
<div className="space-y-2 border bg-background p-4 shadow-xs">
|
|
<p className="text-sm font-semibold">
|
|
نمونه مقادیر برای{" "}
|
|
{SOURCE_FIELD_LABELS[currentFocusedField as keyof InputData] ??
|
|
currentFocusedField}
|
|
</p>
|
|
<ul>
|
|
{DUMMY_INPUT_DATA.map((item) => {
|
|
return (
|
|
<li
|
|
key={item.email}
|
|
className="border-b py-2 text-sm first:border-t last:border-b-0"
|
|
>
|
|
{item[currentFocusedField as keyof typeof item]}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
interface FieldMapping1Props {
|
|
className?: string;
|
|
}
|
|
|
|
const FieldMapping1 = ({ className }: FieldMapping1Props) => {
|
|
const [data, setData] = useState<Record<string, string>[]>([]);
|
|
const [columns, setColumns] = useState<Column[]>(DEFAULT_COLUMNS);
|
|
const [open, setOpen] = useState(false);
|
|
const [currentFocusedField, setCurrentFocusedField] = useState<string | null>(
|
|
null,
|
|
);
|
|
const [mappings, setMappings] = useState<Record<string, string>>({});
|
|
|
|
const handleCreateField = (fieldName: string) => {
|
|
const accessorKey = fieldName.toLowerCase().replace(/\s+/g, "");
|
|
if (!columns.find((col) => col.accessorKey === accessorKey)) {
|
|
setColumns([...columns, { header: fieldName, accessorKey }]);
|
|
}
|
|
};
|
|
|
|
const handleMappingChange = (incomingField: string, destField: string) => {
|
|
setMappings((prev) => ({
|
|
...prev,
|
|
[incomingField]: destField,
|
|
}));
|
|
};
|
|
|
|
const handleSave = () => {
|
|
const transformedData = DUMMY_INPUT_DATA.map((item) => {
|
|
const newRow: Record<string, string> = {};
|
|
|
|
Object.entries(mappings).forEach(([incomingField, destField]) => {
|
|
if (destField) {
|
|
newRow[destField] = item[incomingField as keyof InputData];
|
|
}
|
|
});
|
|
|
|
return newRow;
|
|
});
|
|
|
|
setData(transformedData);
|
|
setOpen(false);
|
|
};
|
|
|
|
const table = useReactTable({
|
|
data,
|
|
columns,
|
|
getCoreRowModel: getCoreRowModel(),
|
|
});
|
|
|
|
return (
|
|
<section className="py-32">
|
|
<div className="relative container">
|
|
<Table>
|
|
<TableHeader>
|
|
{table.getHeaderGroups().map((headerGroup) => (
|
|
<TableRow key={headerGroup.id}>
|
|
{headerGroup.headers.map((header) => {
|
|
return (
|
|
<TableHead key={header.id}>
|
|
{flexRender(
|
|
header.column.columnDef.header,
|
|
header.getContext(),
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))}
|
|
</TableHeader>
|
|
<TableBody>
|
|
{table.getRowModel().rows.length === 0
|
|
? Array.from({ length: 10 }).map((_, i) => (
|
|
<TableRow key={`empty-${i}`}>
|
|
{columns.map((column) => (
|
|
<TableCell
|
|
key={`empty-${i}-${column.accessorKey}`}
|
|
className="h-9"
|
|
/>
|
|
))}
|
|
</TableRow>
|
|
))
|
|
: table.getRowModel().rows.map((row) => (
|
|
<TableRow key={row.id}>
|
|
{row.getVisibleCells().map((cell) => (
|
|
<TableCell key={cell.id}>
|
|
{flexRender(
|
|
cell.column.columnDef.cell,
|
|
cell.getContext(),
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{data.length === 0 && (
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-background/50 backdrop-blur-[1px]">
|
|
<h3 className="text-center text-lg font-semibold md:text-xl">
|
|
فایل را اینجا رها کنید یا برای شروع مرور کنید
|
|
</h3>
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button>مرور فایلها</Button>
|
|
</DialogTrigger>
|
|
<DialogContent
|
|
className={cn(
|
|
"overflow-hidden p-0 md:!max-w-3xl lg:!max-w-6xl",
|
|
className,
|
|
)}
|
|
showCloseButton={false}
|
|
>
|
|
<DialogHeader className="sr-only">
|
|
<DialogTitle>پیکربندی نگاشت فیلد</DialogTitle>
|
|
<DialogDescription>
|
|
ستونهای داده را با فیلدهای مقصد تطبیق دهید
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between gap-4 border-b px-6 py-4">
|
|
<h3 className="text-lg font-semibold md:text-xl">
|
|
پیکربندی نگاشت
|
|
</h3>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
خروج
|
|
</Button>
|
|
<Button
|
|
disabled={Object.keys(mappings).length === 0}
|
|
onClick={handleSave}
|
|
>
|
|
ذخیره
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="grid gap-4 md:grid-cols-9">
|
|
<div className="col-span-5 space-y-4 p-6">
|
|
<p className="text-sm font-semibold md:text-base">
|
|
ستونهای مبدأ را با فیلدهای مقصد تطبیق دهید
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4 text-xs font-semibold text-muted-foreground">
|
|
<p>ستونهای مبدأ</p>
|
|
<p>فیلدهای مقصد</p>
|
|
</div>
|
|
|
|
<ul className="space-y-6">
|
|
{Object.keys(DUMMY_INPUT_DATA[0]).map((key) => {
|
|
return (
|
|
<li
|
|
key={`incoming-field-${key}`}
|
|
className="grid grid-cols-2 gap-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<Badge variant="secondary">
|
|
{
|
|
SOURCE_FIELD_LABELS[
|
|
key as keyof InputData
|
|
]
|
|
}
|
|
</Badge>
|
|
|
|
<ChevronRight className="size-4 text-muted-foreground" />
|
|
</div>
|
|
<FieldCombobox
|
|
columns={columns}
|
|
value={mappings[key] || ""}
|
|
onValueChange={(value) =>
|
|
handleMappingChange(key, value)
|
|
}
|
|
onCreateField={handleCreateField}
|
|
onFocus={() => setCurrentFocusedField(key)}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="col-span-4 hidden bg-muted p-8 md:block">
|
|
<DataPreviewList
|
|
currentFocusedField={currentFocusedField}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
export { FieldMapping1 };
|