TorbatYar/frontend/modules/beauty/pages/shared.tsx
Mortezakoohjani 6f4a484051 Migrate Beauty, Healthcare, and Accounting frontend to modular src/modules architecture.
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>
2026-07-26 22:28:27 +03:30

314 lines
10 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 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="تنظیماتی ثبت نشده" />}
/>
);
}