Introduces modules/hospitality with 54 routes, BFF proxy, capability-gated nav, CRUD factory, and architecture validation docs. Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
19 KiB
JavaScript
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.`);
|