378 lines
16 KiB
TypeScript
378 lines
16 KiB
TypeScript
"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;
|