Files
admin-panel-hokm/components/EditableTable.tsx
T
2026-08-07 09:40:16 +03:30

198 lines
6.1 KiB
TypeScript

"use client";
import { useState } from "react";
import { Save, Trash2, Plus, Loader2 } from "lucide-react";
import NumberInput from "./NumberInput";
import { toastError, toastSuccess } from "@/lib/toast";
export type Col = {
key: string;
label: string;
type?: "text" | "number" | "bool";
readonly?: boolean;
};
type Row = Record<string, unknown>;
// سلولِ ورودی — در سطحِ ماژول تعریف شده تا با هر کلید دوباره mount نشود (وگرنه
// اینپوت پس از یک حرف فوکوس را از دست می‌دهد).
function Cell({
row,
col,
onChange,
}: {
row: Row;
col: Col;
onChange: (v: unknown) => void;
}) {
const v = row[col.key];
if (col.type === "bool") {
return (
<input
type="checkbox"
checked={!!v && v !== 0}
disabled={col.readonly}
onChange={(e) => onChange(e.target.checked)}
/>
);
}
if (col.type === "number") {
// ورودیِ عددیِ گروه‌بندی‌شده (جداکننده‌ی هزارگان) و قابلِ تایپ با کیبورد.
return (
<NumberInput
style={{ minWidth: 90 }}
value={Number(v ?? 0)}
disabled={col.readonly}
onChange={onChange}
/>
);
}
return (
<input
className="inp"
style={{ minWidth: 110 }}
type="text"
value={v === null || v === undefined ? "" : String(v)}
readOnly={col.readonly}
onChange={(e) => onChange(e.target.value)}
/>
);
}
export default function EditableTable({
cols,
rows,
onSave,
onDelete,
newTemplate,
extra,
preview,
}: {
cols: Col[];
rows: Row[];
onSave: (row: Row) => Promise<void>;
onDelete: (id: string) => Promise<void>;
newTemplate: Row;
/** ستون/دکمه‌ی اضافی برای هر ردیف (مثلاً آپلود تصویر). */
extra?: (row: Row) => React.ReactNode;
/** پیش‌نمایشِ تصویرِ ابتدای ردیف (مثلاً فرش یا کارت). */
preview?: (row: Row) => React.ReactNode;
}) {
const [draft, setDraft] = useState<Row>({ ...newTemplate });
const [busy, setBusy] = useState<string | null>(null);
// نسخه‌ی محلیِ قابلِ‌ویرایش هر ردیف.
const [edit, setEdit] = useState<Record<string, Row>>({});
const rowState = (r: Row) => edit[String(r.id)] ?? r;
const patch = (id: string, key: string, val: unknown) =>
setEdit((e) => ({ ...e, [id]: { ...(e[id] ?? rows.find((r) => String(r.id) === id)!), [key]: val } }));
// پیش از ذخیره، هر ستون را به نوعِ اعلام‌شده تبدیل کن تا سرور عددِ رشته‌ای نگیرد
// (وگرنه 400: bad request). اعداد → number، بولین‌ها → boolean.
const coerce = (row: Row): Row => {
const out: Row = { ...row };
for (const c of cols) {
if (c.type === "number") out[c.key] = Number(out[c.key] ?? 0) || 0;
else if (c.type === "bool") out[c.key] = !!out[c.key] && out[c.key] !== 0;
}
return out;
};
return (
<div className="card overflow-x-auto">
<table>
<thead>
<tr>
{preview && <th>پیش‌نمایش</th>}
{cols.map((c) => (
<th key={c.key}>{c.label}</th>
))}
<th></th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const id = String(r.id);
const rs = rowState(r);
return (
<tr key={id}>
{preview && <td>{preview(r)}</td>}
{cols.map((c) => (
<td key={c.key}>
<Cell row={rs} col={c} onChange={(v) => patch(id, c.key, v)} />
</td>
))}
<td className="flex items-center gap-1">
<button
className="btn btn-gold"
disabled={busy === id}
onClick={async () => {
setBusy(id);
try {
await onSave(coerce(rs));
toastSuccess("ذخیره شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
</button>
<button className="btn btn-danger" onClick={() => onDelete(id)}>
<Trash2 size={14} />
</button>
{extra?.(r)}
</td>
</tr>
);
})}
{/* ردیفِ افزودن */}
<tr style={{ background: "rgba(233,185,73,.06)" }}>
{preview && <td />}
{cols.map((c) => (
<td key={c.key}>
<Cell
row={draft}
col={{ ...c, readonly: false }}
onChange={(v) => setDraft((d) => ({ ...d, [c.key]: v }))}
/>
</td>
))}
<td>
<button
className="btn btn-ghost flex items-center gap-1"
disabled={busy === "__new__"}
onClick={async () => {
setBusy("__new__");
try {
await onSave(coerce(draft));
setDraft({ ...newTemplate });
toastSuccess("افزوده شد");
} catch (e) {
toastError((e as Error).message);
} finally {
setBusy(null);
}
}}
>
{busy === "__new__" ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Plus size={14} />
)}{" "}
افزودن
</button>
</td>
</tr>
</tbody>
</table>
</div>
);
}