TorbatYar/frontend/app/admin/tenants/[id]/page.tsx
Mortezakoohjani 0d424c500a feat(platform): complete commercial runtime consolidation
Unify commercial runtime ownership across backend and frontend so platform, experience, and hospitality modules use the shared commercial source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 11:11:53 +03:30

533 lines
17 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, useState } from "react";
import Link from "next/link";
import { useParams } from "next/navigation";
import {
api,
ApiError,
type Tenant,
type TenantDomain,
} from "@/lib/api";
import { getStoredToken } from "@/lib/auth";
import { identityApi, type AdminUser, type TenantMember } from "@/lib/identity";
import {
AdminPageHeader,
Banner,
Button,
Field,
SelectField,
StatusBadge,
TextField,
cardClass,
inputClass,
} from "@/components/admin/controls";
import { commercialGet, commercialPost } from "@/modules/commercial/adapters/http";
const TENANT_STATUSES = [
"draft",
"pending_activation",
"active",
"suspended",
"archived",
"inactive",
].map((s) => ({ value: s, label: s }));
const SUBSCRIPTION_STATUSES = [
"trialing",
"active",
"past_due",
"canceled",
"expired",
].map((s) => ({ value: s, label: s }));
type CommercialPlanOption = {
plan_code: string;
display_name: string;
};
type CommercialSubscriptionView = {
status?: string | null;
plan_code?: string | null;
bundle_code?: string | null;
pricing_item_code?: string | null;
trial_ends_at?: string | null;
};
export default function TenantDetailPage() {
const params = useParams<{ id: string }>();
const tenantId = params.id;
const [tenant, setTenant] = useState<Tenant | null>(null);
const [domains, setDomains] = useState<TenantDomain[]>([]);
const [subscription, setSubscription] = useState<CommercialSubscriptionView | null>(null);
const [members, setMembers] = useState<TenantMember[]>([]);
const [plans, setPlans] = useState<CommercialPlanOption[]>([]);
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const load = useCallback(async () => {
setLoading(true);
setError("");
const token = getStoredToken();
try {
const t = await api.platformTenants.get(tenantId);
setTenant(t);
} catch (e) {
setError(e instanceof ApiError ? e.message : "دریافت Tenant ناموفق بود");
setLoading(false);
return;
}
const [d, dash, plansRes, mem, usersRes] = await Promise.allSettled([
api.domains.listForTenant(tenantId),
commercialGet<{ subscription?: CommercialSubscriptionView | null }>(
`/api/v1/commercial/dashboard?tenant_id=${encodeURIComponent(tenantId)}`
),
commercialGet<{ items?: Array<Record<string, unknown>>; plans?: Array<Record<string, unknown>> }>(
"/api/v1/commercial/plans?status=published&limit=100"
),
token ? identityApi.listMembers(token, tenantId) : Promise.resolve([]),
token ? identityApi.listUsers(token, { limit: 100 }) : Promise.resolve([]),
]);
if (d.status === "fulfilled") setDomains(d.value);
if (dash.status === "fulfilled" && dash.value.ok) {
setSubscription(dash.value.data.subscription ?? null);
} else {
setSubscription(null);
}
if (plansRes.status === "fulfilled" && plansRes.value.ok) {
const raw = plansRes.value.data.items ?? plansRes.value.data.plans ?? [];
setPlans(
raw
.map((p) => ({
plan_code: String(p.plan_code || p.stable_code || ""),
display_name: String(p.display_name || p.plan_code || p.stable_code || ""),
}))
.filter((p) => p.plan_code)
);
} else {
setPlans([]);
}
if (mem.status === "fulfilled") setMembers(mem.value);
if (usersRes.status === "fulfilled") setUsers(usersRes.value);
setLoading(false);
}, [tenantId]);
useEffect(() => {
void load();
}, [load]);
if (loading) {
return <p className="text-sm text-gray-500">در حال بارگذاری...</p>;
}
if (!tenant) {
return (
<div className="space-y-4">
<Banner variant="error">{error || "Tenant یافت نشد"}</Banner>
<Link href="/admin/tenants" className="text-sm text-primary hover:underline">
بازگشت به لیست
</Link>
</div>
);
}
return (
<div className="space-y-6">
<div>
<Link href="/admin/tenants" className="text-sm text-primary hover:underline">
بازگشت به لیست
</Link>
</div>
<AdminPageHeader
title={tenant.name}
subtitle={tenant.slug}
action={<StatusBadge status={tenant.status} />}
/>
{error && <Banner variant="error">{error}</Banner>}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
<TenantEditCard tenant={tenant} onUpdated={setTenant} onError={setError} />
<DomainsCard tenantId={tenantId} domains={domains} onChanged={load} />
<SubscriptionCard
tenantId={tenantId}
subscription={subscription}
plans={plans}
onChanged={load}
/>
<MembersCard tenantId={tenantId} members={members} users={users} onChanged={load} />
</div>
</div>
);
}
function TenantEditCard({
tenant,
onUpdated,
onError,
}: {
tenant: Tenant;
onUpdated: (t: Tenant) => void;
onError: (m: string) => void;
}) {
const [name, setName] = useState(tenant.name);
const [status, setStatus] = useState(tenant.status);
const [ownerUserId, setOwnerUserId] = useState(tenant.owner_user_id || "");
const [saving, setSaving] = useState(false);
const [success, setSuccess] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setSuccess("");
onError("");
setSaving(true);
try {
const updated = await api.platformTenants.update(tenant.id, {
name,
status,
owner_user_id: ownerUserId.trim() || null,
});
onUpdated(updated);
setSuccess("تغییرات ذخیره شد.");
} catch (err) {
onError(err instanceof ApiError ? err.message : "ذخیره ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<form onSubmit={handleSubmit} className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اطلاعات پایه</h2>
<TextField label="نام" required value={name} onValue={setName} disabled={saving} />
<SelectField
label="وضعیت"
value={status}
onValue={setStatus}
options={TENANT_STATUSES}
disabled={saving}
/>
<TextField
label="شناسه کاربر مالک"
dir="ltr"
className="font-mono"
value={ownerUserId}
onValue={setOwnerUserId}
disabled={saving}
placeholder="UUID"
/>
{success && <Banner variant="success">{success}</Banner>}
<Button type="submit" loading={saving} loadingText="در حال ذخیره...">
ذخیره تغییرات
</Button>
</form>
);
}
function DomainsCard({
tenantId,
domains,
onChanged,
}: {
tenantId: string;
domains: TenantDomain[];
onChanged: () => void;
}) {
const [domain, setDomain] = useState("");
const [type, setType] = useState<"subdomain" | "custom_domain">("custom_domain");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleAdd = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!domain.trim()) return;
setSaving(true);
try {
await api.domains.create(tenantId, { domain: domain.trim(), domain_type: type });
setDomain("");
onChanged();
} catch (err) {
setError(err instanceof ApiError ? err.message : "افزودن دامنه ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">دامنهها</h2>
{domains.length === 0 ? (
<p className="text-sm text-gray-500">دامنهای ثبت نشده است.</p>
) : (
<ul className="space-y-2">
{domains.map((d) => (
<li
key={d.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-sm"
>
<span dir="ltr" className="font-mono text-gray-700">
{d.domain}
</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-400">{d.domain_type}</span>
<StatusBadge status={d.is_verified ? "active" : "pending_activation"} />
</span>
</li>
))}
</ul>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleAdd} className="space-y-3 border-t border-gray-100 pt-4">
<TextField
label="دامنه جدید"
dir="ltr"
className="font-mono"
value={domain}
onValue={setDomain}
disabled={saving}
placeholder="shop.example.com"
/>
<SelectField
label="نوع"
value={type}
onValue={(v) => setType(v as "subdomain" | "custom_domain")}
options={[
{ value: "custom_domain", label: "دامنه اختصاصی" },
{ value: "subdomain", label: "زیردامنه" },
]}
disabled={saving}
/>
<Button type="submit" size="sm" loading={saving} loadingText="در حال افزودن...">
افزودن دامنه
</Button>
</form>
</div>
);
}
function SubscriptionCard({
tenantId,
subscription,
plans,
onChanged,
}: {
tenantId: string;
subscription: CommercialSubscriptionView | null;
plans: CommercialPlanOption[];
onChanged: () => void;
}) {
const [planCode, setPlanCode] = useState(plans[0]?.plan_code || "");
const [status, setStatus] = useState("active");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!planCode && plans[0]) setPlanCode(plans[0].plan_code);
}, [plans, planCode]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
if (!planCode) {
setError("ابتدا یک پلن commercial انتخاب کنید.");
return;
}
setSaving(true);
try {
const res = await commercialPost("/api/v1/commercial/subscriptions", {
tenant_id: tenantId,
plan_code: planCode,
status,
});
if (!res.ok) {
setError(res.message || "ثبت اشتراک commercial ناموفق بود");
return;
}
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "ثبت اشتراک ناموفق بود");
} finally {
setSaving(false);
}
};
const planName = subscription?.plan_code
? plans.find((p) => p.plan_code === subscription.plan_code)?.display_name ||
subscription.plan_code
: null;
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اشتراک (Commercial Runtime)</h2>
{subscription?.status ? (
<div className="rounded-xl bg-gray-50 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">پلن</span>
<span className="font-semibold text-secondary">{planName || "—"}</span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="text-gray-500">وضعیت</span>
<StatusBadge status={subscription.status} />
</div>
</div>
) : (
<p className="text-sm text-gray-500">اشتراک commercial فعالی ثبت نشده است.</p>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleSubmit} className="space-y-3 border-t border-gray-100 pt-4">
<p className="text-sm font-medium text-secondary">
{subscription?.status ? "تغییر/ثبت اشتراک جدید" : "ثبت اشتراک"}
</p>
{plans.length === 0 ? (
<p className="text-xs text-amber-600">
ابتدا از{" "}
<Link href="/admin/commercial/plans" className="underline">
Admin Commercial Plans
</Link>{" "}
یک پلن منتشر کنید.
</p>
) : (
<>
<SelectField
label="پلن"
value={planCode}
onValue={setPlanCode}
options={plans.map((p) => ({ value: p.plan_code, label: p.display_name }))}
/>
<SelectField
label="وضعیت"
value={status}
onValue={setStatus}
options={SUBSCRIPTION_STATUSES}
/>
<Button type="submit" disabled={saving}>
{saving ? "در حال ذخیره..." : "ثبت از Commercial Runtime"}
</Button>
</>
)}
</form>
</div>
);
}
function MembersCard({
tenantId,
members,
users,
onChanged,
}: {
tenantId: string;
members: TenantMember[];
users: AdminUser[];
onChanged: () => void;
}) {
const [userId, setUserId] = useState("");
const [role, setRole] = useState<"tenant_admin" | "tenant_member">("tenant_member");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const userLabel = (id: string) => {
const u = users.find((x) => x.id === id);
return u ? u.display_name || u.username || u.mobile || u.email : id;
};
const handleAdd = async (e: FormEvent) => {
e.preventDefault();
setError("");
const token = getStoredToken();
if (!token) {
setError("توکن یافت نشد.");
return;
}
if (!userId) {
setError("یک کاربر انتخاب کنید.");
return;
}
setSaving(true);
try {
await identityApi.addMember(token, tenantId, { user_id: userId, role });
setUserId("");
onChanged();
} catch (err) {
setError(err instanceof Error ? err.message : "افزودن عضو ناموفق بود");
} finally {
setSaving(false);
}
};
return (
<div className={`space-y-4 ${cardClass}`}>
<h2 className="text-lg font-bold text-secondary">اعضا</h2>
{members.length === 0 ? (
<p className="text-sm text-gray-500">عضوی ثبت نشده است.</p>
) : (
<ul className="space-y-2">
{members.map((m) => (
<li
key={m.id}
className="flex items-center justify-between rounded-xl bg-gray-50 px-3 py-2 text-sm"
>
<span className="text-gray-700">{userLabel(m.user_id)}</span>
<span className="flex items-center gap-2">
<span className="text-xs text-gray-400">
{m.role === "tenant_admin" ? "مدیر" : "عضو"}
</span>
<StatusBadge status={m.is_active ? "active" : "inactive"} />
</span>
</li>
))}
</ul>
)}
{error && <Banner variant="error">{error}</Banner>}
<form onSubmit={handleAdd} className="space-y-3 border-t border-gray-100 pt-4">
<p className="text-sm font-medium text-secondary">افزودن عضو</p>
{users.length === 0 ? (
<p className="text-xs text-amber-600">
کاربری برای افزودن یافت نشد. ابتدا از بخش «کاربران» کاربر بسازید.
</p>
) : (
<>
<Field label="کاربر">
<select
className={inputClass}
value={userId}
onChange={(e) => setUserId(e.target.value)}
disabled={saving}
>
<option value=""> انتخاب کاربر </option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.display_name || u.username || u.mobile || u.email}
</option>
))}
</select>
</Field>
<SelectField
label="نقش"
value={role}
onValue={(v) => setRole(v as "tenant_admin" | "tenant_member")}
options={[
{ value: "tenant_member", label: "عضو" },
{ value: "tenant_admin", label: "مدیر" },
]}
disabled={saving}
/>
<Button type="submit" size="sm" loading={saving} loadingText="در حال افزودن...">
افزودن عضو
</Button>
</>
)}
</form>
</div>
);
}