TorbatYar/frontend/modules/commercial/features/admin-registry.tsx
2026-07-28 20:39:10 +03:30

313 lines
11 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,
StatusBadge,
TextareaField,
} from "@/components/admin/controls";
import type { CommercialAdminResourceDef } from "../admin/resources";
import {
createAdminRegistryItem,
deleteAdminRegistryItem,
listAdminRegistry,
type RegistryRecord,
updateAdminRegistryItem,
} from "../adapters/admin";
import type { AdapterResult } from "../types";
function recordLabel(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
const name = item[resource.nameField];
if (typeof name === "string" && name) return name;
const code = item[resource.codeField];
if (typeof code === "string" || typeof code === "number") return String(code);
return "—";
}
function recordCode(item: RegistryRecord, resource: CommercialAdminResourceDef): string {
const code = item[resource.codeField];
return code == null ? "" : String(code);
}
function defaultCreateJson(resource: CommercialAdminResourceDef): string {
const seed: RegistryRecord = {
[resource.codeField]: "",
[resource.nameField]: "",
status: "draft",
};
if (resource.key === "products") {
seed.visibility = "public";
seed.launch_strategy = "route";
seed.default_route = "";
seed.product_slug = "";
seed.display_order = 0;
}
if (resource.key === "trial-rules") {
seed.duration_days = 14;
seed.grace_period_days = 3;
seed.usage_limits = {};
seed.feature_limits = {};
seed.expiration_behavior = "lock";
seed.auto_disable = true;
}
if (resource.key === "policies") {
seed.policy_kind = "domain";
seed.effect = "deny";
seed.parameters = {
custom_domain_allowed: false,
subdomain_only: true,
ssl_ready: false,
dns_instructions: [],
};
}
return JSON.stringify(seed, null, 2);
}
export function AdminRegistryManager({ resource }: { resource: CommercialAdminResourceDef }) {
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 [jsonBody, setJsonBody] = useState("");
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
const r = await listAdminRegistry(resource);
setResult(r);
if (r.availability === "unavailable" || r.availability === "permission_denied" || r.availability === "error") {
setError(r.message || "رجیستری در دسترس نیست");
}
setLoading(false);
}, [resource]);
useEffect(() => {
void load();
}, [load]);
const items = result?.data ?? [];
const openCreate = () => {
setJsonBody(defaultCreateJson(resource));
setCreateOpen(true);
setMsg("");
setError("");
};
const openEdit = (item: RegistryRecord) => {
setEditItem(item);
setJsonBody(JSON.stringify(item, null, 2));
setMsg("");
setError("");
};
const submitCreate = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError("");
try {
const body = JSON.parse(jsonBody) as RegistryRecord;
const res = await createAdminRegistryItem(resource, body);
if (!res.ok) {
setError(res.message);
return;
}
setCreateOpen(false);
setMsg("ثبت شد — tenant UI از همین رجیستری می‌خواند.");
await load();
} catch {
setError("JSON نامعتبر است");
} finally {
setBusy(false);
}
};
const submitEdit = async (e: FormEvent) => {
e.preventDefault();
if (!editItem) return;
setBusy(true);
setError("");
try {
const body = JSON.parse(jsonBody) as RegistryRecord;
const code = recordCode(editItem, resource) || String(body[resource.codeField] ?? "");
if (!code) {
setError("کد رکورد مشخص نیست");
return;
}
const res = await updateAdminRegistryItem(resource, code, body);
if (!res.ok) {
setError(res.message);
return;
}
setEditItem(null);
setMsg("به‌روزرسانی شد — تغییرات بلافاصله در tenant قابل مشاهده است.");
await load();
} catch {
setError("JSON نامعتبر است");
} finally {
setBusy(false);
}
};
const onDelete = async (item: RegistryRecord) => {
const code = recordCode(item, resource);
if (!code) return;
if (!window.confirm(`حذف ${code}؟`)) return;
setBusy(true);
const res = await deleteAdminRegistryItem(resource, code);
if (!res.ok) setError(res.message);
else {
setMsg("حذف شد");
await load();
}
setBusy(false);
};
const availabilityHint = useMemo(() => {
if (!result) return null;
if (result.availability === "unavailable") {
return "Core Commercial API هنوز در دسترس نیست — بدون داده جعلی.";
}
if (result.availability === "empty") return "رجیستری خالی است.";
if (result.availability === "permission_denied") return "دسترسی تجاری ندارید.";
return null;
}, [result]);
return (
<div className="space-y-6">
<AdminPageHeader
title={resource.label}
subtitle={resource.description}
action={
<div className="flex flex-wrap gap-2">
<Link href="/admin/commercial">
<Button variant="outline">بازگشت</Button>
</Link>
<Button onClick={openCreate}>افزودن / ثبت</Button>
</div>
}
/>
{error ? <Banner variant="error">{error}</Banner> : null}
{msg ? <Banner variant="success">{msg}</Banner> : null}
{availabilityHint ? <Banner variant="info">{availabilityHint}</Banner> : null}
<p className="font-mono text-[11px] text-gray-400" dir="ltr">
{resource.listPath}
</p>
<section className="overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-sm">
{loading ? (
<EmptyState message="در حال بارگذاری..." />
) : items.length === 0 ? (
<EmptyState message={result?.message || "موردی در رجیستری نیست."} />
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead className="bg-gray-50 text-right">
<tr>
<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>
<th className="px-4 py-3 font-semibold text-secondary">عملیات</th>
</tr>
</thead>
<tbody>
{items.map((item, idx) => {
const code = recordCode(item, resource) || String(idx);
const status = typeof item.status === "string" ? item.status : "—";
return (
<tr key={code} className="border-t border-gray-100">
<td className="px-4 py-3 font-medium text-secondary">
{recordLabel(item, resource)}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600" dir="ltr">
{code}
</td>
<td className="px-4 py-3">
<StatusBadge status={status === "—" ? "inactive" : status} />
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(item)}>
ویرایش
</Button>
<Button
variant="outline"
size="sm"
disabled={busy}
onClick={() => void onDelete(item)}
>
حذف
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<Modal open={createOpen} onClose={() => setCreateOpen(false)} title={`ثبت ${resource.label}`}>
<form onSubmit={(e) => void submitCreate(e)} className="space-y-4">
<p className="text-xs text-gray-500">
بدنه JSON مستقیم به API رجیستری ارسال میشود هیچ کاتالوگ ثابتی در frontend نیست.
</p>
<TextareaField
label="Payload (JSON)"
value={jsonBody}
onValue={setJsonBody}
rows={16}
dir="ltr"
className="font-mono text-xs"
/>
<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={(e) => void submitEdit(e)} className="space-y-4">
<TextareaField
label="Payload (JSON)"
value={jsonBody}
onValue={setJsonBody}
rows={16}
dir="ltr"
className="font-mono text-xs"
/>
<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>
);
}