TorbatYar/frontend/scripts/scaffold-hospitality-module.mjs
Mortezakoohjani 065c053c16 Add complete Torbat Food hospitality frontend connected to backend APIs.
Introduces modules/hospitality with 54 routes, BFF proxy, capability-gated nav, CRUD factory, and architecture validation docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 20:54:26 +03:30

391 lines
19 KiB
JavaScript

#!/usr/bin/env node
/**
* Scaffolds Torbat Food (Hospitality) frontend module:
* - Feature pages from PAGE_REGISTRY
* - Thin app/hospitality routes
* - BFF proxy
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..");
const MOD = path.join(ROOT, "modules", "hospitality");
const APP = path.join(ROOT, "app", "hospitality");
const FEATURES = path.join(MOD, "features");
/** [routePath, exportName, featureFile, apiKey, title, bundleFeature|null] */
const PAGE_REGISTRY = [
["", "ExecutiveDashboard", "dashboard", null, "داشبورد", null],
["hub", "HospitalityHub", "hub", null, "مرکز تربت فود", null],
["branches", "BranchesPage", "venues", "venues", "شعب و مکان‌ها", null],
["branches/list", "BranchListPage", "branches", "branches", "شعب", null],
["dining-areas", "DiningAreasPage", "diningAreas", "diningAreas", "سالن‌ها", null],
["tables", "TablesPage", "tables", "tables", "میزها", null],
["floor-map", "FloorMapPage", "posFloorPlans", "posFloorPlans", "نقشه سالن", "pos_pro"],
["reservations", "ReservationsPage", "reservations", "reservations", "رزروها", "reservation"],
["queue", "QueuePage", "waitlist", "waitlist", "صف انتظار", "table_service"],
["customers", "CustomersPage", "connectorRegistrationsCrm", "connectorRegistrations", "مشتریان", "crm_connector"],
["guests", "GuestsPage", "qrMenuSessions", "qrMenuSessions", "پروفایل مهمان", "qr_menu"],
["menu", "DigitalMenuPage", "menus", "menus", "منوی دیجیتال", "digital_menu"],
["menu/categories", "MenuCategoriesPage", "menuCategories", "menuCategories", "دسته‌بندی منو", "digital_menu"],
["menu/items", "MenuItemsPage", "menuItems", "menuItems", "آیتم‌های منو", "digital_menu"],
["menu/modifiers", "ModifierGroupsPage", "modifierGroups", "modifierGroups", "گروه افزودنی", "digital_menu"],
["menu/modifier-options", "ModifierOptionsPage", "modifierOptions", "modifierOptions", "گزینه افزودنی", "digital_menu"],
["menu/availability", "AvailabilityPage", "availabilityWindows", "availabilityWindows", "قوانین دسترسی", "digital_menu"],
["menu/media", "MediaLibraryPage", "menuMediaRefs", "menuMediaRefs", "کتابخانه رسانه", "digital_menu"],
["qr/menu", "QrMenuPage", "qrCodes", "qrCodes", "منوی QR", "qr_menu"],
["qr/ordering", "QrOrderingPage", "qrOrderingSessions", "qrOrderingSessions", "سفارش QR", "qr_ordering"],
["ordering/cart", "CartPage", "carts", "carts", "سبد خرید", "qr_ordering"],
["ordering/checkout", "CheckoutPage", "cartLines", "cartLines", "تسویه", "qr_ordering"],
["kitchen/display", "KitchenDisplayPage", "kitchenTickets", "kitchenTickets", "نمایشگر آشپزخانه", "kitchen"],
["kitchen/queue", "KitchenQueuePage", "kitchenTicketItems", "kitchenTicketItems", "صف آشپزخانه", "kitchen"],
["kitchen/preparation", "KitchenPrepPage", "kitchenTicketsPrep", "kitchenTickets", "وضعیت آماده‌سازی", "kitchen"],
["pos/lite", "PosLitePage", "posTickets", "posTickets", "POS Lite", "pos_lite"],
["pos/pro", "PosProPage", "posPayments", "posPayments", "POS Pro", "pos_pro"],
["pos/register", "CashRegisterPage", "posRegisters", "posRegisters", "صندوق", "pos_lite"],
["pos/payments", "PaymentsPage", "posPaymentsList", "posPayments", "پرداخت‌ها", "pos_pro"],
["pos/discounts", "DiscountsPage", "posDiscounts", "posDiscounts", "تخفیف‌ها", "pos_pro"],
["pos/coupons", "CouponsPage", "posDiscountsCoupons", "posDiscounts", "کوپن‌ها", "pos_pro"],
["pos/promotions", "PromotionsPage", "featureTogglesPromo", "featureToggles", "پروموشن‌ها", "pos_pro"],
["integrations/delivery", "DeliveryIntegrationPage", "connectorDelivery", "connectorRegistrations", "پیک", "delivery_connector"],
["orders/pickup", "PickupOrdersPage", "pickupOrders", "qrOrderingSessions", "سفارش بیرون‌بر", "qr_ordering"],
["orders/online", "OnlineOrdersPage", "onlineOrders", "qrOrderingSessions", "سفارش آنلاین", "qr_ordering"],
["orders/timeline", "OrderTimelinePage", "orderTimeline", "kitchenTickets", "خط زمانی سفارش", "kitchen"],
["invoices", "InvoicesPage", "invoices", "posTickets", "فاکتورها", "pos_lite"],
["receipts", "ReceiptsPage", "receipts", "posTickets", "رسیدها", "pos_lite"],
["reports", "ReportsPage", "analyticsReports", "analyticsReports", "گزارش‌ها", "analytics"],
["analytics", "AnalyticsPage", "analyticsSnapshots", "analyticsSnapshots", "آنالیتیکس", "analytics"],
["inventory", "InventoryPage", "inventoryOverview", "analyticsSnapshots", "موجودی", "analytics"],
["integrations/crm", "CrmIntegrationPage", "connectorCrm", "connectorRegistrations", "CRM", "crm_connector"],
["integrations/loyalty", "LoyaltyIntegrationPage", "connectorLoyalty", "connectorRegistrations", "وفاداری", "loyalty_connector"],
["integrations/communication", "CommunicationIntegrationPage", "connectorCommunication", "connectorRegistrations", "ارتباطات", "communication_connector"],
["integrations/website", "WebsiteIntegrationPage", "connectorWebsite", "connectorRegistrations", "وب‌سایت", "website_connector"],
["integrations/marketplace", "MarketplaceIntegrationPage", "connectorDispatches", "connectorDispatches", "مارکت‌پلیس", null],
["settings", "SettingsPage", "settings", "settings", "تنظیمات", null],
["permissions", "PermissionsPage", "permissions", "permissions", "مجوزها", null],
["roles", "RolesPage", "roles", "roles", "نقش‌ها", null],
["audit", "AuditLogsPage", "events", "events", "لاگ ممیزی", null],
["notifications", "NotificationsPage", "notifications", "events", "اعلان‌ها", null],
["health", "HealthPage", "health", null, "سلامت سرویس", null],
["capabilities", "CapabilitiesPage", "capabilities", null, "قابلیت‌ها", null],
["bundles", "BundlesPage", "tenantBundles", "tenantBundles", "بسته‌های فعال", null],
];
function ensureDir(d) {
fs.mkdirSync(d, { recursive: true });
}
function writeIfMissing(file, content) {
if (!fs.existsSync(file)) fs.writeFileSync(file, content);
}
function writeFeature(name, content) {
const file = path.join(FEATURES, `${name}.tsx`);
if (!fs.existsSync(file)) {
ensureDir(FEATURES);
fs.writeFileSync(file, content);
}
}
const listPageTemplate = (exportName, apiKey, title, breadcrumb, bundle) => `"use client";
import { createHospitalityListPage } from "@/modules/hospitality/components/HospitalityListCrudPage";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
export const ${exportName} = createHospitalityListPage({
title: "${title}",
breadcrumb: "${breadcrumb}",
resourceKey: "${apiKey}",
bundleFeature: ${bundle ? `"${bundle}"` : "null"},
permission: "hospitality.view",
api: hospitalityApi.${apiKey},
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
],
});
export default ${exportName};
`;
// Generate list-based feature stubs
const generatedFeatures = new Set(["dashboard", "hub", "health", "capabilities"]);
for (const [, exportName, featureFile, apiKey, title, bundle] of PAGE_REGISTRY) {
if (generatedFeatures.has(featureFile) || !apiKey) continue;
generatedFeatures.add(featureFile);
writeFeature(
featureFile,
listPageTemplate(exportName, apiKey, title, title, bundle)
);
}
// Dashboard
writeFeature(
"dashboard",
`"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { useTenantId } from "@/hooks/useTenantId";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, StatCard, Card, CardContent, Button, Badge } from "@/components/ds";
import { HospitalityBreadcrumbs } from "@/modules/hospitality/components/HospitalityBreadcrumbs";
export const ExecutiveDashboard = function HospitalityDashboard() {
const { tenantId } = useTenantId();
const caps = useHospitalityCapabilities();
const countsQ = useQuery({
queryKey: ["hospitality", tenantId, "dashboard-counts"],
queryFn: async () => {
const [venues, reservations, tickets, kitchen] = await Promise.all([
hospitalityApi.venues.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.reservations.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.posTickets.list(tenantId!, { page: 1, page_size: 500 }),
hospitalityApi.kitchenTickets.list(tenantId!, { page: 1, page_size: 500 }),
]);
return {
venues: venues.length,
reservations: reservations.length,
openOrders: tickets.filter((t) => t.status === "open" || t.status === "draft").length,
kitchenActive: kitchen.filter((k) => k.status !== "completed" && k.status !== "cancelled").length,
};
},
enabled: !!tenantId,
});
if (!tenantId || countsQ.isLoading || caps.isLoading) return <HospitalityPageLoader />;
if (countsQ.error) return <HospitalityPageError error={countsQ.error} onRetry={() => countsQ.refetch()} />;
const c = countsQ.data!;
return (
<div className="space-y-6">
<HospitalityBreadcrumbs current="داشبورد" />
<PageHeader
title="داشبورد مدیریتی"
description="نمای اجرایی فروش، سفارش‌ها و عملیات روزانه."
actions={<Badge tone="success">Torbat Food v{caps.data?.version}</Badge>}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="مکان‌ها" value={String(c.venues)} hint="Venues" />
<StatCard label="رزرو امروز" value={String(c.reservations)} hint="Reservations" />
<StatCard label="سفارش باز" value={String(c.openOrders)} hint="Open tickets" />
<StatCard label="آشپزخانه فعال" value={String(c.kitchenActive)} hint="Kitchen queue" />
</div>
<Card>
<CardContent className="flex flex-wrap gap-2 p-4">
<Link href="/hospitality/reservations"><Button variant="outline">رزروها</Button></Link>
<Link href="/hospitality/kitchen/display"><Button variant="outline">آشپزخانه</Button></Link>
<Link href="/hospitality/pos/lite"><Button variant="outline">POS</Button></Link>
<Link href="/hospitality/analytics"><Button variant="outline">آنالیتیکس</Button></Link>
</CardContent>
</Card>
</div>
);
};
export default ExecutiveDashboard;
`
);
writeFeature(
"hub",
`"use client";
import Link from "next/link";
import { useMe } from "@/hooks/useMe";
import { HOSPITALITY_PORTAL } from "@/modules/hospitality/constants/portals";
import { Card, CardContent, Button, LoadingState, ErrorState } from "@/components/ds";
export const HospitalityHub = function HospitalityHubPage() {
const { me, loading, error, reload } = useMe();
if (loading) return <LoadingState label="در حال بارگذاری تربت فود…" />;
if (error) return <ErrorState message={error} onRetry={reload} />;
if (!me?.current_tenant_id) {
return <ErrorState message="برای استفاده از تربت فود workspace فعال لازم است." />;
}
return (
<div className="mx-auto max-w-lg p-6">
<h1 className="mb-2 text-2xl font-bold text-secondary">تربت فود</h1>
<p className="mb-6 text-sm text-[var(--muted)]">پلتفرم مهمان‌نوازی سازمانی</p>
<Card>
<CardContent className="space-y-4 p-6">
<p className="font-medium">{HOSPITALITY_PORTAL.label}</p>
<p className="text-sm text-[var(--muted)]">{HOSPITALITY_PORTAL.description}</p>
<Link href={HOSPITALITY_PORTAL.basePath}>
<Button className="w-full">ورود به پنل مدیریت</Button>
</Link>
</CardContent>
</Card>
</div>
);
};
export default HospitalityHub;
`
);
writeFeature(
"health",
`"use client";
import { useQuery } from "@tanstack/react-query";
import { hospitalityApi } from "@/modules/hospitality/services/hospitality-api";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
export const HealthPage = function HospitalityHealthPage() {
const q = useQuery({ queryKey: ["hospitality", "health"], queryFn: () => hospitalityApi.health() });
if (q.isLoading) return <HospitalityPageLoader />;
if (q.error) return <HospitalityPageError error={q.error} onRetry={() => q.refetch()} />;
return (
<div>
<PageHeader title="سلامت سرویس" description="وضعیت سرویس Hospitality." />
<Card><CardContent className="space-y-2 p-4">
<Badge tone={q.data?.status === "ok" ? "success" : "danger"}>{q.data?.status}</Badge>
<p className="text-sm">{q.data?.service} — v{q.data?.version}</p>
</CardContent></Card>
</div>
);
};
export default HealthPage;
`
);
writeFeature(
"capabilities",
`"use client";
import { useHospitalityCapabilities } from "@/modules/hospitality/hooks/useHospitalityCapabilities";
import { HospitalityPageLoader, HospitalityPageError } from "@/modules/hospitality/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
export const CapabilitiesPage = function HospitalityCapabilitiesPage() {
const q = useHospitalityCapabilities();
if (q.isLoading) return <HospitalityPageLoader />;
if (q.error) return <HospitalityPageError error={q.error} onRetry={() => q.refetch()} />;
const features = q.data?.features ?? {};
return (
<div>
<PageHeader title="قابلیت‌ها" description="Feature flags و bundles." />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(features).map(([k, v]) => (
<Card key={k}><CardContent className="flex items-center justify-between p-3">
<span className="text-sm font-mono">{k}</span>
<Badge tone={v ? "success" : "default"}>{v ? "فعال" : "غیرفعال"}</Badge>
</CardContent></Card>
))}
</div>
</div>
);
};
export default CapabilitiesPage;
`
);
// Routes
for (const [routePath, exportName, featureFile] of PAGE_REGISTRY) {
const dir = path.join(APP, routePath);
ensureDir(dir);
const pageContent =
`"use client";\n\nexport { ${exportName} as default } from "@/modules/hospitality/features/${featureFile}";\n`;
fs.writeFileSync(path.join(dir, "page.tsx"), pageContent);
writeIfMissing(
path.join(dir, "loading.tsx"),
`import { LoadingState } from "@/components/ds";\n\nexport default function Loading() {\n return <LoadingState label="در حال بارگذاری تربت فود…" />;\n}\n`
);
writeIfMissing(
path.join(dir, "error.tsx"),
`"use client";\n\nimport { ErrorState } from "@/components/ds";\n\nexport default function Error({ error, reset }: { error: Error; reset: () => void }) {\n return <ErrorState message={error.message} onRetry={reset} />;\n}\n`
);
}
// Root layout files
writeIfMissing(
path.join(APP, "layout.tsx"),
`/** Hospitality root — portal shell on management routes. */\nexport default function HospitalityRootLayout({ children }: { children: React.ReactNode }) {\n return children;\n}\n`
);
writeIfMissing(
path.join(APP, "not-found.tsx"),
`import Link from "next/link";\nimport { Button, EmptyState } from "@/components/ds";\n\nexport default function HospitalityNotFound() {\n return (\n <div className="flex min-h-[50vh] items-center justify-center p-6">\n <EmptyState title="صفحه یافت نشد" action={<Link href="/hospitality"><Button>بازگشت</Button></Link>} />\n </div>\n );\n}\n`
);
// Management layout (all routes except hub use shell)
const mgmtLayout = `"use client";\n\nimport { createPortalLayout } from "@/modules/hospitality/components/createPortalLayout";\n\nexport default createPortalLayout("management");\n`;
// Apply layout to root management - use a (mgmt) group or layout at app/hospitality level
// For simplicity, put layout on main routes via a wrapper layout at hospitality level for non-hub
// BFF
const bffDir = path.join(ROOT, "app", "api", "hospitality", "[...path]");
ensureDir(bffDir);
const bff = `import { NextRequest, NextResponse } from "next/server";
const UPSTREAM =
process.env.HOSPITALITY_SERVICE_URL ||
process.env.NEXT_PUBLIC_HOSPITALITY_API_URL ||
"http://localhost:8009";
async function proxy(req: NextRequest, pathSegments: string[]) {
const p = pathSegments.join("/");
const url = new URL(req.url);
const target = \`\${UPSTREAM.replace(/\\/$/, "")}/\${p}\${url.search}\`;
const headers = new Headers();
for (const h of ["authorization", "content-type", "x-tenant-id", "accept"]) {
const v = req.headers.get(h);
if (v) headers.set(h, v);
}
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD") body = await req.arrayBuffer();
try {
const res = await fetch(target, { method: req.method, headers, body, cache: "no-store" });
const out = new Headers();
const ct = res.headers.get("content-type");
if (ct) out.set("content-type", ct);
return new NextResponse(await res.arrayBuffer(), { status: res.status, headers: out });
} catch (err) {
const detail = err instanceof Error ? err.message : "upstream_unreachable";
return NextResponse.json(
{ error: { code: "hospitality_proxy_error", message: \`پروکسی تربت فود — \${detail}\` } },
{ status: 502 }
);
}
}
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function POST(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function PUT(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
const { path: p } = await ctx.params;
return proxy(req, p);
}
`;
fs.writeFileSync(path.join(bffDir, "route.ts"), bff);
console.log(`Scaffolded ${PAGE_REGISTRY.length} hospitality routes and feature stubs.`);