412 lines
20 KiB
TypeScript
412 lines
20 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||
import Link from "next/link";
|
||
import { AdminPageHeader, Banner, Button, EmptyState, Modal } from "@/components/admin/controls";
|
||
import { useMe } from "@/hooks/useMe";
|
||
import type { CommercialAdminResourceDef } from "../admin/resources";
|
||
import {
|
||
createAdminRegistryItem,
|
||
deleteAdminRegistryItem,
|
||
listAdminRegistry,
|
||
type RegistryRecord,
|
||
updateAdminRegistryItem,
|
||
} from "../adapters/admin";
|
||
import type { AdapterResult } from "../types";
|
||
import {
|
||
enumLabel,
|
||
fieldLabel,
|
||
friendlyMessage,
|
||
humanizeCommercialCode,
|
||
isTechnicalField,
|
||
} from "../presentation";
|
||
|
||
const PAGE_SIZE = 10;
|
||
const FIELD_GROUPS = [
|
||
{ title: "اطلاعات پایه", keys: ["display_name", "title", "description", "category"] },
|
||
{ title: "اطلاعات تجاری", keys: ["role", "pricing_model", "amount_minor", "currency", "interval", "trial_days", "duration_days", "grace_period_days"] },
|
||
{ title: "قیمتگذاری", keys: ["pricing_refs", "tax_class_code", "discount_refs", "coupon_code"] },
|
||
{ title: "قابلیتها", keys: ["product_codes", "capability_codes", "feature_refs", "required_capabilities"] },
|
||
{ title: "نمایش", keys: ["visibility", "display_order", "trial_eligible"] },
|
||
{ title: "انتشار", keys: ["status", "published_at", "starts_at", "ends_at"] },
|
||
{ title: "اطلاعات تکمیلی", keys: ["metadata", "attributes", "parameters", "usage_limits", "feature_limits"] },
|
||
] as const;
|
||
|
||
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||
const name = item[resource.nameField];
|
||
return humanizeCommercialCode(item[resource.codeField], typeof name === "string" ? name : null);
|
||
}
|
||
|
||
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||
const code = item[resource.codeField];
|
||
return code == null ? "" : String(code);
|
||
}
|
||
|
||
function recordTargetId(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
|
||
const target = item.registry_object_id ?? item.id ?? item[resource.codeField];
|
||
return target == null ? "" : String(target);
|
||
}
|
||
|
||
function defaultRecord(resource: CommercialAdminResourceDef): RegistryRecord {
|
||
const seed: RegistryRecord = {
|
||
[resource.codeField]: "",
|
||
[resource.nameField]: "",
|
||
status: "draft",
|
||
};
|
||
if (resource.key === "products") Object.assign(seed, { visibility: "public", display_order: 0 });
|
||
if (resource.key === "trial-rules") Object.assign(seed, { duration_days: 14, grace_period_days: 3, auto_disable: true });
|
||
if (resource.key === "pricing") Object.assign(seed, { pricing_model: "monthly", currency: "IRR", amount_minor: 0 });
|
||
if (resource.key === "policies") Object.assign(seed, { effect: "deny", parameters: { custom_domain_allowed: false, subdomain_only: true, ssl_ready: false } });
|
||
return seed;
|
||
}
|
||
|
||
function parseFieldValue(key: string, input: string, current: unknown): unknown {
|
||
if (Array.isArray(current) || /(_refs|_codes|capabilities)$/.test(key)) {
|
||
return input.split("،").flatMap((part) => part.split(",")).map((part) => part.trim()).filter(Boolean);
|
||
}
|
||
if (typeof current === "number" || /(amount|days|order|percent|limit)$/.test(key)) {
|
||
return input === "" ? null : Number(input);
|
||
}
|
||
return input;
|
||
}
|
||
|
||
function FieldInput({
|
||
name,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
name: string;
|
||
value: unknown;
|
||
onChange: (value: unknown) => void;
|
||
}) {
|
||
if (Array.isArray(value)) {
|
||
return (
|
||
<div className="sm:col-span-2">
|
||
<p className="text-sm font-medium text-secondary">{fieldLabel(name)}</p>
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
{value.length ? value.map((item, index) => (
|
||
<span key={`${String(item)}-${index}`} className="rounded-full bg-sky-50 px-3 py-1 text-xs text-sky-800">
|
||
{humanizeCommercialCode(item)}
|
||
</span>
|
||
)) : <span className="text-xs text-gray-500">موردی انتخاب نشده است.</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (typeof value === "boolean") {
|
||
return (
|
||
<label className="flex cursor-pointer items-center justify-between rounded-xl border border-gray-200 px-4 py-3">
|
||
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
|
||
<input type="checkbox" checked={value} onChange={(event) => onChange(event.target.checked)} className="h-4 w-4 accent-primary" />
|
||
</label>
|
||
);
|
||
}
|
||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||
return (
|
||
<fieldset className="rounded-2xl border border-gray-200 p-4">
|
||
<legend className="px-2 text-sm font-semibold text-secondary">{fieldLabel(name)}</legend>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{Object.entries(value as Record<string, unknown>)
|
||
.filter(([key]) => !isTechnicalField(key))
|
||
.map(([key, nested]) => (
|
||
<FieldInput
|
||
key={key}
|
||
name={key}
|
||
value={nested}
|
||
onChange={(next) => onChange({ ...(value as Record<string, unknown>), [key]: next })}
|
||
/>
|
||
))}
|
||
</div>
|
||
</fieldset>
|
||
);
|
||
}
|
||
const displayValue = value == null ? "" : String(value);
|
||
const selectOptions: Record<string, string[]> = {
|
||
status: ["draft", "active", "published", "inactive"],
|
||
visibility: ["public", "private", "hidden"],
|
||
pricing_model: ["monthly", "yearly", "trial", "enterprise_quote"],
|
||
interval: ["monthly", "yearly"],
|
||
currency: ["IRR"],
|
||
};
|
||
if (selectOptions[name]) {
|
||
return (
|
||
<label className="block">
|
||
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
|
||
<select value={displayValue} onChange={(event) => onChange(event.target.value)} className="mt-1 w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm">
|
||
{selectOptions[name].map((option) => <option key={option} value={option}>{enumLabel(option)}</option>)}
|
||
</select>
|
||
</label>
|
||
);
|
||
}
|
||
return (
|
||
<label className={name === "description" ? "block sm:col-span-2" : "block"}>
|
||
<span className="text-sm font-medium text-secondary">{fieldLabel(name)}</span>
|
||
{name === "description" ? (
|
||
<textarea value={displayValue} onChange={(event) => onChange(event.target.value)} rows={3} className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5 text-sm" />
|
||
) : (
|
||
<input
|
||
type={typeof value === "number" ? "number" : /(_at|date)$/.test(name) ? "date" : "text"}
|
||
value={displayValue}
|
||
onChange={(event) => onChange(parseFieldValue(name, event.target.value, value))}
|
||
placeholder={Array.isArray(value) ? "موارد را با ویرگول جدا کنید" : undefined}
|
||
className="mt-1 w-full rounded-xl border border-gray-200 px-3 py-2.5 text-sm"
|
||
/>
|
||
)}
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function CommercialRecordForm({
|
||
value,
|
||
resource,
|
||
onChange,
|
||
}: {
|
||
value: RegistryRecord;
|
||
resource: CommercialAdminResourceDef;
|
||
onChange: (record: RegistryRecord) => void;
|
||
}) {
|
||
const visibleKeys = Object.keys(value).filter((key) => !isTechnicalField(key));
|
||
const grouped = new Set<string>(FIELD_GROUPS.flatMap((group) => [...group.keys]));
|
||
const advanced = visibleKeys.filter((key) => !grouped.has(key) && key !== resource.nameField);
|
||
const setField = (key: string, next: unknown) => onChange({ ...value, [key]: next });
|
||
|
||
return (
|
||
<div className="max-h-[65vh] space-y-6 overflow-y-auto px-1">
|
||
{FIELD_GROUPS.map((group) => {
|
||
const keys = Array.from(new Set([
|
||
...(group.title === "اطلاعات پایه" ? [resource.nameField] : []),
|
||
...group.keys,
|
||
])).filter((key) => key in value && !isTechnicalField(key));
|
||
if (!keys.length) return null;
|
||
return (
|
||
<section key={group.title} className="rounded-2xl bg-gray-50 p-4">
|
||
<h3 className="mb-4 text-sm font-bold text-secondary">{group.title}</h3>
|
||
<div className="grid gap-4 sm:grid-cols-2">
|
||
{keys.map((key) => <FieldInput key={key} name={key} value={value[key]} onChange={(next) => setField(key, next)} />)}
|
||
</div>
|
||
</section>
|
||
);
|
||
})}
|
||
{advanced.length ? (
|
||
<details className="rounded-2xl border border-gray-200 p-4">
|
||
<summary className="cursor-pointer text-sm font-bold text-secondary">تنظیمات پیشرفته</summary>
|
||
<p className="mt-2 text-xs text-gray-500">این بخش برای تنظیمات کماستفاده است. تغییر آنها ممکن است روی انتشار اثر بگذارد.</p>
|
||
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
||
{advanced.map((key) => <FieldInput key={key} name={key} value={value[key]} onChange={(next) => setField(key, next)} />)}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
|
||
const { me } = useMe();
|
||
const [result, setResult] = useState<AdapterResult<RegistryRecord[]> | null>(null);
|
||
const [error, setError] = useState("");
|
||
const [msg, setMsg] = useState("");
|
||
const [loading, setLoading] = useState(true);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [editItem, setEditItem] = useState<RegistryRecord | null>(null);
|
||
const [formBody, setFormBody] = useState<RegistryRecord>({});
|
||
const [busy, setBusy] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const [statusFilter, setStatusFilter] = useState("all");
|
||
const [sortAsc, setSortAsc] = useState(true);
|
||
const [page, setPage] = useState(1);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
setError("");
|
||
const response = await listAdminRegistry(resource, me?.current_tenant_id);
|
||
setResult(response);
|
||
if (["unavailable", "permission_denied", "error"].includes(response.availability)) {
|
||
setError(friendlyMessage(response.message, "بارگذاری اطلاعات انجام نشد. لطفاً دوباره تلاش کنید."));
|
||
}
|
||
setLoading(false);
|
||
}, [me?.current_tenant_id, resource]);
|
||
|
||
useEffect(() => { void load(); }, [load]);
|
||
|
||
const items = useMemo(() => result?.data ?? [], [result]);
|
||
const filtered = useMemo(() => {
|
||
const normalized = query.trim().toLocaleLowerCase("fa");
|
||
return items
|
||
.filter((item) => statusFilter === "all" || item.status === statusFilter)
|
||
.filter((item) => !normalized || recordLabel(item, resource).toLocaleLowerCase("fa").includes(normalized))
|
||
.sort((a, b) => recordLabel(a, resource).localeCompare(recordLabel(b, resource), "fa") * (sortAsc ? 1 : -1));
|
||
}, [items, query, resource, sortAsc, statusFilter]);
|
||
const pages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||
const visible = filtered.slice((Math.min(page, pages) - 1) * PAGE_SIZE, Math.min(page, pages) * PAGE_SIZE);
|
||
|
||
const openCreate = () => {
|
||
setFormBody(defaultRecord(resource));
|
||
setCreateOpen(true);
|
||
setMsg("");
|
||
setError("");
|
||
};
|
||
const openEdit = (item: RegistryRecord) => {
|
||
setEditItem(item);
|
||
setFormBody({ ...item });
|
||
setMsg("");
|
||
setError("");
|
||
};
|
||
|
||
const submitCreate = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
setBusy(true);
|
||
setError("");
|
||
const body = { ...formBody };
|
||
if (!body[resource.codeField]) {
|
||
body[resource.codeField] = `${resource.key.replace(/s$/, "")}.${Date.now().toString(36)}`;
|
||
}
|
||
const response = await createAdminRegistryItem(resource, body);
|
||
if (!response.ok) setError(friendlyMessage(response.message, "ثبت اطلاعات انجام نشد. لطفاً فیلدهای ضروری را بررسی کنید."));
|
||
else {
|
||
setCreateOpen(false);
|
||
setMsg("اطلاعات با موفقیت ثبت شد.");
|
||
await load();
|
||
}
|
||
setBusy(false);
|
||
};
|
||
|
||
const submitEdit = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
if (!editItem) return;
|
||
const targetId = recordTargetId(editItem, resource);
|
||
if (!targetId) return setError("شناسه این مورد پیدا نشد. صفحه را تازهسازی کنید.");
|
||
const changedFields = Object.fromEntries(
|
||
Object.entries(formBody).filter(
|
||
([key, value]) =>
|
||
![
|
||
"registry_object_id",
|
||
"id",
|
||
"kind",
|
||
"slug",
|
||
"stable_code",
|
||
"created_at",
|
||
"updated_at",
|
||
"created_by",
|
||
"updated_by",
|
||
"published_at",
|
||
"archived_at",
|
||
"is_deleted",
|
||
"clone_of_id",
|
||
].includes(key) && !Object.is(value, editItem[key])
|
||
)
|
||
);
|
||
if (!Object.keys(changedFields).length) {
|
||
setEditItem(null);
|
||
setMsg("تغییری برای ذخیره وجود نداشت.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
const response = await updateAdminRegistryItem(resource, targetId, changedFields);
|
||
if (!response.ok) setError(friendlyMessage(response.message, "ذخیره تغییرات انجام نشد. دوباره تلاش کنید."));
|
||
else {
|
||
setEditItem(null);
|
||
setMsg("تغییرات با موفقیت ذخیره شد.");
|
||
await load();
|
||
}
|
||
setBusy(false);
|
||
};
|
||
|
||
const removeItems = async (targets: RegistryRecord[]) => {
|
||
if (!targets.length || !window.confirm(`${targets.length.toLocaleString("fa-IR")} مورد حذف شود؟`)) return;
|
||
setBusy(true);
|
||
for (const item of targets) {
|
||
const targetId = recordTargetId(item, resource);
|
||
if (targetId) {
|
||
const response = await deleteAdminRegistryItem(resource, targetId);
|
||
if (!response.ok) {
|
||
setError(friendlyMessage(response.message, "حذف بعضی موارد انجام نشد."));
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
setSelected(new Set());
|
||
await load();
|
||
setBusy(false);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<AdminPageHeader
|
||
title={resource.label}
|
||
subtitle={`جستوجو، ویرایش، انتشار و مدیریت ${resource.label}`}
|
||
action={<div className="flex flex-wrap gap-2"><Link href="/admin/commercial"><Button variant="outline">بازگشت</Button></Link>{!resource.readOnly ? <Button onClick={openCreate}>افزودن مورد جدید</Button> : null}</div>}
|
||
/>
|
||
{error ? <Banner variant="error">{error}</Banner> : null}
|
||
{msg ? <Banner variant="success">{msg}</Banner> : null}
|
||
|
||
<div className="flex flex-wrap items-center gap-3 rounded-2xl border border-gray-100 bg-white p-4 shadow-sm">
|
||
<label className="relative min-w-[220px] flex-1">
|
||
<span className="sr-only">جستوجو</span>
|
||
<span className="pointer-events-none absolute right-3 top-2.5" aria-hidden="true">⌕</span>
|
||
<input value={query} onChange={(event) => { setQuery(event.target.value); setPage(1); }} placeholder={`جستوجو در ${resource.label}`} className="w-full rounded-xl border border-gray-200 py-2.5 pl-4 pr-10 text-sm" />
|
||
</label>
|
||
<select value={statusFilter} onChange={(event) => { setStatusFilter(event.target.value); setPage(1); }} aria-label="فیلتر وضعیت" className="rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm">
|
||
<option value="all">همه وضعیتها</option><option value="active">فعال</option><option value="published">منتشرشده</option><option value="draft">پیشنویس</option><option value="inactive">غیرفعال</option>
|
||
</select>
|
||
<Button variant="outline" size="sm" onClick={() => setSortAsc((value) => !value)}>مرتبسازی {sortAsc ? "الف تا ی" : "ی تا الف"}</Button>
|
||
{selected.size && !resource.readOnly ? <Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems(items.filter((item) => selected.has(recordCode(item, resource))))}>حذف انتخابشدهها</Button> : null}
|
||
</div>
|
||
|
||
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
|
||
{loading ? (
|
||
<div className="space-y-3 p-5" aria-label="در حال بارگذاری">{[1, 2, 3].map((i) => <div key={i} className="h-12 animate-pulse rounded-xl bg-gray-100" />)}</div>
|
||
) : !visible.length ? (
|
||
<EmptyState message={query || statusFilter !== "all" ? "موردی با این جستوجو پیدا نشد." : "هنوز موردی ثبت نشده است."} />
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full min-w-[680px] text-sm">
|
||
<thead className="bg-gray-50 text-right"><tr><th className="w-12 px-4 py-3"><span className="sr-only">انتخاب</span></th><th className="px-4 py-3 font-semibold text-secondary">نام</th><th className="px-4 py-3 font-semibold text-secondary">وضعیت</th><th className="px-4 py-3 font-semibold text-secondary">عملیات</th></tr></thead>
|
||
<tbody>
|
||
{visible.map((item, index) => {
|
||
const code = recordCode(item, resource) || `row-${index}`;
|
||
const status = typeof item.status === "string" ? item.status : "inactive";
|
||
return (
|
||
<tr key={code} className="border-t border-gray-100 hover:bg-gray-50/60">
|
||
<td className="px-4 py-3"><input type="checkbox" checked={selected.has(code)} aria-label={`انتخاب ${recordLabel(item, resource)}`} onChange={() => setSelected((current) => { const next = new Set(current); next.has(code) ? next.delete(code) : next.add(code); return next; })} /></td>
|
||
<td className="px-4 py-3 font-medium text-secondary">{recordLabel(item, resource)}</td>
|
||
<td className="px-4 py-3"><span className={`rounded-full px-2.5 py-1 text-xs ${status === "active" || status === "published" ? "bg-emerald-50 text-emerald-700" : status === "draft" ? "bg-amber-50 text-amber-700" : "bg-gray-100 text-gray-600"}`}>{enumLabel(status)}</span></td>
|
||
<td className="px-4 py-3">
|
||
{resource.readOnly ? (
|
||
<span className="text-xs text-gray-500">نمایش فقطخواندنی</span>
|
||
) : (
|
||
<div className="flex gap-2"><Button variant="outline" size="sm" onClick={() => openEdit(item)}><span aria-hidden="true">✎</span> ویرایش</Button><Button variant="outline" size="sm" disabled={busy} onClick={() => void removeItems([item])}><span aria-hidden="true">♲</span> حذف</Button></div>
|
||
)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{filtered.length > PAGE_SIZE ? (
|
||
<div className="flex items-center justify-between border-t border-gray-100 px-4 py-3 text-xs text-gray-500">
|
||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((value) => value - 1)}>قبلی</Button>
|
||
<span>صفحه {Math.min(page, pages).toLocaleString("fa-IR")} از {pages.toLocaleString("fa-IR")}</span>
|
||
<Button size="sm" variant="outline" disabled={page >= pages} onClick={() => setPage((value) => value + 1)}>بعدی</Button>
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
|
||
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`افزودن ${resource.label}`}>
|
||
<form onSubmit={(event) => void submitCreate(event)} className="space-y-5">
|
||
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
||
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>انصراف</Button><Button type="submit" loading={busy}>ثبت و ذخیره</Button></div>
|
||
</form>
|
||
</Modal>
|
||
<Modal open={Boolean(editItem)} onClose={() => setEditItem(null)} title={`ویرایش ${resource.label}`}>
|
||
<form onSubmit={(event) => void submitEdit(event)} className="space-y-5">
|
||
<CommercialRecordForm value={formBody} resource={resource} onChange={setFormBody} />
|
||
<div className="flex justify-end gap-2"><Button type="button" variant="outline" onClick={() => setEditItem(null)}>انصراف</Button><Button type="submit" loading={busy}>ذخیره تغییرات</Button></div>
|
||
</form>
|
||
</Modal>
|
||
</div>
|
||
);
|
||
}
|