TorbatYar/frontend/modules/hospitality/features/onboarding.tsx
2026-07-28 20:39:10 +03:30

378 lines
16 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 { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button, Card, CardContent, Input, PageHeader, Textarea, Badge, Label } from "@/components/ds";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { useTenantId } from "@/hooks/useTenantId";
import { useHospitalityProductBrand } from "@/modules/hospitality/hooks/useHospitalityProductBrand";
import { cn } from "@/lib/utils";
const STEPS = [
{ id: "profile", label: "پروفایل کسب‌وکار" },
{ id: "hours", label: "ساعات کار" },
{ id: "taxes", label: "مالیات" },
{ id: "menu", label: "منو" },
{ id: "integrations", label: "یکپارچه‌سازی‌ها" },
{ id: "checklist", label: "چک‌لیست" },
] as const;
type StepId = (typeof STEPS)[number]["id"];
export function RestaurantOnboardingPage() {
const { tenantId } = useTenantId();
const brand = useHospitalityProductBrand();
const qc = useQueryClient();
const [step, setStep] = useState<StepId>("profile");
const [venueName, setVenueName] = useState("");
const [venueCode, setVenueCode] = useState("");
const [address, setAddress] = useState("");
const [hoursJson, setHoursJson] = useState('{"sat":"09:00-23:00","sun":"09:00-23:00"}');
const [taxJson, setTaxJson] = useState('{"vat_percent":9}');
const venuesQ = useQuery({
queryKey: ["hospitality", tenantId, "onboarding-venues"],
queryFn: () => hospitalityApi.venues.list(tenantId!, { page: 1, page_size: 50 }),
enabled: !!tenantId,
});
const menusQ = useQuery({
queryKey: ["hospitality", tenantId, "onboarding-menus"],
queryFn: () => hospitalityApi.menus.list(tenantId!, { page: 1, page_size: 50 }),
enabled: !!tenantId,
});
const settingsQ = useQuery({
queryKey: ["hospitality", tenantId, "onboarding-settings"],
queryFn: () => hospitalityApi.settings.list(tenantId!, { page: 1, page_size: 200 }),
enabled: !!tenantId,
});
const connectorsQ = useQuery({
queryKey: ["hospitality", tenantId, "onboarding-connectors"],
queryFn: () => hospitalityApi.connectorRegistrations.list(tenantId!, { page: 1, page_size: 100 }),
enabled: !!tenantId,
});
const primaryVenue = venuesQ.data?.[0];
useEffect(() => {
if (!primaryVenue) return;
setVenueName(String(primaryVenue.name || ""));
setVenueCode(String(primaryVenue.code || ""));
const meta = primaryVenue.metadata as Record<string, unknown> | undefined;
setAddress(String(primaryVenue.address || meta?.address || ""));
}, [primaryVenue]);
const createVenue = useMutation({
mutationFn: async () => {
if (!tenantId) throw new Error("tenant");
return hospitalityApi.venues.create(tenantId, {
code: venueCode || `venue-${Date.now()}`,
name: venueName || "رستوران",
format: "restaurant",
status: "active",
metadata: { address, source: "hospitality_onboarding" },
});
},
onSuccess: () => {
toast.success("مکان ایجاد شد");
void qc.invalidateQueries({ queryKey: ["hospitality", tenantId] });
setStep("hours");
},
onError: (e: Error) => toast.error(e.message),
});
const upsertSetting = useMutation({
mutationFn: async (payload: { key: string; value: unknown }) => {
if (!tenantId) throw new Error("tenant");
return hospitalityApi.settings.upsert(tenantId, {
key: payload.key,
value: payload.value,
venue_id: primaryVenue?.id,
});
},
onSuccess: () => {
toast.success("تنظیمات ذخیره شد");
void qc.invalidateQueries({ queryKey: ["hospitality", tenantId, "onboarding-settings"] });
},
onError: (e: Error) => toast.error(e.message),
});
const createMenu = useMutation({
mutationFn: async () => {
if (!tenantId || !primaryVenue?.id) throw new Error("venue_required");
return hospitalityApi.menus.create(tenantId, {
code: `menu-${Date.now()}`,
name: "منوی اصلی",
venue_id: primaryVenue.id,
status: "draft",
});
},
onSuccess: () => {
toast.success("منوی پیش‌نویس ساخته شد");
void qc.invalidateQueries({ queryKey: ["hospitality", tenantId] });
setStep("integrations");
},
onError: (e: Error) => toast.error(e.message),
});
const checklist = useMemo(() => {
const settings = settingsQ.data || [];
const hasHours = settings.some((s) => String(s.key).includes("working_hours"));
const hasTax = settings.some((s) => String(s.key).includes("tax"));
const kinds = new Set((connectorsQ.data || []).map((c) => String(c.kind)));
return [
{ id: "venue", label: "پروفایل / مکان", done: (venuesQ.data?.length || 0) > 0, href: "/hospitality/branches" },
{ id: "hours", label: "ساعات کار", done: hasHours, href: "/hospitality/settings" },
{ id: "tax", label: "مالیات", done: hasTax, href: "/hospitality/settings" },
{ id: "menu", label: "منو", done: (menusQ.data?.length || 0) > 0, href: "/hospitality/menu" },
{ id: "payment", label: "پرداخت POS (محلی)", done: false, href: "/hospitality/pos/register", hint: "پرداخت PSP از سرویس Payment — POS محلی Hospitality" },
{ id: "qr", label: "QR", done: false, href: "/hospitality/qr/menu", hint: "پس از منو از QR بسازید" },
{ id: "delivery", label: "پیک", done: kinds.has("delivery"), href: "/hospitality/integrations/delivery" },
{ id: "loyalty", label: "وفاداری", done: kinds.has("loyalty"), href: "/hospitality/integrations/loyalty" },
{ id: "crm", label: "CRM", done: kinds.has("crm"), href: "/hospitality/integrations/crm" },
{ id: "communication", label: "ارتباطات", done: kinds.has("communication"), href: "/hospitality/integrations/communication" },
{ id: "printer", label: "پرینتر / station", done: false, href: "/hospitality/pos/pro", hint: "POS stations در بسته POS Pro" },
];
}, [venuesQ.data, menusQ.data, settingsQ.data, connectorsQ.data]);
const doneCount = checklist.filter((c) => c.done).length;
const pct = Math.round((doneCount / checklist.length) * 100);
if (!tenantId || venuesQ.isLoading) return <HospitalityPageLoader />;
if (venuesQ.error) return <HospitalityPageError error={venuesQ.error} onRetry={() => venuesQ.refetch()} />;
const productLabel = brand.data?.displayName || "محصول Hospitality";
return (
<div className="space-y-6">
<HospitalityBreadcrumbs current="راه‌اندازی رستوران" />
<PageHeader
title="ویزارد راه‌اندازی رستوران"
description={`اولین اجرا برای ${productLabel} — فقط داده واقعی API، بدون کارت دمو.`}
actions={<Badge tone="primary">{pct}% تکمیل</Badge>}
/>
<div className="flex flex-wrap gap-2">
{STEPS.map((s) => (
<button
key={s.id}
type="button"
onClick={() => setStep(s.id)}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium",
step === s.id
? "bg-[var(--hospitality-accent)] text-white"
: "bg-[var(--surface-muted)] text-[var(--muted)]"
)}
>
{s.label}
</button>
))}
</div>
{step === "profile" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-[var(--muted)]">
{primaryVenue
? "مکان موجود یافت شد — می‌توانید ادامه دهید یا مکان جدید بسازید."
: "هنوز مکانی نیست. نام، کد و آدرس را وارد کنید."}
</p>
<div>
<Label>نام مکان</Label>
<Input className="mt-1" value={venueName} onChange={(e) => setVenueName(e.target.value)} />
</div>
<div>
<Label>کد</Label>
<Input className="mt-1" value={venueCode} onChange={(e) => setVenueCode(e.target.value)} dir="ltr" />
</div>
<div>
<Label>آدرس</Label>
<Textarea className="mt-1" value={address} onChange={(e) => setAddress(e.target.value)} rows={2} />
</div>
<div className="flex flex-wrap gap-2">
{!primaryVenue ? (
<Button loading={createVenue.isPending} onClick={() => createVenue.mutate()}>
ایجاد مکان
</Button>
) : (
<Button onClick={() => setStep("hours")}>ادامه</Button>
)}
<Link href="/hospitality/branches">
<Button variant="outline">مدیریت مکانها</Button>
</Link>
</div>
</CardContent>
</Card>
) : null}
{step === "hours" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-[var(--muted)]">ساعات کار در settings با کلید working_hours ذخیره میشود.</p>
<div>
<Label>ساعات (JSON)</Label>
<Textarea
className="mt-1"
value={hoursJson}
onChange={(e) => setHoursJson(e.target.value)}
rows={4}
dir="ltr"
/>
</div>
<div className="flex gap-2">
<Button
loading={upsertSetting.isPending}
onClick={() => {
try {
upsertSetting.mutate(
{ key: "working_hours", value: JSON.parse(hoursJson) },
{ onSuccess: () => setStep("taxes") }
);
} catch {
toast.error("JSON نامعتبر");
}
}}
>
ذخیره ساعات
</Button>
<Button variant="outline" onClick={() => setStep("taxes")}>
رد کردن
</Button>
</div>
</CardContent>
</Card>
) : null}
{step === "taxes" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-[var(--muted)]">نرخ مالیات محلی در settings حسابداری از کانکتور جداست.</p>
<div>
<Label>مالیات (JSON)</Label>
<Textarea
className="mt-1"
value={taxJson}
onChange={(e) => setTaxJson(e.target.value)}
rows={3}
dir="ltr"
/>
</div>
<div className="flex gap-2">
<Button
loading={upsertSetting.isPending}
onClick={() => {
try {
upsertSetting.mutate(
{ key: "tax", value: JSON.parse(taxJson) },
{ onSuccess: () => setStep("menu") }
);
} catch {
toast.error("JSON نامعتبر");
}
}}
>
ذخیره مالیات
</Button>
<Button variant="outline" onClick={() => setStep("menu")}>
رد کردن
</Button>
</div>
</CardContent>
</Card>
) : null}
{step === "menu" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-[var(--muted)]">
{(menusQ.data?.length || 0) > 0
? `${menusQ.data!.length} منو موجود است.`
: "منوی پیش‌نویس بسازید یا بعداً از صفحه منو وارد کنید."}
</p>
<div className="flex flex-wrap gap-2">
{(menusQ.data?.length || 0) === 0 ? (
<Button
loading={createMenu.isPending}
disabled={!primaryVenue}
onClick={() => createMenu.mutate()}
>
ایجاد منوی پیشنویس
</Button>
) : (
<Button onClick={() => setStep("integrations")}>ادامه</Button>
)}
<Link href="/hospitality/menu">
<Button variant="outline">صفحه منو</Button>
</Link>
</div>
</CardContent>
</Card>
) : null}
{step === "integrations" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm text-[var(--muted)]">
کانکتورها فقط ثبت ارجاع به سرویسهای مالک هستند داده تکراری ساخته نمیشود.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{[
{ href: "/hospitality/integrations/delivery", label: "پیک / Delivery" },
{ href: "/hospitality/integrations/crm", label: "CRM" },
{ href: "/hospitality/integrations/loyalty", label: "وفاداری" },
{ href: "/hospitality/integrations/communication", label: "ارتباطات" },
{ href: "/hospitality/integrations/accounting", label: "حسابداری" },
{ href: "/hospitality/qr/menu", label: "QR منو" },
{ href: "/hospitality/pos/register", label: "صندوق / پرداخت محلی" },
].map((l) => (
<Link key={l.href} href={l.href}>
<Button variant="outline" className="w-full justify-start">
{l.label}
</Button>
</Link>
))}
</div>
<Button onClick={() => setStep("checklist")}>رفتن به چکلیست</Button>
</CardContent>
</Card>
) : null}
{step === "checklist" ? (
<Card>
<CardContent className="space-y-3 p-4">
<p className="text-sm font-medium">پیشرفت راهاندازی: {doneCount}/{checklist.length}</p>
<ul className="space-y-2">
{checklist.map((c) => (
<li
key={c.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-[var(--border)] px-3 py-2 text-sm"
>
<span>
{c.done ? "✅" : "⬜"} {c.label}
{c.hint ? <span className="ms-2 text-xs text-[var(--muted)]">{c.hint}</span> : null}
</span>
<Link href={c.href} className="text-xs text-[var(--hospitality-accent)]">
باز کردن
</Link>
</li>
))}
</ul>
<Link href="/hospitality">
<Button>ورود به داشبورد</Button>
</Link>
</CardContent>
</Card>
) : null}
</div>
);
}
export default RestaurantOnboardingPage;