Move business logic out of App Router into module packages, add boundary validation scripts, and keep all routes as thin re-exports without changing URLs or API behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
314 lines
10 KiB
TypeScript
314 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import {
|
||
PageHeader,
|
||
LoadingState,
|
||
ErrorState,
|
||
Badge,
|
||
Card,
|
||
CardContent,
|
||
DataTable,
|
||
EmptyState,
|
||
} from "@/components/ds";
|
||
import { beautyBusinessApi } from "@/modules/beauty/services/beauty-business-api";
|
||
import { useTenantId } from "@/hooks/useTenantId";
|
||
import { useBeautyLookups } from "@/modules/beauty/hooks/useBeautyLookups";
|
||
import { StatWidget, BeautyTablePage } from "@/modules/beauty/design-system";
|
||
|
||
export function BeautyPageLoader({ label = "در حال بارگذاری…" }: { label?: string }) {
|
||
return <LoadingState label={label} />;
|
||
}
|
||
|
||
export function BeautyPageError({
|
||
error,
|
||
onRetry,
|
||
}: {
|
||
error: unknown;
|
||
onRetry?: () => void;
|
||
}) {
|
||
const message = error instanceof Error ? error.message : "خطا در دریافت دادهها";
|
||
return <ErrorState message={message} onRetry={onRetry} />;
|
||
}
|
||
|
||
export function useBeautyServiceMeta() {
|
||
const healthQ = useQuery({
|
||
queryKey: ["beauty", "health"],
|
||
queryFn: () => beautyBusinessApi.health(),
|
||
});
|
||
const capsQ = useQuery({
|
||
queryKey: ["beauty", "capabilities"],
|
||
queryFn: () => beautyBusinessApi.capabilities(),
|
||
});
|
||
const metricsQ = useQuery({
|
||
queryKey: ["beauty", "metrics"],
|
||
queryFn: () => beautyBusinessApi.metrics(),
|
||
});
|
||
return { healthQ, capsQ, metricsQ };
|
||
}
|
||
|
||
export function ServiceMetaBadges() {
|
||
const { healthQ, capsQ } = useBeautyServiceMeta();
|
||
return (
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{healthQ.data ? (
|
||
<Badge tone={healthQ.data.status === "ok" ? "success" : "danger"}>
|
||
سرویس {healthQ.data.version}
|
||
</Badge>
|
||
) : null}
|
||
{capsQ.data ? <Badge tone="default">فاز {capsQ.data.phase}</Badge> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MetricsGrid() {
|
||
const { metricsQ } = useBeautyServiceMeta();
|
||
if (metricsQ.isLoading) return null;
|
||
if (!metricsQ.data) return null;
|
||
return (
|
||
<Card className="mt-6">
|
||
<CardContent className="pt-5">
|
||
<h2 className="mb-2 font-semibold text-secondary">متریکهای سرویس</h2>
|
||
{metricsQ.data.note ? (
|
||
<p className="mb-3 text-xs text-[var(--muted)]">{metricsQ.data.note}</p>
|
||
) : null}
|
||
<div className="flex flex-wrap gap-2">
|
||
{Object.entries(metricsQ.data.metrics).map(([k, v]) => (
|
||
<Badge key={k} tone="default">
|
||
{k}: {String(v)}
|
||
</Badge>
|
||
))}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export function AggregateStatsRow({
|
||
stats,
|
||
}: {
|
||
stats: { label: string; value: string | number; hint?: string }[];
|
||
}) {
|
||
return (
|
||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||
{stats.map((s) => (
|
||
<StatWidget key={s.label} label={s.label} value={s.value} hint={s.hint} />
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ExternalProvidersByKind({
|
||
kind,
|
||
title,
|
||
description,
|
||
accountingLink,
|
||
}: {
|
||
kind: "accounting" | "crm" | "communication" | "delivery" | "loyalty";
|
||
title: string;
|
||
description: string;
|
||
accountingLink?: boolean;
|
||
}) {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "external-providers", kind],
|
||
queryFn: () => beautyBusinessApi.externalProviders.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
select: (rows) => rows.filter((p) => p.provider_kind === kind),
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<BeautyTablePage
|
||
title={title}
|
||
description={description}
|
||
columns={[
|
||
{ key: "code", header: "کد" },
|
||
{ key: "name", header: "نام" },
|
||
{ key: "adapter_key", header: "آداپتر" },
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone="default">{String(r.status)}</Badge>,
|
||
},
|
||
]}
|
||
rows={(q.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="ارائهدهندهای ثبت نشده" />}
|
||
actions={
|
||
accountingLink ? (
|
||
<Link href="/accounting">
|
||
<Badge tone="default">حسابداری تربتیار</Badge>
|
||
</Link>
|
||
) : undefined
|
||
}
|
||
/>
|
||
);
|
||
}
|
||
|
||
export function EntityListPage<T extends { id: string }>({
|
||
title,
|
||
description,
|
||
queryKey,
|
||
queryFn,
|
||
columns,
|
||
renderCard,
|
||
emptyTitle,
|
||
}: {
|
||
title: string;
|
||
description?: string;
|
||
queryKey: string[];
|
||
queryFn: (tenantId: string) => Promise<T[]>;
|
||
columns: { key: string; header: string; render?: (row: T) => React.ReactNode }[];
|
||
renderCard?: (row: T) => React.ReactNode;
|
||
emptyTitle?: string;
|
||
}) {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey,
|
||
queryFn: () => queryFn(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
const rows = q.data ?? [];
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title={title} description={description} />
|
||
{renderCard ? (
|
||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||
{rows.map((r) => (
|
||
<div key={r.id}>{renderCard(r)}</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<DataTable
|
||
columns={columns.map((c) => ({
|
||
...c,
|
||
render: c.render ? (row) => c.render!(row as T) : undefined,
|
||
}))}
|
||
rows={rows as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title={emptyTitle ?? "موردی ثبت نشده"} />}
|
||
/>
|
||
)}
|
||
{renderCard && rows.length === 0 ? <EmptyState title={emptyTitle ?? "موردی ثبت نشده"} /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function AnalyticsPage({ title }: { title: string }) {
|
||
const { tenantId } = useTenantId();
|
||
const { organizations, branches, customers, staff, services, isLoading } = useBeautyLookups();
|
||
const apptsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "analytics-appointments"],
|
||
queryFn: () => beautyBusinessApi.appointments.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
const { capsQ } = useBeautyServiceMeta();
|
||
|
||
if (!tenantId || isLoading || apptsQ.isLoading) return <BeautyPageLoader />;
|
||
if (apptsQ.error) return <BeautyPageError error={apptsQ.error} onRetry={() => apptsQ.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title={title} description="تحلیل بر اساس دادههای واقعی سرویس زیبایی." />
|
||
<AggregateStatsRow
|
||
stats={[
|
||
{ label: "سازمانها", value: organizations.length },
|
||
{ label: "شعب", value: branches.length },
|
||
{ label: "مشتریان", value: customers.length },
|
||
{ label: "پرسنل", value: staff.length },
|
||
{ label: "خدمات", value: services.length },
|
||
{ label: "نوبتها", value: apptsQ.data?.length ?? 0 },
|
||
{
|
||
label: "قابلیتهای فعال",
|
||
value: Object.values(capsQ.data?.features ?? {}).filter(Boolean).length,
|
||
},
|
||
]}
|
||
/>
|
||
<MetricsGrid />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function MonitoringPage() {
|
||
const { healthQ, capsQ, metricsQ } = useBeautyServiceMeta();
|
||
|
||
if (healthQ.isLoading || capsQ.isLoading) return <BeautyPageLoader />;
|
||
const err = healthQ.error || capsQ.error || metricsQ.error;
|
||
if (err) return <BeautyPageError error={err} onRetry={() => healthQ.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="مانیتورینگ سرویس"
|
||
description="وضعیت سلامت، قابلیتها و متریکهای realtime سرویس beauty-business."
|
||
/>
|
||
<div className="grid gap-4 lg:grid-cols-3">
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<h3 className="font-semibold text-secondary">Health</h3>
|
||
<p className="mt-2 text-sm">{healthQ.data?.status}</p>
|
||
<p className="text-xs text-[var(--muted)]">
|
||
{healthQ.data?.service} v{healthQ.data?.version}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<h3 className="font-semibold text-secondary">Capabilities</h3>
|
||
<p className="mt-2 text-sm">فاز {capsQ.data?.phase}</p>
|
||
<p className="text-xs text-[var(--muted)]">{capsQ.data?.commercial_product}</p>
|
||
</CardContent>
|
||
</Card>
|
||
<Card>
|
||
<CardContent className="pt-5">
|
||
<h3 className="font-semibold text-secondary">Independence</h3>
|
||
<p className="mt-2 text-xs text-[var(--muted)]">
|
||
{capsQ.data?.independence.integration_mode}
|
||
</p>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
<MetricsGrid />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function SettingsListPage({ title, description }: { title: string; description: string }) {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "settings"],
|
||
queryFn: () => beautyBusinessApi.settings.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<BeautyTablePage
|
||
title={title}
|
||
description={description}
|
||
columns={[
|
||
{ key: "key", header: "کلید" },
|
||
{
|
||
key: "value",
|
||
header: "مقدار",
|
||
render: (r) => (
|
||
<span className="text-xs text-[var(--muted)]">{JSON.stringify(r.value ?? {})}</span>
|
||
),
|
||
},
|
||
{ key: "description", header: "توضیح", render: (r) => String(r.description ?? "—") },
|
||
]}
|
||
rows={(q.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="تنظیماتی ثبت نشده" />}
|
||
/>
|
||
);
|
||
}
|