313 lines
11 KiB
TypeScript
313 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|