first commit
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user