198 lines
6.1 KiB
TypeScript
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>
|
|
);
|
|
}
|