TorbatYar/frontend/modules/commercial/features/dashboard.tsx

528 lines
22 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 { 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";
import {
enumLabel,
friendlyMessage,
humanizeCommercialCode,
} from "../presentation";
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 byCode = useMemo(() => {
const m = new Map<string, CommercialProduct>();
for (const p of products?.data ?? []) m.set(p.product_code, p);
return m;
}, [products]);
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">
اشتراک و ارتقا
</Button>
</Link>
<Link href="/domains">
<Button variant="outline" size="sm">
دامنه
</Button>
</Link>
<Link href="/apps">
<Button variant="outline" size="sm">
محصولات
</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">
{friendlyMessage(meError || snap?.message, "بارگذاری داشبورد انجام نشد. دوباره تلاش کنید.")}
</p>
) : null}
{!ws ? (
<div className="mt-4">
<CommercialEmptyState
title="فضای کاری شما هنوز آماده نیست"
description="راه‌اندازی اولیه را کامل کنید تا داشبورد شما فعال شود."
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">وضعیت فضای کاری: {enumLabel(ws.workspace_health)}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={ws.status === "active" ? "primary" : "default"}>{enumLabel(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={`وضعیت: ${enumLabel(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>
{humanizeCommercialCode(step.step_key, step.label)}{" "}
<span className="text-xs text-gray-400">({enumLabel(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="بر اساس نیازها و نحوه فعالیت کسب‌وکار شما" />
<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="text-xs font-medium">{humanizeCommercialCode(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="محصولات شما" />
{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="محصولات انتخاب‌شده پس از راه‌اندازی اینجا نمایش داده می‌شوند."
hint="برای دریافت پیشنهاد مناسب، ارزیابی کسب‌وکار را کامل کنید."
actionHref="/apps"
actionLabel="مشاهده محصولات"
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="امکانات فعال پلن شما پس از انتخاب اشتراک اینجا نمایش داده می‌شود."
hint="برای افزودن قابلیت‌های بیشتر، پلن‌ها را مقایسه کنید."
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">
{humanizeCommercialCode(inv.invoice_code, inv.display_name)} · {enumLabel(inv.status)}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="فاکتور نیست"
description="هنوز فاکتوری برای این فضای کاری صادر نشده است."
actionHref="/billing"
actionLabel="اشتراک و پرداخت"
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">
{humanizeCommercialCode(lic.license_code, lic.display_name)} · {enumLabel(lic.status)}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="لایسنس نیست"
description="پس از فعال‌سازی پلن، مجوزهای شما اینجا نمایش داده می‌شود."
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="همه چیز مرتب است؛ اعلان جدیدی ندارید."
hint="یادآوری‌های دوره آزمایشی، دامنه و پرداخت اینجا ظاهر می‌شوند."
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">
{humanizeCommercialCode(q.key, q.label)}: {(q.used ?? 0).toLocaleString("fa-IR")} از {q.limit == null ? "نامحدود" : q.limit.toLocaleString("fa-IR")}
</li>
))}
</ul>
) : (
<CommercialEmptyState
title="شمارنده مصرف در دسترس نیست"
hint="پس از شروع استفاده، میزان مصرف و سقف پلن اینجا نمایش داده می‌شود."
actionHref="/billing"
actionLabel="مشاهده اشتراک"
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>
);
}