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>
891 lines
38 KiB
JavaScript
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.`);
|