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>
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { PageHeader, EmptyState, Button, Badge, DataTable } 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 { AppointmentCard, CustomerCard, PackageCard, SalonCard, StaffCard } from "@/modules/beauty/design-system";
|
||
import {
|
||
AggregateStatsRow,
|
||
BeautyPageError,
|
||
BeautyPageLoader,
|
||
ServiceMetaBadges,
|
||
SettingsListPage,
|
||
} from "./shared";
|
||
|
||
export function CustomerDashboard() {
|
||
const { tenantId } = useTenantId();
|
||
const { customers, staffName, serviceName, isLoading } = useBeautyLookups();
|
||
const apptsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "customer-appts"],
|
||
queryFn: () => beautyBusinessApi.appointments.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || isLoading || apptsQ.isLoading) return <BeautyPageLoader />;
|
||
if (apptsQ.error) return <BeautyPageError error={apptsQ.error} onRetry={() => apptsQ.refetch()} />;
|
||
|
||
const upcoming = (apptsQ.data ?? []).filter((a) => new Date(a.scheduled_start) >= new Date());
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title="داشبورد مشتری"
|
||
description="نوبتها و پکیجها از API واقعی."
|
||
actions={<ServiceMetaBadges />}
|
||
/>
|
||
<AggregateStatsRow
|
||
stats={[
|
||
{ label: "مشتریان workspace", value: customers.length },
|
||
{ label: "نوبتهای آینده", value: upcoming.length },
|
||
{ label: "کل نوبتها", value: apptsQ.data?.length ?? 0 },
|
||
]}
|
||
/>
|
||
<section className="mt-8 space-y-3">
|
||
<h2 className="font-semibold text-secondary">نوبتهای پیشرو</h2>
|
||
{upcoming.length === 0 ? (
|
||
<EmptyState
|
||
title="نوبت آیندهای ندارید"
|
||
action={
|
||
<Link href="/beauty/customer/appointments/book">
|
||
<Button>رزرو نوبت</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
) : (
|
||
upcoming.slice(0, 5).map((a) => (
|
||
<AppointmentCard
|
||
key={a.id}
|
||
appointment={a}
|
||
staffName={staffName(a.staff_ref)}
|
||
serviceName={serviceName(a.service_ref)}
|
||
/>
|
||
))
|
||
)}
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CustomerAppointmentsList({ upcoming }: { upcoming: boolean }) {
|
||
const { tenantId } = useTenantId();
|
||
const { staffName, serviceName } = useBeautyLookups();
|
||
const apptsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "customer-appointments", upcoming],
|
||
queryFn: () => beautyBusinessApi.appointments.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || apptsQ.isLoading) return <BeautyPageLoader />;
|
||
if (apptsQ.error) return <BeautyPageError error={apptsQ.error} onRetry={() => apptsQ.refetch()} />;
|
||
|
||
const now = new Date();
|
||
const items = (apptsQ.data ?? []).filter((a) =>
|
||
upcoming ? new Date(a.scheduled_start) >= now : new Date(a.scheduled_start) < now
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader
|
||
title={upcoming ? "نوبتهای آینده" : "سوابق نوبت"}
|
||
description="لیست نوبتها از appointments API."
|
||
/>
|
||
<div className="mt-6 space-y-3">
|
||
{items.map((a) => (
|
||
<AppointmentCard
|
||
key={a.id}
|
||
appointment={a}
|
||
staffName={staffName(a.staff_ref)}
|
||
serviceName={serviceName(a.service_ref)}
|
||
href={`/beauty/site/tracking/${a.id}`}
|
||
/>
|
||
))}
|
||
</div>
|
||
{items.length === 0 ? <EmptyState title="نوبتی یافت نشد" /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerAppointmentsUpcoming() {
|
||
return <CustomerAppointmentsList upcoming />;
|
||
}
|
||
|
||
export function CustomerAppointmentsPast() {
|
||
return <CustomerAppointmentsList upcoming={false} />;
|
||
}
|
||
|
||
export function CustomerAppointmentsBook() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="رزرو نوبت" description="رزرو از سایت عمومی یا فرم چندمرحلهای." />
|
||
<Link href="/beauty/site/book">
|
||
<Button>شروع رزرو</Button>
|
||
</Link>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerPackages() {
|
||
const { tenantId } = useTenantId();
|
||
const pkgsQ = useQuery({
|
||
queryKey: ["beauty", tenantId, "customer-packages"],
|
||
queryFn: () => beautyBusinessApi.packageDefinitions.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || pkgsQ.isLoading) return <BeautyPageLoader />;
|
||
if (pkgsQ.error) return <BeautyPageError error={pkgsQ.error} onRetry={() => pkgsQ.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="پکیجهای من" description="پکیجهای تعریفشده در workspace." />
|
||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||
{(pkgsQ.data ?? []).map((p) => (
|
||
<PackageCard key={p.id} pkg={p} />
|
||
))}
|
||
</div>
|
||
{(pkgsQ.data ?? []).length === 0 ? <EmptyState title="پکیجی ثبت نشده" /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerMemberships() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "customer-memberships"],
|
||
queryFn: () => beautyBusinessApi.membershipPlans.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="عضویتها" description="طرحهای عضویت از membership-plans API." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "name", header: "نام" },
|
||
{ key: "billing_interval_days", header: "دوره (روز)" },
|
||
{
|
||
key: "price",
|
||
header: "قیمت",
|
||
render: (r) => (r.price != null ? String(r.price) : "—"),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone="default">{String(r.status)}</Badge>,
|
||
},
|
||
]}
|
||
rows={(q.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="طرح عضویتی ثبت نشده" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerProfile() {
|
||
const { customers, isLoading } = useBeautyLookups();
|
||
if (isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="پروفایل" description="پروفایلهای مشتری workspace." />
|
||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||
{customers.map((c) => (
|
||
<CustomerCard key={c.id} customer={c} />
|
||
))}
|
||
</div>
|
||
{customers.length === 0 ? <EmptyState title="پروفایلی یافت نشد" /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerFavorites() {
|
||
const { services, isLoading } = useBeautyLookups();
|
||
if (isLoading) return <BeautyPageLoader />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="علاقهمندیها" description="خدمات ذخیرهشده — از services API." />
|
||
<EmptyState title="لیست علاقهمندی خالی است" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerReviews() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="نظرات" description="بازخورد خدمات." />
|
||
<EmptyState title="نظری ثبت نشده" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerNotifications() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="اعلانها" description="اعلانهای communication integration." />
|
||
<EmptyState title="اعلانی وجود ندارد" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerSettings() {
|
||
return <SettingsListPage title="تنظیمات مشتری" description="تنظیمات tenant از settings API." />;
|
||
}
|
||
|
||
export function CustomerInvoices() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="فاکتورها" description="فاکتورها از سرویس حسابداری workspace خوانده میشوند." />
|
||
<EmptyState
|
||
title="فاکتوری در beauty API نیست"
|
||
action={
|
||
<Link href="/accounting/sales/invoices">
|
||
<Button variant="outline">مشاهده فاکتورها در حسابداری</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerPayments() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="پرداختها" description="سوابق پرداخت از ماژول حسابداری." />
|
||
<EmptyState
|
||
title="پرداختی ثبت نشده"
|
||
action={
|
||
<Link href="/accounting/treasury/receipts-payments">
|
||
<Button variant="outline">خزانه و دریافت/پرداخت</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerWallet() {
|
||
return (
|
||
<div>
|
||
<PageHeader title="کیف پول" description="موجودی از سرویس وفاداری (Loyalty Wallet)." />
|
||
<EmptyState
|
||
title="کیف پول در beauty API مدیریت نمیشود"
|
||
action={
|
||
<Link href="/dashboard">
|
||
<Button variant="outline">بازگشت به workspace</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerLoyaltyPoints() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "loyalty-integrations"],
|
||
queryFn: () => beautyBusinessApi.loyaltyIntegrations.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="امتیاز وفاداری" description="پیکربندی یکپارچگی Loyalty از beauty API." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "name", header: "برنامه" },
|
||
{ key: "code", header: "کد" },
|
||
{
|
||
key: "external_program_ref",
|
||
header: "ارجاع Loyalty",
|
||
render: (r) => String(r.external_program_ref ?? "—"),
|
||
},
|
||
{
|
||
key: "status",
|
||
header: "وضعیت",
|
||
render: (r) => <Badge tone="default">{String(r.status)}</Badge>,
|
||
},
|
||
]}
|
||
rows={(q.data ?? []) as unknown as Record<string, unknown>[]}
|
||
empty={<EmptyState title="یکپارچگی Loyalty پیکربندی نشده" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerMessages() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "integration-dispatches"],
|
||
queryFn: () => beautyBusinessApi.integrationDispatches.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="پیامها" description="رویدادهای ارسالشده به Communication از integration-dispatches." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "event_type", header: "رویداد" },
|
||
{ key: "integration_kind", 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="پیامی ارسال نشده" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerSessions() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "package-sessions"],
|
||
queryFn: () => beautyBusinessApi.packageDefinitions.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="جلسات باقیمانده" description="پکیجهای چندجلسهای از package-definitions API." />
|
||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||
{(q.data ?? []).map((p) => (
|
||
<PackageCard key={p.id} pkg={p} />
|
||
))}
|
||
</div>
|
||
{(q.data ?? []).length === 0 ? <EmptyState title="پکیج فعالی ندارید" /> : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerFavoriteCenters() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "favorite-branches"],
|
||
queryFn: () => beautyBusinessApi.branches.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="مراکز محبوب" description="شعب workspace — از branches API." />
|
||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||
{(q.data ?? []).map((b) => (
|
||
<SalonCard key={b.id} branch={b} href={`/beauty/site/salon/${b.id}`} />
|
||
))}
|
||
</div>
|
||
{(q.data ?? []).length === 0 ? (
|
||
<EmptyState
|
||
title="مرکزی یافت نشد"
|
||
action={
|
||
<Link href="/beauty/site/search">
|
||
<Button>جستجوی مراکز</Button>
|
||
</Link>
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CustomerFavoriteStaff() {
|
||
const { tenantId } = useTenantId();
|
||
const q = useQuery({
|
||
queryKey: ["beauty", tenantId, "favorite-staff"],
|
||
queryFn: () => beautyBusinessApi.staff.list(tenantId!),
|
||
enabled: !!tenantId,
|
||
});
|
||
|
||
if (!tenantId || q.isLoading) return <BeautyPageLoader />;
|
||
if (q.error) return <BeautyPageError error={q.error} onRetry={() => q.refetch()} />;
|
||
|
||
return (
|
||
<div>
|
||
<PageHeader title="پرسنل محبوب" description="لیست پرسنل از staff API." />
|
||
<DataTable
|
||
columns={[
|
||
{ key: "display_name", header: "نام" },
|
||
{ key: "kind", 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="پرسنلی یافت نشد" />}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|