TorbatYar/frontend/scripts/scaffold-delivery-module.mjs
Mortezakoohjani 7978970783 feat(delivery): add Torbat Driver frontend module with real API wiring.
Ship the delivery platform UI under modules/delivery with thin app routes, BFF proxy, CRUD for phase 10.0-10.1 APIs, and capability-gated future screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:02:01 +03:30

891 lines
38 KiB
JavaScript

#!/usr/bin/env node
/**
* Scaffolds Torbat Driver (Delivery) frontend module:
* - Feature pages from PAGE_REGISTRY
* - Thin app/delivery 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", "delivery");
const APP = path.join(ROOT, "app", "delivery");
const FEATURES = path.join(MOD, "features");
/** [routePath, exportName, featureFile] */
const PAGE_REGISTRY = [
["", "ExecutiveDashboard", "dashboard"],
["hub", "DeliveryHub", "hub"],
["dispatcher", "DispatcherDashboard", "dispatcher"],
["operations", "LiveOperationsDashboard", "operations"],
["fleet-dashboard", "FleetDashboard", "fleetDashboard"],
["drivers", "DriversPage", "drivers"],
["drivers/availability", "DriverAvailabilityPage", "driverAvailability"],
["drivers/performance", "DriverPerformancePage", "driverPerformance"],
["orders", "OrdersPage", "orders"],
["dispatch/queue", "DispatchQueuePage", "dispatchQueue"],
["dispatch/assignment", "AssignmentCenterPage", "assignment"],
["tracking/live", "LiveTrackingPage", "liveTracking"],
["tracking/customer", "CustomerTrackingPage", "customerTracking"],
["maps/view", "MapViewPage", "mapView"],
["maps/routes/planner", "RoutePlannerPage", "routePlanner"],
["maps/routes/optimization", "RouteOptimizationPage", "routeOptimization"],
["maps/geofence", "GeofencePage", "geofence"],
["maps/zones", "DeliveryZonesPage", "deliveryZones"],
["organizations", "OrganizationsPage", "organizations"],
["branches", "BranchesPage", "branches"],
["vehicles", "VehiclesPage", "vehicles"],
["vehicle-types", "VehicleTypesPage", "vehicleTypes"],
["fleet/maintenance", "FleetMaintenancePage", "maintenance"],
["schedules", "SchedulesPage", "schedules"],
["shifts", "ShiftsPage", "shifts"],
["tasks", "TasksPage", "tasks"],
["pickups", "PickupsPage", "pickups"],
["deliveries", "DeliveriesPage", "deliveries"],
["returns", "ReturnsPage", "returns"],
["failed", "FailedDeliveriesPage", "failed"],
["cod", "CodManagementPage", "cod"],
["settlements", "SettlementsPage", "settlements"],
["payments", "PaymentsPage", "payments"],
["wallet", "DriverWalletPage", "wallet"],
["commissions", "CommissionsPage", "commissions"],
["integrations/providers", "ExternalProvidersPage", "externalProviders"],
["integrations/routing", "RoutingEnginesPage", "routingEngines"],
["vendor-portal", "VendorPortalPage", "vendorPortal"],
["notifications", "NotificationsPage", "notifications"],
["incidents", "IncidentsPage", "incidents"],
["support", "SupportTicketsPage", "support"],
["reports", "ReportsPage", "reports"],
["analytics", "AnalyticsPage", "analytics"],
["analytics/heatmaps", "HeatMapsPage", "heatmaps"],
["analytics/kpis", "PerformanceKpisPage", "kpis"],
["ai/recommendations", "AiRecommendationsPage", "aiRecommendations"],
["automation", "AutomationPage", "automation"],
["configurations", "ConfigurationsPage", "configurations"],
["settings", "SettingsPage", "settings"],
["permissions", "PermissionsPage", "permissions"],
["roles", "RolesPage", "roles"],
["audit", "AuditLogsPage", "audit"],
["api-keys", "ApiKeysPage", "apiKeys"],
["health", "HealthPage", "health"],
["capabilities", "CapabilitiesPage", "capabilities"],
];
function ensureDir(d) {
fs.mkdirSync(d, { recursive: true });
}
function writeFeature(name, content) {
ensureDir(FEATURES);
fs.writeFileSync(path.join(FEATURES, `${name}.tsx`), content);
}
const listPage = (exportName, apiKey, title, perm, createFieldsExtra) => `"use client";
import { createDeliveryListPage } from "@/modules/delivery/components/DeliveryListCrudPage";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
export const ${exportName} = createDeliveryListPage({
title: "${title}",
breadcrumb: "${title}",
resourceKey: "${apiKey}",
permission: "${perm}",
api: deliveryApi.${apiKey},
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: ${createFieldsExtra || `[
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
{ name: "status", label: "وضعیت", type: "select", options: [
{ value: "draft", label: "پیش‌نویس" },
{ value: "active", label: "فعال" },
]},
]`},
});
export default ${exportName};
`;
const phasePage = (exportName, registryKey) => `"use client";
import { createDeliveryPhasePage } from "@/modules/delivery/components/DeliveryPhaseGate";
import { PHASE_REGISTRY } from "@/modules/delivery/constants/phaseRegistry";
export const ${exportName} = createDeliveryPhasePage(PHASE_REGISTRY.${registryKey});
export default ${exportName};
`;
const mapPage = (exportName, registryKey, title) => `"use client";
import { createDeliveryMapPage } from "@/modules/delivery/components/DeliveryMapPlaceholder";
import { PHASE_REGISTRY } from "@/modules/delivery/constants/phaseRegistry";
export const ${exportName} = createDeliveryMapPage(PHASE_REGISTRY.${registryKey}, "${title}");
export default ${exportName};
`;
// Real API list pages
writeFeature("organizations", listPage("OrganizationsPage", "organizations", "سازمان‌ها", "delivery.organizations.view"));
writeFeature("branches", `"use client";
import { createDeliveryListPage } from "@/modules/delivery/components/DeliveryListCrudPage";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
export const BranchesPage = createDeliveryListPage({
title: "شعب و هاب‌ها",
breadcrumb: "شعب",
resourceKey: "hubs",
permission: "delivery.hubs.view",
api: deliveryApi.hubs,
columns: [
{ key: "code", header: "کد" },
{ key: "name", header: "نام" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "organization_id", label: "سازمان", type: "organization", required: true },
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
{ name: "status", label: "وضعیت", type: "select", options: [
{ value: "active", label: "فعال" },
{ value: "draft", label: "پیش‌نویس" },
]},
],
});
export default BranchesPage;
`);
writeFeature("externalProviders", listPage("ExternalProvidersPage", "externalProviders", "ارائه‌دهندگان خارجی", "delivery.external_providers.view", `[
{ name: "organization_id", label: "سازمان", type: "organization", required: true },
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
{ name: "provider_kind", label: "نوع", type: "select", options: [
{ value: "routing_engine", label: "مسیریابی" },
{ value: "fleet", label: "ناوگان" },
{ value: "courier", label: "پیک" },
{ value: "maps", label: "نقشه" },
]},
]`));
writeFeature("routingEngines", listPage("RoutingEnginesPage", "routingEngines", "موتورهای مسیریابی", "delivery.routing_engines.view", `[
{ name: "organization_id", label: "سازمان", type: "organization", required: true },
{ name: "code", label: "کد", required: true },
{ name: "name", label: "نام", required: true },
]`));
writeFeature("configurations", listPage("ConfigurationsPage", "configurations", "پیکربندی", "delivery.configurations.view", `[
{ name: "organization_id", label: "سازمان", type: "organization", required: true },
{ name: "code", label: "کد", required: true },
]`));
// Drivers - special list with detail link
writeFeature("drivers", `"use client";
import { createDeliveryListPage } from "@/modules/delivery/components/DeliveryListCrudPage";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
export const DriversPage = createDeliveryListPage({
title: "رانندگان",
breadcrumb: "رانندگان",
resourceKey: "drivers",
permission: "delivery.drivers.view",
api: {
list: async (tenantId, params) => {
const res = await deliveryApi.drivers.list(tenantId, params);
return Array.isArray(res) ? res : res.items;
},
get: deliveryApi.drivers.get,
create: deliveryApi.drivers.create,
update: deliveryApi.drivers.update,
remove: deliveryApi.drivers.remove,
},
columns: [
{ key: "code", header: "کد", type: "link", linkPrefix: "/delivery/drivers" },
{ key: "display_name", header: "نام" },
{ key: "mobile", header: "موبایل" },
{ key: "status", header: "وضعیت", type: "status" },
{ key: "created_at", header: "ایجاد", type: "datetime" },
],
createFields: [
{ name: "organization_id", label: "سازمان", type: "organization", required: true },
{ name: "hub_id", label: "هاب", type: "hub" },
{ name: "code", label: "کد", required: true },
{ name: "display_name", label: "نام نمایشی", required: true },
{ name: "mobile", label: "موبایل" },
{ name: "email", label: "ایمیل", type: "email" },
],
detailHref: (row) => row.id ? \`/delivery/drivers/\${row.id}\` : null,
});
export default DriversPage;
`);
// Phase-gated pages
const phasePages = [
["dispatcher", "DispatcherDashboard", "dispatcher"],
["operations", "LiveOperationsDashboard", "operations"],
["fleetDashboard", "FleetDashboard", "fleetDashboard"],
["driverAvailability", "DriverAvailabilityPage", "driverAvailability"],
["driverPerformance", "DriverPerformancePage", "driverPerformance"],
["orders", "OrdersPage", "orders"],
["dispatchQueue", "DispatchQueuePage", "dispatchQueue"],
["assignment", "AssignmentCenterPage", "assignment"],
["liveTracking", "LiveTrackingPage", "liveTracking"],
["customerTracking", "CustomerTrackingPage", "customerTracking"],
["vehicles", "VehiclesPage", "vehicles"],
["vehicleTypes", "VehicleTypesPage", "vehicleTypes"],
["maintenance", "FleetMaintenancePage", "maintenance"],
["schedules", "SchedulesPage", "schedules"],
["shifts", "ShiftsPage", "shifts"],
["tasks", "TasksPage", "tasks"],
["pickups", "PickupsPage", "pickups"],
["deliveries", "DeliveriesPage", "deliveries"],
["returns", "ReturnsPage", "returns"],
["failed", "FailedDeliveriesPage", "failed"],
["cod", "CodManagementPage", "cod"],
["settlements", "SettlementsPage", "settlements"],
["payments", "PaymentsPage", "payments"],
["wallet", "DriverWalletPage", "wallet"],
["commissions", "CommissionsPage", "commissions"],
["vendorPortal", "VendorPortalPage", "vendorPortal"],
["notifications", "NotificationsPage", "notifications"],
["incidents", "IncidentsPage", "incidents"],
["support", "SupportTicketsPage", "support"],
["reports", "ReportsPage", "reports"],
["analytics", "AnalyticsPage", "analytics"],
["heatmaps", "HeatMapsPage", "heatmaps"],
["kpis", "PerformanceKpisPage", "kpis"],
["aiRecommendations", "AiRecommendationsPage", "aiRecommendations"],
["automation", "AutomationPage", "automation"],
["roles", "RolesPage", "roles"],
["apiKeys", "ApiKeysPage", "apiKeys"],
];
for (const [file, exportName, key] of phasePages) {
writeFeature(file, phasePage(exportName, key));
}
const mapPages = [
["mapView", "MapViewPage", "mapView", "نمای نقشه"],
["routePlanner", "RoutePlannerPage", "routePlanner", "برنامه‌ریزی مسیر"],
["routeOptimization", "RouteOptimizationPage", "routeOptimization", "بهینه‌سازی"],
["geofence", "GeofencePage", "geofence", "ژئوفنس"],
["deliveryZones", "DeliveryZonesPage", "deliveryZones", "مناطق تحویل"],
];
for (const [file, exportName, key, title] of mapPages) {
writeFeature(file, mapPage(exportName, key, title));
}
// Dashboard
writeFeature("dashboard", `"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
import { useTenantId } from "@/hooks/useTenantId";
import { useDeliveryCapabilities, useDeliveryMetrics } from "@/modules/delivery/hooks/useDeliveryCapabilities";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, StatCard, Card, CardContent, Button, Badge } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
export const ExecutiveDashboard = function DeliveryExecutiveDashboard() {
const { tenantId } = useTenantId();
const caps = useDeliveryCapabilities();
const metrics = useDeliveryMetrics();
const countsQ = useQuery({
queryKey: ["delivery", tenantId, "dashboard-counts"],
queryFn: async () => {
const [orgs, hubs, drivers] = await Promise.all([
deliveryApi.organizations.list(tenantId!, { page: 1, page_size: 500 }),
deliveryApi.hubs.list(tenantId!, { page: 1, page_size: 500 }),
deliveryApi.drivers.list(tenantId!, { page: 1, page_size: 500 }),
]);
const driverItems = Array.isArray(drivers) ? drivers : drivers.items;
return {
organizations: orgs.length,
hubs: hubs.length,
drivers: driverItems.length,
driversActive: driverItems.filter((d) => d.status === "active").length,
};
},
enabled: !!tenantId,
});
if (!tenantId || countsQ.isLoading || caps.isLoading) return <DeliveryPageLoader />;
if (countsQ.error) return <DeliveryPageError error={countsQ.error} onRetry={() => countsQ.refetch()} />;
const c = countsQ.data!;
return (
<div className="space-y-6">
<DeliveryBreadcrumbs current="داشبورد اجرایی" />
<PageHeader
title="داشبورد اجرایی"
description="نمای کلی عملیات لجستیک، رانندگان و زیرساخت."
actions={<Badge tone="success">Torbat Driver v{caps.data?.version}</Badge>}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<StatCard label="سازمان‌ها" value={String(c.organizations)} hint="Organizations" />
<StatCard label="شعب / هاب" value={String(c.hubs)} hint="Hubs" />
<StatCard label="رانندگان" value={String(c.drivers)} hint="Drivers" />
<StatCard label="راننده فعال" value={String(c.driversActive)} hint="Active" />
</div>
<Card>
<CardContent className="flex flex-wrap gap-2 p-4">
<Link href="/delivery/drivers"><Button variant="outline">رانندگان</Button></Link>
<Link href="/delivery/branches"><Button variant="outline">شعب</Button></Link>
<Link href="/delivery/maps/view"><Button variant="outline">نقشه</Button></Link>
<Link href="/delivery/capabilities"><Button variant="outline">قابلیت‌ها</Button></Link>
</CardContent>
</Card>
{metrics.data ? (
<Card>
<CardContent className="p-4 text-xs text-[var(--muted)]">
فاز {metrics.data.phase} — {metrics.data.note}
</CardContent>
</Card>
) : null}
</div>
);
};
export default ExecutiveDashboard;
`);
writeFeature("hub", `"use client";
import Link from "next/link";
import { useMe } from "@/hooks/useMe";
import { DELIVERY_PORTAL } from "@/modules/delivery/constants/portals";
import { Card, CardContent, Button, LoadingState, ErrorState } from "@/components/ds";
export const DeliveryHub = function DeliveryHubPage() {
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">{DELIVERY_PORTAL.label}</p>
<p className="text-sm text-[var(--muted)]">{DELIVERY_PORTAL.description}</p>
<Link href={DELIVERY_PORTAL.basePath}>
<Button className="w-full">ورود به پنل عملیات</Button>
</Link>
</CardContent>
</Card>
</div>
);
};
export default DeliveryHub;
`);
writeFeature("health", `"use client";
import { useDeliveryHealth } from "@/modules/delivery/hooks/useDeliveryCapabilities";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
export const HealthPage = function DeliveryHealthPage() {
const q = useDeliveryHealth();
if (q.isLoading) return <DeliveryPageLoader />;
if (q.error) return <DeliveryPageError error={q.error} onRetry={() => q.refetch()} />;
return (
<div>
<DeliveryBreadcrumbs current="سلامت سرویس" />
<PageHeader title="سلامت سرویس" description="وضعیت delivery-service." />
<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 { useDeliveryCapabilities } from "@/modules/delivery/hooks/useDeliveryCapabilities";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
export const CapabilitiesPage = function DeliveryCapabilitiesPage() {
const q = useDeliveryCapabilities();
if (q.isLoading) return <DeliveryPageLoader />;
if (q.error) return <DeliveryPageError error={q.error} onRetry={() => q.refetch()} />;
const features = q.data?.features ?? {};
return (
<div>
<DeliveryBreadcrumbs current="قابلیت‌ها" />
<PageHeader title="قابلیت‌ها" description={\`فاز \${q.data?.phase} — \${q.data?.commercial_product}\`} />
<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;
`);
writeFeature("permissions", `"use client";
import { useQuery } from "@tanstack/react-query";
import { useTenantId } from "@/hooks/useTenantId";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Badge } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
export const PermissionsPage = function DeliveryPermissionsPage() {
const { tenantId } = useTenantId();
const q = useQuery({
queryKey: ["delivery", tenantId, "permissions-catalog"],
queryFn: () => deliveryApi.permissions.catalog(tenantId!),
enabled: !!tenantId,
});
if (!tenantId || q.isLoading) return <DeliveryPageLoader />;
if (q.error) return <DeliveryPageError error={q.error} onRetry={() => q.refetch()} />;
return (
<div>
<DeliveryBreadcrumbs current="مجوزها" />
<PageHeader title="کاتالوگ مجوزها" description={\`پیشوند: \${q.data?.prefix} — \${q.data?.count} مجوز\`} />
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{q.data?.permissions.map((p) => (
<Card key={p}><CardContent className="p-2"><code className="text-xs">{p}</code></CardContent></Card>
))}
</div>
</div>
);
};
export default PermissionsPage;
`);
writeFeature("settings", `"use client";
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTenantId } from "@/hooks/useTenantId";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Button, Input, FormField } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
import { useDeliveryLookups } from "@/modules/delivery/hooks/useDeliveryCapabilities";
export const SettingsPage = function DeliverySettingsPage() {
const { tenantId } = useTenantId();
const qc = useQueryClient();
const lookups = useDeliveryLookups(tenantId);
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const [orgId, setOrgId] = useState("");
const listQ = useQuery({
queryKey: ["delivery", tenantId, "settings"],
queryFn: () => deliveryApi.settings.list(tenantId!),
enabled: !!tenantId,
});
const saveM = useMutation({
mutationFn: () => deliveryApi.settings.upsert(tenantId!, {
organization_id: orgId || null,
key,
value: JSON.parse(value || "{}"),
}),
onSuccess: () => {
toast.success("تنظیمات ذخیره شد");
qc.invalidateQueries({ queryKey: ["delivery", tenantId, "settings"] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || listQ.isLoading || lookups.isLoading) return <DeliveryPageLoader />;
if (listQ.error) return <DeliveryPageError error={listQ.error} onRetry={() => listQ.refetch()} />;
return (
<div className="space-y-6">
<DeliveryBreadcrumbs current="تنظیمات" />
<PageHeader title="تنظیمات" description="کلید-مقدار tenant-scoped." />
<Card>
<CardContent className="space-y-4 p-4">
<FormField label="سازمان (اختیاری)">
<select className="w-full rounded-xl border px-3 py-2" value={orgId} onChange={(e) => setOrgId(e.target.value)}>
<option value="">—</option>
{lookups.data?.organizations.map((o) => (
<option key={String(o.id)} value={String(o.id)}>{String(o.name)}</option>
))}
</select>
</FormField>
<FormField label="کلید"><Input value={key} onChange={(e) => setKey(e.target.value)} /></FormField>
<FormField label="مقدار (JSON)"><Input value={value} onChange={(e) => setValue(e.target.value)} placeholder='{"enabled":true}' /></FormField>
<Button onClick={() => saveM.mutate()} loading={saveM.isPending}>ذخیره</Button>
</CardContent>
</Card>
<div className="grid gap-2">
{(listQ.data ?? []).map((s) => (
<Card key={String(s.id)}><CardContent className="p-3 text-sm">
<strong>{String(s.key)}</strong>
<pre className="mt-1 overflow-x-auto text-xs">{JSON.stringify(s.value, null, 2)}</pre>
</CardContent></Card>
))}
</div>
</div>
);
};
export default SettingsPage;
`);
writeFeature("audit", `"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTenantId } from "@/hooks/useTenantId";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
import { DeliveryPageLoader, DeliveryPageError, DeliveryEmptyState } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Input, FormField, Button } from "@/components/ds";
import { DeliveryBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
export const AuditLogsPage = function DeliveryAuditPage() {
const { tenantId } = useTenantId();
const [entityType, setEntityType] = useState("driver");
const [entityId, setEntityId] = useState("");
const [submitted, setSubmitted] = useState<{ type: string; id: string } | null>(null);
const q = useQuery({
queryKey: ["delivery", tenantId, "audit", submitted],
queryFn: () => deliveryApi.audit.list(tenantId!, {
entity_type: submitted!.type,
entity_id: submitted!.id,
}),
enabled: !!tenantId && !!submitted,
});
return (
<div className="space-y-6">
<DeliveryBreadcrumbs current="ممیزی" />
<PageHeader title="لاگ ممیزی" description="جستجو بر اساس entity_type و entity_id." />
<Card>
<CardContent className="flex flex-wrap gap-4 p-4">
<FormField label="نوع موجودیت"><Input value={entityType} onChange={(e) => setEntityType(e.target.value)} /></FormField>
<FormField label="شناسه"><Input value={entityId} onChange={(e) => setEntityId(e.target.value)} /></FormField>
<Button onClick={() => setSubmitted({ type: entityType, id: entityId })}>جستجو</Button>
</CardContent>
</Card>
{submitted && q.isLoading ? <DeliveryPageLoader /> : null}
{q.error ? <DeliveryPageError error={q.error} onRetry={() => q.refetch()} /> : null}
{submitted && !q.isLoading && !q.error && (q.data?.length ?? 0) === 0 ? (
<DeliveryEmptyState title="لاگی یافت نشد" />
) : null}
<div className="space-y-2">
{(q.data ?? []).map((log) => (
<Card key={String(log.id)}><CardContent className="p-3 text-sm">
<strong>{String(log.action)}</strong> — {new Date(String(log.created_at)).toLocaleString("fa-IR")}
{log.message ? <p className="text-[var(--muted)]">{String(log.message)}</p> : null}
</CardContent></Card>
))}
</div>
</div>
);
};
export default AuditLogsPage;
`);
// Driver detail - dynamic route feature
writeFeature("driverDetail", `"use client";
import { useParams } from "next/navigation";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTenantId } from "@/hooks/useTenantId";
import { deliveryApi } from "@/modules/delivery/services/delivery-api";
import { DeliveryPageLoader, DeliveryPageError } from "@/modules/delivery/pages/shared";
import { PageHeader, Card, CardContent, Button, Badge, Tabs, ConfirmDialog } from "@/components/ds";
import { DeliveryDetailBreadcrumbs } from "@/modules/delivery/components/DeliveryBreadcrumbs";
import { DeliveryStatusChip } from "@/modules/delivery/design-system";
import { useDeliveryPermissions } from "@/modules/delivery/hooks/useDeliveryPermissions";
import { useState } from "react";
export const DriverDetailPage = function DriverDetail() {
const params = useParams();
const driverId = String(params.id);
const { tenantId } = useTenantId();
const qc = useQueryClient();
const perms = useDeliveryPermissions();
const [tab, setTab] = useState("profile");
const [lifecycleAction, setLifecycleAction] = useState<string | null>(null);
const driverQ = useQuery({
queryKey: ["delivery", tenantId, "driver", driverId],
queryFn: () => deliveryApi.drivers.get(tenantId!, driverId),
enabled: !!tenantId && !!driverId,
});
const lifecycleQ = useQuery({
queryKey: ["delivery", tenantId, "driver-lifecycle", driverId],
queryFn: () => deliveryApi.drivers.lifecycle(tenantId!, driverId),
enabled: !!tenantId && !!driverId && tab === "lifecycle",
});
const credsQ = useQuery({
queryKey: ["delivery", tenantId, "driver-creds", driverId],
queryFn: () => deliveryApi.drivers.credentials.list(tenantId!, driverId),
enabled: !!tenantId && !!driverId && tab === "credentials",
});
const docsQ = useQuery({
queryKey: ["delivery", tenantId, "driver-docs", driverId],
queryFn: () => deliveryApi.drivers.documents.list(tenantId!, driverId),
enabled: !!tenantId && !!driverId && tab === "documents",
});
const lifecycleM = useMutation({
mutationFn: (action: string) => {
const api = deliveryApi.drivers as Record<string, Function>;
const fn = api[action];
return fn(tenantId!, driverId, { version: driverQ.data!.version });
},
onSuccess: () => {
toast.success("وضعیت به‌روز شد");
setLifecycleAction(null);
qc.invalidateQueries({ queryKey: ["delivery", tenantId, "driver", driverId] });
qc.invalidateQueries({ queryKey: ["delivery", tenantId, "driver-lifecycle", driverId] });
},
onError: (e: Error) => toast.error(e.message),
});
if (!tenantId || driverQ.isLoading) return <DeliveryPageLoader />;
if (driverQ.error) return <DeliveryPageError error={driverQ.error} onRetry={() => driverQ.refetch()} />;
const d = driverQ.data!;
const lifecycleActions = ["activate", "suspend", "resume", "deactivate", "block", "unblock", "archive"];
return (
<div className="space-y-6">
<DeliveryDetailBreadcrumbs section="رانندگان" sectionHref="/delivery/drivers" current={String(d.display_name)} />
<PageHeader
title={String(d.display_name)}
description={\`کد: \${d.code} — \${d.mobile ?? ""}\`}
actions={<DeliveryStatusChip status={String(d.status)} />}
/>
<Tabs
items={[
{ id: "profile", label: "پروفایل" },
{ id: "lifecycle", label: "چرخه حیات" },
{ id: "credentials", label: "مدارک" },
{ id: "documents", label: "اسناد" },
]}
value={tab}
onChange={setTab}
/>
{tab === "profile" ? (
<Card><CardContent className="grid gap-2 p-4 text-sm sm:grid-cols-2">
<p><span className="text-[var(--muted)]">ایمیل:</span> {String(d.email ?? "—")}</p>
<p><span className="text-[var(--muted)]">نسخه:</span> {String(d.version)}</p>
<p><span className="text-[var(--muted)]">ایجاد:</span> {new Date(String(d.created_at)).toLocaleString("fa-IR")}</p>
{d.notes ? <p className="sm:col-span-2">{String(d.notes)}</p> : null}
</CardContent></Card>
) : null}
{tab === "lifecycle" ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{lifecycleActions.map((a) => (
<Button key={a} variant="outline" size="sm" onClick={() => setLifecycleAction(a)} disabled={!perms.can("delivery.drivers." + a)}>
{a}
</Button>
))}
</div>
{(lifecycleQ.data ?? []).map((ev) => (
<Card key={String(ev.id)}><CardContent className="p-3 text-sm">
{String(ev.action)}: {String(ev.from_status)} → {String(ev.to_status)}
<span className="mr-2 text-[var(--muted)]">{new Date(String(ev.created_at)).toLocaleString("fa-IR")}</span>
</CardContent></Card>
))}
</div>
) : null}
{tab === "credentials" ? (
<div className="space-y-2">
{(credsQ.data ?? []).map((c) => (
<Card key={String(c.id)}><CardContent className="p-3 text-sm">
{String(c.kind)} — {String(c.number)}
{c.is_verified ? <Badge tone="success" className="mr-2">تأیید شده</Badge> : null}
</CardContent></Card>
))}
</div>
) : null}
{tab === "documents" ? (
<div className="space-y-2">
{(docsQ.data ?? []).map((doc) => (
<Card key={String(doc.id)}><CardContent className="p-3 text-sm">
{String(doc.title)} — {String(doc.kind)}
</CardContent></Card>
))}
</div>
) : null}
<ConfirmDialog
open={!!lifecycleAction}
onClose={() => setLifecycleAction(null)}
title="تغییر وضعیت"
description={\`آیا از اجرای \${lifecycleAction} اطمینان دارید؟\`}
onConfirm={() => lifecycleAction && lifecycleM.mutate(lifecycleAction)}
loading={lifecycleM.isPending}
/>
</div>
);
};
export default DriverDetailPage;
`);
// Generate routes
for (const [routePath, exportName, featureFile] of PAGE_REGISTRY) {
const dir = path.join(APP, routePath);
ensureDir(dir);
fs.writeFileSync(
path.join(dir, "page.tsx"),
`"use client";\n\nexport { ${exportName} as default } from "@/modules/delivery/features/${featureFile}";\n`
);
fs.writeFileSync(
path.join(dir, "loading.tsx"),
`import { LoadingState } from "@/components/ds";\n\nexport default function Loading() {\n return <LoadingState label="در حال بارگذاری تربت درایور…" />;\n}\n`
);
fs.writeFileSync(
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`
);
}
// Driver dynamic route
const driverDetailDir = path.join(APP, "drivers", "[id]");
ensureDir(driverDetailDir);
fs.writeFileSync(
path.join(driverDetailDir, "page.tsx"),
`"use client";\n\nexport { DriverDetailPage as default } from "@/modules/delivery/features/driverDetail";\n`
);
fs.writeFileSync(path.join(driverDetailDir, "loading.tsx"), `import { LoadingState } from "@/components/ds";\nexport default function Loading() { return <LoadingState label="بارگذاری راننده…" />; }\n`);
fs.writeFileSync(path.join(driverDetailDir, "error.tsx"), `"use client";\nimport { ErrorState } from "@/components/ds";\nexport default function Error({ error, reset }: { error: Error; reset: () => void }) { return <ErrorState message={error.message} onRetry={reset} />; }\n`);
// Root layout
fs.writeFileSync(
path.join(APP, "layout.tsx"),
`"use client";\n\nimport { DeliveryRootLayout } from "@/modules/delivery/components/createPortalLayout";\n\nexport default DeliveryRootLayout;\n`
);
fs.writeFileSync(
path.join(APP, "not-found.tsx"),
`import Link from "next/link";\nimport { Button, EmptyState } from "@/components/ds";\n\nexport default function DeliveryNotFound() {\n return (\n <div className="flex min-h-[50vh] items-center justify-center p-6">\n <EmptyState title="صفحه یافت نشد" action={<Link href="/delivery"><Button>بازگشت</Button></Link>} />\n </div>\n );\n}\n`
);
// BFF
const bffDir = path.join(ROOT, "app", "api", "delivery", "[...path]");
ensureDir(bffDir);
fs.writeFileSync(
path.join(bffDir, "route.ts"),
`import { NextRequest, NextResponse } from "next/server";
const UPSTREAM =
process.env.DELIVERY_SERVICE_URL ||
process.env.NEXT_PUBLIC_DELIVERY_API_URL ||
"http://localhost:8007";
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: "delivery_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);
}
`
);
// Module index
fs.writeFileSync(
path.join(MOD, "index.ts"),
`export { deliveryApi, DeliveryApiError } from "./services/delivery-api";
export { DELIVERY_PORTAL, navForDelivery } from "./constants/portals";
export { DeliveryPortalShell } from "./components/DeliveryPortalShell";
`
);
fs.writeFileSync(
path.join(MOD, "README.md"),
`# Torbat Driver — Delivery Module
Business UI for \`/delivery/*\`. Routes are thin re-exports in \`app/delivery/\`.
## Structure
- \`features/\` — page implementations
- \`components/\` — portal shell, CRUD factory, phase gates
- \`services/delivery-api.ts\` — real backend client (BFF \`/api/delivery\`)
- \`constants/\` — nav, permissions, phase registry
## Scaffold
\`\`\`bash
node scripts/scaffold-delivery-module.mjs
\`\`\`
`
);
console.log(`Scaffolded ${PAGE_REGISTRY.length + 1} delivery routes and features.`);