304 lines
11 KiB
TypeScript
304 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
ArrowDownIcon,
|
|
ArrowLeftIcon,
|
|
ArrowRightIcon,
|
|
ArrowUpIcon,
|
|
Columns3Icon,
|
|
PlusIcon,
|
|
Rows3Icon,
|
|
Trash2Icon,
|
|
} from "lucide-react";
|
|
import Spreadsheet, { type Point } from "react-spreadsheet";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { DeleteConfirmationDialog } from "@/components/admin/delete-confirmation-dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuGroup,
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
addExtraSheetColumn,
|
|
addExtraSheetRow,
|
|
deleteExtraSheetColumn,
|
|
deleteExtraSheetRow,
|
|
extraSheetToMatrix,
|
|
matrixToExtraSheet,
|
|
moveExtraSheetColumn,
|
|
moveExtraSheetRow,
|
|
nextExtraSheetColumnLabel,
|
|
type ExtraSheet,
|
|
type ExtraSheetMatrix,
|
|
} from "@/lib/guides/extra-sheet";
|
|
|
|
type DeleteTarget = "column" | "row" | null;
|
|
|
|
function LiteralDataViewer({ cell }: { cell?: { value?: unknown } }) {
|
|
return (
|
|
<span className="Spreadsheet__data-viewer Spreadsheet__data-viewer--preserve-breaks">
|
|
{String(cell?.value ?? "")}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function ExtraSheetEditor({
|
|
value,
|
|
createId,
|
|
onChange,
|
|
onRemove,
|
|
}: {
|
|
value: ExtraSheet;
|
|
createId: (prefix: string) => string;
|
|
onChange: (value: ExtraSheet) => void;
|
|
onRemove: () => void;
|
|
}) {
|
|
const [active, setActive] = useState<Point>({ row: 0, column: 0 });
|
|
const [labelDialogOpen, setLabelDialogOpen] = useState(false);
|
|
const [renaming, setRenaming] = useState(false);
|
|
const [columnLabel, setColumnLabel] = useState("");
|
|
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget>(null);
|
|
const [removeOpen, setRemoveOpen] = useState(false);
|
|
const matrix = useMemo(() => extraSheetToMatrix(value), [value]);
|
|
const activeColumn = value.columns[active.column];
|
|
const activeRow = value.rows[active.row];
|
|
const normalizedLabel = columnLabel.trim().toLocaleLowerCase();
|
|
const labelInvalid = !normalizedLabel || value.columns.some((column, index) =>
|
|
index !== (renaming ? active.column : -1) &&
|
|
column.label.toLocaleLowerCase() === normalizedLabel,
|
|
);
|
|
|
|
const openAddColumn = () => {
|
|
setRenaming(false);
|
|
setColumnLabel(nextExtraSheetColumnLabel(value.columns));
|
|
setLabelDialogOpen(true);
|
|
};
|
|
const openRenameColumn = () => {
|
|
if (!activeColumn) return;
|
|
setRenaming(true);
|
|
setColumnLabel(activeColumn.label);
|
|
setLabelDialogOpen(true);
|
|
};
|
|
const saveColumnLabel = () => {
|
|
if (labelInvalid) return;
|
|
if (renaming) {
|
|
onChange({
|
|
...value,
|
|
columns: value.columns.map((column, index) =>
|
|
index === active.column ? { ...column, label: columnLabel.trim() } : column,
|
|
),
|
|
});
|
|
} else {
|
|
const column = value.columns.length;
|
|
onChange(addExtraSheetColumn(
|
|
value,
|
|
columnLabel.trim(),
|
|
createId("extra-sheet-column"),
|
|
));
|
|
setActive((current) => ({ ...current, column }));
|
|
}
|
|
setLabelDialogOpen(false);
|
|
};
|
|
|
|
return (
|
|
<div className="flex min-w-0 flex-col gap-3" aria-label="ตารางข้อมูลเสริม">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
const row = value.rows.length;
|
|
onChange(addExtraSheetRow(value, createId("extra-sheet-row")));
|
|
setActive((current) => ({ ...current, row }));
|
|
}}
|
|
>
|
|
<PlusIcon data-icon="inline-start" />
|
|
เพิ่มแถว
|
|
</Button>
|
|
<Button type="button" variant="outline" size="sm" onClick={openAddColumn}>
|
|
<PlusIcon data-icon="inline-start" />
|
|
เพิ่มคอลัมน์
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<Button type="button" variant="outline" size="sm" />}
|
|
>
|
|
<Rows3Icon data-icon="inline-start" />
|
|
แถว {active.row + 1}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>จัดการแถว {active.row + 1}</DropdownMenuLabel>
|
|
<DropdownMenuItem
|
|
disabled={!activeRow || active.row === 0}
|
|
onClick={() => {
|
|
onChange(moveExtraSheetRow(value, active.row, active.row - 1));
|
|
setActive((current) => ({ ...current, row: current.row - 1 }));
|
|
}}
|
|
>
|
|
<ArrowUpIcon />ย้ายขึ้น
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!activeRow || active.row === value.rows.length - 1}
|
|
onClick={() => {
|
|
onChange(moveExtraSheetRow(value, active.row, active.row + 1));
|
|
setActive((current) => ({ ...current, row: current.row + 1 }));
|
|
}}
|
|
>
|
|
<ArrowDownIcon />ย้ายลง
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={value.rows.length === 1}
|
|
onClick={() => setDeleteTarget("row")}
|
|
>
|
|
<Trash2Icon />ลบแถว
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger
|
|
render={<Button type="button" variant="outline" size="sm" />}
|
|
>
|
|
<Columns3Icon data-icon="inline-start" />
|
|
{activeColumn?.label ?? "คอลัมน์"}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuLabel>{activeColumn?.label}</DropdownMenuLabel>
|
|
<DropdownMenuItem onClick={openRenameColumn}>แก้ไขชื่อ</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!activeColumn || active.column === 0}
|
|
onClick={() => {
|
|
onChange(moveExtraSheetColumn(value, active.column, active.column - 1));
|
|
setActive((current) => ({ ...current, column: current.column - 1 }));
|
|
}}
|
|
>
|
|
<ArrowLeftIcon />ย้ายไปทางซ้าย
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
disabled={!activeColumn || active.column === value.columns.length - 1}
|
|
onClick={() => {
|
|
onChange(moveExtraSheetColumn(value, active.column, active.column + 1));
|
|
setActive((current) => ({ ...current, column: current.column + 1 }));
|
|
}}
|
|
>
|
|
<ArrowRightIcon />ย้ายไปทางขวา
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuGroup>
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={value.columns.length === 1}
|
|
onClick={() => setDeleteTarget("column")}
|
|
>
|
|
<Trash2Icon />ลบคอลัมน์
|
|
</DropdownMenuItem>
|
|
</DropdownMenuGroup>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setRemoveOpen(true)}>
|
|
<Trash2Icon data-icon="inline-start" />
|
|
ลบตาราง
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto rounded-lg border">
|
|
<Spreadsheet
|
|
className="extra-sheet-grid"
|
|
data={matrix}
|
|
columnLabels={value.columns.map((column) => column.label)}
|
|
DataViewer={LiteralDataViewer}
|
|
onActivate={setActive}
|
|
onChange={(next) => onChange(matrixToExtraSheet(
|
|
value,
|
|
next as ExtraSheetMatrix,
|
|
createId,
|
|
))}
|
|
/>
|
|
</div>
|
|
|
|
<Dialog open={labelDialogOpen} onOpenChange={setLabelDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{renaming ? "แก้ไขชื่อคอลัมน์" : "เพิ่มคอลัมน์"}</DialogTitle>
|
|
<DialogDescription>ตั้งชื่อที่ใช้อธิบายข้อมูลในคอลัมน์นี้</DialogDescription>
|
|
</DialogHeader>
|
|
<FieldGroup>
|
|
<Field data-invalid={labelInvalid || undefined}>
|
|
<FieldLabel htmlFor="extra-sheet-column-label">ชื่อคอลัมน์</FieldLabel>
|
|
<Input
|
|
id="extra-sheet-column-label"
|
|
value={columnLabel}
|
|
aria-invalid={labelInvalid || undefined}
|
|
autoFocus
|
|
onChange={(event) => setColumnLabel(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") saveColumnLabel();
|
|
}}
|
|
/>
|
|
</Field>
|
|
</FieldGroup>
|
|
<DialogFooter showCloseButton>
|
|
<Button type="button" disabled={labelInvalid} onClick={saveColumnLabel}>
|
|
{renaming ? "บันทึกชื่อ" : "เพิ่มคอลัมน์"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<DeleteConfirmationDialog
|
|
open={deleteTarget !== null}
|
|
onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}
|
|
title={deleteTarget === "column" ? `ลบ ${activeColumn?.label}?` : `ลบแถว ${active.row + 1}?`}
|
|
description="ข้อมูลในรายการนี้จะถูกลบออกจากตาราง"
|
|
showSuccessToast={false}
|
|
onConfirm={() => {
|
|
if (deleteTarget === "column") {
|
|
onChange(deleteExtraSheetColumn(value, active.column));
|
|
setActive((current) => ({ ...current, column: Math.max(0, current.column - 1) }));
|
|
} else if (deleteTarget === "row") {
|
|
onChange(deleteExtraSheetRow(value, active.row));
|
|
setActive((current) => ({ ...current, row: Math.max(0, current.row - 1) }));
|
|
}
|
|
setDeleteTarget(null);
|
|
}}
|
|
/>
|
|
<DeleteConfirmationDialog
|
|
open={removeOpen}
|
|
onOpenChange={setRemoveOpen}
|
|
title="ลบตารางนี้?"
|
|
description="คอลัมน์ แถว และข้อมูลในเซลล์ทั้งหมดจะถูกลบออกจากการ์ด"
|
|
confirmLabel="ยืนยันลบตาราง"
|
|
showSuccessToast={false}
|
|
onConfirm={() => {
|
|
onRemove();
|
|
setRemoveOpen(false);
|
|
}}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|