526 lines
21 KiB
TypeScript
526 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|