TorbatYar/frontend/modules/commercial/features/admin-registry.tsx

412 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}