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

526 lines
21 KiB
TypeScript
Raw Permalink 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 { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AuthGuard } from "@/components/AuthGuard";
import { Badge, Button, Container, SectionTitle } from "@/components/ui";
import { useAuth } from "@/hooks/useAuth";
import { useMe } from "@/hooks/useMe";
import {
buildSetupChecklist,
loadActivationPipeline,
loadCommercialAssets,
loadCommercialAutomationPacks,
loadCommercialCapabilities,
loadCommercialExtensions,
loadCommercialInvoices,
loadCommercialLicenses,
loadCommercialNotifications,
loadCommercialPolicies,
loadCommercialProducts,
loadSetupChecklistFromApi,
loadTenantRecommendations,
loadWorkspaceSnapshot,
} from "../adapters";
import type { ActivationPipeline } from "../adapters/activation";
import type {
CommercialInvoice,
CommercialLicense,
} from "../adapters/billing-ledger";
import type { TenantRecommendationFeed } from "../adapters/tenant-recommendations";
import type {
AdapterResult,
CommercialAsset,
CommercialAutomationPack,
CommercialCapability,
CommercialExtension,
CommercialNotification,
CommercialPolicy,
CommercialProduct,
CommercialWorkspaceSnapshot,
SetupChecklistItem,
} from "../types";
import {
CapabilityBadge,
CommercialEmptyState,
DomainPolicyPanel,
PlanBadge,
PlatformShell,
ProductCard,
RecommendationsPanel,
RegistryBrowser,
SetupChecklist,
SubscriptionBanner,
TrialBanner,
UpgradePrompt,
} from "../components";
import { loadAssessmentSession } from "../session";
function CommercialDashboardContent() {
const router = useRouter();
const { user } = useAuth();
const { me, loading: meLoading, error: meError, reload: reloadMe } = useMe();
const [snap, setSnap] = useState<AdapterResult<CommercialWorkspaceSnapshot | null> | null>(null);
const [products, setProducts] = useState<AdapterResult<CommercialProduct[]> | null>(null);
const [caps, setCaps] = useState<AdapterResult<CommercialCapability[]> | null>(null);
const [assets, setAssets] = useState<AdapterResult<CommercialAsset[]> | null>(null);
const [extensions, setExtensions] = useState<AdapterResult<CommercialExtension[]> | null>(null);
const [automation, setAutomation] = useState<AdapterResult<CommercialAutomationPack[]> | null>(null);
const [policies, setPolicies] = useState<AdapterResult<CommercialPolicy[]> | null>(null);
const [notifications, setNotifications] = useState<AdapterResult<CommercialNotification[]> | null>(null);
const [recs, setRecs] = useState<AdapterResult<TenantRecommendationFeed> | null>(null);
const [invoices, setInvoices] = useState<AdapterResult<CommercialInvoice[]> | null>(null);
const [licenses, setLicenses] = useState<AdapterResult<CommercialLicense[]> | null>(null);
const [activation, setActivation] = useState<AdapterResult<ActivationPipeline | null> | null>(null);
const [checklist, setChecklist] = useState<SetupChecklistItem[]>([]);
const [checklistPct, setChecklistPct] = useState<number | null>(null);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [upgradeHint, setUpgradeHint] = useState<string | undefined>();
const load = useCallback(async () => {
const [s, p, c, a, e, auto, pol, n] = await Promise.all([
loadWorkspaceSnapshot(),
loadCommercialProducts(),
loadCommercialCapabilities(),
loadCommercialAssets(),
loadCommercialExtensions(),
loadCommercialAutomationPacks(),
loadCommercialPolicies(),
loadCommercialNotifications(),
]);
setSnap(s);
setProducts(p);
setCaps(c);
setAssets(a);
setExtensions(e);
setAutomation(auto);
setPolicies(pol);
setNotifications(n);
const tenantId = s.data?.tenant_id;
if (tenantId) {
const [r, inv, lic, act, chk] = await Promise.all([
loadTenantRecommendations(tenantId),
loadCommercialInvoices(tenantId),
loadCommercialLicenses(tenantId),
loadActivationPipeline(tenantId),
loadSetupChecklistFromApi(tenantId),
]);
setRecs(r);
setInvoices(inv);
setLicenses(lic);
setActivation(act);
if (chk.availability === "ready" && chk.data.length) {
setChecklist(chk.data);
setChecklistPct(
(chk as AdapterResult<SetupChecklistItem[]> & { completion_percent?: number })
.completion_percent ??
s.data?.checklist_completion_percent ??
null
);
} else {
const session = loadAssessmentSession();
setChecklist(
buildSetupChecklist(p.data ?? [], { installedCodes: session.selected_product_codes })
);
setChecklistPct(s.data?.checklist_completion_percent ?? null);
}
}
}, []);
useEffect(() => {
if (meLoading) return;
if (!me) return;
if (me.onboarding_required) {
router.replace("/onboarding");
return;
}
void load();
}, [me, meLoading, router, load]);
const productList = products?.data ?? [];
const byCode = useMemo(() => {
const m = new Map<string, CommercialProduct>();
for (const p of productList) m.set(p.product_code, p);
return m;
}, [productList]);
const pickProducts = (codes?: string[]) =>
(codes || [])
.map((code) => byCode.get(code))
.filter((x): x is CommercialProduct => Boolean(x));
const ws = snap?.data;
const pinned = pickProducts(ws?.pinned_product_codes);
const recent = pickProducts(ws?.recent_product_codes);
const quick = ws?.quick_actions ?? [];
const locked = ws?.locked_features ?? [];
const trialEnded =
ws?.subscription_status === "expired" ||
(ws?.trial &&
!ws.trial.active &&
ws.subscription_status === "trialing" &&
(ws.trial.days_remaining ?? 1) <= 0);
useEffect(() => {
if (trialEnded) {
setUpgradeHint("trial به پایان رسیده یا منقضی شده است.");
setUpgradeOpen(true);
}
}, [trialEnded]);
if (meLoading || (me && me.onboarding_required)) {
return (
<Container className="py-16 text-center">
<p className="text-sm text-gray-500">در حال بارگذاری...</p>
</Container>
);
}
return (
<PlatformShell
title="داشبورد تجاری"
subtitle={`خوش آمدید ${user?.username || user?.email || "کاربر"} — سیستم‌عامل کسب‌وکار`}
>
<div className="mb-4 flex flex-wrap gap-2">
<Link href="/billing">
<Button variant="outline" size="sm">
Billing / ارتقا
</Button>
</Link>
<Link href="/domains">
<Button variant="outline" size="sm">
دامنه
</Button>
</Link>
<Link href="/apps">
<Button variant="outline" size="sm">
Apps
</Button>
</Link>
<Button
variant="outline"
size="sm"
onClick={async () => {
await reloadMe();
await load();
}}
>
تازهسازی
</Button>
</div>
{(meError || snap?.message) && snap?.availability === "error" ? (
<p className="mb-6 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-600">
{meError || snap?.message}
</p>
) : null}
{!ws ? (
<div className="mt-4">
<CommercialEmptyState
title="Workspace در دسترس نیست"
description={snap?.message}
actionHref="/onboarding"
actionLabel="ادامه راه‌اندازی"
/>
</div>
) : (
<div className="space-y-6">
<section className="rounded-2xl border border-gray-100 bg-white p-6 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-xl font-bold text-secondary">{ws.tenant_name}</h2>
<p className="mt-1 font-mono text-sm text-gray-500" dir="ltr">
{ws.primary_domain || `${ws.slug}.torbatyar.ir`}
</p>
{ws.workspace_health ? (
<p className="mt-1 text-xs text-gray-500">سلامت workspace: {ws.workspace_health}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={ws.status === "active" ? "primary" : "default"}>{ws.status}</Badge>
<PlanBadge label={ws.plan_name} status={ws.subscription_status} />
</div>
</div>
</section>
<TrialBanner trial={ws.trial} />
<SubscriptionBanner status={ws.subscription_status} planName={ws.plan_name} />
<DomainPolicyPanel
unlocked={ws.custom_domain_unlocked}
policy={ws.domain_policy}
tenantId={ws.tenant_id}
/>
{activation?.data?.steps?.length ? (
<section>
<SectionTitle
title="فعال‌سازی"
subtitle={`وضعیت: ${activation.data.status || "—"} · ${
activation.data.completion_percent ?? "—"
}%`}
/>
<ol className="space-y-2">
{activation.data.steps.map((step) => (
<li
key={step.step_key}
className="flex items-center justify-between rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm"
>
<span>
{step.label || step.step_key}{" "}
<span className="text-xs text-gray-400">({step.status || "pending"})</span>
</span>
{step.href ? (
<Link href={step.href} className="text-xs text-primary">
ادامه
</Link>
) : null}
</li>
))}
</ol>
</section>
) : null}
{quick.length ? (
<section>
<SectionTitle title="اقدامات سریع" />
<div className="flex flex-wrap gap-2">
{quick.map((q) =>
q.href ? (
<Link key={q.id} href={q.href}>
<Button size="sm" variant="outline">
{q.label}
</Button>
</Link>
) : (
<Button key={q.id} size="sm" variant="outline" disabled>
{q.label}
</Button>
)
)}
</div>
</section>
) : null}
<section>
<SectionTitle title="پیشنهادها" subtitle="از recommendation API" />
<RecommendationsPanel result={recs} />
</section>
{locked.length ? (
<section>
<SectionTitle title="قابلیت‌های قفل" />
<ul className="space-y-2">
{locked.map((f) => (
<li
key={f.feature_key}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-100 bg-amber-50 px-3 py-2 text-sm"
>
<span className="font-mono text-xs" dir="ltr">
{f.feature_key}
</span>
<button
type="button"
className="text-xs font-semibold text-primary"
onClick={() => {
setUpgradeHint(f.reason || undefined);
setUpgradeOpen(true);
}}
>
ارتقا
</button>
</li>
))}
</ul>
</section>
) : null}
{pinned.length ? (
<section>
<SectionTitle title="پین شده" />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{pinned.map((p) => (
<ProductCard key={`pin-${p.product_code}`} product={p} />
))}
</div>
</section>
) : null}
{recent.length ? (
<section>
<SectionTitle title="اخیر" />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{recent.map((p) => (
<ProductCard key={`recent-${p.product_code}`} product={p} />
))}
</div>
</section>
) : null}
<section>
<SectionTitle title="Apps launcher" />
{products?.availability === "ready" && products.data.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{products.data.map((p) => (
<ProductCard key={p.product_code} product={p} tenantId={ws.tenant_id} />
))}
</div>
) : (
<CommercialEmptyState
title="محصولی کشف نشد"
description={products?.message}
hint="کاتالوگ از رجیستری تجاری بارگذاری می‌شود. تا پر شدن، از کشف و راهنما ادامه دهید."
actionHref="/apps"
actionLabel="Apps Launcher"
secondaryHref="/discover"
secondaryLabel="کشف کسب‌وکار"
/>
)}
</section>
<section>
<SectionTitle title="قابلیت‌ها" />
{caps?.availability === "ready" && caps.data.length ? (
<div className="flex flex-wrap gap-2">
{caps.data.slice(0, 40).map((c) => (
<CapabilityBadge
key={c.capability_code}
code={c.capability_code}
label={c.display_name}
/>
))}
</div>
) : (
<CommercialEmptyState
title="قابلیتی نیست"
description={caps?.message}
hint="قابلیت‌ها با entitlement و باندل فعال ظاهر می‌شوند."
actionHref="/billing"
actionLabel="بررسی پلن"
secondaryHref="/help"
secondaryLabel="راهنما"
/>
)}
</section>
<section>
<SectionTitle title="فاکتورها / لایسنس" />
{invoices?.availability === "ready" && invoices.data.length ? (
<ul className="mb-3 space-y-1">
{invoices.data.map((inv) => (
<li key={inv.invoice_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{inv.display_name || inv.invoice_code} · {inv.status || "—"}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="فاکتور نیست"
description={invoices?.message}
actionHref="/billing"
actionLabel="Billing"
secondaryHref="/help"
secondaryLabel="راهنمای اشتراک"
/>
)}
{licenses?.availability === "ready" && licenses.data.length ? (
<ul className="mt-3 space-y-1">
{licenses.data.map((lic) => (
<li key={lic.license_code} className="rounded-lg border border-gray-100 px-3 py-2 text-sm">
{lic.display_name || lic.license_code} · {lic.status || "—"}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="لایسنس نیست"
description={licenses?.message}
actionHref="/billing"
actionLabel="فعال‌سازی پلن"
secondaryHref="/discover"
secondaryLabel="انتخاب باندل"
/>
)}
</section>
<RegistryBrowser title="دارایی‌ها" result={assets} codeKey="asset_code" />
<RegistryBrowser title="افزونه‌ها" result={extensions} codeKey="extension_code" />
<RegistryBrowser title="اتوماسیون" result={automation} codeKey="automation_pack_code" />
<RegistryBrowser title="سیاست‌ها" result={policies} codeKey="policy_code" />
<section>
<SectionTitle title="اعلان‌ها" />
{notifications?.availability === "ready" && notifications.data.length ? (
<ul className="space-y-2">
{notifications.data.map((n) => (
<li key={n.id} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
<p className="font-medium text-secondary">{n.title}</p>
{n.body ? <p className="mt-1 text-xs text-gray-500">{n.body}</p> : null}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="اعلانی نیست"
description={notifications?.message}
hint="اعلان‌های Trial، دامنه و Billing اینجا ظاهر می‌شوند."
actionHref="/notifications"
actionLabel="مرکز اعلان‌ها"
secondaryHref="/help"
secondaryLabel="راهنما"
/>
)}
</section>
<section>
<SectionTitle title="چک‌لیست راه‌اندازی" />
<SetupChecklist items={checklist} completionPercent={checklistPct} />
</section>
<section>
<SectionTitle title="مصرف / سهمیه" />
{ws.trial?.quotas?.length ? (
<ul className="grid gap-2 sm:grid-cols-2">
{ws.trial.quotas.map((q) => (
<li key={q.key} className="rounded-xl border border-gray-100 bg-white px-3 py-2 text-sm">
{q.label || q.key}: {q.used ?? "—"} / {q.limit ?? "∞"}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="شمارنده مصرف در دسترس نیست"
hint="سهمیه‌های Trial و usage از API entitlement می‌آیند."
actionHref="/billing"
actionLabel="Billing / Usage"
secondaryHref="/help"
secondaryLabel="راهنما"
/>
)}
</section>
</div>
)}
<UpgradePrompt
open={upgradeOpen}
title="نیاز به ارتقا"
message={upgradeHint || ws?.domain_policy?.reason || undefined}
onClose={() => setUpgradeOpen(false)}
/>
</PlatformShell>
);
}
export function CommercialDashboardPage() {
return (
<AuthGuard>
<CommercialDashboardContent />
</AuthGuard>
);
}