From 07a120af5ecba2772e6312706733df745b9f17c8 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Tue, 11 Aug 2026 11:48:16 +0300 Subject: [PATCH 01/11] feat(permissions): add granular permissions for train management actions feat(train-builder): update permissions checks for train actions in UI feat(contracts): enhance contract view and shipment request pages with new features test(shipment-preview): add tests for customs clearing logic in booking preview style(contract-sign-bar): create CSS for fixed sign bar layout --- .../contracts/contract-booking.service.ts | 5 + .../contracts/shipment-preview-parity.spec.ts | 123 ++++++++++++++++++ .../trains/train-builder.controller.ts | 14 +- .../src/seed/freight-permissions.registry.ts | 31 +++++ .../backoffice/src/lib/permissions.ts | 5 + .../trainBuilder/TrainBuilderDetailPage.tsx | 94 ++++++------- .../src/pages/contracts/ContractViewPage.tsx | 33 ++--- .../contracts/NewShipmentRequestPage.tsx | 24 ++-- .../src/pages/contracts/contract-sign-bar.css | 11 ++ 9 files changed, 267 insertions(+), 73 deletions(-) create mode 100644 apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 441544b39..d5989f2ac 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -1974,6 +1974,11 @@ export class ContractBookingService { isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'), equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto), isGovernment: contract.isGovernment, + // The clearance fee is gated on this flag in BookingPricingService, and + // createUnderContract copies it off the contract. Omitting it here priced + // the preview WITHOUT the customs line the created booking is then billed + // — the customer confirmed one total and got invoiced a larger one. + customsClearingEnabled: contract.customsClearingEnabled, shippingLineId: null, contractRouteId: route?.id ?? null, originYardId: route?.originYardId ?? null, diff --git a/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts new file mode 100644 index 000000000..4152ffb30 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/shipment-preview-parity.spec.ts @@ -0,0 +1,123 @@ +import { ContractBookingService } from './contract-booking.service'; +import type { Contract } from './entities/contract.entity'; +import type { Booking } from '../bookings/entities/booking.entity'; +import type { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto'; + +/** + * The price the customer confirms in the modal comes from validateShipment, + * which prices an UNSAVED twin of the booking createUnderContract will write. + * Any contract field that reaches the pricing service must be copied onto that + * twin — a field left off doesn't fail loudly, it silently drops whole charge + * lines from the quote while the created booking is still billed for them. + * + * The regression this locks: `customsClearingEnabled` was missing, so + * BookingPricingService's `if (booking.customsClearingEnabled)` gate never + * opened in the preview. Container bookings quoted rail freight alone, then + * invoiced rail + customs clearance. + */ +describe('shipment preview / created booking parity', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c1', + freightType: 'CONTAINER', + tradeDirection: 'EXPORT', + paymentCurrency: 'ETB', + serviceTypeId: 'svc1', + customsClearingEnabled: true, + equipmentReturn: 'NO_RETURN', + isHazardous: false, + isReefer: false, + isGovernment: false, + cargoScope: [], + firstMilePickupAddress: null, + lastMileDeliveryAddress: null, + ...over, + }) as Contract; + + /** + * Run validateShipment against stubbed collaborators and hand back the + * booking the pricing service was actually asked to price. + */ + const previewBookingFor = async (c: Contract): Promise => { + let priced: Booking | null = null; + + const svc = { + contractsRepository: { findByIdWithRelations: async () => c }, + bookingPricingService: { + computePriceForBooking: async (b: Booking) => { + priced = b; + return { + lineItems: [], + totalAmount: 0, + currency: 'ETB', + overweightLines: [], + hardBlocked: [], + }; + }, + }, + ruleEngineService: { capacityViolations: async () => [] }, + resolveRoute: async () => null, + resolveShipmentCurrency: () => 'ETB', + resolveCargoTypeId: () => null, + resolveShipmentHandlingFlag: () => false, + resolveShipmentEquipmentReturn: () => c.equipmentReturn, + resolveBulkTons: () => 0, + resolveBulkWeightTons: () => 0, + resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }), + handlingCounts: () => ({ + hazardousQuantity: 0, + reeferQuantity: 0, + returnQuantity: 0, + }), + max20ftPairDiffTons: async () => 2, + findContainerClashesOnTrain: async () => [], + }; + + const dto = { + containers: [ + { + containerSize: '40ft', + quantity: 2, + units: [{ vgmTons: 10 }, { vgmTons: 10 }], + }, + ], + } as unknown as CreateBookingUnderContractDto; + + await ( + ContractBookingService.prototype as unknown as { + validateShipment: ( + this: unknown, + id: string, + dto: CreateBookingUnderContractDto, + ) => Promise; + } + ).validateShipment.call(svc, 'c1', dto); + + if (!priced) throw new Error('pricing service was never called'); + return priced; + }; + + it('prices the preview with customs clearing on when the contract clears', async () => { + // Without this the clearance fee is quoted as 0 and billed in full later. + const booking = await previewBookingFor(contract()); + expect(booking.customsClearingEnabled).toBe(true); + }); + + it('leaves customs clearing off when the contract does not clear', async () => { + const booking = await previewBookingFor( + contract({ customsClearingEnabled: false }), + ); + expect(booking.customsClearingEnabled).toBe(false); + }); + + it('carries the contract mile legs so trucking is quoted too', async () => { + const booking = await previewBookingFor( + contract({ + firstMilePickupAddress: 'Modjo Dry Port', + lastMileDeliveryAddress: 'Djibouti Port', + }), + ); + expect(booking.firstMilePickupAddress).toBe('Modjo Dry Port'); + expect(booking.lastMileDeliveryAddress).toBe('Djibouti Port'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index 834663170..c1b1459a3 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -39,6 +39,10 @@ import { TrainBuilderService } from './train-builder.service'; FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.assignWagons, FREIGHT_PERMS.trains.delete, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, ]) export class TrainBuilderController { constructor(private readonly trainBuilderService: TrainBuilderService) {} @@ -72,7 +76,7 @@ export class TrainBuilderController { } @Put(':id/locomotives') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeLocomotives) @ApiOperation({ summary: 'Replace the locomotive set (minimum 1, same yard)' }) setLocomotives( @Param('id', ParseUUIDPipe) id: string, @@ -94,7 +98,7 @@ export class TrainBuilderController { } @Patch(':id/yard') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.changeYard) @ApiOperation({ summary: 'Relocate the train — its locomotives and wagons move to the new yard with it', }) @@ -143,7 +147,7 @@ export class TrainBuilderController { } @Post(':id/deactivate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Deactivate the train (park it) — only allowed with no active schedule', }) @@ -152,14 +156,14 @@ export class TrainBuilderController { } @Post(':id/activate') - @FleetManage(FREIGHT_PERMS.trains.update) + @FleetManage(FREIGHT_PERMS.trains.toggleActive) @ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' }) activate(@Param('id', ParseUUIDPipe) id: string) { return this.trainBuilderService.activate(id); } @Delete(':id') - @FleetManage(FREIGHT_PERMS.trains.delete) + @FleetManage(FREIGHT_PERMS.trains.disband) @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: 'Disband the train (release wagons and locomotives)' }) disband(@Param('id', ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 06511cb2b..47fde9fb4 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -707,6 +707,29 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:trains:assign_wagons", "Assign wagons to train", ), + // Granular splits of trains:update / trains:delete for the train-builder + // detail page's Actions menu — each item gets its own grant instead of + // sharing the coarse update/delete keys. + perm( + "e1c00001-0001-4000-8000-000000000006", + "edr_freight_app:trains:change_locomotives", + "Change train locomotives", + ), + perm( + "e1c00001-0001-4000-8000-000000000007", + "edr_freight_app:trains:change_yard", + "Change train yard", + ), + perm( + "e1c00001-0001-4000-8000-000000000008", + "edr_freight_app:trains:toggle_active", + "Activate or deactivate train", + ), + perm( + "e1c00001-0001-4000-8000-000000000009", + "edr_freight_app:trains:disband", + "Disband train", + ), perm( "e1d00001-0001-4000-8000-000000000001", "edr_freight_app:routes:view", @@ -1714,6 +1737,10 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", @@ -2021,6 +2048,10 @@ const FLEET_GRANULAR_KEYS: string[] = [ FREIGHT_PERMS.trains.update, FREIGHT_PERMS.trains.delete, FREIGHT_PERMS.trains.assignWagons, + FREIGHT_PERMS.trains.changeLocomotives, + FREIGHT_PERMS.trains.changeYard, + FREIGHT_PERMS.trains.toggleActive, + FREIGHT_PERMS.trains.disband, FREIGHT_PERMS.routes.view, FREIGHT_PERMS.routes.create, FREIGHT_PERMS.routes.update, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 09790091d..b74c7bef9 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -188,6 +188,11 @@ export const FREIGHT_PERMS = { update: "edr_freight_app:trains:update", delete: "edr_freight_app:trains:delete", assignWagons: "edr_freight_app:trains:assign_wagons", + /** Train-builder detail Actions menu — each item its own grant. */ + changeLocomotives: "edr_freight_app:trains:change_locomotives", + changeYard: "edr_freight_app:trains:change_yard", + toggleActive: "edr_freight_app:trains:toggle_active", + disband: "edr_freight_app:trains:disband", }, routes: { view: "edr_freight_app:routes:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx index 5291e26f1..400bfc1e5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainBuilder/TrainBuilderDetailPage.tsx @@ -47,7 +47,7 @@ import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompo import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { api } from "@/services/api"; import { useAuth } from "@/auth/useAuth"; -import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; +import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import { useToast } from "@/hooks/use-toast"; import type { TrainCompositionWagon } from "@/services/trainBuilder.service"; @@ -83,9 +83,11 @@ export default function TrainBuilderDetailPage() { const [maintenanceTarget, setMaintenanceTarget] = useState(null); const { user } = useAuth(); - const canUpdate = canFleetAction(user, "trains", "update"); - const canDelete = canFleetAction(user, "trains", "delete"); const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons); + const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives); + const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard); + const canToggleActive = hasPermission(user, FREIGHT_PERMS.trains.toggleActive); + const canDisband = hasPermission(user, FREIGHT_PERMS.trains.disband); const compositionQuery = useQuery( api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }), @@ -172,7 +174,7 @@ export default function TrainBuilderDetailPage() { } action={ - canUpdate || canDelete ? ( + canChangeLocomotives || canChangeYard || canToggleActive || canDisband ? ( - {canUpdate ? ( - <> - } - disabled={!composition.editable} - onClick={() => setLocoModalOpen(true)} - > - Change locomotives - - } - disabled={!composition.editable} - onClick={() => setYardModalOpen(true)} - > - Change yard - - {composition.status === "DEACTIVATED" ? ( - } - disabled={blockingLocomotives.length > 0} - onClick={() => - void withToast(async () => { - await activate.mutateAsync(composition.id); - toast({ title: `Train ${composition.code} reactivated` }); - }, "Could not reactivate train") - } - > - Reactivate train - - ) : ( - } - disabled={composition.activeSchedules.length > 0} - onClick={() => setDeactivateOpen(true)} - > - Deactivate train - - )} - + {canChangeLocomotives ? ( + } + disabled={!composition.editable} + onClick={() => setLocoModalOpen(true)} + > + Change locomotives + ) : null} - {canDelete ? ( + {canChangeYard ? ( + } + disabled={!composition.editable} + onClick={() => setYardModalOpen(true)} + > + Change yard + + ) : null} + {canToggleActive ? ( + composition.status === "DEACTIVATED" ? ( + } + disabled={blockingLocomotives.length > 0} + onClick={() => + void withToast(async () => { + await activate.mutateAsync(composition.id); + toast({ title: `Train ${composition.code} reactivated` }); + }, "Could not reactivate train") + } + > + Reactivate train + + ) : ( + } + disabled={composition.activeSchedules.length > 0} + onClick={() => setDeactivateOpen(true)} + > + Deactivate train + + ) + ) : null} + {canDisband ? ( } @@ -277,7 +281,7 @@ export default function TrainBuilderDetailPage() { . - {canUpdate ? ( + {canChangeLocomotives ? ( - - + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx index f5be74416..9c6974641 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentRequestPage.tsx @@ -87,6 +87,8 @@ export default function NewShipmentRequestPage() { contract.contractKind === "GENERAL" && (contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled); const isIntercity = contract.tradeDirection === "DOMESTIC"; + // Export shipments are invoiced in ETB only — USD is not offered. + const isExport = contract.tradeDirection === "EXPORT"; // Only the container sizes the contract was scoped for (20ft, 40ft, or both). const SIZE_ORDER = ["20ft", "40ft"]; @@ -115,7 +117,7 @@ export default function NewShipmentRequestPage() { const dto: Freight.CreateBookingRequestDto = { contractRouteId: route?.id, scheduledDate: hasCustoms ? undefined : scheduledDate || undefined, - paymentCurrency: isIntercity ? "ETB" : paymentCurrency, + paymentCurrency: isIntercity || isExport ? "ETB" : paymentCurrency, notes: notes.trim() || undefined, }; @@ -246,16 +248,22 @@ export default function NewShipmentRequestPage() { {isIntercity ? "Intercity shipments are invoiced in ETB." - : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} + : isExport + ? "Export shipments are invoiced in ETB." + : "Your contract is quoted in USD. Global Logistics will book this shipment and invoice you in the currency you pick here."} setPaymentCurrency(v as "USD" | "ETB")} - disabled={isIntercity} - data={[ - { label: "USD", value: "USD" }, - { label: "ETB", value: "ETB" }, - ]} + disabled={isIntercity || isExport} + data={ + isExport + ? [{ label: "ETB", value: "ETB" }] + : [ + { label: "USD", value: "USD" }, + { label: "ETB", value: "ETB" }, + ] + } color="teal" radius={10} /> diff --git a/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css new file mode 100644 index 000000000..270b91e16 --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css @@ -0,0 +1,11 @@ +/* Keeps the fixed sign bar confined to the content area (right of the + navbar) instead of spanning the full viewport and drifting off-center. */ +.contract-sign-bar { + --sign-bar-left: 0px; +} + +@media (min-width: 48em) { + .contract-sign-bar { + --sign-bar-left: 260px; + } +} From dac2186c020712526050ce4698c113a2a9802755 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 11 Aug 2026 08:31:27 +0000 Subject: [PATCH 02/11] feat(permissions): grant the director full warehouse authority The director position held no warehouse permissions at all. It now carries the same warehouse set as the chief tier: dashboard, warehouse /yard/zone CRUD, allocation and fee rule CRUD, the inventory operation set, inspection reports, interchange documents and fee invoices. Unlike the dispatcher, the director owns the allocation and fee rules themselves. The positions seeder backfills existing environments on boot. --- .../src/seed/freight-permissions.registry.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 06511cb2b..227158db3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -2256,7 +2256,8 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.payments.view, ]), // Director additionally manages train scheduling + rail fleet (same block the - // operation officer/chief hold), on top of the approval-chain role preset. + // operation officer/chief hold), on top of the approval-chain role preset, + // and carries the same full warehouse authority the chief tier holds. director: dedupe([ ...ROLE_PERMISSION_PRESETS.director, FREIGHT_PERMS.trainScheduling.view, @@ -2268,6 +2269,18 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, + // Warehouse — full CRUD, matching the chief tier. Unlike the dispatcher, + // the director also owns the allocation and fee rules themselves. + FREIGHT_PERMS.warehouseDashboard.view, + ...Object.values(FREIGHT_PERMS.warehouses), + ...Object.values(FREIGHT_PERMS.warehouseYards), + ...Object.values(FREIGHT_PERMS.warehouseZones), + ...Object.values(FREIGHT_PERMS.warehouseAllocationRules), + ...Object.values(FREIGHT_PERMS.warehouseFeeRules), + ...Object.values(FREIGHT_PERMS.warehouseInventory), + ...Object.values(FREIGHT_PERMS.warehouseInspectionReports), + ...Object.values(FREIGHT_PERMS.interchangeDocuments), + ...Object.values(FREIGHT_PERMS.warehouseFeeInvoices), ]), ceo: dedupe([...ROLE_PERMISSION_PRESETS.ceo]), ethiopianGl: dedupe([...ROLE_PERMISSION_PRESETS.glEthiopia]), From bd8187578eda327ef6d421cc08883600bd79b1e0 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Tue, 11 Aug 2026 15:02:16 +0300 Subject: [PATCH 03/11] Overall revenue updates --- .../src/app/reports/overall/page.tsx | 174 ++++++++++++++++-- 1 file changed, 163 insertions(+), 11 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx index 6ab811622..5e23b92d6 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx @@ -24,6 +24,8 @@ export default function ReportsPage() { const [dateRange, setDateRange] = useState('30'); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); + const [routeOrigin, setRouteOrigin] = useState(''); + const [routeDestination, setRouteDestination] = useState(''); const [exportModalOpen, setExportModalOpen] = useState(false); const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); @@ -78,6 +80,12 @@ export default function ReportsPage() { return r ? 1 / r.rate : null; }; + const getBookingTicketCount = (booking: any): number => { + if (Array.isArray(booking.tickets)) return booking.tickets.length; + if (typeof booking.ticketCount === 'number') return booking.ticketCount; + return 0; + }; + const calcGrand = (rows: { currency: string; totalMinor: number }[]) => rows.reduce((sum, { currency, totalMinor }) => { const rate = toEtbRate(currency); @@ -107,6 +115,14 @@ export default function ReportsPage() { const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED'); + const totalRevenueMinor = confirmedBookings.reduce((sum, b: any) => { + const rate = toEtbRate(b.currency); + return rate !== null ? sum + Math.round((b.totalMinor || 0) * rate) : sum; + }, 0); + + const totalBookingsCount = confirmedBookings.length; + const totalTicketsCount = confirmedBookings.reduce((sum, b: any) => sum + getBookingTicketCount(b), 0); + const byDate = confirmedBookings.reduce((acc: Record, b: any) => { const date = new Date(b.createdAt).toISOString().split('T')[0]; if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 }; @@ -123,7 +139,50 @@ export default function ReportsPage() { bookings: d.count || 0, })); - const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0; + const avgDailyRevenueMinor = chartData.length > 0 ? Math.round(totalRevenueMinor / chartData.length) : 0; + + const totalRegularBookingsCount = confirmedBookings.filter((b: any) => { + const bookingType = String(b.bookingType || '').toUpperCase(); + return bookingType !== 'PACKAGE' && !b.packageId; + }).length; + + const totalPackageBookingsCount = confirmedBookings.filter((b: any) => { + const bookingType = String(b.bookingType || '').toUpperCase(); + return bookingType === 'PACKAGE' || Boolean(b.packageId); + }).length; + + const filteredRouteBookings = confirmedBookings.filter((b: any) => { + const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown'; + const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown'; + if (routeOrigin && origin !== routeOrigin) return false; + if (routeDestination && destination !== routeDestination) return false; + return true; + }); + + const routeOriginOptions = [ + ...new Set(confirmedBookings.map((b: any) => b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown').filter(Boolean)), + ].sort() as string[]; + const routeDestinationOptions = [ + ...new Set(confirmedBookings.map((b: any) => b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown').filter(Boolean)), + ].sort() as string[]; + + const routeRevenueData = Object.entries( + filteredRouteBookings.reduce((acc: Record, b: any) => { + const origin = b.schedule?.originStation?.name || b.originStationName || b.origin || 'Unknown'; + const destination = b.schedule?.destinationStation?.name || b.destinationStationName || b.destination || 'Unknown'; + const route = `${origin} → ${destination}`; + const rate = toEtbRate(b.currency); + const etbMinor = rate !== null ? Math.round((b.totalMinor || 0) * rate) : 0; + if (!acc[route]) acc[route] = { totalEtbMinor: 0, bookings: 0 }; + acc[route].totalEtbMinor += etbMinor; + acc[route].bookings += 1; + return acc; + }, {}), + ).map(([route, value]) => ({ + route, + totalEtbMinor: value.totalEtbMinor, + bookings: value.bookings, + })).sort((a, b) => b.totalEtbMinor - a.totalEtbMinor); const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings']; @@ -161,6 +220,20 @@ export default function ReportsPage() { setExportModalOpen(false); }; + const doExportRouteRevenue = () => { + if (!routeRevenueData.length) { alert('No route revenue to export'); return; } + const rows = routeRevenueData.map((row) => [row.route, String(row.bookings), formatCurrency(row.totalEtbMinor, 'ETB')]); + const headers = ['Route', 'Bookings', 'Revenue (ETB)']; + const csv = [headers.map((h) => `"${h}"`).join(','), ...rows.map((r) => r.map((v) => `"${v}"`).join(','))].join('\n'); + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `route-revenue-${dates.startDate}-${dates.endDate}.csv`; + a.click(); + URL.revokeObjectURL(url); + }; + const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => { const rate = toEtbRate(currency); const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null; @@ -231,7 +304,7 @@ export default function ReportsPage() {

- {statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')} + {isLoading ? '—' : formatCurrency(totalRevenueMinor, 'ETB')}

@@ -254,16 +327,16 @@ export default function ReportsPage() {

- {statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()} + {isLoading ? '—' : totalBookingsCount.toLocaleString()}

Regular - {statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()} + {isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}
Package - {statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()} + {isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}
@@ -277,16 +350,16 @@ export default function ReportsPage() {

- {statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()} + {isLoading ? '—' : totalTicketsCount.toLocaleString()}

Regular - {statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()} + {isLoading ? '—' : totalRegularBookingsCount.toLocaleString()}
Package - {statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()} + {isLoading ? '—' : totalPackageBookingsCount.toLocaleString()}
@@ -300,7 +373,7 @@ export default function ReportsPage() {

- {isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')} + {isLoading ? '—' : formatCurrency(avgDailyRevenueMinor, 'ETB')}

Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range @@ -368,6 +441,85 @@ export default function ReportsPage() { )} + {/* Route Revenue Breakdown */} +

+
+
+

+ Revenue by Route +

+

+ Confirmed booking revenue for the selected date range, grouped by route. +

+
+
+ {routeRevenueData.length} route{routeRevenueData.length !== 1 ? 's' : ''} + +
+
+ +
+
+ + +
+
+ + +
+
+ + Export route revenue + +
+
+ + {routeRevenueData.length === 0 ? ( +

No route revenue data available for this range.

+ ) : ( +
+ {routeRevenueData.slice(0, 10).map((route) => ( +
+
{route.route}
+
{route.bookings.toLocaleString()} booking{route.bookings !== 1 ? 's' : ''}
+
+ {formatCurrency(route.totalEtbMinor, 'ETB')} +
+
+ ))} +
+ )} +
+ {/* Charts */}
{/* Revenue Trend */} @@ -489,8 +641,8 @@ export default function ReportsPage() { { label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false }, { label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false }, { label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false }, - { label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true }, - { label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true }, + { label: 'Regular Bookings', value: totalRegularBookingsCount, fromStats: false }, + { label: 'Package Bookings', value: totalPackageBookingsCount, fromStats: false }, ].map(({ label, value, fromStats }) => (

{label}

From 1be72799f4bbf8938b3c4579b4ac663c01665c7b Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Tue, 11 Aug 2026 12:13:34 +0000 Subject: [PATCH 04/11] fix(train-scheduling): exempt direct-to-train export bookings from GRN gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirmScheduleLoading's assertExportBookingsReceived checked every wagon-assigned export booking for a warehouse GRN, with no exception for DIRECT_TO_TRAIN (manual truck-straight-to-wagon) bookings — the one call site that lacked the carve-out already applied everywhere else GRN is checked (export-received-gate, booking-journey, carriage acceptance). Warehouse-routed export cargo still requires GRN; direct handovers rely on the carriage acceptance sheet instead. --- .../train-scheduling/services/train-scheduling.service.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 657703ada..1cd900d66 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -3262,6 +3262,12 @@ export class TrainSchedulingService { * Every export booking being confirmed loaded must already be received at the * warehouse with a GRN. An allocation puts a booking on a wagon on paper; this * is the check that the cargo is physically in the yard before we call it loaded. + * + * Direct truck-to-train (exportHandoverMode = DIRECT_TO_TRAIN) is excluded — + * that cargo is manually loaded from the customer's truck straight onto the + * wagon, never sees the warehouse, and is never GRN'd. Its custody is attested + * by the carriage acceptance sheet instead (same carve-out as the shared + * assertExportReceivedWithGrn gate — see common/export-received-gate.ts). */ private async assertExportBookingsReceived(bookingIds: string[]): Promise { if (!bookingIds.length) return; @@ -3270,6 +3276,7 @@ export class TrainSchedulingService { FROM freight.bookings b WHERE b.id = ANY($1) AND b.deleted_at IS NULL + AND b.export_handover_mode IS DISTINCT FROM 'DIRECT_TO_TRAIN' AND NOT EXISTS ( SELECT 1 FROM freight.warehouse_inventory inv WHERE inv.booking_id = b.id From 35e5404b4143fafa0036ff12556c44349aca4c9a Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Tue, 11 Aug 2026 19:36:00 +0300 Subject: [PATCH 05/11] Refactor code structure for improved readability and maintainability --- apps/edr-freight-api/docs/audit-endpoints.md | 922 +++++++ apps/edr-freight-api/package.json | 1 - apps/edr-freight-api/src/app.module.ts | 16 - .../src/config/database.config.ts | 6 - apps/edr-freight-api/src/main.ts | 6 - .../src/modules/audit/audit.controller.ts | 32 - .../src/modules/audit/audit.module.ts | 13 - .../src/modules/audit/audit.service.ts | 70 - .../payment/payment-events.consumer.ts | 7 +- .../repositories/cargo-types.repository.ts | 5 +- .../src/seed/freight-permissions.registry.ts | 8 - apps/edr-freight-web/backoffice/src/App.tsx | 9 - .../src/components/layout/route-meta.ts | 7 - .../components/layout/sidebar-sections.tsx | 7 - .../backoffice/src/constants/URLS.ts | 4 - .../backoffice/src/lib/permissions.ts | 3 - .../src/pages/audit/AuditLogsPage.tsx | 190 -- .../backoffice/src/services/api.ts | 14 - .../backoffice/src/services/audit.service.ts | 70 - pnpm-lock.yaml | 2227 ++++++----------- 20 files changed, 1684 insertions(+), 1933 deletions(-) create mode 100644 apps/edr-freight-api/docs/audit-endpoints.md delete mode 100644 apps/edr-freight-api/src/modules/audit/audit.controller.ts delete mode 100644 apps/edr-freight-api/src/modules/audit/audit.module.ts delete mode 100644 apps/edr-freight-api/src/modules/audit/audit.service.ts delete mode 100644 apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx delete mode 100644 apps/edr-freight-web/backoffice/src/services/audit.service.ts diff --git a/apps/edr-freight-api/docs/audit-endpoints.md b/apps/edr-freight-api/docs/audit-endpoints.md new file mode 100644 index 000000000..38e89e367 --- /dev/null +++ b/apps/edr-freight-api/docs/audit-endpoints.md @@ -0,0 +1,922 @@ +# Freight API — Mutating Endpoints (Audit Surface) + +Every state-changing route in `apps/edr-freight-api` — `POST`, `PUT`, `PATCH`, `DELETE`. +This is the candidate surface for audit logging: each row is an action a user can take +that changes persisted state and therefore needs a who / what / when trail. + +All paths include the global prefix `api` (`app.setGlobalPrefix("api")` in `src/main.ts`). +Titles come from each route's `@ApiOperation({ summary })`; where a route has none, +the title is derived from its handler name. + +> Generated by reading the `@Post` / `@Put` / `@Patch` / `@Delete` decorators in +> `src/**/*.controller.ts`. Re-generate after adding routes so this stays complete. + +## Summary + +| Method | Count | +| --- | ---: | +| `POST` | 351 | +| `PUT` | 8 | +| `PATCH` | 72 | +| `DELETE` | 61 | +| **Total** | **492** | + +Across **66** entities. + +## Entity index + +| Entity | Endpoints | +| --- | ---: | +| [Account](#account) | 3 | +| [AI Assist](#ai-assist) | 1 | +| [Approval Rule](#approval-rule) | 5 | +| [Booking](#booking) | 57 | +| [Cargo](#cargo) | 6 | +| [Cargo Type](#cargo-type) | 5 | +| [Company](#company) | 30 | +| [Compliance](#compliance) | 3 | +| [Consignment](#consignment) | 1 | +| [Container](#container) | 5 | +| [Container Type](#container-type) | 5 | +| [Contract](#contract) | 68 | +| [Contract Template](#contract-template) | 8 | +| [Driver](#driver) | 5 | +| [Dropdown Setting](#dropdown-setting) | 7 | +| [EIMS Invoice](#eims-invoice) | 3 | +| [Exchange Setting](#exchange-setting) | 1 | +| [Facility](#facility) | 3 | +| [Fayda Verification](#fayda-verification) | 1 | +| [File Upload Setting](#file-upload-setting) | 7 | +| [First Mile](#first-mile) | 7 | +| [Fuel](#fuel) | 1 | +| [GPS Tracking](#gps-tracking) | 3 | +| [Import Operation](#import-operation) | 9 | +| [Incident](#incident) | 3 | +| [Interchange Document](#interchange-document) | 3 | +| [Last Mile](#last-mile) | 10 | +| [Last Mile Request](#last-mile-request) | 4 | +| [Locomotive](#locomotive) | 4 | +| [Maintenance](#maintenance) | 13 | +| [Notification Inbox](#notification-inbox) | 2 | +| [Organization User](#organization-user) | 2 | +| [OTP](#otp) | 2 | +| [Password Reset](#password-reset) | 4 | +| [Payment](#payment) | 7 | +| [Priority Config](#priority-config) | 5 | +| [Priority Rule Change Request](#priority-rule-change-request) | 3 | +| [Procurement](#procurement) | 8 | +| [Rate](#rate) | 5 | +| [Rate Change Request](#rate-change-request) | 3 | +| [Route](#route) | 4 | +| [Schedule](#schedule) | 3 | +| [Service Type](#service-type) | 5 | +| [Shipping Line](#shipping-line) | 3 | +| [Signature](#signature) | 1 | +| [Support Chat](#support-chat) | 5 | +| [Support Content](#support-content) | 3 | +| [Train](#train) | 3 | +| [Train Build](#train-build) | 11 | +| [Train Schedule](#train-schedule) | 48 | +| [Transit Agent](#transit-agent) | 3 | +| [Truck Type](#truck-type) | 3 | +| [User Trade Access](#user-trade-access) | 1 | +| [Vehicle](#vehicle) | 3 | +| [Wagon](#wagon) | 8 | +| [Wagon Transfer Request](#wagon-transfer-request) | 5 | +| [Wagon Type](#wagon-type) | 3 | +| [Warehouse](#warehouse) | 12 | +| [Warehouse Fee Invoice](#warehouse-fee-invoice) | 5 | +| [Warehouse Inspection Report](#warehouse-inspection-report) | 3 | +| [Warehouse Inventory](#warehouse-inventory) | 24 | +| [Warehouse Yard](#warehouse-yard) | 2 | +| [Warehouse Zone](#warehouse-zone) | 1 | +| [Weight Limit Rule](#weight-limit-rule) | 3 | +| [Yard](#yard) | 5 | +| [Yard Distance](#yard-distance) | 3 | + +--- + +## Endpoints by entity + +### Account + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a verification code to a new email/phone before changing it | `POST` | `/api/me/contact/otp` | `modules/auth/account.controller.ts:26` | +| Change the account's email or phone, gated by a verification code | `PATCH` | `/api/me/contact` | `modules/auth/account.controller.ts:40` | +| Change the account's display name | `PATCH` | `/api/me/name` | `modules/auth/account.controller.ts:54` | + +### AI Assist + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mock AI: extract structured booking fields from free-text request | `POST` | `/api/ai/booking/extract` | `modules/ai/ai.controller.ts:16` | + +### Approval Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an approval rule step | `POST` | `/api/approval-rules` | `modules/rule-engine/controllers/approval-rules.controller.ts:66` | +| Move an approval step up or down within its chain | `POST` | `/api/approval-rules/:id/move-order` | `modules/rule-engine/controllers/approval-rules.controller.ts:51` | +| Bulk reorder approval steps within a chain | `POST` | `/api/approval-rules/reorder` | `modules/rule-engine/controllers/approval-rules.controller.ts:43` | +| Update an approval rule | `PATCH` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:73` | +| Soft-delete an approval rule | `DELETE` | `/api/approval-rules/:id` | `modules/rule-engine/controllers/approval-rules.controller.ts:80` | + +### Booking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new freight booking (DRAFT) | `POST` | `/api/bookings` | `modules/bookings/bookings.controller.ts:170` | +| Allocate containers to vehicles | `POST` | `/api/bookings/:bookingId/allocate-containers` | `modules/bookings/booking-allocation.controller.ts:13` | +| Cancel booking | `POST` | `/api/bookings/:id/cancel` | `modules/bookings/bookings.controller.ts:1544` | +| Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED); | `POST` | `/api/bookings/:id/cancel-hold` | `modules/bookings/bookings.controller.ts:1568` | +| GL ET uploads customs declaration on booking (GENERAL customs) | `POST` | `/api/bookings/:id/clearance/declaration` | `modules/bookings/bookings.controller.ts:1126` | +| Upload Booking Delivery Order | `POST` | `/api/bookings/:id/clearance/delivery-order` | `modules/bookings/bookings.controller.ts:1268` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/bookings/:id/clearance/documents` | `modules/bookings/bookings.controller.ts:959` | +| GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review | `POST` | `/api/bookings/:id/clearance/draft-declaration` | `modules/bookings/bookings.controller.ts:1175` | +| Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia | `POST` | `/api/bookings/:id/clearance/draft-declaration/accept` | `modules/bookings/bookings.controller.ts:1200` | +| Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable) | `POST` | `/api/bookings/:id/clearance/draft-declaration/change` | `modules/bookings/bookings.controller.ts:1211` | +| GL ET sets duty/tax on booking with notice attachment | `POST` | `/api/bookings/:id/clearance/duty` | `modules/bookings/bookings.controller.ts:1144` | +| Customer uploads duty/tax payment slip on booking | `POST` | `/api/bookings/:id/clearance/duty-slip` | `modules/bookings/bookings.controller.ts:1238` | +| Confirm Booking Export Release | `POST` | `/api/bookings/:id/clearance/export-release` | `modules/bookings/bookings.controller.ts:1326` | +| GL finalizes clearance (requires 100% approved) → CLEARANCE_READY | `POST` | `/api/bookings/:id/clearance/finalize` | `modules/bookings/bookings.controller.ts:1087` | +| GL ET finalizes import pre-clearance on booking | `POST` | `/api/bookings/:id/clearance/finalize-pre-clearance` | `modules/bookings/bookings.controller.ts:1230` | +| GL uploads customs output documents (IM4/EX3/…) | `POST` | `/api/bookings/:id/clearance/output-documents` | `modules/bookings/bookings.controller.ts:1071` | +| Customer requests operation with a schedule day | `POST` | `/api/bookings/:id/clearance/proceed` | `modules/bookings/bookings.controller.ts:979` | +| Upload Booking Release Order | `POST` | `/api/bookings/:id/clearance/release-order` | `modules/bookings/bookings.controller.ts:1288` | +| GL reviews a clearance document (Approve | Query) | `POST` | `/api/bookings/:id/clearance/review` | `modules/bookings/bookings.controller.ts:1051` | +| Request Booking RO Amendment | `POST` | `/api/bookings/:id/clearance/ro-amendment` | `modules/bookings/bookings.controller.ts:1311` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/bookings/:id/clearance/transit-assignee/assign` | `modules/bookings/bookings.controller.ts:1112` | +| GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration | `POST` | `/api/bookings/:id/clearance/transit-assignee/request` | `modules/bookings/bookings.controller.ts:1098` | +| Upload Booking Transit Permit | `POST` | `/api/bookings/:id/clearance/transit-permit` | `modules/bookings/bookings.controller.ts:1251` | +| Confirm submit after price change | `POST` | `/api/bookings/:id/confirm-submit` | `modules/bookings/bookings.controller.ts:906` | +| Request freight consolidation | `POST` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1583` | +| Generate contract PDF from template | `POST` | `/api/bookings/:id/contract/generate` | `modules/bookings/bookings.controller.ts:1406` | +| Apply digital signature (customer or staff) | `POST` | `/api/bookings/:id/contract/sign` | `modules/bookings/bookings.controller.ts:1452` | +| Customer cancels their own booking before payment — no cancellation fee | `POST` | `/api/bookings/:id/customer-cancel` | `modules/bookings/bookings.controller.ts:1555` | +| Customer assigns external truck and driver for terminal pickup | `POST` | `/api/bookings/:id/customer-truck-assignment` | `modules/bookings/bookings.controller.ts:460` | +| Add a customer self-haul truck carrying 1–2 of the booking containers | `POST` | `/api/bookings/:id/customer-trucks` | `modules/bookings/bookings.controller.ts:686` | +| Register an import truck leaving: containers loaded + weighed gross (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/depart` | `modules/bookings/bookings.controller.ts:788` | +| Truck_dispatch: load selected containers onto a truck (staff) | `POST` | `/api/bookings/:id/customer-trucks/:assignmentId/load` | `modules/bookings/bookings.controller.ts:773` | +| Bulk add customer trucks from array payload (Excel parsed) | `POST` | `/api/bookings/:id/customer-trucks/bulk` | `modules/bookings/bookings.controller.ts:701` | +| Customer digital signature (deprecated — use POST contract/sign) | `POST` | `/api/bookings/:id/customer/sign` | `modules/bookings/bookings.controller.ts:1487` | +| Upload documents for a booking (DRAFT only) | `POST` | `/api/bookings/:id/documents` | `modules/bookings/bookings.controller.ts:869` | +| Generate a GRN over the received containers (all received, or a subset) — one GRN per batch | `POST` | `/api/bookings/:id/generate-grn` | `modules/bookings/bookings.controller.ts:820` | +| Generate price preview (DRAFT or CHANGES_REQUESTED) | `POST` | `/api/bookings/:id/generate-price` | `modules/bookings/bookings.controller.ts:882` | +| Expedite government booking to PAID / ELIGIBLE for scheduling | `POST` | `/api/bookings/:id/government-expedite` | `modules/bookings/bookings.controller.ts:1390` | +| Staff contract signature and fully execute (use contract/sign STAFF preferred) | `POST` | `/api/bookings/:id/marketing/approve` | `modules/bookings/bookings.controller.ts:1505` | +| Operations reviews an operation request: ACCEPT (→ batch pool), | `POST` | `/api/bookings/:id/operation/review` | `modules/bookings/bookings.controller.ts:1030` | +| Mark completed | `POST` | `/api/bookings/:id/operations/complete` | `modules/bookings/bookings.controller.ts:1536` | +| Mark in transit | `POST` | `/api/bookings/:id/operations/start-transit` | `modules/bookings/bookings.controller.ts:1528` | +| Customer reject price estimate | `POST` | `/api/bookings/:id/reject` | `modules/bookings/bookings.controller.ts:918` | +| Staff accept intake → set contract validity window + start approval chain | `POST` | `/api/bookings/:id/staff/accept` | `modules/bookings/bookings.controller.ts:1355` | +| Staff final reject | `POST` | `/api/bookings/:id/staff/reject` | `modules/bookings/bookings.controller.ts:1374` | +| Staff return booking for customer updates | `POST` | `/api/bookings/:id/staff/request-changes` | `modules/bookings/bookings.controller.ts:1339` | +| Customer submit booking | `POST` | `/api/bookings/:id/submit` | `modules/bookings/bookings.controller.ts:894` | +| Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles | `POST` | `/api/bookings/:id/wagon-cancellations` | `modules/bookings/bookings.controller.ts:558` | +| Preview the fee/credit of a partial wagon cancellation (no writes) | `POST` | `/api/bookings/:id/wagon-cancellations/preview` | `modules/bookings/bookings.controller.ts:544` | +| Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/rebook` | `modules/bookings/bookings.controller.ts:638` | +| Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission) | `POST` | `/api/bookings/wagon-cancellations/:cancellationId/withdraw` | `modules/bookings/bookings.controller.ts:624` | +| Update booking | `PATCH` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:211` | +| Edit a not-yet-arrived customer truck (plate/driver/type + containers) | `PATCH` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:716` | +| Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first | `PATCH` | `/api/bookings/:id/export-handover-mode` | `modules/bookings/bookings.controller.ts:761` | +| Soft-delete DRAFT booking | `DELETE` | `/api/bookings/:id` | `modules/bookings/bookings.controller.ts:861` | +| Remove consolidation pairing | `DELETE` | `/api/bookings/:id/consolidation` | `modules/bookings/bookings.controller.ts:1590` | +| Remove a not-yet-arrived customer truck from a booking | `DELETE` | `/api/bookings/:id/customer-trucks/:assignmentId` | `modules/bookings/bookings.controller.ts:732` | + +### Cargo + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new cargo | `POST` | `/api/cargoes` | `modules/cargoes/cargoes.controller.ts:34` | +| Mark cargo as delivered | `POST` | `/api/cargoes/:id/deliver` | `modules/cargoes/cargoes.controller.ts:81` | +| Load cargo into a container | `POST` | `/api/cargoes/:id/load` | `modules/cargoes/cargoes.controller.ts:67` | +| Unload cargo from container | `POST` | `/api/cargoes/:id/unload` | `modules/cargoes/cargoes.controller.ts:74` | +| Update a cargo | `PATCH` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:53` | +| Delete a cargo | `DELETE` | `/api/cargoes/:id` | `modules/cargoes/cargoes.controller.ts:60` | + +### Cargo Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a cargo type | `POST` | `/api/cargo-types` | `modules/rule-engine/controllers/cargo-types.controller.ts:51` | +| Move a cargo type up or down in display order | `POST` | `/api/cargo-types/:id/move-order` | `modules/rule-engine/controllers/cargo-types.controller.ts:36` | +| Bulk reorder cargo types by ID list | `POST` | `/api/cargo-types/reorder` | `modules/rule-engine/controllers/cargo-types.controller.ts:28` | +| Update a cargo type | `PATCH` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:58` | +| Soft-delete a cargo type | `DELETE` | `/api/cargo-types/:id` | `modules/rule-engine/controllers/cargo-types.controller.ts:65` | + +### Company + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter) | `POST` | `/api/companies` | `modules/companies/companies.controller.ts:565` | +| Upload documents for a company (onboarding) | `POST` | `/api/companies/:companyId/documents` | `modules/companies/companies.controller.ts:728` | +| Add a profile (employee) to a company | `POST` | `/api/companies/:companyId/profiles` | `modules/companies/companies.controller.ts:843` | +| Approve a pending profile change request (applies the changes) | `POST` | `/api/companies/change-requests/:id/approve` | `modules/companies/companies.controller.ts:790` | +| Reject a pending profile change request with a note | `POST` | `/api/companies/change-requests/:id/reject` | `modules/companies/companies.controller.ts:806` | +| Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it) | `POST` | `/api/companies/change-requests/:id/request-changes` | `modules/companies/companies.controller.ts:824` | +| Create a single operational profile for the current user's company. The role starts pending and does not become the active mode | `POST` | `/api/companies/company-profile` | `modules/companies/companies.controller.ts:272` | +| Add operational profile(s) (importer/exporter/forwarder) to the current user's company | `POST` | `/api/companies/company-profiles` | `modules/companies/companies.controller.ts:229` | +| Add business-license document(s) to a profile. For an approved company | `POST` | `/api/companies/company-profiles/:profileId/license` | `modules/companies/companies.controller.ts:290` | +| Replace a business-license file with a newly uploaded one (staged for | `POST` | `/api/companies/company-profiles/:profileId/license/:fileId/replace` | `modules/companies/companies.controller.ts:311` | +| Resubmit a rejected operational role for approval (→ pending) | `POST` | `/api/companies/company-profiles/:profileId/reapply` | `modules/companies/companies.controller.ts:165` | +| Create a company with its associated external profile (onboarding) | `POST` | `/api/companies/create` | `modules/companies/companies.controller.ts:539` | +| Ask the customer to correct one uploaded document | `POST` | `/api/companies/documents/:fileId/request-change` | `modules/companies/companies.controller.ts:699` | +| Fetch company info from eTrade by TIN | `POST` | `/api/companies/fetch-etrade-info` | `modules/companies/companies.controller.ts:197` | +| Bind a completed Fayda verification to the company's owner or Power of Attorney | `POST` | `/api/companies/identity/fayda/complete` | `modules/companies/companies.controller.ts:414` | +| Declare the General Manager is the company's owner, copying the owner's verified identity across | `POST` | `/api/companies/identity/gm/same-as-owner` | `modules/companies/companies.controller.ts:432` | +| Declare the Power of Attorney is the company's owner, copying the owner's identity across | `POST` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:461` | +| Mark the current user's onboarding as complete | `POST` | `/api/companies/onboarding/complete` | `modules/companies/companies.controller.ts:527` | +| Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally | `POST` | `/api/companies/onboarding/start` | `modules/companies/companies.controller.ts:246` | +| Upload the Power of Attorney delegation letter, replacing any existing one | `POST` | `/api/companies/poa-delegation` | `modules/companies/companies.controller.ts:380` | +| Update a company | `PATCH` | `/api/companies/:id` | `modules/companies/companies.controller.ts:613` | +| Update a company profile's approval status | `PATCH` | `/api/companies/company-profiles/:profileId/status` | `modules/companies/companies.controller.ts:748` | +| Persist the user's current onboarding wizard step | `PATCH` | `/api/companies/onboarding-step` | `modules/companies/companies.controller.ts:504` | +| Update profile (flattened settings page) | `PATCH` | `/api/companies/profile` | `modules/companies/companies.controller.ts:219` | +| Soft-delete a company | `DELETE` | `/api/companies/:id` | `modules/companies/companies.controller.ts:634` | +| Remove a business-license file (staged for review on an approved company) | `DELETE` | `/api/companies/company-profiles/:profileId/license/:fileId` | `modules/companies/companies.controller.ts:338` | +| Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together | `DELETE` | `/api/companies/identity/fayda/poa` | `modules/companies/companies.controller.ts:491` | +| Clear the General Manager's identity — the \"same as owner\" declaration or a verification, and the details either wrote | `DELETE` | `/api/companies/identity/gm` | `modules/companies/companies.controller.ts:448` | +| Undo the Power of Attorney \"same as owner\" declaration and the identity it copied, leaving the representative open to be verified in their own right | `DELETE` | `/api/companies/identity/poa/same-as-owner` | `modules/companies/companies.controller.ts:478` | +| Remove the Power of Attorney delegation letter (staged for review on an approved company) | `DELETE` | `/api/companies/poa-delegation/:fileId` | `modules/companies/companies.controller.ts:401` | + +### Compliance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a compliance record | `POST` | `/api/compliance` | `modules/compliance/compliance.controller.ts:23` | +| Update a compliance record | `PATCH` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:51` | +| Soft-delete a compliance record | `DELETE` | `/api/compliance/:id` | `modules/compliance/compliance.controller.ts:58` | + +### Consignment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new consignment | `POST` | `/api/consignments` | `modules/consignments/consignments.controller.ts:29` | + +### Container + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new container | `POST` | `/api/containers` | `modules/container-management/containers.controller.ts:33` | +| Assign container to a wagon | `POST` | `/api/containers/:id/assign-wagon` | `modules/container-management/containers.controller.ts:66` | +| Unassign container from wagon | `POST` | `/api/containers/:id/unassign-wagon` | `modules/container-management/containers.controller.ts:73` | +| Update a container | `PATCH` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:52` | +| Delete a container | `DELETE` | `/api/containers/:id` | `modules/container-management/containers.controller.ts:59` | + +### Container Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a container type | `POST` | `/api/container-types` | `modules/rule-engine/controllers/container-types.controller.ts:51` | +| Move a container type up or down in display order | `POST` | `/api/container-types/:id/move-order` | `modules/rule-engine/controllers/container-types.controller.ts:36` | +| Bulk reorder container types by ID list | `POST` | `/api/container-types/reorder` | `modules/rule-engine/controllers/container-types.controller.ts:28` | +| Update a container type | `PATCH` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:58` | +| Soft-delete a container type | `DELETE` | `/api/container-types/:id` | `modules/rule-engine/controllers/container-types.controller.ts:65` | + +### Contract + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new contract (DRAFT) with routes + cargo scope | `POST` | `/api/contracts` | `modules/contracts/contracts.controller.ts:188` | +| Approve one approval step in sequence | `POST` | `/api/contracts/:id/approval-steps/:stepId/approve` | `modules/contracts/contracts.controller.ts:552` | +| Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there) | `POST` | `/api/contracts/:id/approval-steps/:stepId/reject` | `modules/contracts/contracts.controller.ts:571` | +| Customer submits a shipment request on a GENERAL customs contract | `POST` | `/api/contracts/:id/booking-requests` | `modules/contracts/contracts.controller.ts:170` | +| Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia) | `POST` | `/api/contracts/:id/bookings` | `modules/contracts/contracts.controller.ts:1076` | +| Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing | `POST` | `/api/contracts/:id/bookings/:bookingId/complete` | `modules/contracts/contracts.controller.ts:1133` | +| Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request | `POST` | `/api/contracts/:id/bookings/initiate` | `modules/contracts/contracts.controller.ts:1105` | +| Customer cancels their own contract (blocked while a booking is live) | `POST` | `/api/contracts/:id/cancel` | `modules/contracts/contracts.controller.ts:526` | +| GL ET uploads customs declaration documents (multi-file) | `POST` | `/api/contracts/:id/clearance/declaration` | `modules/contracts/contracts.controller.ts:795` | +| GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates | `POST` | `/api/contracts/:id/clearance/delivery-order` | `modules/contracts/contracts.controller.ts:957` | +| Customer uploads clearance documents (fieldname = document key) | `POST` | `/api/contracts/:id/clearance/documents` | `modules/contracts/contracts.controller.ts:734` | +| GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving | `POST` | `/api/contracts/:id/clearance/documents/:fileKey/replace` | `modules/contracts/contracts.controller.ts:894` | +| GL ET sets duty/tax requirement and advises amount with notice attachment | `POST` | `/api/contracts/:id/clearance/duty` | `modules/contracts/contracts.controller.ts:808` | +| Customer uploads duty/tax payment slip on contract | `POST` | `/api/contracts/:id/clearance/duty-slip` | `modules/contracts/contracts.controller.ts:932` | +| Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable) | `POST` | `/api/contracts/:id/clearance/duty/dispute` | `modules/contracts/contracts.controller.ts:918` | +| GL ET confirms export release after declaration | `POST` | `/api/contracts/:id/clearance/export-release` | `modules/contracts/contracts.controller.ts:1008` | +| GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy) | `POST` | `/api/contracts/:id/clearance/finalize` | `modules/contracts/contracts.controller.ts:788` | +| GL ET finalizes export clearance after post-booking transit permit upload | `POST` | `/api/contracts/:id/clearance/finalize-export-clearance` | `modules/contracts/contracts.controller.ts:1018` | +| GL ET finalizes import pre-clearance — unlocks Djibouti DO upload | `POST` | `/api/contracts/:id/clearance/finalize-pre-clearance` | `modules/contracts/contracts.controller.ts:838` | +| Operations finalizes self-clearance → customer may create the booking | `POST` | `/api/contracts/:id/clearance/ops-finalize` | `modules/contracts/contracts.controller.ts:1051` | +| Operations reviews a customer self-clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/ops-review` | `modules/contracts/contracts.controller.ts:1032` | +| GL uploads customs output documents (IM4/EX3/…) pre-booking | `POST` | `/api/contracts/:id/clearance/output-documents` | `modules/contracts/contracts.controller.ts:776` | +| GL DJ uploads Release Order + vessel departure date (export) | `POST` | `/api/contracts/:id/clearance/release-order` | `modules/contracts/contracts.controller.ts:978` | +| GL ET reviews a clearance document (Approve | Query) | `POST` | `/api/contracts/:id/clearance/review` | `modules/contracts/contracts.controller.ts:756` | +| GL DJ requests port amendment when RO vessel window is too short | `POST` | `/api/contracts/:id/clearance/ro-amendment` | `modules/contracts/contracts.controller.ts:997` | +| GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns | `POST` | `/api/contracts/:id/clearance/transit-assignee/assign` | `modules/contracts/contracts.controller.ts:863` | +| GL ET asks GL Djibouti to name the transit officer — required before the customs declaration | `POST` | `/api/contracts/:id/clearance/transit-assignee/request` | `modules/contracts/contracts.controller.ts:845` | +| GL ET uploads import transit permit documents (multi-file) | `POST` | `/api/contracts/:id/clearance/transit-permit` | `modules/contracts/contracts.controller.ts:944` | +| Confirm submit after a price change | `POST` | `/api/contracts/:id/confirm-submit` | `modules/contracts/contracts.controller.ts:380` | +| Generate contract document → CONTRACT_READY | `POST` | `/api/contracts/:id/contract/generate` | `modules/contracts/contracts.controller.ts:596` | +| Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number) | `POST` | `/api/contracts/:id/contract/send-signing-otp` | `modules/contracts/contracts.controller.ts:667` | +| Apply digital signature (customer or staff/director/ceo) | `POST` | `/api/contracts/:id/contract/sign` | `modules/contracts/contracts.controller.ts:680` | +| Upload intake documents for a contract (DRAFT only) | `POST` | `/api/contracts/:id/documents` | `modules/contracts/contracts.controller.ts:354` | +| Generate unit-rate breakdown (no totals at contract phase) | `POST` | `/api/contracts/:id/generate-price` | `modules/contracts/contracts.controller.ts:366` | +| GL marks a pre-booking (contract) milestone complete | `POST` | `/api/contracts/:id/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1222` | +| Create a renewal draft linked via renewalOfId | `POST` | `/api/contracts/:id/renew` | `modules/contracts/contracts.controller.ts:705` | +| Staff lift a suspension — contract returns to its prior status | `POST` | `/api/contracts/:id/resume` | `modules/contracts/contracts.controller.ts:510` | +| Staff accept → set validity window + start approval chain | `POST` | `/api/contracts/:id/staff/accept` | `modules/contracts/contracts.controller.ts:387` | +| Staff reject contract | `POST` | `/api/contracts/:id/staff/reject` | `modules/contracts/contracts.controller.ts:476` | +| Staff return contract for customer updates | `POST` | `/api/contracts/:id/staff/request-changes` | `modules/contracts/contracts.controller.ts:460` | +| Customer submit contract (freezes contract_rate_snapshots) | `POST` | `/api/contracts/:id/submit` | `modules/contracts/contracts.controller.ts:373` | +| Staff freeze a signed contract (reversible, any post-signature step) | `POST` | `/api/contracts/:id/suspend` | `modules/contracts/contracts.controller.ts:492` | +| Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created) | `POST` | `/api/contracts/:id/validate-shipment` | `modules/contracts/contracts.controller.ts:1162` | +| GL marks a shipment request accepted + links the created booking | `POST` | `/api/contracts/booking-requests/:reqId/accept` | `modules/contracts/contracts.controller.ts:134` | +| Customer cancels their own pending shipment request | `POST` | `/api/contracts/booking-requests/:reqId/cancel` | `modules/contracts/contracts.controller.ts:160` | +| GL rejects a shipment request | `POST` | `/api/contracts/booking-requests/:reqId/reject` | `modules/contracts/contracts.controller.ts:149` | +| GL uploads post-booking operational documents (DO/RO/T1/…) | `POST` | `/api/contracts/bookings/:bookingId/documents` | `modules/contracts/contracts.controller.ts:1441` | +| GL ET advises duty & tax amount + declaration serial | `POST` | `/api/contracts/bookings/:bookingId/duty` | `modules/contracts/contracts.controller.ts:1260` | +| Customer uploads the duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/duty-slip` | `modules/contracts/contracts.controller.ts:1455` | +| GL DJ raises the post-offload final invoice (amount + invoice document) | `POST` | `/api/contracts/bookings/:bookingId/final-invoice` | `modules/contracts/contracts.controller.ts:1332` | +| Customer attaches the payment slip for the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice-slip` | `modules/contracts/contracts.controller.ts:1374` | +| Customer approves the drafted final invoice — unlocks the payment slip | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/approve` | `modules/contracts/contracts.controller.ts:1359` | +| GL (ET or DJ) confirms the payment slip — settles the final invoice | `POST` | `/api/contracts/bookings/:bookingId/final-invoice/confirm` | `modules/contracts/contracts.controller.ts:1386` | +| GL DJ logs a cargo exception with photo evidence | `POST` | `/api/contracts/bookings/:bookingId/incidents` | `modules/contracts/contracts.controller.ts:1479` | +| GL / Ops / Terminal marks a post-booking milestone complete | `POST` | `/api/contracts/bookings/:bookingId/milestones/:code/complete` | `modules/contracts/contracts.controller.ts:1205` | +| GL ET assigns a customs risk level (GREEN/YELLOW/RED) | `POST` | `/api/contracts/bookings/:bookingId/risk` | `modules/contracts/contracts.controller.ts:1241` | +| GL ET advises (or skips) the post-arrival additional duty/tax round (import) | `POST` | `/api/contracts/bookings/:bookingId/second-duty` | `modules/contracts/contracts.controller.ts:1399` | +| Customer attaches the additional duty/tax payment slip | `POST` | `/api/contracts/bookings/:bookingId/second-duty-slip` | `modules/contracts/contracts.controller.ts:1429` | +| GL station manager routes the shipment + binds staff | `POST` | `/api/contracts/bookings/:bookingId/station-assign` | `modules/contracts/contracts.controller.ts:1276` | +| Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export) | `POST` | `/api/contracts/bookings/:bookingId/t1-close` | `modules/contracts/contracts.controller.ts:1316` | +| GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs | `POST` | `/api/contracts/bookings/:bookingId/t1-documents` | `modules/contracts/contracts.controller.ts:1301` | +| GL ET uploads export transit permit documents (multi-file) | `POST` | `/api/contracts/bookings/:bookingId/transport-document` | `modules/contracts/contracts.controller.ts:1289` | +| Share a document with the other GL desk | `POST` | `/api/gl-exchange/:entityId` | `modules/contracts/gl-exchange.controller.ts:59` | +| Edit this contract\'s document articles only (per-contract; never touches the six shared templates) | `PUT` | `/api/contracts/:id/document/articles` | `modules/contracts/contracts.controller.ts:441` | +| Update contract | `PATCH` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:327` | +| Uploader edits a shared document (title, visibility, file) | `PATCH` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:79` | +| Soft-delete DRAFT contract | `DELETE` | `/api/contracts/:id` | `modules/contracts/contracts.controller.ts:346` | +| Uploader removes a shared document | `DELETE` | `/api/gl-exchange/documents/:documentId` | `modules/contracts/gl-exchange.controller.ts:105` | + +### Contract Template + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a bulk contract template for a (cargo type, customs option) pair | `POST` | `/api/contract-templates` | `modules/contract-templates/contract-templates.controller.ts:56` | +| Add an article to the template | `POST` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:118` | +| Render an HTML preview of the template against mock contract data | `POST` | `/api/contract-templates/:code/preview` | `modules/contract-templates/contract-templates.controller.ts:97` | +| Replace the full ordered article list (used for reorder) | `PUT` | `/api/contract-templates/:code/articles` | `modules/contract-templates/contract-templates.controller.ts:111` | +| Update template metadata (name, title, recitals, active flag) | `PATCH` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:77` | +| Update an article's title or body | `PATCH` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:125` | +| Delete a staff-created bulk template (system templates refuse) | `DELETE` | `/api/contract-templates/:code` | `modules/contract-templates/contract-templates.controller.ts:84` | +| Remove an article from the template | `DELETE` | `/api/contract-templates/:code/articles/:articleId` | `modules/contract-templates/contract-templates.controller.ts:136` | + +### Driver + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new driver | `POST` | `/api/drivers` | `modules/drivers/drivers.controller.ts:41` | +| Upload driver documents (code driver_docs) | `POST` | `/api/drivers/:id/documents` | `modules/drivers/drivers.controller.ts:80` | +| Update a driver | `PATCH` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:128` | +| Delete a driver | `DELETE` | `/api/drivers/:id` | `modules/drivers/drivers.controller.ts:138` | +| Delete a driver document | `DELETE` | `/api/drivers/:id/documents/:fileId` | `modules/drivers/drivers.controller.ts:121` | + +### Dropdown Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new dropdown setting | `POST` | `/api/dropdown-settings` | `modules/dropdown-settings/dropdown-settings.controller.ts:61` | +| Append a single option to a setting | `POST` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:98` | +| Replace the full option list for a setting | `PUT` | `/api/dropdown-settings/:id/options` | `modules/dropdown-settings/dropdown-settings.controller.ts:88` | +| Update a dropdown setting's metadata | `PATCH` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:68` | +| Update a single option | `PATCH` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:108` | +| Soft-delete a dropdown setting | `DELETE` | `/api/dropdown-settings/:id` | `modules/dropdown-settings/dropdown-settings.controller.ts:78` | +| Soft-delete a single option | `DELETE` | `/api/dropdown-settings/options/:optionId` | `modules/dropdown-settings/dropdown-settings.controller.ts:118` | + +### EIMS Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged | `POST` | `/api/invoices/:id/eims/register` | `modules/eims/eims-invoice.controller.ts:32` | +| Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block | `POST` | `/api/invoices/:id/eims/resolve` | `modules/eims/eims-invoice.controller.ts:49` | +| Verify the invoice's stored IRN against EIMS | `POST` | `/api/invoices/:id/eims/verify` | `modules/eims/eims-invoice.controller.ts:42` | + +### Exchange Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the USD→ETB fallback by hand (used only while CBE is unreachable) | `PATCH` | `/api/exchange-settings` | `modules/exchange-settings/exchange-settings.controller.ts:35` | + +### Facility + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new facility | `POST` | `/api/facilities` | `modules/facilities/facilities.controller.ts:22` | +| Update a facility | `PATCH` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:41` | +| Delete a facility (soft delete) | `DELETE` | `/api/facilities/:id` | `modules/facilities/facilities.controller.ts:48` | + +### Fayda Verification + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start a VeriFayda 2.0 verification session | `POST` | `/api/fayda/verification/start` | `modules/verifayda/verifayda.controller.ts:44` | + +### File Upload Setting + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new file upload setting | `POST` | `/api/file-upload-settings` | `modules/file-upload-settings/file-upload-settings.controller.ts:56` | +| Append a single field to a setting | `POST` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:93` | +| Replace the full field list for a setting | `PUT` | `/api/file-upload-settings/:id/fields` | `modules/file-upload-settings/file-upload-settings.controller.ts:83` | +| Update a file upload setting's metadata | `PATCH` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:63` | +| Update a single field | `PATCH` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:103` | +| Soft-delete a file upload setting | `DELETE` | `/api/file-upload-settings/:id` | `modules/file-upload-settings/file-upload-settings.controller.ts:73` | +| Soft-delete a single field | `DELETE` | `/api/file-upload-settings/fields/:fieldId` | `modules/file-upload-settings/file-upload-settings.controller.ts:113` | + +### First Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a first-mile leg | `POST` | `/api/first-mile` | `modules/first-mile/first-mile.controller.ts:84` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/first-mile/:id/distances` | `modules/first-mile/first-mile.controller.ts:124` | +| Generate the first-mile delivery-fee invoice | `POST` | `/api/first-mile/:id/invoice` | `modules/first-mile/first-mile.controller.ts:100` | +| Set the vehicles assigned to a first-mile pickup (multi-truck) | `POST` | `/api/first-mile/:id/vehicles` | `modules/first-mile/first-mile.controller.ts:114` | +| Accept a paid booking and create a first-mile leg | `POST` | `/api/first-mile/accept/:reference` | `modules/first-mile/first-mile.controller.ts:77` | +| Update a first-mile leg | `PATCH` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:91` | +| Soft-delete a first-mile leg | `DELETE` | `/api/first-mile/:id` | `modules/first-mile/first-mile.controller.ts:134` | + +### Fuel + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record fuel purchase | `POST` | `/api/fuel/purchases` | `modules/fuel/fuel.controller.ts:22` | + +### GPS Tracking + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a GPS tracker | `POST` | `/api/gps/devices` | `modules/gps-tracking/gps-tracking.controller.ts:52` | +| Update a GPS tracker (name / assigned vehicle) | `PATCH` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:59` | +| Delete a GPS tracker | `DELETE` | `/api/gps/devices/:id` | `modules/gps-tracking/gps-tracking.controller.ts:66` | + +### Import Operation + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Batch 12: record declaration serial number | `POST` | `/api/import-operations/customs/:bookingId/declaration` | `modules/import-operations/import-operations.controller.ts:53` | +| Batch 12: upload IM4/IM5/T1/permit/payment-slip documents | `POST` | `/api/import-operations/customs/:bookingId/documents` | `modules/import-operations/import-operations.controller.ts:44` | +| Batch 12: mark duties and taxes paid | `POST` | `/api/import-operations/customs/:bookingId/duties-taxes-paid` | `modules/import-operations/import-operations.controller.ts:71` | +| Batch 12: notify duties and taxes | `POST` | `/api/import-operations/customs/:bookingId/notify-duties-taxes` | `modules/import-operations/import-operations.controller.ts:62` | +| Batch 12: mark import release permitted | `POST` | `/api/import-operations/customs/:bookingId/release-permitted` | `modules/import-operations/import-operations.controller.ts:86` | +| Batch 12: assign customs risk | `POST` | `/api/import-operations/customs/:bookingId/risk` | `modules/import-operations/import-operations.controller.ts:80` | +| Batch 8: report a Djibouti import incident / exception | `POST` | `/api/import-operations/djibouti-incidents` | `modules/import-operations/import-operations.controller.ts:32` | +| Batch 16: create an empty container return record | `POST` | `/api/import-operations/empty-container-returns` | `modules/import-operations/import-operations.controller.ts:101` | +| Batch 16: advance empty container return workflow | `POST` | `/api/import-operations/empty-container-returns/:id/status` | `modules/import-operations/import-operations.controller.ts:107` | + +### Incident + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Report an incident | `POST` | `/api/incidents` | `modules/incidents/incidents.controller.ts:36` | +| Update an incident | `PATCH` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:72` | +| Delete an incident | `DELETE` | `/api/incidents/:id` | `modules/incidents/incidents.controller.ts:79` | + +### Interchange Document + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate interchange document from a train schedule handover | `POST` | `/api/interchange-documents/generate-from-schedule` | `modules/interchange-documents/interchange-documents.controller.ts:41` | +| Acknowledge an interchange document | `PATCH` | `/api/interchange-documents/:id/acknowledge` | `modules/interchange-documents/interchange-documents.controller.ts:48` | +| Dispute an interchange document | `PATCH` | `/api/interchange-documents/:id/dispute` | `modules/interchange-documents/interchange-documents.controller.ts:58` | + +### Last Mile + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a last-mile leg | `POST` | `/api/last-mile` | `modules/last-mile/last-mile.controller.ts:97` | +| Set each truck\'s own detention window (arrived at destination / returned) | `POST` | `/api/last-mile/:id/detention-times` | `modules/last-mile/last-mile.controller.ts:142` | +| Set per-vehicle actual distances (does not generate an invoice) | `POST` | `/api/last-mile/:id/distances` | `modules/last-mile/last-mile.controller.ts:132` | +| Generate the delivery-fee invoice for a last-mile leg | `POST` | `/api/last-mile/:id/invoice` | `modules/last-mile/last-mile.controller.ts:179` | +| Record proof of delivery (signature + photos) and complete the leg | `POST` | `/api/last-mile/:id/proof-of-delivery` | `modules/last-mile/last-mile.controller.ts:166` | +| Set the vehicles assigned to a last-mile delivery (multi-truck) | `POST` | `/api/last-mile/:id/vehicles` | `modules/last-mile/last-mile.controller.ts:122` | +| Set each truck\'s warehouse gate arrival/departure times | `POST` | `/api/last-mile/:id/warehouse-gate-times` | `modules/last-mile/last-mile.controller.ts:154` | +| Accept a paid booking and create a last-mile leg | `POST` | `/api/last-mile/accept/:reference` | `modules/last-mile/last-mile.controller.ts:90` | +| Update a last-mile leg | `PATCH` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:104` | +| Soft-delete a last-mile leg | `DELETE` | `/api/last-mile/:id` | `modules/last-mile/last-mile.controller.ts:113` | + +### Last Mile Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature | `POST` | `/api/last-mile-requests/:id/approve` | `modules/last-mile-requests/last-mile-requests.controller.ts:119` | +| Customer agrees and signs the LM contract — then the advance invoice is issued | `POST` | `/api/last-mile-requests/:id/contract/sign` | `modules/last-mile-requests/last-mile-requests.controller.ts:83` | +| Truck & Machinery chief rejects the request with a reason | `POST` | `/api/last-mile-requests/:id/reject` | `modules/last-mile-requests/last-mile-requests.controller.ts:130` | +| Customer confirms which containers go via EDR last-mile | `POST` | `/api/last-mile-requests/:id/submit` | `modules/last-mile-requests/last-mile-requests.controller.ts:108` | + +### Locomotive + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a locomotive | `POST` | `/api/locomotives` | `modules/locomotives/locomotives.controller.ts:58` | +| Decommission a locomotive | `POST` | `/api/locomotives/:id/decommission` | `modules/locomotives/locomotives.controller.ts:72` | +| Update a locomotive | `PATCH` | `/api/locomotives/:id` | `modules/locomotives/locomotives.controller.ts:65` | +| Permanently delete a locomotive (irreversible; refused if any train references it) | `DELETE` | `/api/locomotives/:id/permanent` | `modules/locomotives/locomotives.controller.ts:82` | + +### Maintenance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Record maintenance cost | `POST` | `/api/maintenance/costs` | `modules/maintenance/maintenance.controller.ts:38` | +| Define/adjust a service interval (e.g. oil change every 10,000 km) | `POST` | `/api/maintenance/intervals` | `modules/maintenance/maintenance.controller.ts:59` | +| Create part | `POST` | `/api/maintenance/parts` | `modules/maintenance/maintenance.controller.ts:150` | +| Schedule maintenance | `POST` | `/api/maintenance/schedules` | `modules/maintenance/maintenance.controller.ts:31` | +| Create warranty | `POST` | `/api/maintenance/warranties` | `modules/maintenance/maintenance.controller.ts:186` | +| Create work order | `POST` | `/api/maintenance/work-orders` | `modules/maintenance/maintenance.controller.ts:110` | +| Update part | `PATCH` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:170` | +| Update maintenance schedule | `PATCH` | `/api/maintenance/schedules/:id` | `modules/maintenance/maintenance.controller.ts:45` | +| Update work order | `PATCH` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:134` | +| Deactivate a service interval (stops auto-scheduling) | `DELETE` | `/api/maintenance/intervals/:id` | `modules/maintenance/maintenance.controller.ts:73` | +| Delete part | `DELETE` | `/api/maintenance/parts/:id` | `modules/maintenance/maintenance.controller.ts:177` | +| Delete warranty | `DELETE` | `/api/maintenance/warranties/:id` | `modules/maintenance/maintenance.controller.ts:200` | +| Delete work order | `DELETE` | `/api/maintenance/work-orders/:id` | `modules/maintenance/maintenance.controller.ts:141` | + +### Notification Inbox + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Mark all my notifications as read | `POST` | `/api/notifications/read-all` | `modules/notification-inbox/notification-inbox.controller.ts:53` | +| Mark one of my notifications as read | `PATCH` | `/api/notifications/:id/read` | `modules/notification-inbox/notification-inbox.controller.ts:44` | + +### Organization User + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an organization user without assigning positions | `POST` | `/api/backoffice/organizations/:orgId/users` | `modules/backoffice/backoffice.controller.ts:24` | +| Replace org-scoped roles assigned to an employee user | `PUT` | `/api/backoffice/organizations/:orgId/employee-users/:userId/roles` | `modules/backoffice/backoffice.controller.ts:58` | + +### OTP + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send OTP | `POST` | `/api/otp/send` | `modules/otp/otp.controller.ts:41` | +| Verify OTP | `POST` | `/api/otp/verify` | `modules/otp/otp.controller.ts:62` | + +### Password Reset + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Send a password-reset code to the account's email AND phone | `POST` | `/api/auth/forgot-password/request` | `modules/auth/forgot-password.controller.ts:30` | +| Validate a staff-issued reset link and return its set-password ticket | `POST` | `/api/auth/forgot-password/resolve-link` | `modules/auth/forgot-password.controller.ts:73` | +| Exchange a valid reset code for a single-use set-password ticket | `POST` | `/api/auth/forgot-password/verify` | `modules/auth/forgot-password.controller.ts:62` | +| Send a password-reset link to a customer's primary contact | `POST` | `/api/backoffice/customers/:companyId/reset-password` | `modules/auth/customer-reset.controller.ts:49` | + +### Payment + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance | `POST` | `/api/billing/invoices/:id/confirm-offline` | `modules/billing/billing.controller.ts:81` | +| Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/confirm` | `modules/billing/portal-billing.controller.ts:102` | +| Initiate payment for one of the customer's invoices | `POST` | `/api/billing/my-invoices/:id/pay` | `modules/billing/portal-billing.controller.ts:86` | +| Live still-payable check + payer name for a CBE bill (called while CBE is on the line) | `POST` | `/api/internal/payments/bill-query` | `modules/payment/internal-payment.controller.ts:56` | +| Apply a payment.succeeded / payment.failed event from the payment service (idempotent) | `POST` | `/api/internal/payments/mark-paid` | `modules/payment/internal-payment.controller.ts:45` | +| Initiate payment for an invoice | `POST` | `/api/payments/initiate` | `modules/billing/payment.controller.ts:39` | +| Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth) | `POST` | `/api/payments/redirect-success/:bookingId` | `modules/payment/payment.controller.ts:89` | + +### Priority Config + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a priority config | `POST` | `/api/priority-configs` | `modules/rule-engine/controllers/priority-configs.controller.ts:51` | +| Move a priority config up or down in display order | `POST` | `/api/priority-configs/:id/move-order` | `modules/rule-engine/controllers/priority-configs.controller.ts:66` | +| Bulk reorder priority configs by ID list | `POST` | `/api/priority-configs/reorder` | `modules/rule-engine/controllers/priority-configs.controller.ts:58` | +| Update a priority config | `PATCH` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:74` | +| Soft-delete a priority config | `DELETE` | `/api/priority-configs/:id` | `modules/rule-engine/controllers/priority-configs.controller.ts:81` | + +### Priority Rule Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Submit a priority-rule change for approval | `POST` | `/api/priority-rule-change-requests` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:34` | +| Approve and apply a pending change | `POST` | `/api/priority-rule-change-requests/:id/approve` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:52` | +| Reject a pending change | `POST` | `/api/priority-rule-change-requests/:id/reject` | `modules/rule-engine/controllers/priority-rule-change-requests.controller.ts:65` | + +### Procurement + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create an asset acquisition | `POST` | `/api/procurement/acquisitions` | `modules/procurement/procurement.controller.ts:56` | +| Create an asset disposal | `POST` | `/api/procurement/disposals` | `modules/procurement/procurement.controller.ts:90` | +| Create a vendor | `POST` | `/api/procurement/vendors` | `modules/procurement/procurement.controller.ts:28` | +| Update an asset acquisition | `PATCH` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:75` | +| Update a vendor | `PATCH` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:41` | +| Delete an asset acquisition | `DELETE` | `/api/procurement/acquisitions/:id` | `modules/procurement/procurement.controller.ts:82` | +| Delete an asset disposal | `DELETE` | `/api/procurement/disposals/:id` | `modules/procurement/procurement.controller.ts:103` | +| Delete a vendor | `DELETE` | `/api/procurement/vendors/:id` | `modules/procurement/procurement.controller.ts:48` | + +### Rate + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a rate (DRAFT) | `POST` | `/api/rates` | `modules/rule-engine/controllers/rates.controller.ts:46` | +| CEO approves a rate | `POST` | `/api/rates/:id/approve` | `modules/rule-engine/controllers/rates.controller.ts:70` | +| Submit rate for CEO approval | `POST` | `/api/rates/:id/submit` | `modules/rule-engine/controllers/rates.controller.ts:63` | +| Update a DRAFT rate | `PATCH` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:56` | +| Soft-delete a rate | `DELETE` | `/api/rates/:id` | `modules/rule-engine/controllers/rates.controller.ts:82` | + +### Rate Change Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Propose a change to a LIVE rate | `POST` | `/api/rate-change-requests` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:23` | +| Approve a rate change and put it into effect | `POST` | `/api/rate-change-requests/:id/approve` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:37` | +| Reject a rate change — the rate keeps its current value | `POST` | `/api/rate-change-requests/:id/reject` | `modules/rule-engine/controllers/rate-change-requests.controller.ts:48` | + +### Route + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create route | `POST` | `/api/routes` | `modules/routes/routes.controller.ts:61` | +| Update route | `PATCH` | `/api/routes/:id` | `modules/routes/routes.controller.ts:68` | +| Deactivate route | `DELETE` | `/api/routes/:id` | `modules/routes/routes.controller.ts:90` | +| Permanently delete a route (irreversible; refused while any train schedule references it) | `DELETE` | `/api/routes/:id/permanent` | `modules/routes/routes.controller.ts:79` | + +### Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Reschedule train for maintenance (new departure + rebalance) | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52` | +| Execute a confirmed reschedule plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/execute` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:30` | +| Preview reschedule / government preempt plan | `POST` | `/api/train-scheduling/schedules/:id/reschedule/preview` | `modules/scheduling-reschedule/scheduling-reschedule.controller.ts:20` | + +### Service Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a service type | `POST` | `/api/service-types` | `modules/rule-engine/controllers/service-types.controller.ts:51` | +| Move a service type up or down in display order | `POST` | `/api/service-types/:id/move-order` | `modules/rule-engine/controllers/service-types.controller.ts:36` | +| Bulk reorder service types by ID list | `POST` | `/api/service-types/reorder` | `modules/rule-engine/controllers/service-types.controller.ts:28` | +| Update a service type | `PATCH` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:58` | +| Soft-delete a service type | `DELETE` | `/api/service-types/:id` | `modules/rule-engine/controllers/service-types.controller.ts:65` | + +### Shipping Line + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a shipping line | `POST` | `/api/shipping-lines` | `modules/rule-engine/controllers/shipping-lines.controller.ts:33` | +| Update a shipping line | `PATCH` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:40` | +| Soft-delete a shipping line | `DELETE` | `/api/shipping-lines/:id` | `modules/rule-engine/controllers/shipping-lines.controller.ts:47` | + +### Signature + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create or update the reusable saved signature | `PUT` | `/api/me/signature` | `modules/signatures/signatures.controller.ts:23` | + +### Support Chat + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Start chatting with a company (returns the thread if one exists) | `POST` | `/api/support/agent/conversations` | `modules/support-chat/support-chat-agent.controller.ts:49` | +| Reply as an agent, optionally with attachments | `POST` | `/api/support/agent/conversations/:id/messages` | `modules/support-chat/support-chat-agent.controller.ts:74` | +| Mark a thread read (agent side) | `POST` | `/api/support/agent/conversations/:id/read` | `modules/support-chat/support-chat-agent.controller.ts:114` | +| Send a message as the customer (optionally with attachments), opening the thread if needed | `POST` | `/api/support/conversation/messages` | `modules/support-chat/support-chat.controller.ts:65` | +| Mark my company's thread read (customer side) | `POST` | `/api/support/conversation/read` | `modules/support-chat/support-chat.controller.ts:102` | + +### Support Content + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Restore a version — re-saves it as a new version, never destructive | `POST` | `/api/support-content/documents/:slug/versions/:version/restore` | `modules/support-content/support-content.controller.ts:123` | +| Upload an image or video for a help section | `POST` | `/api/support-content/media` | `modules/support-content/support-content.controller.ts:55` | +| Replace a document's payload, recording a new version | `PATCH` | `/api/support-content/documents/:slug` | `modules/support-content/support-content.controller.ts:93` | + +### Train + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Register a new train | `POST` | `/api/trains` | `modules/trains/trains.controller.ts:33` | +| Update a train | `PATCH` | `/api/trains/:id` | `modules/trains/trains.controller.ts:52` | +| Delete a train | `DELETE` | `/api/trains/:id` | `modules/trains/trains.controller.ts:59` | + +### Train Build + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Build a train: code + yard + 2+ locomotives (+ optional wagons) | `POST` | `/api/train-builder` | `modules/trains/train-builder.controller.ts:50` | +| Reactivate a deactivated train back to AVAILABLE | `POST` | `/api/train-builder/:id/activate` | `modules/trains/train-builder.controller.ts:158` | +| Deactivate the train (park it) — only allowed with no active schedule | `POST` | `/api/train-builder/:id/deactivate` | `modules/trains/train-builder.controller.ts:149` | +| Persist a drag-reorder of the full consist | `POST` | `/api/train-builder/:id/reorder-wagons` | `modules/trains/train-builder.controller.ts:142` | +| Append AVAILABLE wagons from the train's yard to the consist | `POST` | `/api/train-builder/:id/wagons` | `modules/trains/train-builder.controller.ts:109` | +| Detach one wagon and move it to MAINTENANCE status | `POST` | `/api/train-builder/:id/wagons/:wagonId/maintenance` | `modules/trains/train-builder.controller.ts:131` | +| Replace the locomotive set (minimum 1, same yard) | `PUT` | `/api/train-builder/:id/locomotives` | `modules/trains/train-builder.controller.ts:78` | +| Edit the train's name and fixed import/export run numbers | `PATCH` | `/api/train-builder/:id/details` | `modules/trains/train-builder.controller.ts:88` | +| Relocate the train — its locomotives and wagons move to the new yard with it | `PATCH` | `/api/train-builder/:id/yard` | `modules/trains/train-builder.controller.ts:100` | +| Disband the train (release wagons and locomotives) | `DELETE` | `/api/train-builder/:id` | `modules/trains/train-builder.controller.ts:165` | +| Detach one wagon from the consist | `DELETE` | `/api/train-builder/:id/wagons/:wagonId` | `modules/trains/train-builder.controller.ts:120` | + +### Train Schedule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Staff: place a paid booking onto a fitting train (notifies customer on date change) | `POST` | `/api/train-scheduling/bookings/:bookingId/allocate` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:880` | +| Staff: expire a reservation and free its capacity | `POST` | `/api/train-scheduling/bookings/:bookingId/expire` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:845` | +| Staff: mark a reserved booking paid and allocate it now | `POST` | `/api/train-scheduling/bookings/:bookingId/mark-paid` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:835` | +| Re-point a booking to another OPEN same-route schedule | `POST` | `/api/train-scheduling/bookings/:bookingId/move-schedule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:855` | +| Preview a bulk train schedule | `POST` | `/api/train-scheduling/bulk/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:302` | +| Create a bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:316` | +| Assign bulk bookings to a train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:349` | +| Cancel bulk train schedule | `POST` | `/api/train-scheduling/bulk/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:976` | +| Preview a container train schedule | `POST` | `/api/train-scheduling/container/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:295` | +| Create a container train schedule | `POST` | `/api/train-scheduling/container/schedules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:309` | +| Assign container bookings to a train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:335` | +| Cancel container train schedule | `POST` | `/api/train-scheduling/container/schedules/:id/cancel` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:969` | +| Preview a mixed-capable train schedule | `POST` | `/api/train-scheduling/preview` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:288` | +| Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged) | `POST` | `/api/train-scheduling/schedules/:id/adjust-consist` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:197` | +| Mark a dispatched train arrived (move assets to destination yard, free assets) | `POST` | `/api/train-scheduling/schedules/:id/arrive` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:915` | +| Assign bookings to a train schedule (mixed-capable) | `POST` | `/api/train-scheduling/schedules/:id/assign-bookings` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:323` | +| Assign one linked unallocated booking to wagons (preserves existing assignments) | `POST` | `/api/train-scheduling/schedules/:id/assign-unassigned-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:423` | +| Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard) | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:564` | +| Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival | `POST` | `/api/train-scheduling/schedules/:id/bookings/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:577` | +| Log the train passing a station (final station triggers arrival) | `POST` | `/api/train-scheduling/schedules/:id/checkpoints` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:903` | +| Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch) | `POST` | `/api/train-scheduling/schedules/:id/confirm-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:662` | +| Dispatch a scheduled train | `POST` | `/api/train-scheduling/schedules/:id/dispatch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:514` | +| Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group) | `POST` | `/api/train-scheduling/schedules/:id/doc-review-complete` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:824` | +| Finalize a draft train schedule | `POST` | `/api/train-scheduling/schedules/:id/finalize` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:507` | +| Depart loaded import train from Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/depart` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:675` | +| Upload/check an import Djibouti-side document | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/documents` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:622` | +| Mark import Djibouti gatepass permission granted | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:632` | +| Generate import load list / marshalling document summary | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/load-list` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:685` | +| Confirm import cargo loaded on train at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:652` | +| Mark import train ready for loading at Djibouti | `POST` | `/api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:642` | +| Confirm intercity cargo loaded (train must be at the booking's origin yard) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:590` | +| Confirm intercity cargo unloaded at the booking's destination yard (completes the booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/:bookingId/unload` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:602` | +| Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking) | `POST` | `/api/train-scheduling/schedules/:id/intercity/accept` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:541` | +| Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged | `POST` | `/api/train-scheduling/schedules/:id/maintenance` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:798` | +| Pin physical wagons to train set slots | `POST` | `/api/train-scheduling/schedules/:id/pin-wagons` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:500` | +| Run wagon-level allocation for all eligible linked bookings | `POST` | `/api/train-scheduling/schedules/:id/run-allocation` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:747` | +| Manually run the batch fill for a schedule | `POST` | `/api/train-scheduling/schedules/:id/run-batch` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:739` | +| Switch out commercial bookings to allocate a government booking in their place | `POST` | `/api/train-scheduling/schedules/:id/switch-government-booking` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:439` | +| Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads) | `POST` | `/api/train-scheduling/schedules/:id/wagons/:wagonId/move-load` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:402` | +| Update global train scheduling rules (singleton) | `PATCH` | `/api/train-scheduling/global-rules` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:124` | +| Open or close a schedule booking window | `PATCH` | `/api/train-scheduling/schedules/:id/booking-window` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:756` | +| Update a container number on a wagon slot | `PATCH` | `/api/train-scheduling/schedules/:id/container-items/:itemId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:391` | +| Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch) | `PATCH` | `/api/train-scheduling/schedules/:id/import-loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:474` | +| Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only) | `PATCH` | `/api/train-scheduling/schedules/:id/loading-status` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:487` | +| Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window | `PATCH` | `/api/train-scheduling/schedules/:id/schedule-date` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:784` | +| Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens | `PATCH` | `/api/train-scheduling/schedules/:id/window-rule` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:770` | +| Unassign a booking from a train schedule | `DELETE` | `/api/train-scheduling/schedules/:id/bookings/:bookingId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:363` | +| Remove an empty wagon slot from a train | `DELETE` | `/api/train-scheduling/schedules/:id/wagons/:trainSetWagonId` | `modules/train-scheduling/controllers/train-scheduling.controller.ts:378` | + +### Transit Agent + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a transit agent | `POST` | `/api/transit-agents` | `modules/transit-agents/transit-agents.controller.ts:66` | +| Update a transit agent | `PATCH` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:73` | +| Soft-delete a transit agent | `DELETE` | `/api/transit-agents/:id` | `modules/transit-agents/transit-agents.controller.ts:80` | + +### Truck Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a truck type | `POST` | `/api/truck-types` | `modules/truck-types/truck-types.controller.ts:58` | +| Update a truck type | `PATCH` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:65` | +| Soft-delete a truck type | `DELETE` | `/api/truck-types/:id` | `modules/truck-types/truck-types.controller.ts:72` | + +### User Trade Access + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Set the trade directions a backoffice user may see | `PUT` | `/api/user-trade-access/:userId` | `modules/user-trade-access/user-trade-access.controller.ts:45` | + +### Vehicle + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new vehicle | `POST` | `/api/vehicles` | `modules/vehicles/vehicles.controller.ts:37` | +| Update a vehicle | `PATCH` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:78` | +| Delete a vehicle | `DELETE` | `/api/vehicles/:id` | `modules/vehicles/vehicles.controller.ts:88` | + +### Wagon + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a new wagon | `POST` | `/api/wagons` | `modules/wagons/wagons.controller.ts:39` | +| Assign wagon to a train | `POST` | `/api/wagons/:id/assign-train` | `modules/wagons/wagons.controller.ts:100` | +| Unassign wagon from train | `POST` | `/api/wagons/:id/unassign-train` | `modules/wagons/wagons.controller.ts:107` | +| Set the status of multiple wagons (audited in wagon_status_logs) | `POST` | `/api/wagons/bulk-status` | `modules/wagons/wagons.controller.ts:121` | +| Transfer multiple wagons to a destination yard | `POST` | `/api/wagons/bulk-transfer` | `modules/wagons/wagons.controller.ts:114` | +| Update a wagon | `PATCH` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:71` | +| Delete a wagon | `DELETE` | `/api/wagons/:id` | `modules/wagons/wagons.controller.ts:93` | +| Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots) | `DELETE` | `/api/wagons/:id/permanent` | `modules/wagons/wagons.controller.ts:82` | + +### Wagon Transfer Request + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| File a count-only wagon-transfer request | `POST` | `/api/wagon-transfer-requests` | `modules/wagons/wagon-transfer-requests.controller.ts:50` | +| Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved) | `POST` | `/api/wagon-transfer-requests/:id/cancel` | `modules/wagons/wagon-transfer-requests.controller.ts:167` | +| OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall | `POST` | `/api/wagon-transfer-requests/:id/close-short` | `modules/wagons/wagon-transfer-requests.controller.ts:153` | +| OCC: pick wagons and execute the transfer | `POST` | `/api/wagon-transfer-requests/:id/fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:142` | +| OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING) | `POST` | `/api/wagon-transfer-requests/bulk-fulfill` | `modules/wagons/wagon-transfer-requests.controller.ts:73` | + +### Wagon Type + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a wagon type | `POST` | `/api/wagon-types` | `modules/wagon-types/wagon-types.controller.ts:53` | +| Update a wagon type | `PATCH` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:60` | +| Soft-delete a wagon type | `DELETE` | `/api/wagon-types/:id` | `modules/wagon-types/wagon-types.controller.ts:67` | + +### Warehouse + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a warehouse allocation rule | `POST` | `/api/warehouse-allocation-rules` | `modules/warehouses/warehouse-rules.controller.ts:33` | +| Preview the yard/warehouse/zone a booking would be allocated to | `POST` | `/api/warehouse-allocation/preview` | `modules/warehouses/warehouse-rules.controller.ts:55` | +| Create a storage / demurrage fee rule | `POST` | `/api/warehouse-fee-rules` | `modules/warehouses/warehouse-rules.controller.ts:70` | +| Acknowledge / snooze an item fee-accrual alert | `POST` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:106` | +| Create warehouse | `POST` | `/api/warehouses` | `modules/warehouses/warehouses.controller.ts:51` | +| Create a yard within a warehouse | `POST` | `/api/warehouses/:warehouseId/yards` | `modules/warehouses/warehouses.controller.ts:78` | +| Update a warehouse allocation rule | `PATCH` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:40` | +| Update a fee rule | `PATCH` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:77` | +| Update warehouse | `PATCH` | `/api/warehouses/:id` | `modules/warehouses/warehouses.controller.ts:64` | +| Delete a warehouse allocation rule | `DELETE` | `/api/warehouse-allocation-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:47` | +| Delete a fee rule | `DELETE` | `/api/warehouse-fee-rules/:id` | `modules/warehouses/warehouse-rules.controller.ts:84` | +| Remove an accrual acknowledgement (re-surface for alerts) | `DELETE` | `/api/warehouse-fees/accrual/:inventoryId/acknowledge` | `modules/warehouses/warehouse-rules.controller.ts:119` | + +### Warehouse Fee Invoice + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Generate a truck-detention invoice for a last-mile leg (per truck per day) | `POST` | `/api/last-mile/:id/generate-truck-detention-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:28` | +| Record a payment against a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay` | `modules/warehouses/warehouse-invoice.controller.ts:109` | +| Initiate Telebirr/Waafi payment for a warehouse fee invoice | `POST` | `/api/warehouse-fee-invoices/:id/pay-online` | `modules/warehouses/warehouse-invoice.controller.ts:116` | +| Generate a warehouse fee invoice from Batch 5 fee calculation | `POST` | `/api/warehouse-inventory/:id/generate-fee-invoice` | `modules/warehouses/warehouse-invoice.controller.ts:20` | +| Cancel a warehouse fee invoice | `PATCH` | `/api/warehouse-fee-invoices/:id/cancel` | `modules/warehouses/warehouse-invoice.controller.ts:102` | + +### Warehouse Inspection Report + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Upload inspection images / documents | `POST` | `/api/warehouse-inspection-reports/:id/attachments` | `modules/warehouses/warehouse-inspection.controller.ts:68` | +| Create an inspection / damage report for an inventory item | `POST` | `/api/warehouse-inventory/:inventoryId/inspection-reports` | `modules/warehouses/warehouse-inspection.controller.ts:37` | +| Update an inspection report | `PATCH` | `/api/warehouse-inspection-reports/:id` | `modules/warehouses/warehouse-inspection.controller.ts:61` | + +### Warehouse Inventory + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Deliver import goods to the customer + capture proof of delivery | `POST` | `/api/warehouse-inventory/:id/deliver` | `modules/warehouses/warehouse-inventory.controller.ts:594` | +| Final terminal release / gate clearance (blocked while fees unpaid) | `POST` | `/api/warehouse-inventory/:id/gate-clearance` | `modules/warehouses/warehouse-inventory.controller.ts:219` | +| Load READY_FOR_LOADING inventory onto a wagon | `POST` | `/api/warehouse-inventory/:id/load` | `modules/warehouses/warehouse-inventory.controller.ts:384` | +| Move inventory to another warehouse/yard/zone | `POST` | `/api/warehouse-inventory/:id/move` | `modules/warehouses/warehouse-inventory.controller.ts:359` | +| Mark reserved inventory READY_FOR_LOADING | `POST` | `/api/warehouse-inventory/:id/ready-for-loading` | `modules/warehouses/warehouse-inventory.controller.ts:373` | +| Mark inspected IMPORT inventory READY_FOR_PICKUP | `POST` | `/api/warehouse-inventory/:id/ready-for-pickup` | `modules/warehouses/warehouse-inventory.controller.ts:391` | +| Issue a DO / release order for ready-for-pickup inventory | `POST` | `/api/warehouse-inventory/:id/release` | `modules/warehouses/warehouse-inventory.controller.ts:402` | +| Mark received inventory as STORED (optional explicit warehouse/yard/zone) | `POST` | `/api/warehouse-inventory/:id/store` | `modules/warehouses/warehouse-inventory.controller.ts:366` | +| Auto-load READY_FOR_LOADING inventory with PAID bookings | `POST` | `/api/warehouse-inventory/auto-load-ready` | `modules/warehouses/warehouse-inventory.controller.ts:125` | +| Bulk auto-unload all arrived bookings into the warehouse | `POST` | `/api/warehouse-inventory/auto-unload-arrived` | `modules/warehouses/warehouse-inventory.controller.ts:118` | +| Approve delivery — customer records their full name (signature optional) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/approve-delivery` | `modules/warehouses/warehouse-inventory.controller.ts:470` | +| Ask the customer to sign the handover (creates one if none, then notifies) | `POST` | `/api/warehouse-inventory/bookings/:bookingId/request-handover-signature` | `modules/warehouses/warehouse-inventory.controller.ts:509` | +| Unload a single arrived booking into a location | `POST` | `/api/warehouse-inventory/bookings/:bookingId/unload` | `modules/warehouses/warehouse-inventory.controller.ts:209` | +| Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED) | `POST` | `/api/warehouse-inventory/bulk-dispatch-export` | `modules/warehouses/warehouse-inventory.controller.ts:195` | +| Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING) | `POST` | `/api/warehouse-inventory/bulk-mark-inspected` | `modules/warehouses/warehouse-inventory.controller.ts:202` | +| Unload all eligible export items assigned to an arrived Djibouti-side train | `POST` | `/api/warehouse-inventory/export/auto-unload-at-djibouti` | `modules/warehouses/warehouse-inventory.controller.ts:294` | +| Customer signs one handover (EDR last-mile: one signature per truck) | `POST` | `/api/warehouse-inventory/handovers/:handoverId/sign` | `modules/warehouses/warehouse-inventory.controller.ts:493` | +| Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED) | `POST` | `/api/warehouse-inventory/import/auto-unload-arrived-bookings` | `modules/warehouses/warehouse-inventory.controller.ts:244` | +| Receive inventory at a warehouse location | `POST` | `/api/warehouse-inventory/receive` | `modules/warehouses/warehouse-inventory.controller.ts:322` | +| Bulk-receive selected eligible PAID bookings into a location | `POST` | `/api/warehouse-inventory/receive-bulk` | `modules/warehouses/warehouse-inventory.controller.ts:140` | +| Reserve stored inventory for a PAID booking | `POST` | `/api/warehouse-inventory/reserve` | `modules/warehouses/warehouse-inventory.controller.ts:330` | +| Load selected inventory items onto their allocated wagons for a train | `POST` | `/api/warehouse-inventory/train/:scheduleId/load` | `modules/warehouses/warehouse-inventory.controller.ts:184` | +| Mark loaded inventory DISPATCHED (left the terminal) | `PATCH` | `/api/warehouse-inventory/:id/dispatch` | `modules/warehouses/warehouse-inventory.controller.ts:602` | +| Record Yes/No double handling after unloading (Yes applies the double-handling fee rule) | `PATCH` | `/api/warehouse-inventory/bookings/:bookingId/double-handling` | `modules/warehouses/warehouse-inventory.controller.ts:556` | + +### Warehouse Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a zone within a yard | `POST` | `/api/warehouse-yards/:yardId/zones` | `modules/warehouses/warehouse-yards.controller.ts:50` | +| Update warehouse yard | `PATCH` | `/api/warehouse-yards/:id` | `modules/warehouses/warehouse-yards.controller.ts:36` | + +### Warehouse Zone + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Update warehouse zone | `PATCH` | `/api/warehouse-zones/:id` | `modules/warehouses/warehouse-zones.controller.ts:37` | + +### Weight Limit Rule + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a weight limit rule | `POST` | `/api/weight-limit-rules` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:32` | +| Update a weight limit rule | `PATCH` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:39` | +| Soft-delete a weight limit rule | `DELETE` | `/api/weight-limit-rules/:id` | `modules/rule-engine/controllers/weight-limit-rules.controller.ts:46` | + +### Yard + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard | `POST` | `/api/yards` | `modules/rule-engine/controllers/yards.controller.ts:53` | +| Move a yard up or down in display order | `POST` | `/api/yards/:id/move-order` | `modules/rule-engine/controllers/yards.controller.ts:38` | +| Bulk reorder yards by ID list | `POST` | `/api/yards/reorder` | `modules/rule-engine/controllers/yards.controller.ts:30` | +| Update a yard | `PATCH` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:60` | +| Soft-delete a yard | `DELETE` | `/api/yards/:id` | `modules/rule-engine/controllers/yards.controller.ts:67` | + +### Yard Distance + +| Title | Method | Endpoint | Source | +| --- | --- | --- | --- | +| Create a yard distance | `POST` | `/api/yard-distances` | `modules/rule-engine/controllers/yard-distances.controller.ts:42` | +| Update a yard distance | `PATCH` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:49` | +| Soft-delete a yard distance | `DELETE` | `/api/yard-distances/:id` | `modules/rule-engine/controllers/yard-distances.controller.ts:56` | + diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index fb6a0bd31..f3ba897a0 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -60,7 +60,6 @@ "@nestjs/typeorm": "^11.0.1", "@nestjs/websockets": "^11.1.27", "@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.6.0.tgz", - "@tria-plc/auditlog": "file:../../local-packages/tria-plc-auditlog-1.1.2.tgz", "@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz", "amqp-connection-manager": "^5.0.0", "amqplib": "^2.0.1", diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index c1b890719..2ef6d2fb9 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -16,7 +16,6 @@ import { import { IamBaselineSeeder, IamSeedModule } from "@edr/iam-seed"; import { IamModule } from "@tria-plc/iamapi-common"; import { SharedAuthModule } from "@tria-plc/api-common/modules/auth/shared-auth.module"; -import { MezgebModule } from "@tria-plc/auditlog"; import appConfig from "./config/app.config"; import databaseConfig from "./config/database.config"; @@ -112,7 +111,6 @@ import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-r import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module"; import { ImportOperationsModule } from "./modules/import-operations/import-operations.module"; import { AiModule } from "./modules/ai/ai.module"; -import { AuditModule } from "./modules/audit/audit.module"; import { LoggerMiddleware } from "./logger.middleware"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -169,19 +167,6 @@ if (!process.env.APPLICATION_NAME) { return dataSource; }, }), - // Request + entity-level audit logging over RabbitMQ (@tria-plc/auditlog). - // Must come after TypeOrmModule above so it picks up this app's DataSource. - // rmqUrl falls back the same way notifications.module.ts's RABBITMQ_URL - // does: the dev broker only provisions the `edr` user on the `payment` - // vhost (docker-compose's RABBITMQ_DEFAULT_USER/VHOST), so an unset - // RABBITMQ_URL must land there too, not on guest@'/' (403 ACCESS_REFUSED). - MezgebModule.forRoot({ - applicationName: "freight-api", - rmqUrl: - process.env.RABBITMQ_URL ?? - process.env.PAYMENT_RABBITMQ_URL ?? - "amqp://localhost:5672", - }), SharedAuthModule, IamModule.forRoot({ applications: [EDR_FREIGHT_APPLICATION], @@ -257,7 +242,6 @@ if (!process.env.APPLICATION_NAME) { EimsModule, FleetHistoryModule, AiModule, - AuditModule, ], providers: [ EdrOrgSeeder, diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index af4a5b017..77f4b37e4 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -56,11 +56,6 @@ import { EmployeePositionActivePeriod } from "@tria-plc/iamapi-common/entities/i import { UnitConfiguration } from "@tria-plc/iamapi-common/entities/iam/organization-structure/unit-configuration.entity"; import { Site } from "@tria-plc/iamapi-common/entities/iam/site/site.entity"; import { SiteSetting } from "@tria-plc/iamapi-common/entities/iam/site/site-setting.entity"; -import { AuditLog, AuditLogCommand } from "@tria-plc/auditlog"; - -// @tria-plc/auditlog's entities live in node_modules, same as the iam ones — -// the glob below only matches this app's own src/**/*.entity.ts. -const auditEntities = [AuditLog, AuditLogCommand]; const iamEntities = [ UnitSetting, @@ -185,7 +180,6 @@ export function buildDataSourceOptions(): DataSourceOptions { entities: [ __dirname + "/../**/*.entity.{ts,js}", ...iamEntities, - ...auditEntities, ], migrations: [], }; diff --git a/apps/edr-freight-api/src/main.ts b/apps/edr-freight-api/src/main.ts index f26cd19e7..f16493273 100644 --- a/apps/edr-freight-api/src/main.ts +++ b/apps/edr-freight-api/src/main.ts @@ -10,7 +10,6 @@ import { ResponseTransformInterceptor, createValidationPipe, } from "@edr/api-common"; -import { getAuditLoggerConfig } from "@tria-plc/auditlog"; import { AppModule } from "./app.module"; @@ -167,11 +166,6 @@ export async function createFreightApp(): Promise { app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new ResponseTransformInterceptor()); - // Audit listener: consumes the RMQ events MezgebModule's client interceptor - // (app.module.ts) emits and persists them via the AuditLogController / - // AuditLogCommandController @EventPattern handlers. Same queue config the - // client side uses, reused from the package so the two never drift apart. - app.connectMicroservice(getAuditLoggerConfig()); await app.startAllMicroservices(); const config = new DocumentBuilder() diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts deleted file mode 100644 index a7c8782b2..000000000 --- a/apps/edr-freight-api/src/modules/audit/audit.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Controller, Get, Query } from "@nestjs/common"; -import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; - -import { BookingStaff } from "../../common/booking-guards"; -import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; -import { AuditService } from "./audit.service"; - -@ApiTags("audit") -@Controller("audit") -@BookingStaff(FREIGHT_PERMS.audit.view) -export class AuditController { - constructor(private readonly auditService: AuditService) {} - - @Get("logs") - @ApiOperation({ summary: "List freight-api audit log commands" }) - @ApiQuery({ name: "skip", type: Number, required: false }) - @ApiQuery({ name: "take", type: Number, required: false }) - list(@Query("skip") skip?: string, @Query("take") take?: string) { - // Same fallback chain @tria-plc/auditlog's client interceptor uses to - // stamp AuditLog.application (mezgeb/client/client-audit.interceptor.js) - // — reading it here instead of a hardcoded literal means this can't - // silently drift out of sync with whatever APPLICATION_NAME/APP_NAME - // actually is at runtime. - const application = - process.env.APPLICATION_NAME ?? process.env.APP_NAME ?? "DEFAULT"; - return this.auditService.list( - application, - skip !== undefined ? parseInt(skip, 10) : undefined, - take !== undefined ? parseInt(take, 10) : undefined, - ); - } -} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts deleted file mode 100644 index 635973fc6..000000000 --- a/apps/edr-freight-api/src/modules/audit/audit.module.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Module } from "@nestjs/common"; -import { TypeOrmModule } from "@nestjs/typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; - -import { AuditController } from "./audit.controller"; -import { AuditService } from "./audit.service"; - -@Module({ - imports: [TypeOrmModule.forFeature([AuditLogCommand])], - controllers: [AuditController], - providers: [AuditService], -}) -export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts deleted file mode 100644 index 04ea4beed..000000000 --- a/apps/edr-freight-api/src/modules/audit/audit.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { InjectRepository } from "@nestjs/typeorm"; -import { Repository } from "typeorm"; -import { AuditLogCommand } from "@tria-plc/auditlog"; - -import { CLIENT_APP_HEADER } from "../auth/login-audience.middleware"; - -export interface AuditLogListResult { - count: number; - items: AuditLogCommand[]; -} - -/** - * Own read path onto @tria-plc/auditlog's tables, gated by AuditController's - * @BookingStaff — the package's own AuditLogCommandController (mounted at - * /api/audit-log-commands) ships with no guards at all, so it can't be used - * directly for a permission-gated UI. Query mirrors the package's - * AuditLogCommandService.buildAuditLogQuery/getAllAuditLogs exactly. - */ -@Injectable() -export class AuditService { - constructor( - @InjectRepository(AuditLogCommand) - private readonly auditLogCommandRepository: Repository, - ) {} - - async list( - application: string, - skip = 0, - take = 10, - ): Promise { - const [items, count] = await this.auditLogCommandRepository - .createQueryBuilder("audit_log_commands") - .leftJoinAndSelect("audit_log_commands.auditLog", "auditLog") - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.application = :application)", - { application }, - ) - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.status = :status)", - { status: "Commit" }, - ) - // Backoffice-only view: portal (customer-facing) writes carry the same - // request-header set by every axios call from that app — see - // login-audience.middleware.ts. Rows with no linked auditLog (child/ - // event commands with no request context) stay visible; they aren't - // attributable to any frontend, so they're not portal noise either. - .andWhere( - "(audit_log_commands.auditLogId IS NULL OR auditLog.requestHeader ->> :clientAppHeader = :clientApp)", - { clientAppHeader: CLIENT_APP_HEADER, clientApp: "backoffice" }, - ) - .select([ - "audit_log_commands.id", - "audit_log_commands.createdAt", - "audit_log_commands.deletedAt", - "audit_log_commands.entityName", - "audit_log_commands.queryMethod", - "audit_log_commands.changes", - "audit_log_commands.payload", - "auditLog.id", - "auditLog.user", - ]) - .addOrderBy("audit_log_commands.createdAt", "DESC") - .skip(skip) - .take(take) - .getManyAndCount(); - - return { count, items }; - } -} diff --git a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts index e285c181c..91237223c 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-events.consumer.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, SetMetadata } from "@nestjs/common"; +import { Injectable, Logger } from "@nestjs/common"; import { Nack, RabbitSubscribe } from "@golevelup/nestjs-rabbitmq"; import { Public } from "@edr/api-common"; import { @@ -14,11 +14,6 @@ import { PaymentService as PaymentSvc } from "./payment.service"; const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentService.FREIGHT]; -// @tria-plc/auditlog's global ClientLoggerInterceptor (present in deployed builds) -// crashes on non-HTTP contexts (`originalUrl.split` on a RabbitMQ message) and the -// resulting requeue storm blocks payment.succeeded forever. Its IgnoreLoggerAudit -// decorator is just this metadata key — set it directly so we don't need the package. -@SetMetadata("ignoreAuditLogger", true) @Injectable() export class PaymentEventsConsumer { private readonly logger = new Logger(PaymentEventsConsumer.name); diff --git a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts index 07993c267..d285deeb0 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/repositories/cargo-types.repository.ts @@ -91,9 +91,8 @@ export class CargoTypesRepository implements ICargoTypesRepository { /** * Diffs the wagon-type links through the relation query builder rather than - * an entity save: junction-row inserts from save() broadcast afterInsert with - * no entity attached, which the @tria-plc/auditlog subscriber (deployed - * builds) dereferences and crashes the request on. + * an entity save, so junction rows are written without broadcasting + * afterInsert events for entity-less inserts. */ private async syncWagonTypes( id: string, diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 47fde9fb4..4728cf5f3 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1187,11 +1187,6 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:dropdown:manage", "Manage dropdown settings", ), - perm( - "b4c00001-0001-4000-8000-000000000001", - "edr_freight_app:audit:view", - "View audit logs", - ), ]; // M. Staff / IAM admin — NEW keys only. The employee_registration / role_assignment @@ -1894,9 +1889,6 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:support_content:manage", }, }, - audit: { - view: "edr_freight_app:audit:view", - }, support: { agentView: "edr_freight_app:support:agent_view", agentSend: "edr_freight_app:support:agent_send", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 00d5e0ddb..8d221aaad 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -43,7 +43,6 @@ import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; -import AuditLogsPage from "./pages/audit/AuditLogsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; import { RequirePermission } from "./components/auth/RequirePermission"; import { FREIGHT_PERMS } from "./lib/permissions"; @@ -773,14 +772,6 @@ const App = () => { } /> - - - - } - /> = [ subtitle: "Manage dropdown options used across the platform", }, }, - { - prefix: "/dashboard/audit-logs", - meta: { - title: "Audit Logs", - subtitle: "Request and entity-level activity recorded across the freight API", - }, - }, { prefix: "/dashboard/configuration/contract-validity-periods", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 83e80d11e..726711a8b 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -8,7 +8,6 @@ import { FileSignature, FileText, Hammer, - History, Landmark, LayoutDashboard, LayoutGrid, @@ -501,12 +500,6 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] FREIGHT_PERMS.settings.supportContent.manage, ], }, - { - label: "Audit logs", - href: "/dashboard/audit-logs", - icon: , - permission: FREIGHT_PERMS.audit.view, - }, { label: "Configuration", href: "/dashboard/configuration", diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 3b482571c..8a6a52bca 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -321,10 +321,6 @@ export const URL_CONSTANTS = { SUMMARY: "/payments/summary", }, - AUDIT: { - LOGS: "/audit/logs", - }, - LOCOMOTIVES: { BASE: "/locomotives", BY_ID: (id: string) => `/locomotives/${id}`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index b74c7bef9..8e16c1d96 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -338,9 +338,6 @@ export const FREIGHT_PERMS = { manage: "edr_freight_app:settings:support_content:manage", }, }, - audit: { - view: "edr_freight_app:audit:view", - }, staff: { roles: { view: "edr_freight_app:staff:roles:view", diff --git a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx deleted file mode 100644 index ef3412a3a..000000000 --- a/apps/edr-freight-web/backoffice/src/pages/audit/AuditLogsPage.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { Badge, Box, Card, Stack, Text } from "@mantine/core"; -import { useQuery } from "@tanstack/react-query"; - -import { PageContainer, PageHeader } from "@/components/page"; -import { api } from "@/services/api"; -import type { - AuditLogRow, - AuditQueryMethod, - AuditUser, - LocalizedText, -} from "@/services/audit.service"; -import { - DataTable, - DataTableFooter, - usePagination, - type ColumnDef, -} from "@edr/ui-common"; - -const ACTION_LABELS: Record = { - INSERT: "Created", - UPDATE: "Updated", - DELETE: "Deleted", - INSERT_CHILD: "Linked child", - DELETE_CHILD: "Unlinked child", -}; - -const ACTION_COLORS: Record = { - INSERT: "edr-green", - UPDATE: "yellow", - DELETE: "red", - INSERT_CHILD: "indigo", - DELETE_CHILD: "gray", -}; - -function formatDateTime(iso: string): string { - const d = new Date(iso); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }); -} - -// See LocalizedText: `name`/`title` lifted from a raw audited entity can be -// a plain string or IAM's { am, en } — never render either directly. -// "undefined undefined" is the producer's own broken template when no user -// was attached at all (unauthenticated/customer flows, e.g. Fayda -// verification) — filtered out here rather than shown as raw garbage. -function localize(value: LocalizedText | null | undefined): string | undefined { - if (!value) return undefined; - if (typeof value === "object") return value.en ?? value.am ?? undefined; - if (/^undefined(\s+undefined)?$/.test(value.trim())) return undefined; - return value; -} - -function formatUser(user: AuditUser | null | undefined): string { - return localize(user?.name) ?? user?.id ?? "—"; -} - -function summarize(row: AuditLogRow): string { - if (row.changes?.length) { - return row.changes - .slice(0, 2) - .map((c) => c.field) - .join(", ") + (row.changes.length > 2 ? `, +${row.changes.length - 2} more` : ""); - } - if (row.payload) { - return ( - localize(row.payload.name) ?? localize(row.payload.title) ?? row.payload.id ?? "—" - ); - } - return "—"; -} - -const tableHeader = - "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; - -export default function AuditLogsPage() { - const { pagination, setPagination } = usePagination({ pageSize: 20 }); - - const filter = { - skip: pagination.pageIndex * pagination.pageSize, - take: pagination.pageSize, - }; - - const { data, isLoading, isError } = useQuery( - api.audit.list.queryOptions({ input: { filter } }), - ); - - const rows = data?.items ?? []; - const total = data?.count ?? 0; - const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize)); - - const columns: ColumnDef[] = [ - { - id: "time", - header: () => Time, - cell: ({ row }) => ( - - {formatDateTime(row.original.createdAt)} - - ), - }, - { - id: "action", - header: () => Action, - cell: ({ row }) => ( - - {ACTION_LABELS[row.original.queryMethod] ?? row.original.queryMethod} - - ), - }, - { - id: "entity", - header: () => Entity, - cell: ({ row }) => ( - - {row.original.entityName} - - ), - }, - { - id: "user", - header: () => User, - cell: ({ row }) => ( - - {formatUser(row.original.auditLog?.user)} - - ), - }, - { - id: "summary", - header: () => Summary, - cell: ({ row }) => ( - - {summarize(row.original)} - - ), - }, - ]; - - return ( - - - - - - - - {total} record{total !== 1 ? "s" : ""} - - - - - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 895a54070..c7679056e 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -166,11 +166,6 @@ import { type SaveLocomotivePayload, } from "./locomotives.service"; import { overviewService } from "./overview.service"; -import { - auditService, - type AuditLogListFilter, - type PaginatedAuditLogs, -} from "./audit.service"; import { reportsService } from "./reports.service"; import type { ReportQueryInput, ReportResult } from "@/types/reports"; import { @@ -2153,15 +2148,6 @@ export const api = { ), }, - audit: { - list: endpoint<{ filter?: AuditLogListFilter }, PaginatedAuditLogs>( - "audit", - "list", - ({ filter }) => auditService.list(filter), - ({ filter }) => ["audit", "list", filter ?? {}], - ), - }, - signatures: { mySignature: endpoint( "me", diff --git a/apps/edr-freight-web/backoffice/src/services/audit.service.ts b/apps/edr-freight-web/backoffice/src/services/audit.service.ts deleted file mode 100644 index ed0642d83..000000000 --- a/apps/edr-freight-web/backoffice/src/services/audit.service.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { api as client } from "../auth/http"; -import { unwrap } from "@/utils/endpoint"; -import { URL_CONSTANTS } from "@/constants/URLS"; - -const A = URL_CONSTANTS.AUDIT; - -// Shape from @tria-plc/auditlog's AuditLogCommandController — see -// local-packages/FRONTEND_GUIDE.md. -export type AuditQueryMethod = - | "INSERT" - | "UPDATE" - | "DELETE" - | "INSERT_CHILD" - | "DELETE_CHILD"; - -export interface AuditFieldChange { - field: string; - from: unknown; - to: unknown; -} - -// IAM entities (users, orgs, positions, ...) name themselves bilingually — -// see edr-org.seeder.ts. Any `name`/`title` field lifted from a raw audited -// entity (auditLog.user, payload) can come back as either a plain string or -// this shape; both `name` fields below reflect that. -export type LocalizedText = string | { am?: string; en?: string }; - -// The vendored interceptor's own broken template produces a plain string -// ("undefined undefined") when no user was attached at all (unauthenticated/ -// customer flows) — that's the non-bilingual string case for `name` here. -export interface AuditUser { - id?: string; - name?: LocalizedText; - organizationId?: string; - organizationName?: string; - [key: string]: unknown; -} - -export interface AuditLogRow { - id?: string; - createdAt: string; - deletedAt?: string | null; - entityName: string; - queryMethod: AuditQueryMethod; - changes?: AuditFieldChange[] | null; - payload?: { name?: LocalizedText; title?: LocalizedText; id?: string } | null; - auditLog?: { id?: string; user?: AuditUser | null }; -} - -export interface AuditLogListFilter { - skip?: number; - take?: number; -} - -export interface PaginatedAuditLogs { - items: AuditLogRow[]; - count: number; -} - -export const auditService = { - list: async (filter?: AuditLogListFilter): Promise => { - const params: Record = { - skip: filter?.skip, - take: filter?.take, - }; - const response = await client.get(A.LOGS, { params }); - const data = unwrap(response.data) as PaginatedAuditLogs; - return { items: data.items ?? [], count: data.count ?? 0 }; - }, -}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68573264b..0caaa0905 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,13 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + date-fns: ^3.6.0 + pdfjs-dist: ^3.11.174 + react: 19.2.6 + react-dom: 19.2.6 + typeorm: 0.3.30 + importers: .: @@ -98,13 +105,10 @@ importers: version: 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz - version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) - '@tria-plc/auditlog': - specifier: file:../../local-packages/tria-plc-auditlog-1.1.2.tgz - version: file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(2te6mb6vb2upxetsvhkgi54vi4) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz - version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5) + version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(776yg74vcz655pvzf2fzttkf2q) amqp-connection-manager: specifier: ^5.0.0 version: 5.0.0(amqplib@2.0.1) @@ -157,7 +161,7 @@ importers: specifier: ^4.8.3 version: 4.8.3 typeorm: - specifier: ^0.3.30 + specifier: 0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': @@ -357,7 +361,7 @@ importers: version: 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@8.6.0) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(elggzzmuouas3d7ccap2lbvoyu) '@types/three': specifier: ^0.185.3 version: 0.185.3 @@ -559,7 +563,7 @@ importers: version: 10.5.0(postcss@8.5.15) jsdom: specifier: ^25.0.1 - version: 25.0.1 + version: 25.0.1(canvas@2.11.2) postcss: specifier: ^8.4.47 version: 8.5.15 @@ -574,7 +578,7 @@ importers: version: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) vitest: specifier: ^2.1.2 - version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0) + version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0) apps/edr-freight-web/portal: dependencies: @@ -604,7 +608,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(vhj5jsbx2ogblwtd5crolhrpku) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -698,7 +702,7 @@ importers: version: 10.5.0(postcss@8.5.15) jsdom: specifier: ^25.0.1 - version: 25.0.1 + version: 25.0.1(canvas@2.11.2) postcss: specifier: ^8.4.47 version: 8.5.15 @@ -713,7 +717,7 @@ importers: version: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) vitest: specifier: ^2.1.2 - version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0) + version: 2.1.9(@types/node@24.13.1)(jsdom@25.0.1(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0) apps/edr-gps-tracker: dependencies: @@ -745,7 +749,7 @@ importers: specifier: ^7.8.1 version: 7.8.2 typeorm: - specifier: ^0.3.30 + specifier: 0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) devDependencies: '@edr/eslint-config': @@ -834,10 +838,10 @@ importers: version: 8.1.6 '@tria-plc/api-common': specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz - version: file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b) + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(66zjxtt2zhydyq6w3arhrktl4y) '@tria-plc/iamapi-common': specifier: file:../../local-packages/tria-plc-iamapi-common-1.0.0.tgz - version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024) + version: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2duxgcsfjn5usnyutzzh6i5xr4) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -896,7 +900,7 @@ importers: specifier: ^4.2.0 version: 4.2.0 typeorm: - specifier: ^0.3.30 + specifier: 0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) uuid: specifier: ^10.0.0 @@ -973,7 +977,7 @@ importers: version: link:../../../packages/ui-common '@tanstack/react-query': specifier: ^5.59.0 - version: 5.101.0(react@18.3.1) + version: 5.101.0(react@19.2.6) axios: specifier: ^1.7.7 version: 1.17.0 @@ -981,32 +985,32 @@ importers: specifier: ^2.1.1 version: 2.1.1 date-fns: - specifier: ^3.0.0 + specifier: ^3.6.0 version: 3.6.0 lucide-react: specifier: ^0.446.0 - version: 0.446.0(react@18.3.1) + version: 0.446.0(react@19.2.6) next: specifier: ^14.2.0 - version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: 19.2.6 + version: 19.2.6 react-day-picker: specifier: ^9.14.0 - version: 9.14.0(react@18.3.1) + version: 9.14.0(react@19.2.6) react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) recharts: specifier: ^2.12.0 - version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) socket.io-client: specifier: ^4.8.3 version: 4.8.3 zustand: specifier: ^5.0.0 - version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: '@types/node': specifier: ^20.0.0 @@ -1046,10 +1050,10 @@ importers: version: link:../../../packages/ui-common '@hookform/resolvers': specifier: ^3.3.4 - version: 3.10.0(react-hook-form@7.77.0(react@18.3.1)) + version: 3.10.0(react-hook-form@7.77.0(react@19.2.6)) '@tanstack/react-query': specifier: ^5.59.0 - version: 5.101.0(react@18.3.1) + version: 5.101.0(react@19.2.6) '@types/qrcode': specifier: ^1.5.6 version: 1.5.6 @@ -1060,7 +1064,7 @@ importers: specifier: ^2.1.1 version: 2.1.1 date-fns: - specifier: ^3.0.0 + specifier: ^3.6.0 version: 3.6.0 jspdf: specifier: ^4.2.1 @@ -1070,25 +1074,25 @@ importers: version: 5.0.8(jspdf@4.2.1) lucide-react: specifier: ^0.446.0 - version: 0.446.0(react@18.3.1) + version: 0.446.0(react@19.2.6) next: specifier: ^14.2.0 - version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) qrcode: specifier: ^1.5.4 version: 1.5.4 qrcode.react: specifier: ^3.1.0 - version: 3.2.0(react@18.3.1) + version: 3.2.0(react@19.2.6) react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: 19.2.6 + version: 19.2.6 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) react-hook-form: specifier: ^7.51.0 - version: 7.77.0(react@18.3.1) + version: 7.77.0(react@19.2.6) socket.io-client: specifier: ^4.8.3 version: 4.8.3 @@ -1097,7 +1101,7 @@ importers: version: 3.25.76 zustand: specifier: ^5.0.0 - version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) + version: 5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)) devDependencies: '@types/node': specifier: ^20.0.0 @@ -1279,7 +1283,7 @@ importers: version: 5.9.3 vitest: specifier: ^2.1.2 - version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0) + version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0) packages/api-common: dependencies: @@ -1309,7 +1313,7 @@ importers: specifier: ^7.8.1 version: 7.8.2 typeorm: - specifier: ^0.3.20 + specifier: 0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) typescript: specifier: ^5.5.4 @@ -1377,7 +1381,7 @@ importers: specifier: ^29.2.5 version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) typeorm: - specifier: ^0.3.20 + specifier: 0.3.30 version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) typescript: specifier: ^5.5.4 @@ -1439,10 +1443,10 @@ importers: version: link:../types '@mantine/core': specifier: ^9.3.0 - version: 9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 9.3.0(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@tanstack/react-table': specifier: ^8.21.3 - version: 8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1451,10 +1455,10 @@ importers: version: 2.1.1 lucide-react: specifier: ^1.14.0 - version: 1.17.0(react@18.3.1) + version: 1.17.0(react@19.2.6) radix-ui: specifier: ^1.4.3 - version: 1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + version: 1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) shadcn: specifier: ^4.7.0 version: 4.10.0(@types/node@24.13.1)(babel-plugin-macros@3.1.0)(typescript@5.9.3) @@ -1484,11 +1488,11 @@ importers: specifier: ^8.4.47 version: 8.5.15 react: - specifier: ^18.3.1 - version: 18.3.1 + specifier: 19.2.6 + version: 19.2.6 react-dom: - specifier: ^18.3.1 - version: 18.3.1(react@18.3.1) + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) tailwindcss: specifier: ^4.3.0 version: 4.3.0 @@ -2002,7 +2006,7 @@ packages: resolution: {integrity: sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==} peerDependencies: '@types/react': '*' - react: '>=16.8.0' + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -2018,7 +2022,7 @@ packages: peerDependencies: '@emotion/react': ^11.0.0-rc.0 '@types/react': '*' - react: '>=16.8.0' + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -2035,7 +2039,7 @@ packages: '@emotion/use-insertion-effect-with-fallbacks@1.2.0': resolution: {integrity: sha512-yJMtVdH59sxi/aVJBpk9FQq+OR8ll5GT8oWd57UpeaKEVGab41JWaCFA7FRLoMLloOZF/c/wsPoe+bfGmRKgDg==} peerDependencies: - react: '>=16.8.0' + react: 19.2.6 '@emotion/utils@1.4.2': resolution: {integrity: sha512-3vLclRofFziIa3J2wDh9jjbkUz9qk5Vi3IZ/FSTKViB0k+ef0fPV7dYrUIugbgupYDx7v9ud/SjrtEP8Y4xLoA==} @@ -2221,20 +2225,20 @@ packages: '@floating-ui/react-dom@2.1.8': resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@floating-ui/react@0.26.28': resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@floating-ui/react@0.27.19': resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} peerDependencies: - react: '>=17.0.0' - react-dom: '>=17.0.0' + react: 19.2.6 + react-dom: 19.2.6 '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} @@ -2259,8 +2263,8 @@ packages: '@hello-pangea/dnd@18.0.1': resolution: {integrity: sha512-xojVWG8s/TGrKT1fC8K2tIWeejJYTAeJuj36zM//yEm/ZrnZUSFGS15BpO+jGZT1ybWvyXmeDJwPYb4dhWlbZQ==} peerDependencies: - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} @@ -2271,8 +2275,8 @@ packages: '@hookform/devtools@4.4.0': resolution: {integrity: sha512-Mtlic+uigoYBPXlfvPBfiYYUZuyMrD3pTjDpVIhL6eCZTvQkHsKBSKeZCvXWUZr8fqrkzDg27N+ZuazLKq6Vmg==} peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 - react-dom: ^16.8.0 || ^17 || ^18 || ^19 + react: 19.2.6 + react-dom: 19.2.6 '@hookform/resolvers@3.10.0': resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} @@ -2680,8 +2684,8 @@ packages: '@lexical/devtools-core@0.48.0': resolution: {integrity: sha512-4kvKWW6ebgQnJNLXPLmw7dqgSChvzYIBNYtfuR6c48Sw+V/QXQTWqfIUbCIe5X4uG8EEXd5O/udXaJx7GBuP+w==} peerDependencies: - react: '>=18.x' - react-dom: '>=18.x' + react: 19.2.6 + react-dom: 19.2.6 typescript: '>=5.2' peerDependenciesMeta: typescript: @@ -2786,8 +2790,8 @@ packages: '@lexical/react@0.48.0': resolution: {integrity: sha512-uVh9/QSrbtjLjVbxfJ+sfiMyhUq/rv7H6uBEVDDIw1rkZJSDY1fvf/CX+dyKgwcDFjKQZ8/9i5f9UCVPeQ01hA==} peerDependencies: - react: '>=18.x' - react-dom: '>=18.x' + react: 19.2.6 + react-dom: 19.2.6 typescript: '>=5.2' yjs: '>=13.5.22' peerDependenciesMeta: @@ -2899,7 +2903,7 @@ packages: '@lottiefiles/react-lottie-player@3.6.0': resolution: {integrity: sha512-WK5TriLJT93VF3w4IjSVyveiedraZCnDhKzCPhpbeLgQeMi6zufxa3dXNc4HmAFRXq+LULPAy+Idv1rAfkReMA==} peerDependencies: - react: 16 - 19 + react: 19.2.6 '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} @@ -2910,30 +2914,30 @@ packages: peerDependencies: '@mantine/core': 7.17.8 '@mantine/hooks': 7.17.8 - react: ^18.x || ^19.x - react-dom: ^18.x || ^19.x + react: 19.2.6 + react-dom: 19.2.6 recharts: ^2.13.3 '@mantine/core@7.17.8': resolution: {integrity: sha512-42sfdLZSCpsCYmLCjSuntuPcDg3PLbakSmmYfz5Auea8gZYLr+8SS5k647doVu0BRAecqYOytkX2QC5/u/8VHw==} peerDependencies: '@mantine/hooks': 7.17.8 - react: ^18.x || ^19.x - react-dom: ^18.x || ^19.x + react: 19.2.6 + react-dom: 19.2.6 '@mantine/core@9.3.0': resolution: {integrity: sha512-mHVCm61YVW9ipy9eHiKMqsRUm3TkOErbdw7zHs0HRw5g403nf7tSTqNGvaYE+aX1Py874qMkrUzeQfj4bjiiBA==} peerDependencies: '@mantine/hooks': 9.3.0 - react: ^19.2.0 - react-dom: ^19.2.0 + react: 19.2.6 + react-dom: 19.2.6 '@mantine/core@9.3.2': resolution: {integrity: sha512-Upy/Z9Sj2eW2dGrFgUy/2kISVsxMTBYTDfP2TFdsIA3PdPSBkdqfSOPX/ug3d3F3a4bnwQSqcO6+aPEpBIh8dg==} peerDependencies: '@mantine/hooks': 9.3.2 - react: ^19.2.0 - react-dom: ^19.2.0 + react: 19.2.6 + react-dom: 19.2.6 '@mantine/dates@7.17.8': resolution: {integrity: sha512-KYog/YL83PnsMef7EZagpOFq9I2gfnK0eYSzC8YvV9Mb6t/x9InqRssGWVb0GIr+TNILpEkhKoGaSKZNy10Q1g==} @@ -2941,8 +2945,8 @@ packages: '@mantine/core': 7.17.8 '@mantine/hooks': 7.17.8 dayjs: '>=1.0.0' - react: ^18.x || ^19.x - react-dom: ^18.x || ^19.x + react: 19.2.6 + react-dom: 19.2.6 '@mantine/dates@9.3.2': resolution: {integrity: sha512-MzHrXGoOb3rCnHBlsI/BPAjC8K5tZ4f8pzg66tsTsupVCQBaBpHSVatQwNRJ6K2JC9pyCyTa9BL803C8AiWycA==} @@ -2950,36 +2954,40 @@ packages: '@mantine/core': 9.3.2 '@mantine/hooks': 9.3.2 dayjs: '>=1.0.0' - react: ^19.2.0 - react-dom: ^19.2.0 + react: 19.2.6 + react-dom: 19.2.6 '@mantine/hooks@7.17.8': resolution: {integrity: sha512-96qygbkTjRhdkzd5HDU8fMziemN/h758/EwrFu7TlWrEP10Vw076u+Ap/sG6OT4RGPZYYoHrTlT+mkCZblWHuw==} peerDependencies: - react: ^18.x || ^19.x + react: 19.2.6 '@mantine/hooks@9.3.0': resolution: {integrity: sha512-QoSr9WI4WsKWrM3qFYYizHUn3+n+CVcFMYe4sdlnmFPStvs6BacPODKJSbFlYl73Z20t82JIy0eKqt4noHQI2g==} peerDependencies: - react: ^19.2.0 + react: 19.2.6 '@mantine/hooks@9.3.2': resolution: {integrity: sha512-jOjpUe0x1A/k3XUiu2/aaSCasXRI5ZKZOucm3ypwsvm9F2u8C1xzwBvagrzlbNZwJRF5vNYIJtCaT7NunPyc0A==} peerDependencies: - react: ^19.2.0 + react: 19.2.6 '@mantine/notifications@7.17.8': resolution: {integrity: sha512-/YK16IZ198W6ru/IVecCtHcVveL08u2c8TbQTu/2p26LSIM9AbJhUkrU6H+AO0dgVVvmdmNdvPxcJnfq3S9TMg==} peerDependencies: '@mantine/core': 7.17.8 '@mantine/hooks': 7.17.8 - react: ^18.x || ^19.x - react-dom: ^18.x || ^19.x + react: 19.2.6 + react-dom: 19.2.6 '@mantine/store@7.17.8': resolution: {integrity: sha512-/FrB6PAVH4NEjQ1dsc9qOB+VvVlSuyjf4oOOlM9gscPuapDP/79Ryq7JkhHYfS55VWQ/YUlY24hDI2VV+VptXg==} peerDependencies: - react: ^18.x || ^19.x + react: 19.2.6 + + '@mapbox/node-pre-gyp@1.0.11': + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} @@ -2988,15 +2996,15 @@ packages: resolution: {integrity: sha512-S/IPY8AjWTV2v1TGbEy5lsbQ5w+J2M2j2QDeswxP4FpNh2B49OPkpSqHbbX5GbV0YBlhfXJxtzwBJv/sQACufw==} engines: {node: '>=16'} peerDependencies: - react: '>= 18 || >= 19' - react-dom: '>= 18 || >= 19' + react: 19.2.6 + react-dom: 19.2.6 '@mdxeditor/gurx@1.2.4': resolution: {integrity: sha512-9ZykIFYhKaXaaSPCs1cuI+FvYDegJjbKwmA4ASE/zY+hJY6EYqvoye4esiO85CjhOw9aoD/izD/CU78/egVqmg==} engines: {node: '>=16'} peerDependencies: - react: '>= 18 || >= 19' - react-dom: '>= 18 || >= 19' + react: 19.2.6 + react-dom: 19.2.6 '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} @@ -3024,8 +3032,8 @@ packages: deprecated: This package has been replaced by @base-ui/react peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3039,7 +3047,7 @@ packages: peerDependencies: '@mui/material': ^5.0.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3051,8 +3059,8 @@ packages: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@emotion/react': optional: true @@ -3066,7 +3074,7 @@ packages: engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3077,7 +3085,7 @@ packages: peerDependencies: '@emotion/react': ^11.4.1 '@emotion/styled': ^11.3.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@emotion/react': optional: true @@ -3091,7 +3099,7 @@ packages: '@emotion/react': ^11.5.0 '@emotion/styled': ^11.3.0 '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@emotion/react': optional: true @@ -3113,7 +3121,7 @@ packages: engines: {node: '>=12.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3123,7 +3131,7 @@ packages: engines: {node: '>=14.0.0'} peerDependencies: '@types/react': ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3136,15 +3144,15 @@ packages: '@emotion/styled': ^11.8.1 '@mui/material': ^5.8.6 '@mui/system': ^5.8.0 - date-fns: ^2.25.0 || ^3.2.0 + date-fns: ^3.6.0 date-fns-jalali: ^2.13.0-0 dayjs: ^1.10.7 luxon: ^3.0.2 moment: ^2.29.4 moment-hijri: ^2.1.2 moment-jalaali: ^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0 - react: ^17.0.0 || ^18.0.0 - react-dom: ^17.0.0 || ^18.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@emotion/react': optional: true @@ -3194,35 +3202,30 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-arm64-musl@0.1.100': resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-gnu@0.1.100': resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@napi-rs/canvas-linux-x64-musl@0.1.100': resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@napi-rs/canvas-win32-arm64-msvc@0.1.100': resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} @@ -3471,7 +3474,7 @@ packages: '@nestjs/core': ^10.0.0 || ^11.0.0 reflect-metadata: ^0.1.13 || ^0.2.0 rxjs: ^7.2.0 - typeorm: ^0.3.0 || ^1.0.0-dev + typeorm: 0.3.30 '@nestjs/websockets@11.1.27': resolution: {integrity: sha512-X3OgJt9KgYTvt9D7sNz9SOj3A1daAHy7DZrYhM1pky8Fh+erlKQH5IQ/tKm+GaJKA5M0srBUr1CMqjak/qNxOw==} @@ -3508,28 +3511,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -3626,42 +3625,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -3715,7 +3708,7 @@ packages: peerDependencies: '@types/react': '>=16.8.0' posthog-js: '>=1.257.2' - react: '>=16.8.0' + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3775,8 +3768,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3788,8 +3781,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3801,8 +3794,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3814,8 +3807,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3827,8 +3820,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3840,8 +3833,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3853,8 +3846,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3866,8 +3859,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3879,8 +3872,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3891,7 +3884,7 @@ packages: resolution: {integrity: sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3901,8 +3894,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3913,7 +3906,7 @@ packages: resolution: {integrity: sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3923,8 +3916,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3935,7 +3928,7 @@ packages: resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3945,8 +3938,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3958,8 +3951,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3970,7 +3963,7 @@ packages: resolution: {integrity: sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3980,8 +3973,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -3993,8 +3986,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4006,8 +3999,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4017,13 +4010,13 @@ packages: '@radix-ui/react-icons@1.3.2': resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==} peerDependencies: - react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 '@radix-ui/react-id@1.1.2': resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4033,8 +4026,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4046,8 +4039,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4059,8 +4052,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4072,8 +4065,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4085,8 +4078,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4098,8 +4091,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4111,8 +4104,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4124,8 +4117,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4137,8 +4130,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4150,8 +4143,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4163,8 +4156,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4176,8 +4169,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4189,8 +4182,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4202,8 +4195,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4215,8 +4208,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4228,8 +4221,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4241,8 +4234,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4254,8 +4247,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4266,7 +4259,7 @@ packages: resolution: {integrity: sha512-rCMO3QsIVKv5JTY5CVbo2MvO77SpEqqYc8AvRE7OWqRDOIqAKjsp+DrmnY9uc8NPdxB5E2z47HTYGeE2+NTptg==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4276,8 +4269,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4289,8 +4282,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4302,8 +4295,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4315,8 +4308,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4328,8 +4321,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4341,8 +4334,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4354,8 +4347,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4366,7 +4359,7 @@ packages: resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4375,7 +4368,7 @@ packages: resolution: {integrity: sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4384,7 +4377,7 @@ packages: resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4393,7 +4386,7 @@ packages: resolution: {integrity: sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4402,7 +4395,7 @@ packages: resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4411,7 +4404,7 @@ packages: resolution: {integrity: sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4420,7 +4413,7 @@ packages: resolution: {integrity: sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4429,7 +4422,7 @@ packages: resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4438,7 +4431,7 @@ packages: resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==} peerDependencies: '@types/react': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4448,8 +4441,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -4462,111 +4455,111 @@ packages: '@react-pdf-viewer/attachment@3.12.0': resolution: {integrity: sha512-mhwrYJSIpCvHdERpLUotqhMgSjhtF+BTY1Yb9Fnzpcq3gLZP+Twp5Rynq21tCrVdDizPaVY7SKu400GkgdMfZw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/bookmark@3.12.0': resolution: {integrity: sha512-i7nEit8vIFMAES8RFGwprZ9cXOOZb9ZStPW6E6yuObJEXcvBj/ctsbBJGZxqUZOGklM0JoB7sjHyxAriHfe92A==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/core@3.12.0': resolution: {integrity: sha512-8MsdlQJ4jaw3GT+zpCHS33nwnvzpY0ED6DEahZg9WngG++A5RMhk8LSlxdHelwaFFHFiXBjmOaj2Kpxh50VQRg==} peerDependencies: - pdfjs-dist: ^2.16.105 || ^3.0.279 - react: '>=16.8.0' - react-dom: '>=16.8.0' + pdfjs-dist: ^3.11.174 + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/default-layout@3.12.0': resolution: {integrity: sha512-K2fS4+TJynHxxCBFuIDiFuAw3nqOh4bkBgtVZ/2pGvnFn9lLg46YGLMnTXCQqtyZzzXYh696jmlFViun3is4pA==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/full-screen@3.12.0': resolution: {integrity: sha512-hQouJ26QUaRBCXNMU1aI1zpJn4l4PJRvlHhuE2dZYtLl37ycjl7vBCQYZW1FwnuxMWztZsY47R43DKaZORg0pg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/get-file@3.12.0': resolution: {integrity: sha512-Uhq45n2RWlZ7Ec/BtBJ0WQESRciaYIltveDXHNdWvXgFdOS8XsvB+mnTh/wzm7Cfl9hpPyzfeezifdU9AkQgQg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/open@3.12.0': resolution: {integrity: sha512-vhiDEYsiQLxvZkIKT9VPYHZ1BOnv46x9eCEmRWxO1DJ8fa/GRDTA9ivXmq/ap0dGEJs6t+epleCkCEfllLR/Yw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/page-navigation@3.12.0': resolution: {integrity: sha512-tVEJ48Dd5kajV1nKkrPWijglJRNBiKBTyYDKVexhiRdTHUP1f6QQXiSyDgCUb0IGSZeJzOJb1h7ApKHe8OTtuw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/print@3.12.0': resolution: {integrity: sha512-xJn76CgbU/M2iNaN7wLHTg+sdOekkRMfCakFLwPrE+SR7qD6NUF4vQQKJBSVCCK5bUijzb6cWfKGfo8VA72o4Q==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/properties@3.12.0': resolution: {integrity: sha512-dYTCHtVwFNkpDo7QxL2qk/8zAKndLwdD1FFxBftl6jIlQbtvNdxkFfkv1HcQING9Ic+7DBryOiD7W0ze4IERYg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/rotate@3.12.0': resolution: {integrity: sha512-yaxaMYPChvNOjR8+AxRmj0kvojyJKPq4XHEcIB2lJJgBY1Zra3mliDUP3Nlb4yV8BS9+yBqWn9U9mtnopQD+tw==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/scroll-mode@3.12.0': resolution: {integrity: sha512-okII7Xqhl6cMvl1izdEvlXNJ+vJVq/qdg53hJIDYVgBCWskLk/cpjUg/ZonBxseG9lIDP3w2VO1McT8Gn11OAg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/search@3.12.0': resolution: {integrity: sha512-jAkLpis49fsDDY/HrbUZIOIhzF5vynONQNA4INQKI38r/MjveblrkNv7qbr9j5lQ/WFic5+gD1e+Mtpf1/7DiA==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/selection-mode@3.12.0': resolution: {integrity: sha512-yysWEu2aCtBvzSgbhgI9kT5cq2hf0FU6Z+3B7MMXz14Kxyc3y18wUqxtgbvpFEfWF0bNUUq16JtWRljtxvZ83w==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/theme@3.12.0': resolution: {integrity: sha512-cdBi+wR1VOZ6URCcO9plmAZQu4ZGFcd7HJdBe7VIFiGyrvl9I/Of74ONLycnDImSuONt8D3uNjPBLieeaShVeg==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/thumbnail@3.12.0': resolution: {integrity: sha512-Vc8j3bO6wumWZV4o6pAbktPWKDSC9tQAzOCJ3cof541u4i44C11ccYC4W9aNcsMMUSO3bNwAGWtP8OFthV5akQ==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/toolbar@3.12.0': resolution: {integrity: sha512-qACTU3qXHgtNK8J+T13EWio+0liilj86SJ87BdapqXynhl720OKPlSKOQqskUGqg3oTUJAhrse9XG6SFdHJx+g==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf-viewer/zoom@3.12.0': resolution: {integrity: sha512-V0GUTyPM77+LzhoKX+T3XI10/HfGdqRTbgeP7ID60FCzcwu6kXWqJn5tzabjDKLTlFv8mJmn0aa/ppkIU97nfA==} peerDependencies: - react: '>=16.8.0' - react-dom: '>=16.8.0' + react: 19.2.6 + react-dom: 19.2.6 '@react-pdf/fns@3.1.3': resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} @@ -4589,7 +4582,7 @@ packages: '@react-pdf/reconciler@2.0.0': resolution: {integrity: sha512-7zaPRujpbHSmCpIrZ+b9HSTJHthcVZzX0Wx7RzvQGsGBUbHP4p6s5itXrAIOuQuPvDepoHGNOvf6xUuMVvdoyw==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 '@react-pdf/render@4.5.1': resolution: {integrity: sha512-IW/N4HWJWtioBXCf7n02IR24VJJ8gbdS3jGypf+vW/rSErEx3/URRzh9UK6Ma8Fpog9+T/W6GE2NHJ5AAKHhVA==} @@ -4597,7 +4590,7 @@ packages: '@react-pdf/renderer@4.5.1': resolution: {integrity: sha512-5r1VQrE6FRLXX5wWUxwZzM24E2BJMo6g8AQWuS8WyPs9ugu5yMnb2g8/RpPYka/Z6J+RUEWc32wty2NoUJF42Q==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 '@react-pdf/stylesheet@6.2.1': resolution: {integrity: sha512-2+UEk+7e+z8baaWi2l5kPLWmwtJeOI+T5wW9GGeN3iDH7vd3kbTqOpN1yt9mmfNVZFxQsnDHpznFb5v5UF983A==} @@ -4614,7 +4607,7 @@ packages: '@reduxjs/toolkit@2.12.0': resolution: {integrity: sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==} peerDependencies: - react: ^16.9.0 || ^17.0.0 || ^18 || ^19 + react: 19.2.6 react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 peerDependenciesMeta: react: @@ -4663,79 +4656,66 @@ packages: resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} @@ -4832,7 +4812,7 @@ packages: '@tabler/icons-react@3.44.0': resolution: {integrity: sha512-8+rvzBbVm/1Z3sG3x7GUNAaxIKxwgz8xaMhRs23nrCnMTKRFAhEC+82zAIFeAA0seXdrAGX5HFCkaLpGK2rVHg==} peerDependencies: - react: '>= 16' + react: 19.2.6 '@tabler/icons@3.44.0': resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==} @@ -4879,28 +4859,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.0': resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.0': resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.0': resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.0': resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} @@ -4949,32 +4925,32 @@ packages: resolution: {integrity: sha512-cpZA0+WqKXwrwMfiWZEGGF6QrIWVQFbhBtxqDF5sQsAfrFf47HIE6fiPbQU3wyAUEN2+7UNqLCQe7oG6m3f93w==} peerDependencies: '@tanstack/react-query': ^5.101.0 - react: ^18 || ^19 + react: 19.2.6 '@tanstack/react-query@5.101.0': resolution: {integrity: sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==} peerDependencies: - react: ^18 || ^19 + react: 19.2.6 '@tanstack/react-table@8.20.5': resolution: {integrity: sha512-WEHopKw3znbUZ61s9i0+i9g8drmDo6asTWbrQh8Us63DAk/M0FkmIqERew6P71HI75ksZ2Pxyuf4vvKh9rAkiA==} engines: {node: '>=12'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: 19.2.6 + react-dom: 19.2.6 '@tanstack/react-table@8.21.3': resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} engines: {node: '>=12'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: 19.2.6 + react-dom: 19.2.6 '@tanstack/react-virtual@3.11.2': resolution: {integrity: sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 '@tanstack/table-core@8.20.5': resolution: {integrity: sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==} @@ -4990,8 +4966,8 @@ packages: '@tinymce/tinymce-react@6.3.0': resolution: {integrity: sha512-E++xnn0XzDzpKr40jno2Kj7umfAE6XfINZULEBBeNjTMvbACWzA6CjiR6V8eTDc9yVmdVhIPqVzV4PqD5TZ/4g==} peerDependencies: - react: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0 - react-dom: ^19.0.0 || ^18.0.0 || ^17.0.1 || ^16.7.0 + react: 19.2.6 + react-dom: 19.2.6 tinymce: ^8.0.0 || ^7.0.0 || ^6.0.0 || ^5.5.1 peerDependenciesMeta: tinymce: @@ -5021,20 +4997,7 @@ packages: '@nestjs/typeorm': ^11.0.0 reflect-metadata: ^0.2.0 rxjs: ^7.8.0 - typeorm: ^0.3.0 - - '@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz': - resolution: {integrity: sha512-3kRaAtETvM9wVRSbwd2jyrts9DCcSV9yFUDoEpxJfVQDsIGY2d9K0c+h1LAjfNNaogZ8eeBcroyCrnWjwBmkYA==, tarball: file:local-packages/tria-plc-auditlog-1.1.2.tgz} - version: 1.1.2 - engines: {node: '>=18'} - peerDependencies: - '@nestjs/common': ^10.0.0 || ^11.0.0 - '@nestjs/core': ^10.0.0 || ^11.0.0 - '@nestjs/microservices': ^10.0.0 || ^11.0.0 - '@nestjs/swagger': ^10.0.0 || ^11.0.0 - '@nestjs/typeorm': ^10.0.0 || ^11.0.0 - rxjs: ^7.0.0 - typeorm: ^0.3.0 + typeorm: 0.3.30 '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz': resolution: {integrity: sha512-rfHSXOm/0VUMTj7HrvYysrEAqxItqfPs4Rd35qWJyaAk+snF1wTKFRVz6cRGPoQNj8fhplkVAefnvuKBuHT+xQ==, tarball: file:local-packages/tria-plc-iamapi-common-1.0.0.tgz} @@ -5056,15 +5019,15 @@ packages: class-validator: ^0.14.1 reflect-metadata: ^0.2.0 rxjs: ^7.8.0 - typeorm: ^0.3.0 + typeorm: 0.3.30 '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz': resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz} version: 0.1.1 engines: {node: '>=18'} peerDependencies: - react: ^18.3.1 || ^19.0.0 - react-dom: ^18.3.1 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -5513,61 +5476,51 @@ packages: resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.12.2': resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} cpu: [loong64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-loong64-musl@1.12.2': resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} cpu: [loong64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} cpu: [riscv64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} cpu: [riscv64] os: [linux] - libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} cpu: [s390x] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.12.2': resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} cpu: [x64] os: [linux] - libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.12.2': resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} cpu: [x64] os: [linux] - libc: [musl] '@unrs/resolver-binding-openharmony-arm64@1.12.2': resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} @@ -5597,8 +5550,8 @@ packages: '@vis.gl/react-google-maps@1.8.3': resolution: {integrity: sha512-DW7nEuvOJ299DmdBnvGiUARrgS/+sTEO1iJgG9J8YaErZqLoq7S4TJ22f3EjJvR4dti4L4gft43JEK77nnKXDw==} peerDependencies: - react: '>=16.8.0 || ^19.0 || ^19.0.0-rc' - react-dom: '>=16.8.0 || ^19.0 || ^19.0.0-rc' + react: 19.2.6 + react-dom: 19.2.6 '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} @@ -5693,6 +5646,9 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -5973,6 +5929,9 @@ packages: append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} @@ -5988,6 +5947,11 @@ packages: resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} engines: {node: '>= 10'} + are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + arg@4.1.3: resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==} @@ -6462,6 +6426,10 @@ packages: caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} + canvas@2.11.2: + resolution: {integrity: sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==} + engines: {node: '>=6'} + canvg@3.0.11: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} @@ -6529,6 +6497,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + chrome-trace-event@1.0.4: resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} engines: {node: '>=6.0'} @@ -6647,8 +6619,8 @@ packages: cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - react-dom: ^18 || ^19 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} @@ -6690,6 +6662,10 @@ packages: resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} engines: {node: '>=18'} + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -6765,6 +6741,9 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -7035,9 +7014,6 @@ packages: date-fns@3.6.0: resolution: {integrity: sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==} - date-fns@4.4.0: - resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} - date.js@0.3.3: resolution: {integrity: sha512-HgigOS3h3k6HnW011nAb43c5xx5rBXk8P2v/WIT9Zv4koIaVXiH2BURguI78VVp+5Qc076T7OR378JViCnZtBw==} @@ -7094,6 +7070,10 @@ packages: resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} engines: {node: '>=0.10'} + decompress-response@4.2.1: + resolution: {integrity: sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==} + engines: {node: '>=8'} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -7171,6 +7151,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -7288,7 +7271,7 @@ packages: downshift@7.6.2: resolution: {integrity: sha512-iOv+E1Hyt3JDdL9yYcOgW7nZ7GQ2Uz6YbggwXvKUSleetYhU2nXD482Rz6CzvM4lvI1At34BYruKAL4swRGxaA==} peerDependencies: - react: '>=16.12.0' + react: 19.2.6 dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -7982,8 +7965,8 @@ packages: resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} peerDependencies: '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@emotion/is-prop-valid': optional: true @@ -8019,6 +8002,10 @@ packages: resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} engines: {node: '>=10'} + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + fs-monkey@1.1.0: resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} @@ -8053,6 +8040,11 @@ packages: fuzzysort@3.1.0: resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -8254,6 +8246,9 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + has-value@0.3.1: resolution: {integrity: sha512-gpG936j8/MzaeID5Yif+577c17TxaDmhuyVgSwtnL/q8UUTySg8Mecb+8Cf1otgLoD7DDH75axp86ER7LFsf3Q==} engines: {node: '>=0.10.0'} @@ -8488,8 +8483,8 @@ packages: input-format@0.3.14: resolution: {integrity: sha512-gHMrgrbCgmT4uK5Um5eVDUohuV9lcs95ZUUN9Px2Y0VIfjTzT2wF8Q3Z4fwLFm7c5Z2OXCm53FHoovj6SlOKdg==} peerDependencies: - react: '>=18.1.0' - react-dom: '>=18.1.0' + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: react: optional: true @@ -9295,28 +9290,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -9363,7 +9354,7 @@ packages: little-state-machine@4.8.1: resolution: {integrity: sha512-liPHqaWMQ7rzZryQUDnbZ1Gclnnai3dIyaJ0nAgwZRXMzqbYrydrlCI0NDojRUbE5VYh5vu6hygEUZiH77nQkQ==} peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 + react: 19.2.6 load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} @@ -9554,17 +9545,17 @@ packages: lucide-react@0.446.0: resolution: {integrity: sha512-BU7gy8MfBMqvEdDPH79VhOXSEgyG8TSPOKWaExWGCQVqnGH7wGgDngPbofu+KdtVjPQBWbEmnfMTq90CTiiDRg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + react: 19.2.6 lucide-react@0.513.0: resolution: {integrity: sha512-CJZKq2g8Y8yN4Aq002GahSXbG2JpFv9kXwyiOAMvUBv7pxeOFHUWKB0mO7MiY4ZVFCV4aNjv2BJFq/z3DgKPQg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 lucide-react@1.17.0: resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 luxon@3.7.2: resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} @@ -9579,6 +9570,10 @@ packages: make-cancellable-promise@2.0.0: resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -9602,8 +9597,8 @@ packages: '@tabler/icons-react': '>=2.23.0' clsx: '>=2' dayjs: '>=1.11' - react: '>=18.0' - react-dom: '>=18.0' + react: 19.2.6 + react-dom: 19.2.6 map-cache@0.2.2: resolution: {integrity: sha512-8y/eV9QQZCiyn1SprXSrCmqJN0yNRATe+PO8ztwqrvrbdRLA3eYJF0yaR0YayLWkMbsQSKWS9N2gPcGEc4UsZg==} @@ -9864,6 +9859,10 @@ packages: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} + mimic-response@2.1.0: + resolution: {integrity: sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==} + engines: {node: '>=8'} + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -9886,10 +9885,22 @@ packages: resolution: {integrity: sha512-xPrLjWkTT5E7H7VnzOjF//xBp9I40jYB4aWhb2xTFopXXfw+Wo82DDWngdUju7Doy3Wk7R8C4LAgwhLHHnf0wA==} engines: {node: ^16 || ^18 || >=20} + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -9901,6 +9912,11 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + moment@2.30.1: resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} @@ -9938,9 +9954,9 @@ packages: '@mui/icons-material': ^5.11.16 '@mui/material': ^5.13.3 '@mui/x-date-pickers': ^6.7.0 - date-fns: ^2.30.0 - react: ^18.2.0 - react-dom: ^18.2.0 + date-fns: ^3.6.0 + react: 19.2.6 + react-dom: 19.2.6 multer@2.1.1: resolution: {integrity: sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==} @@ -9960,6 +9976,9 @@ packages: mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10001,8 +10020,8 @@ packages: next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: - react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 next@14.2.35: resolution: {integrity: sha512-KhYd2Hjt/O1/1aZVX3dCwGXM1QmOV4eNM2UTacK5gipDdPN/oHHK/4oVGy7X8GMfPMsUTUEmGlsy0EY1YGAkig==} @@ -10011,8 +10030,8 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 '@playwright/test': ^1.41.2 - react: ^18.2.0 - react-dom: ^18.2.0 + react: 19.2.6 + react-dom: 19.2.6 sass: ^1.3.0 peerDependenciesMeta: '@opentelemetry/api': @@ -10050,6 +10069,15 @@ packages: node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -10072,6 +10100,11 @@ packages: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -10091,6 +10124,10 @@ packages: resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} engines: {node: '>=18'} + npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} @@ -10358,6 +10395,10 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + path2d-polyfill@2.0.1: + resolution: {integrity: sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==} + engines: {node: '>=8'} + path@0.12.7: resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==} @@ -10377,6 +10418,10 @@ packages: pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdfjs-dist@3.11.174: + resolution: {integrity: sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==} + engines: {node: '>=18'} + pdfjs-dist@5.4.296: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} @@ -10676,7 +10721,7 @@ packages: qrcode.react@3.2.0: resolution: {integrity: sha512-YietHHltOHA4+l5na1srdaMx4sVSOjV9tamHs+mwiLWAMr6QVACRUw1Neax5CptFILcNoITctJY0Ipyn5enQ8g==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react: 19.2.6 qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} @@ -10716,8 +10761,8 @@ packages: peerDependencies: '@types/react': '*' '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -10751,60 +10796,55 @@ packages: react-cookie@8.1.2: resolution: {integrity: sha512-S45Z1y1dHyYfLEI4bFKQICuP+SwJqTPWbdc2ZpE6aQSdjSVJAjUDfwTPq8B7BWieIsgyyEWMb/QOrudtwJMjXA==} peerDependencies: - react: '>= 16.3.0' + react: 19.2.6 react-css-nocode-editor@1.0.13: resolution: {integrity: sha512-RV1ZbG8aXORiQ5mDKZbKCHStCJPamp/n5Rb34q22Ug2xzMDW4DjjqO23+Qo/Y+LAoyyrFV/+lI1qq4+/O5nf2A==} peerDependencies: - react: '>=16.8.0 <= 18.1' - react-dom: '>=16.8.0 <= 18.1' + react: 19.2.6 + react-dom: 19.2.6 react-day-picker@8.10.2: resolution: {integrity: sha512-LK68OTbHB3oJNhl9cA0qVizzp3o26w61YSjAFkYi67N86iro32wx86kSNeFU/hq+gI8m1yzWhnomMLfZ041RzQ==} peerDependencies: - date-fns: ^2.28.0 || ^3.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + date-fns: ^3.6.0 + react: 19.2.6 react-day-picker@9.14.0: resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} engines: {node: '>=18'} peerDependencies: - react: '>=16.8.0' - - react-dom@18.3.1: - resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} - peerDependencies: - react: ^18.3.1 + react: 19.2.6 react-dom@19.2.6: resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} peerDependencies: - react: ^19.2.6 + react: 19.2.6 react-dropzone@14.4.1: resolution: {integrity: sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==} engines: {node: '>= 10.13'} peerDependencies: - react: '>= 16.8 || 18.0.0' + react: 19.2.6 react-hook-form@7.77.0: resolution: {integrity: sha512-Sslh9YDYc0GDlWT/lxasnIduNo4v3yyvqRGvmGKUre5AFjDs/HV9/OafHGD8d+sB2yoL4UIL9L8X9i0WlZZebg==} engines: {node: '>=18.0.0'} peerDependencies: - react: ^16.8.0 || ^17 || ^18 || ^19 + react: 19.2.6 react-hot-toast@2.6.0: resolution: {integrity: sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==} engines: {node: '>=10'} peerDependencies: - react: '>=16' - react-dom: '>=16' + react: 19.2.6 + react-dom: 19.2.6 react-i18next@15.7.4: resolution: {integrity: sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==} peerDependencies: i18next: '>= 23.4.0' - react: '>= 16.8.0' + react: 19.2.6 react-dom: '*' react-native: '*' typescript: ^5 @@ -10820,7 +10860,7 @@ packages: resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} peerDependencies: i18next: '>= 26.2.0' - react: '>= 16.8.0' + react: 19.2.6 react-dom: '*' react-native: '*' typescript: ^5 || ^6 @@ -10835,18 +10875,18 @@ packages: react-icons@5.6.0: resolution: {integrity: sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==} peerDependencies: - react: '*' + react: 19.2.6 react-image-crop@11.0.10: resolution: {integrity: sha512-+5FfDXUgYLLqBh1Y/uQhIycpHCbXkI50a+nbfkB1C0xXXUTwkisHDo2QCB1SQJyHCqIuia4FeyReqXuMDKWQTQ==} peerDependencies: - react: '>=16.13.1' + react: 19.2.6 react-intersection-observer@9.16.0: resolution: {integrity: sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==} peerDependencies: - react: ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: react-dom: optional: true @@ -10867,27 +10907,27 @@ packages: resolution: {integrity: sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==} peerDependencies: '@types/react': '>=18' - react: '>=18' + react: 19.2.6 react-number-format@5.4.5: resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} peerDependencies: - react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 react-pdf-html@2.1.5: resolution: {integrity: sha512-KhmiTUcnUNbLVdZbxMcV/+Rp+PyBt+eVJHu425eNwjSsDUtytvcwS/SBdBbbpM0c4+MnnOQBOs3AiColUesM8A==} engines: {node: '>=16.0.0'} peerDependencies: '@react-pdf/renderer': '>=3.4.4' - react: '>=16' + react: 19.2.6 react-pdf@10.4.1: resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -10895,21 +10935,21 @@ packages: react-phone-number-input@3.4.17: resolution: {integrity: sha512-1wcjhBAWHgEBAGLi5/XbeZI7Q3aEHNb2z/dHY6R2Gz70TQvu0ZoOT28NTdwtZf4lyRKXWufnTzVhLPBUD8LfmQ==} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: 19.2.6 + react-dom: 19.2.6 react-quill-new@3.8.3: resolution: {integrity: sha512-c96PYqFTo0pI4R3e79B3rH9LUIce1kIQbmTBu/imJQZk8305ogyLyBqKKjG2UoInDlquXqePSzmBo2aVia3ttw==} peerDependencies: quill-delta: ^5.1.0 - react: ^16 || ^17 || ^18 || ^19 - react-dom: ^16 || ^17 || ^18 || ^19 + react: 19.2.6 + react-dom: 19.2.6 react-redux@9.3.0: resolution: {integrity: sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==} peerDependencies: '@types/react': ^18.2.25 || ^19 - react: ^18.0 || ^19 + react: 19.2.6 redux: ^5.0.0 peerDependenciesMeta: '@types/react': @@ -10926,7 +10966,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -10936,7 +10976,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -10944,35 +10984,35 @@ packages: react-resizable-panels@3.0.6: resolution: {integrity: sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew==} peerDependencies: - react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 react-router-dom@6.30.4: resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} engines: {node: '>=14.0.0'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: 19.2.6 + react-dom: 19.2.6 react-router-dom@7.17.0: resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} engines: {node: '>=20.0.0'} peerDependencies: - react: '>=18' - react-dom: '>=18' + react: 19.2.6 + react-dom: 19.2.6 react-router@6.30.4: resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} engines: {node: '>=14.0.0'} peerDependencies: - react: '>=16.8' + react: 19.2.6 react-router@7.17.0: resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} engines: {node: '>=20.0.0'} peerDependencies: - react: '>=18' - react-dom: '>=18' + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: react-dom: optional: true @@ -10983,8 +11023,8 @@ packages: '@types/prop-types': ^15.7.3 '@types/react': 0.14 - 19 prop-types: ^15.5.8 - react: 0.14 - 19 - react-dom: 0.14 - 19 + react: 19.2.6 + react-dom: 19.2.6 peerDependenciesMeta: '@types/prop-types': optional: true @@ -10994,20 +11034,20 @@ packages: react-simple-animate@3.5.3: resolution: {integrity: sha512-Ob+SmB5J1tXDEZyOe2Hf950K4M8VaWBBmQ3cS2BUnTORqHjhK0iKG8fB+bo47ZL15t8d3g/Y0roiqH05UBjG7A==} peerDependencies: - react-dom: ^16.8.0 || ^17 || ^18 || ^19 + react-dom: 19.2.6 react-smooth@4.0.4: resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -11016,17 +11056,13 @@ packages: resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} engines: {node: '>=10'} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 react-transition-group@4.4.5: resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} peerDependencies: - react: '>=16.6.0' - react-dom: '>=16.6.0' - - react@18.3.1: - resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} - engines: {node: '>=0.10.0'} + react: 19.2.6 + react-dom: 19.2.6 react@19.2.6: resolution: {integrity: sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==} @@ -11076,15 +11112,15 @@ packages: engines: {node: '>=14'} deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: - react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 recharts@3.8.1: resolution: {integrity: sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==} engines: {node: '>=18'} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 + react-dom: 19.2.6 react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 redux-thunk@3.1.0: @@ -11334,9 +11370,6 @@ packages: resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} engines: {node: '>=v12.22.7'} - scheduler@0.23.2: - resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} - scheduler@0.25.0-rc-603e6108-20241029: resolution: {integrity: sha512-pFwF6H1XrSdYYNLfOcGlM28/j8CGLu8IvdrxqhjWULe2bPcKiKW4CV+OWqR/9fT52mywx65l7ysNkjLKBda7eA==} @@ -11464,6 +11497,12 @@ packages: signature_pad@2.3.2: resolution: {integrity: sha512-peYXLxOsIY6MES2TrRLDiNg2T++8gGbpP2yaC+6Ohtxr+a2dzoaqWosWDY9sWqTAAk6E/TyQO+LJw9zQwyu5kA==} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@3.1.1: + resolution: {integrity: sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -11529,8 +11568,8 @@ packages: sonner@2.0.7: resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} peerDependencies: - react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} @@ -11777,8 +11816,8 @@ packages: resolution: {integrity: sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==} engines: {node: '>=10'} peerDependencies: - react: '>= 16.8.0' - react-dom: '>= 16.8.0' + react: 19.2.6 + react-dom: 19.2.6 react-is: '>= 16.8.0' styled-jsx@5.1.1: @@ -11787,7 +11826,7 @@ packages: peerDependencies: '@babel/core': '*' babel-plugin-macros: '*' - react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + react: 19.2.6 peerDependenciesMeta: '@babel/core': optional: true @@ -11908,6 +11947,11 @@ packages: tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + teex@1.0.1: resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} @@ -12105,6 +12149,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@5.1.1: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} @@ -12287,7 +12334,7 @@ packages: hasBin: true peerDependencies: '@faker-js/faker': '>=8.4.1' - typeorm: ~0.3.0 + typeorm: 0.3.30 typeorm@0.3.30: resolution: {integrity: sha512-8T35PzjefOdqc2ZR9mwLQj0pUGp6lQhMbK2EvVMwJVJWlaoHm0v/Q6dThNOZkFchD+0yMg8gwjKM28ePiLSXSQ==} @@ -12467,7 +12514,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -12476,7 +12523,7 @@ packages: resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -12485,13 +12532,13 @@ packages: resolution: {integrity: sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q==} engines: {node: '>=10', npm: '>=6'} peerDependencies: - react: '>=16.13' + react: 19.2.6 use-isomorphic-layout-effect@1.2.1: resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -12500,7 +12547,7 @@ packages: resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -12510,7 +12557,7 @@ packages: engines: {node: '>=10'} peerDependencies: '@types/react': '*' - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 peerDependenciesMeta: '@types/react': optional: true @@ -12518,7 +12565,7 @@ packages: use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react: 19.2.6 use@3.1.1: resolution: {integrity: sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==} @@ -12581,8 +12628,8 @@ packages: vaul@1.1.2: resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} peerDependencies: - react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc - react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react: 19.2.6 + react-dom: 19.2.6 verror@1.10.0: resolution: {integrity: sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==} @@ -12710,6 +12757,9 @@ packages: webdriver-bidi-protocol@0.4.1: resolution: {integrity: sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -12745,6 +12795,9 @@ packages: resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} engines: {node: '>=18'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -12779,6 +12832,9 @@ packages: engines: {node: '>=8'} hasBin: true + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -12890,6 +12946,9 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yaml@1.10.3: resolution: {integrity: sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==} engines: {node: '>= 6'} @@ -12986,7 +13045,7 @@ packages: peerDependencies: '@types/react': '>=18.0.0' immer: '>=9.0.6' - react: '>=18.0.0' + react: 19.2.6 use-sync-external-store: '>=1.2.0' peerDependenciesMeta: '@types/react': @@ -14014,12 +14073,6 @@ snapshots: '@floating-ui/core': 1.7.5 '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/dom': 1.7.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@floating-ui/react-dom@2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/dom': 1.7.6 @@ -14034,14 +14087,6 @@ snapshots: react-dom: 19.2.6(react@19.2.6) tabbable: 6.4.0 - '@floating-ui/react@0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@floating-ui/utils': 0.2.11 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - tabbable: 6.4.0 - '@floating-ui/react@0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14105,9 +14150,9 @@ snapshots: - '@types/react' - supports-color - '@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@18.3.1))': + '@hookform/resolvers@3.10.0(react-hook-form@7.77.0(react@19.2.6))': dependencies: - react-hook-form: 7.77.0(react@18.3.1) + react-hook-form: 7.77.0(react@19.2.6) '@hookform/resolvers@5.4.0(react-hook-form@7.77.0(react@19.2.6))': dependencies: @@ -14895,19 +14940,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@18.3.1))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react': 0.27.19(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@mantine/hooks': 9.3.0(react@18.3.1) - clsx: 2.1.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-number-format: 5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) - type-fest: 5.7.0 - transitivePeerDependencies: - - '@types/react' - '@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14921,6 +14953,19 @@ snapshots: transitivePeerDependencies: - '@types/react' + '@mantine/core@9.3.0(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) + clsx: 2.1.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-number-format: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@19.2.6) + type-fest: 5.7.0 + transitivePeerDependencies: + - '@types/react' + '@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react': 0.27.19(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -14965,10 +15010,6 @@ snapshots: dependencies: react: 19.2.6 - '@mantine/hooks@9.3.0(react@18.3.1)': - dependencies: - react: 18.3.1 - '@mantine/hooks@9.3.0(react@19.2.6)': dependencies: react: 19.2.6 @@ -14990,6 +15031,22 @@ snapshots: dependencies: react: 19.2.6 + '@mapbox/node-pre-gyp@1.0.11': + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.8.2 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + '@marijn/find-cluster-break@1.0.3': {} '@mdxeditor/editor@4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': @@ -15749,15 +15806,6 @@ snapshots: '@radix-ui/primitive@1.1.4': {} - '@radix-ui/react-accessible-icon@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-accessible-icon@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15767,23 +15815,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-accordion@1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-accordion@1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -15801,20 +15832,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-alert-dialog@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-alert-dialog@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -15829,15 +15846,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-arrow@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-arrow@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15847,15 +15855,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-aspect-ratio@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-aspect-ratio@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -15865,19 +15864,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-avatar@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-avatar@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@19.2.6) @@ -15891,22 +15877,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-checkbox@1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-checkbox@1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -15923,22 +15893,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-collapsible@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-collapsible@1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -15955,18 +15909,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-collection@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-collection@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@19.2.6) @@ -15979,31 +15921,12 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-compose-refs@1.1.3(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-compose-refs@1.1.3(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-context-menu@2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-context-menu@2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16017,40 +15940,12 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-context@1.1.4(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-context@1.1.4(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-dialog@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-dialog@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16073,31 +15968,12 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-direction@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-direction@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-dismissable-layer@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16111,21 +15987,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-dropdown-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16141,29 +16002,12 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-focus-guards@1.1.4(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-focus-guards@1.1.4(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-focus-scope@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-focus-scope@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@19.2.6) @@ -16175,20 +16019,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-form@0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-form@0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16203,23 +16033,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-hover-card@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-hover-card@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16241,13 +16054,6 @@ snapshots: dependencies: react: 19.2.6 - '@radix-ui/react-id@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-id@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@19.2.6) @@ -16255,15 +16061,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-label@2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-label@2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16273,32 +16070,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-menu@2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16325,24 +16096,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-menubar@1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-menubar@1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16361,28 +16114,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-navigation-menu@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-navigation-menu@1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16405,26 +16136,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-one-time-password-field@0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-one-time-password-field@0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.2 @@ -16445,22 +16156,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-password-toggle-field@0.1.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-password-toggle-field@0.1.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16477,29 +16172,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popover@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popover@1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16523,24 +16195,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popper@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@floating-ui/react-dom': 2.1.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-rect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/rect': 1.1.2 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-popper@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@floating-ui/react-dom': 2.1.8(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16559,16 +16213,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-portal@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-portal@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16579,15 +16223,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-presence@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-presence@1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@19.2.6) @@ -16597,15 +16232,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-primitive@2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-primitive@2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) @@ -16615,16 +16241,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-progress@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-progress@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@19.2.6) @@ -16635,24 +16251,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-radio-group@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-radio-group@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16671,23 +16269,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-roving-focus@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16705,23 +16286,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-scroll-area@1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-scroll-area@1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.2 @@ -16739,36 +16303,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-select@2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - aria-hidden: 1.2.6 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-remove-scroll: 2.7.2(@types/react@18.3.31)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-select@2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.2 @@ -16799,15 +16333,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-separator@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-separator@1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -16817,25 +16342,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-slider@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/number': 1.1.2 - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-slider@1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/number': 1.1.2 @@ -16855,13 +16361,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-slot@1.2.5(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-slot@1.2.5(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@19.2.6) @@ -16869,21 +16368,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-switch@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-previous': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-switch@1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16899,22 +16383,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tabs@1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tabs@1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16931,26 +16399,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toast@1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toast@1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -16971,21 +16419,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toggle-group@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toggle-group@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -17001,17 +16434,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toggle@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toggle@1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -17023,21 +16445,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toolbar@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-toolbar@1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -17053,26 +16460,6 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tooltip@1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-id': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-tooltip@1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/primitive': 1.1.4 @@ -17093,26 +16480,12 @@ snapshots: '@types/react': 18.3.31 '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-use-callback-ref@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-callback-ref@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-controllable-state@1.2.3(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@19.2.6) @@ -17121,13 +16494,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-effect-event@0.0.3(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@19.2.6) @@ -17135,13 +16501,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-escape-keydown@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@19.2.6) @@ -17149,49 +16508,24 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-is-hydrated@0.1.1(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-layout-effect@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-layout-effect@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-previous@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-previous@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: react: 19.2.6 optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-rect@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/rect': 1.1.2 - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-rect@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/rect': 1.1.2 @@ -17199,13 +16533,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-use-size@1.1.2(@types/react@18.3.31)(react@18.3.1)': - dependencies: - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - react: 18.3.1 - optionalDependencies: - '@types/react': 18.3.31 - '@radix-ui/react-use-size@1.1.2(@types/react@18.3.31)(react@19.2.6)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@19.2.6) @@ -17213,15 +16540,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - '@radix-ui/react-visually-hidden@1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -17746,11 +17064,6 @@ snapshots: '@tanstack/react-query': 5.101.0(react@19.2.6) react: 19.2.6 - '@tanstack/react-query@5.101.0(react@18.3.1)': - dependencies: - '@tanstack/query-core': 5.101.0 - react: 18.3.1 - '@tanstack/react-query@5.101.0(react@19.2.6)': dependencies: '@tanstack/query-core': 5.101.0 @@ -17762,12 +17075,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - '@tanstack/react-table@8.21.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': - dependencies: - '@tanstack/table-core': 8.21.3 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - '@tanstack/react-table@8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@tanstack/table-core': 8.21.3 @@ -17813,50 +17120,7 @@ snapshots: '@tootallnate/quickjs-emscripten@0.23.0': {} - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b)': - dependencies: - '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) - '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - argon2: 0.43.1 - axios: 1.17.0 - change-case: 5.4.4 - class-transformer: 0.5.1 - class-validator: 0.14.4 - dotenv: 16.6.1 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-date: 0.0.6 - exceljs: 4.4.0 - file-type: 21.3.4 - handlebars: 4.7.9 - handlebars-helpers: 0.10.0 - jmespath: 0.16.0 - jose: 5.10.0 - jsonwebtoken: 9.0.3 - libphonenumber-js: 1.13.6 - libreoffice-convert: 1.8.1 - nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) - passport-jwt: 4.0.1 - qrcode: 1.5.4 - reflect-metadata: 0.2.2 - rxjs: 7.8.2 - style-object-to-css-string: 1.1.3 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - uuid: 11.1.1 - xlsx: 0.18.5 - transitivePeerDependencies: - - '@faker-js/faker' - - debug - - supports-color - - '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(2te6mb6vb2upxetsvhkgi54vi4)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17899,19 +17163,7 @@ snapshots: - debug - supports-color - '@tria-plc/auditlog@file:local-packages/tria-plc-auditlog-1.1.2.tgz(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/swagger@11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2))(@nestjs/typeorm@11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))))(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))': - dependencies: - '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) - '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - amqp-connection-manager: 5.0.0(amqplib@0.10.9) - amqplib: 0.10.9 - rxjs: 7.8.2 - typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) - - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(cc085a020c559b355f168432c579a024)': + '@tria-plc/api-common@file:local-packages/tria-plc-api-common-1.6.0.tgz(66zjxtt2zhydyq6w3arhrktl4y)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17922,7 +17174,50 @@ snapshots: '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(2b4e99ab22f78c34e7861d649f4ff29b) + argon2: 0.43.1 + axios: 1.17.0 + change-case: 5.4.4 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 16.6.1 + ethiopian-calendar-date-converter: 2.1.6 + ethiopian-date: 0.0.6 + exceljs: 4.4.0 + file-type: 21.3.4 + handlebars: 4.7.9 + handlebars-helpers: 0.10.0 + jmespath: 0.16.0 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + libreoffice-convert: 1.8.1 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + style-object-to-css-string: 1.1.3 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + xlsx: 0.18.5 + transitivePeerDependencies: + - '@faker-js/faker' + - debug + - supports-color + + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(2duxgcsfjn5usnyutzzh6i5xr4)': + dependencies: + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(66zjxtt2zhydyq6w3arhrktl4y) argon2: 0.43.1 axios: 1.17.0 class-transformer: 0.5.1 @@ -17945,7 +17240,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(d0be280d95adfc1b38e59bdc80c5dec5)': + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.0.0.tgz(776yg74vcz655pvzf2fzttkf2q)': dependencies: '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -17956,7 +17251,7 @@ snapshots: '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) - '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(2te6mb6vb2upxetsvhkgi54vi4) argon2: 0.43.1 axios: 1.17.0 class-transformer: 0.5.1 @@ -17979,7 +17274,7 @@ snapshots: - '@faker-js/faker' - supports-color - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)': + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(elggzzmuouas3d7ccap2lbvoyu)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) @@ -18045,7 +17340,7 @@ snapshots: lodash: 4.18.1 lucide-react: 0.513.0(react@19.2.6) mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + mui-ethiopian-datepicker: 0.3.2(ruap3lc7my4alz6jx6pekjrhou) next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) path: 0.12.7 pdf-lib: 1.17.1 @@ -18091,6 +17386,7 @@ snapshots: - '@types/react-dom' - bufferutil - debug + - encoding - pdfjs-dist - prop-types - react-is @@ -18103,7 +17399,7 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(vhj5jsbx2ogblwtd5crolhrpku)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) @@ -18169,7 +17465,7 @@ snapshots: lodash: 4.18.1 lucide-react: 0.513.0(react@19.2.6) mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) + mui-ethiopian-datepicker: 0.3.2(ruap3lc7my4alz6jx6pekjrhou) next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) path: 0.12.7 pdf-lib: 1.17.1 @@ -18215,6 +17511,7 @@ snapshots: - '@types/react-dom' - bufferutil - debug + - encoding - pdfjs-dist - prop-types - react-is @@ -18923,6 +18220,9 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + abbrev@1.1.1: + optional: true + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -19010,11 +18310,6 @@ snapshots: amqplib: 0.10.9 promise-breaker: 6.0.0 - amqp-connection-manager@5.0.0(amqplib@0.10.9): - dependencies: - amqplib: 0.10.9 - promise-breaker: 6.0.0 - amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -19206,6 +18501,9 @@ snapshots: append-field@1.0.0: {} + aproba@2.1.0: + optional: true + arch@2.2.0: {} archiver-utils@2.1.0: @@ -19244,6 +18542,12 @@ snapshots: tar-stream: 2.2.0 zip-stream: 4.1.1 + are-we-there-yet@2.0.0: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + optional: true + arg@4.1.3: {} arg@5.0.2: {} @@ -19798,6 +19102,16 @@ snapshots: caniuse-lite@1.0.30001797: {} + canvas@2.11.2: + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + nan: 2.28.0 + simple-get: 3.1.1 + transitivePeerDependencies: + - encoding + - supports-color + optional: true + canvg@3.0.11: dependencies: '@babel/runtime': 7.29.7 @@ -19878,6 +19192,9 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@2.0.0: + optional: true + chrome-trace-event@1.0.4: {} chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): @@ -20041,6 +19358,9 @@ snapshots: dependencies: color-name: 2.1.0 + color-support@1.1.3: + optional: true + colorette@2.0.20: {} colors@1.4.0: @@ -20104,6 +19424,9 @@ snapshots: consola@3.4.2: {} + console-control-strings@1.1.0: + optional: true + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -20401,8 +19724,6 @@ snapshots: date-fns@3.6.0: {} - date-fns@4.4.0: {} - date.js@0.3.3: dependencies: debug: 3.1.0 @@ -20449,6 +19770,11 @@ snapshots: decode-uri-component@0.2.2: {} + decompress-response@4.2.1: + dependencies: + mimic-response: 2.1.0 + optional: true + dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -20515,6 +19841,9 @@ snapshots: delayed-stream@1.0.0: {} + delegates@1.0.0: + optional: true + depd@2.0.0: {} dequal@2.0.3: {} @@ -21661,6 +20990,11 @@ snapshots: jsonfile: 6.2.1 universalify: 2.0.1 + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + optional: true + fs-monkey@1.1.0: {} fs.realpath@1.0.0: {} @@ -21693,6 +21027,19 @@ snapshots: fuzzysort@3.1.0: {} + gauge@3.0.2: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + optional: true + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -21935,6 +21282,9 @@ snapshots: dependencies: has-symbols: 1.1.0 + has-unicode@2.0.1: + optional: true + has-value@0.3.1: dependencies: get-value: 2.0.6 @@ -22925,7 +22275,7 @@ snapshots: jsbn@0.1.1: {} - jsdom@25.0.1: + jsdom@25.0.1(canvas@2.11.2): dependencies: cssstyle: 4.6.0 data-urls: 5.0.0 @@ -22948,6 +22298,8 @@ snapshots: whatwg-url: 14.2.0 ws: 8.21.0 xml-name-validator: 5.0.0 + optionalDependencies: + canvas: 2.11.2 transitivePeerDependencies: - bufferutil - supports-color @@ -23384,18 +22736,14 @@ snapshots: lru-cache@7.18.3: {} - lucide-react@0.446.0(react@18.3.1): + lucide-react@0.446.0(react@19.2.6): dependencies: - react: 18.3.1 + react: 19.2.6 lucide-react@0.513.0(react@19.2.6): dependencies: react: 19.2.6 - lucide-react@1.17.0(react@18.3.1): - dependencies: - react: 18.3.1 - lucide-react@1.17.0(react@19.2.6): dependencies: react: 19.2.6 @@ -23412,6 +22760,11 @@ snapshots: make-cancellable-promise@2.0.0: {} + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + optional: true + make-dir@4.0.0: dependencies: semver: 7.8.2 @@ -23935,6 +23288,9 @@ snapshots: mimic-function@5.0.1: {} + mimic-response@2.1.0: + optional: true + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -23970,8 +23326,22 @@ snapshots: xml: 1.0.1 xml2js: 0.5.0 + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + optional: true + + minipass@5.0.0: + optional: true + minipass@7.1.3: {} + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + optional: true + mitt@3.0.1: {} mixin-deep@1.3.2: @@ -23983,6 +23353,9 @@ snapshots: dependencies: minimist: 1.2.8 + mkdirp@1.0.4: + optional: true + moment@2.30.1: {} motion-dom@12.40.0: @@ -24048,7 +23421,7 @@ snapshots: transitivePeerDependencies: - '@types/node' - mui-ethiopian-datepicker@0.3.2(4b3af212eafdf0059f009b005d7e343d): + mui-ethiopian-datepicker@0.3.2(ruap3lc7my4alz6jx6pekjrhou): dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) @@ -24078,6 +23451,9 @@ snapshots: object-assign: 4.1.1 thenify-all: 1.6.0 + nan@2.28.0: + optional: true + nanoid@3.3.12: {} nanomatch@1.2.13: @@ -24121,7 +23497,7 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - next@14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + next@14.2.35(@playwright/test@1.61.1)(babel-plugin-macros@3.1.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@next/env': 14.2.35 '@swc/helpers': 0.5.5 @@ -24129,9 +23505,9 @@ snapshots: caniuse-lite: 1.0.30001797 graceful-fs: 4.2.11 postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + styled-jsx: 5.1.1(babel-plugin-macros@3.1.0)(react@19.2.6) optionalDependencies: '@next/swc-darwin-arm64': 14.2.33 '@next/swc-darwin-x64': 14.2.33 @@ -24173,6 +23549,11 @@ snapshots: node-fetch-native@1.6.7: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + optional: true + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -24192,6 +23573,11 @@ snapshots: node-releases@2.0.47: {} + nopt@5.0.0: + dependencies: + abbrev: 1.1.1 + optional: true + normalize-path@3.0.0: {} normalize-svg-path@1.1.0: @@ -24211,6 +23597,14 @@ snapshots: path-key: 4.0.0 unicorn-magic: 0.3.0 + npmlog@5.0.1: + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + optional: true + nth-check@2.1.1: dependencies: boolbase: 1.0.0 @@ -24506,6 +23900,9 @@ snapshots: path-type@4.0.0: {} + path2d-polyfill@2.0.1: + optional: true + path@0.12.7: dependencies: process: 0.11.10 @@ -24526,6 +23923,14 @@ snapshots: pako: 1.0.11 tslib: 1.14.1 + pdfjs-dist@3.11.174: + optionalDependencies: + canvas: 2.11.2 + path2d-polyfill: 2.0.1 + transitivePeerDependencies: + - encoding + - supports-color + pdfjs-dist@5.4.296: optionalDependencies: '@napi-rs/canvas': 0.1.100 @@ -24808,9 +24213,9 @@ snapshots: pure-rand@6.1.0: {} - qrcode.react@3.2.0(react@18.3.1): + qrcode.react@3.2.0(react@19.2.6): dependencies: - react: 18.3.1 + react: 19.2.6 qrcode@1.5.4: dependencies: @@ -24852,69 +24257,6 @@ snapshots: parchment: 3.0.0 quill-delta: 5.1.0 - radix-ui@1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@radix-ui/primitive': 1.1.4 - '@radix-ui/react-accessible-icon': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-arrow': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-aspect-ratio': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-collection': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-compose-refs': 1.1.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-direction': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-dismissable-layer': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-focus-guards': 1.1.4(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-focus-scope': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-form': 0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-menubar': 1.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-one-time-password-field': 0.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-password-toggle-field': 0.1.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-popper': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-portal': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-presence': 1.1.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-primitive': 2.1.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-roving-focus': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slider': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle': 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toggle-group': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-toolbar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@radix-ui/react-use-callback-ref': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-effect-event': 0.0.3(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-escape-keydown': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-is-hydrated': 0.1.1(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-layout-effect': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-use-size': 1.1.2(@types/react@18.3.31)(react@18.3.1) - '@radix-ui/react-visually-hidden': 1.2.5(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - '@types/react-dom': 18.3.7(@types/react@18.3.31) - radix-ui@1.5.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@radix-ui/primitive': 1.1.4 @@ -25043,28 +24385,14 @@ snapshots: date-fns: 3.6.0 react: 19.2.6 - react-day-picker@9.14.0(react@18.3.1): - dependencies: - '@date-fns/tz': 1.5.0 - '@tabby_ai/hijri-converter': 1.0.5 - date-fns: 4.4.0 - date-fns-jalali: 4.1.0-0 - react: 18.3.1 - react-day-picker@9.14.0(react@19.2.6): dependencies: '@date-fns/tz': 1.5.0 '@tabby_ai/hijri-converter': 1.0.5 - date-fns: 4.4.0 + date-fns: 3.6.0 date-fns-jalali: 4.1.0-0 react: 19.2.6 - react-dom@18.3.1(react@18.3.1): - dependencies: - loose-envify: 1.4.0 - react: 18.3.1 - scheduler: 0.23.2 - react-dom@19.2.6(react@19.2.6): dependencies: react: 19.2.6 @@ -25077,10 +24405,6 @@ snapshots: prop-types: 15.8.1 react: 19.2.6 - react-hook-form@7.77.0(react@18.3.1): - dependencies: - react: 18.3.1 - react-hook-form@7.77.0(react@19.2.6): dependencies: react: 19.2.6 @@ -25153,11 +24477,6 @@ snapshots: transitivePeerDependencies: - supports-color - react-number-format@5.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-number-format@5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: react: 19.2.6 @@ -25177,13 +24496,16 @@ snapshots: make-cancellable-promise: 2.0.0 make-event-props: 2.0.0 merge-refs: 2.0.0(@types/react@18.3.31) - pdfjs-dist: 5.4.296 + pdfjs-dist: 3.11.174 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) tiny-invariant: 1.3.3 warning: 4.0.3 optionalDependencies: '@types/react': 18.3.31 + transitivePeerDependencies: + - encoding + - supports-color react-phone-number-input@3.4.17(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: @@ -25214,14 +24536,6 @@ snapshots: react-refresh@0.17.0: {} - react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@18.3.1): - dependencies: - react: 18.3.1 - react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.31 - react-remove-scroll-bar@2.3.8(@types/react@18.3.31)(react@19.2.6): dependencies: react: 19.2.6 @@ -25230,17 +24544,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - react-remove-scroll@2.7.2(@types/react@18.3.31)(react@18.3.1): - dependencies: - react: 18.3.1 - react-remove-scroll-bar: 2.3.8(@types/react@18.3.31)(react@18.3.1) - react-style-singleton: 2.2.3(@types/react@18.3.31)(react@18.3.1) - tslib: 2.8.1 - use-callback-ref: 1.3.3(@types/react@18.3.31)(react@18.3.1) - use-sidecar: 1.1.3(@types/react@18.3.31)(react@18.3.1) - optionalDependencies: - '@types/react': 18.3.31 - react-remove-scroll@2.7.2(@types/react@18.3.31)(react@19.2.6): dependencies: react: 19.2.6 @@ -25300,21 +24603,13 @@ snapshots: dependencies: react-dom: 19.2.6(react@19.2.6) - react-smooth@4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-smooth@4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: fast-equals: 5.4.0 prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-transition-group: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - - react-style-singleton@2.2.3(@types/react@18.3.31)(react@18.3.1): - dependencies: - get-nonce: 1.0.1 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.31 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) react-style-singleton@2.2.3(@types/react@18.3.31)(react@19.2.6): dependencies: @@ -25333,15 +24628,6 @@ snapshots: transitivePeerDependencies: - '@types/react' - react-transition-group@4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.29.7 - dom-helpers: 5.2.1 - loose-envify: 1.4.0 - prop-types: 15.8.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - react-transition-group@4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/runtime': 7.29.7 @@ -25351,10 +24637,6 @@ snapshots: react: 19.2.6 react-dom: 19.2.6(react@19.2.6) - react@18.3.1: - dependencies: - loose-envify: 1.4.0 - react@19.2.6: {} read-cache@1.0.0: @@ -25417,15 +24699,15 @@ snapshots: dependencies: decimal.js-light: 2.5.1 - recharts@2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + recharts@2.15.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: clsx: 2.1.1 eventemitter3: 4.0.7 lodash: 4.18.1 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) react-is: 18.3.1 - react-smooth: 4.0.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + react-smooth: 4.0.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) recharts-scale: 0.4.5 tiny-invariant: 1.3.3 victory-vendor: 36.9.2 @@ -25723,10 +25005,6 @@ snapshots: dependencies: xmlchars: 2.2.0 - scheduler@0.23.2: - dependencies: - loose-envify: 1.4.0 - scheduler@0.25.0-rc-603e6108-20241029: {} scheduler@0.27.0: {} @@ -25938,6 +25216,16 @@ snapshots: signature_pad@2.3.2: {} + simple-concat@1.0.1: + optional: true + + simple-get@3.1.1: + dependencies: + decompress-response: 4.2.1 + once: 1.4.0 + simple-concat: 1.0.1 + optional: true + sisteransi@1.0.5: {} slash@3.0.0: {} @@ -26341,10 +25629,10 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): + styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@19.2.6): dependencies: client-only: 0.0.1 - react: 18.3.1 + react: 19.2.6 optionalDependencies: babel-plugin-macros: 3.1.0 @@ -26499,6 +25787,16 @@ snapshots: - bare-buffer - react-native-b4a + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + optional: true + teex@1.0.1: dependencies: streamx: 2.27.0 @@ -26656,6 +25954,9 @@ snapshots: dependencies: tldts: 7.4.2 + tr46@0.0.3: + optional: true + tr46@5.1.1: dependencies: punycode: 2.3.1 @@ -27083,13 +26384,6 @@ snapshots: punycode: 1.4.1 qs: 6.15.2 - use-callback-ref@1.3.3(@types/react@18.3.31)(react@18.3.1): - dependencies: - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.31 - use-callback-ref@1.3.3(@types/react@18.3.31)(react@19.2.6): dependencies: react: 19.2.6 @@ -27122,14 +26416,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - use-sidecar@1.1.3(@types/react@18.3.31)(react@18.3.1): - dependencies: - detect-node-es: 1.1.0 - react: 18.3.1 - tslib: 2.8.1 - optionalDependencies: - '@types/react': 18.3.31 - use-sidecar@1.1.3(@types/react@18.3.31)(react@19.2.6): dependencies: detect-node-es: 1.1.0 @@ -27138,11 +26424,6 @@ snapshots: optionalDependencies: '@types/react': 18.3.31 - use-sync-external-store@1.6.0(react@18.3.1): - dependencies: - react: 18.3.1 - optional: true - use-sync-external-store@1.6.0(react@19.2.6): dependencies: react: 19.2.6 @@ -27319,7 +26600,7 @@ snapshots: lightningcss: 1.32.0 terser: 5.48.0 - vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0): + vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)) @@ -27343,7 +26624,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1 - jsdom: 25.0.1 + jsdom: 25.0.1(canvas@2.11.2) transitivePeerDependencies: - less - lightningcss @@ -27355,7 +26636,7 @@ snapshots: - supports-color - terser - vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0): + vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1(canvas@2.11.2))(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 '@vitest/mocker': 2.1.9(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) @@ -27379,7 +26660,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.1 - jsdom: 25.0.1 + jsdom: 25.0.1(canvas@2.11.2) transitivePeerDependencies: - less - lightningcss @@ -27443,6 +26724,9 @@ snapshots: webdriver-bidi-protocol@0.4.1: {} + webidl-conversions@3.0.1: + optional: true + webidl-conversions@7.0.0: {} webpack-node-externals@3.0.0: {} @@ -27501,6 +26785,12 @@ snapshots: tr46: 5.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + optional: true + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 @@ -27557,6 +26847,11 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + optional: true + wmf@1.0.2: {} word-wrap@1.2.5: {} @@ -27643,6 +26938,9 @@ snapshots: yallist@3.1.1: {} + yallist@4.0.0: + optional: true + yaml@1.10.3: {} yaml@2.9.0: {} @@ -27734,13 +27032,6 @@ snapshots: zod@4.4.3: {} - zustand@5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)): - optionalDependencies: - '@types/react': 18.3.31 - immer: 11.1.8 - react: 18.3.1 - use-sync-external-store: 1.6.0(react@18.3.1) - zustand@5.0.14(@types/react@18.3.31)(immer@11.1.8)(react@19.2.6)(use-sync-external-store@1.6.0(react@19.2.6)): optionalDependencies: '@types/react': 18.3.31 From 5da36eb128dd3251ed1dd03b68d2554675adb239 Mon Sep 17 00:00:00 2001 From: marshalyordanos Date: Wed, 12 Aug 2026 09:36:50 +0300 Subject: [PATCH 06/11] feat: add wagon usage computation and maintenance logging features - Implemented utility to calculate wagon usage metrics for train schedules. - Created for sending wagons to maintenance with optional notes. - Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes. - Developed component for merging train schedules with detailed previews and reasons for merging. - Introduced component for selecting wagons with search functionality and selection limits. - Created for displaying and filtering audit logs, including detailed views of individual log entries. - Added for handling API interactions related to audit logs, including fetching logs and entity types. --- apps/edr-freight-api/data/audit-endpoints.js | 639 +++++++++++++++++ .../src/common/booking-guards.ts | 4 + .../3390000000000-CreateAuditLogs.ts | 81 +++ ...00000000-TransferRequestPreferredWagons.ts | 29 + .../3410000000000-ScheduleVoyageNumber.ts | 28 + .../src/modules/audit/audit-actor.ts | 84 +++ .../modules/audit/audit-endpoint-matcher.ts | 140 ++++ .../src/modules/audit/audit-endpoints.ts | 641 ++++++++++++++++++ .../src/modules/audit/audit-log.repository.ts | 79 +++ .../src/modules/audit/audit.controller.ts | 46 ++ .../src/modules/audit/audit.interceptor.ts | 189 ++++++ .../src/modules/audit/audit.module.ts | 34 + .../src/modules/audit/audit.sanitizer.ts | 195 ++++++ .../src/modules/audit/audit.service.ts | 71 ++ .../modules/audit/dto/audit-log-query.dto.ts | 64 ++ .../audit/entities/audit-log.entity.ts | 132 ++++ .../modules/bookings/bookings.controller.ts | 12 +- .../src/modules/bookings/bookings.service.ts | 35 +- .../booking-clearance.service.spec.ts | 2 +- .../contracts/booking-clearance.service.ts | 26 +- .../contracts/contract-clearance.service.ts | 26 +- .../modules/contracts/contracts.controller.ts | 16 +- .../contracts/phased-clearance.util.ts | 128 ++++ .../entities/train-schedule.entity.ts | 8 + .../train-scheduling.controller.ts | 44 ++ .../dto/merge-schedule-train.dto.ts | 23 + .../dto/update-schedule-train-number.dto.ts | 40 ++ .../train-scheduling/schedule-merge.spec.ts | 399 +++++++++++ .../schedule-train-number.spec.ts | 114 ++++ .../services/train-scheduling.service.ts | 468 ++++++++++++- .../utils/schedule-wagon-usage.util.spec.ts | 96 +++ .../utils/schedule-wagon-usage.util.ts | 58 ++ .../src/modules/trains/dto/build-train.dto.ts | 4 +- .../dto/send-wagon-to-maintenance.dto.ts | 15 + .../trains/train-builder.controller.ts | 9 +- .../trains/train-builder.maintenance.spec.ts | 83 +++ .../modules/trains/train-builder.service.ts | 70 +- .../wagons/dto/create-transfer-request.dto.ts | 26 +- .../entities/wagon-transfer-request.entity.ts | 8 + .../wagon-transfer-requests.service.spec.ts | 91 +++ .../wagons/wagon-transfer-requests.service.ts | 124 +++- .../src/seed/edr-freight.seed.ts | 19 + .../src/seed/freight-permissions.registry.ts | 15 + .../src/seed/freight-positions.seeder.ts | 87 ++- apps/edr-freight-web/backoffice/src/App.tsx | 2 + .../detail/BookingSchedulingWindowCard.tsx | 34 +- .../contracts/ExportClearanceStepper.tsx | 19 +- .../contracts/GlClearanceUploadModal.tsx | 41 +- .../contracts/PhasedClearanceActionPanel.tsx | 117 ++-- .../components/layout/sidebar-sections.tsx | 7 + .../trainBuilder/BuildTrainModal.tsx | 6 +- .../trainScheduling/EligibleBookingsPanel.tsx | 17 +- .../MergeScheduleTrainModal.tsx | 344 ++++++++++ .../trainScheduling/ScheduleBookingsStep.tsx | 36 +- .../src/components/wagons/WagonPicker.tsx | 209 ++++++ .../wagons/WagonYardWorkspaceModal.tsx | 107 ++- .../backoffice/src/constants/URLS.ts | 7 + .../backoffice/src/lib/permissions.ts | 8 + .../backoffice/src/pages/AuditLogsPage.tsx | 340 ++++++++++ .../bookings/BookingRequestDetailPage.tsx | 6 +- .../trainBuilder/TrainBuilderDetailPage.tsx | 42 +- .../TrainScheduleV2DetailPage.tsx | 43 +- .../TrainScheduleV2ListPage.tsx | 133 +++- .../src/pages/wagons/TransferFulfillModal.tsx | 108 ++- .../pages/wagons/TransferRequestModals.tsx | 75 +- .../src/pages/wagons/WagonTransfersPage.tsx | 17 + .../src/pages/wagons/wagon-transfer-ui.tsx | 59 ++ .../backoffice/src/services/api.ts | 40 +- .../src/services/auditLogs.service.ts | 97 +++ .../src/services/bookings.service.ts | 8 +- .../src/services/contracts.service.ts | 8 +- .../src/services/trainBuilder.service.ts | 7 +- .../src/services/trainScheduling.service.ts | 24 + .../backoffice/src/services/wagon.service.ts | 6 + .../backoffice/src/types/booking.ts | 6 + .../backoffice/src/types/trainScheduling.ts | 54 ++ .../src/freight/clearance-files.catalog.ts | 42 ++ 77 files changed, 6275 insertions(+), 296 deletions(-) create mode 100644 apps/edr-freight-api/data/audit-endpoints.js create mode 100644 apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts create mode 100644 apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts create mode 100644 apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-actor.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-endpoints.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit-log.repository.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.controller.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.interceptor.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.module.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts create mode 100644 apps/edr-freight-api/src/modules/audit/audit.service.ts create mode 100644 apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts create mode 100644 apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts create mode 100644 apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts create mode 100644 apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/wagons/WagonPicker.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/AuditLogsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/auditLogs.service.ts diff --git a/apps/edr-freight-api/data/audit-endpoints.js b/apps/edr-freight-api/data/audit-endpoints.js new file mode 100644 index 000000000..2c11ea95b --- /dev/null +++ b/apps/edr-freight-api/data/audit-endpoints.js @@ -0,0 +1,639 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 488 endpoints. + */ +const AUDIT_ENDPOINTS = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"], + "DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"], + "POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"], + "POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"], + "DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + // NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; + +module.exports = AUDIT_ENDPOINTS; diff --git a/apps/edr-freight-api/src/common/booking-guards.ts b/apps/edr-freight-api/src/common/booking-guards.ts index d1b5364c3..36ed7ae74 100644 --- a/apps/edr-freight-api/src/common/booking-guards.ts +++ b/apps/edr-freight-api/src/common/booking-guards.ts @@ -90,6 +90,10 @@ export const TrainSchedulingReschedule = () => export const TrainSchedulingRulesManage = () => BookingStaff(FREIGHT_PERMS.trainScheduling.rulesManage); +/** Edit a schedule's operational run numbers (train + voyage) before dispatch. */ +export const TrainSchedulingEditTrainNumber = () => + BookingStaff(FREIGHT_PERMS.trainScheduling.editTrainNumber); + /** * Fleet guards take an optional granular per-resource key (locomotives:create, * wagons:delete, …). The legacy coarse fleet:view / fleet:manage keys remain diff --git a/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts new file mode 100644 index 000000000..b5b865ad7 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3390000000000-CreateAuditLogs.ts @@ -0,0 +1,81 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Backoffice audit trail for every state-changing freight endpoint. + * + * No `updated_at` / `deleted_at` columns, unlike every other table here: audit + * rows are insert-only evidence. A soft-delete column would let an actor erase + * their own trail and TypeORM would then hide those rows from default queries + * silently — see the entity comment. + * + * `user_id` intentionally carries NO foreign key to the `iam` schema. + * Cross-schema FKs are forbidden platform-wide, and one here would let user + * deletion cascade away the record of what that user did. + * + * DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race + * `migrationsRun` against each other on the shared dev database. + */ +export class CreateAuditLogs3390000000000 implements MigrationInterface { + name = "CreateAuditLogs3390000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.audit_logs ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + title varchar(255) NOT NULL, + method varchar(10) NOT NULL, + url text NOT NULL, + route_path varchar(255), + type varchar(50) NOT NULL, + is_success boolean NOT NULL, + user_id uuid, + resource_id varchar(64), + request jsonb, + status_code smallint, + error_message text, + user_name varchar(150), + user_role varchar(100), + ip_address inet, + user_agent text, + request_id varchar(64), + duration_ms integer, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT "PK_audit_logs" PRIMARY KEY (id) + ) + `); + + // Every audit query is time-bounded, so created_at leads each index. + // DESC matches the "newest first" read path the controller exposes. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at" + ON freight.audit_logs (created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at" + ON freight.audit_logs (user_id, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at" + ON freight.audit_logs (type, created_at DESC) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id" + ON freight.audit_logs (type, resource_id) + `); + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at" + ON freight.audit_logs (route_path, created_at DESC) + `); + // Failures are a small slice of the table but carry the security signal + // (403s especially), so they get their own partial index. + await queryRunner.query(` + CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures" + ON freight.audit_logs (created_at DESC) + WHERE is_success = false + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`); + } +} diff --git a/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts new file mode 100644 index 000000000..b86327147 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3400000000000-TransferRequestPreferredWagons.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Wagons the requester specifically asked for. A transfer request stays + * count-driven (`quantity` is what must be delivered), but a requester who + * picked wagons off the yard desk now records WHICH ones — OCC sees the numbers + * on the queue and the fulfil picker pre-selects them. + * + * Stored as a uuid[] column rather than a join table: the list is read and + * written whole, never queried by wagon, and a preference carries no lifecycle + * of its own (no FK — a purged wagon simply drops out of the display). + */ +export class TransferRequestPreferredWagons3400000000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[] + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.wagon_transfer_requests + DROP COLUMN IF EXISTS preferred_wagon_ids + `); + } +} diff --git a/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts new file mode 100644 index 000000000..dd62276d8 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3410000000000-ScheduleVoyageNumber.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Voyage number for one departure. + * + * `train_number` already exists on a schedule (the run number), but operations + * also quote a VOYAGE number — the sailing/run identifier yards and customs use + * for a specific departure. It belongs on the schedule, not the built train: one + * train serves many departures and each carries its own voyage. + * + * Nullable and un-indexed: it is display/reference data typed by staff, not a + * lookup key, and older schedules simply have none. + */ +export class ScheduleVoyageNumber3410000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + ADD COLUMN IF NOT EXISTS voyage_number varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.train_schedules + DROP COLUMN IF EXISTS voyage_number + `); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-actor.ts b/apps/edr-freight-api/src/modules/audit/audit-actor.ts new file mode 100644 index 000000000..f89a7ee26 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-actor.ts @@ -0,0 +1,84 @@ +/** + * Who acted, and does this API audit them? + * + * The staff/customer split reuses the exact discriminator the permission guards + * already apply (`freight-permission.guard.ts`): `userType === 'employee'` is + * backoffice, `individual` / `external_organization` are customers. Restating + * the rule instead of importing it would let the two drift apart silently. + */ + +const EMPLOYEE_USER_TYPE = 'employee'; +const SUPER_ADMIN_ROLE = 'super_admin'; + +/** The subset of the JWT payload this module reads. */ +export interface AuditActorSource { + id?: string; + sub?: string; + userType?: string; + username?: string; + name?: string | { en?: string; am?: string }; + firstName?: string; + lastName?: string; + email?: string; + roles?: { key?: string; name?: string }[]; + employee?: unknown; +} + +export interface AuditActor { + userId: string | null; + userName: string | null; + userRole: string | null; +} + +function isSuperAdmin(user: AuditActorSource): boolean { + return Boolean(user.roles?.some((role) => role.key === SUPER_ADMIN_ROLE)); +} + +/** + * Is this caller a backoffice user whose actions are audited? + * + * Only employees qualify. Customers are excluded by request, and unauthenticated + * callers are excluded too — which means failed logins, OTP sends and password + * resets produce no audit rows. That was a deliberate call: those endpoints are + * not backoffice actions. Note the trade-off, since failed-auth attempts are + * often what an incident review looks for first. + */ +export function isAuditableActor(user: AuditActorSource | null | undefined): boolean { + if (!user) return false; + // Super admins may not carry an `employee` userType on every token, but are + // unambiguously staff — the permission guards treat them the same way. + return user.userType === EMPLOYEE_USER_TYPE || isSuperAdmin(user); +} + +/** Best-effort display name, tolerating the several shapes tokens use. */ +function resolveUserName(user: AuditActorSource): string | null { + if (typeof user.name === 'string' && user.name.trim()) return user.name.trim(); + + if (user.name && typeof user.name === 'object') { + const localized = user.name.en ?? user.name.am; + if (localized?.trim()) return localized.trim(); + } + + const composed = [user.firstName, user.lastName].filter(Boolean).join(' ').trim(); + if (composed) return composed; + + return user.username?.trim() || user.email?.trim() || null; +} + +/** + * Snapshot the actor at the moment of the action. + * + * Name and role are copied, never referenced: resolving them from IAM at read + * time would rewrite history whenever someone is renamed, changes role or is + * deleted. An audit row from last year must still say who acted and with what + * authority *then*. + */ +export function resolveAuditActor(user: AuditActorSource): AuditActor { + const roleKey = user.roles?.[0]?.key ?? user.roles?.[0]?.name ?? null; + + return { + userId: user.id ?? user.sub ?? null, + userName: resolveUserName(user), + userRole: roleKey, + }; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts new file mode 100644 index 000000000..75ae51fa6 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoint-matcher.ts @@ -0,0 +1,140 @@ +import { AUDIT_ENDPOINTS, type AuditEndpointMeta } from './audit-endpoints'; + +/** What a matched request resolved to. */ +export interface MatchedAuditEndpoint { + /** Human-readable action, e.g. "Approve contract". */ + title: string; + /** Primary entity, e.g. "Contract". */ + type: string; + /** The route template, e.g. `/api/contracts/:id/cancel`. */ + routePath: string; + /** First path parameter of the template, when the route has one. */ + resourceId: string | null; +} + +interface CompiledRoute { + regex: RegExp; + /** Param names in capture-group order, e.g. ['id', 'stepId']. */ + paramNames: string[]; + routePath: string; + meta: AuditEndpointMeta; + /** Literal (non-parameter) segment count — used to rank specificity. */ + staticSegments: number; +} + +const ESCAPE_REGEX = /[.*+?^${}()|[\]\\]/g; + +/** + * Two keys in AUDIT_ENDPOINTS point at the same path: one route is declared by + * two different controllers, so the generator suffixed the second with + * ` [modules/...controller.ts]` to keep both entries. Only the path itself is + * matchable, so the suffix is stripped here. + */ +function stripSourceSuffix(key: string): string { + const bracket = key.indexOf(' ['); + return bracket === -1 ? key : key.slice(0, bracket); +} + +/** + * Compile one `"/api/contracts/:id/cancel"` template into an anchored regex. + * + * A parameter matches a single path segment only (`[^/]+`), so + * `/api/contracts/:id` cannot swallow `/api/contracts/:id/cancel`. + */ +function compileTemplate(path: string): { regex: RegExp; paramNames: string[] } { + const paramNames: string[] = []; + const pattern = path + .split('/') + .map((segment) => { + if (!segment.startsWith(':')) { + return segment.replace(ESCAPE_REGEX, '\\$&'); + } + paramNames.push(segment.slice(1)); + return '([^/]+)'; + }) + .join('/'); + + return { regex: new RegExp(`^${pattern}$`), paramNames }; +} + +/** + * Method-bucketed lookup table for the audited routes. + * + * A direct `AUDIT_ENDPOINTS[url]` lookup cannot work: the keys are templates + * with `:params` while a live request carries real ids and a query string, so + * every parameterized route — most of the 488 — would miss. Templates are + * compiled to regexes once at module load and matched per request. + * + * Within a method, routes are ordered by literal-segment count descending, so + * a specific route always wins over a parameterized one that could also match + * (`/api/routes/:id/permanent` before `/api/routes/:id`). + */ +class AuditEndpointMatcher { + private readonly byMethod = new Map(); + + constructor() { + for (const [key, meta] of Object.entries(AUDIT_ENDPOINTS)) { + const [method, rawPath] = stripSourceSuffix(key).split(' '); + if (!method || !rawPath) continue; + + const { regex, paramNames } = compileTemplate(rawPath); + const bucket = this.byMethod.get(method) ?? []; + bucket.push({ + regex, + paramNames, + routePath: rawPath, + meta, + staticSegments: rawPath + .split('/') + .filter((s) => s && !s.startsWith(':')).length, + }); + this.byMethod.set(method, bucket); + } + + for (const bucket of this.byMethod.values()) { + bucket.sort((a, b) => b.staticSegments - a.staticSegments); + } + } + + /** + * Resolve a live request to its audit metadata, or null when the route is + * not audited (every GET, and anything absent from AUDIT_ENDPOINTS). + * + * `url` may include a query string; it is ignored for matching. + */ + match(method: string, url: string): MatchedAuditEndpoint | null { + const bucket = this.byMethod.get(method.toUpperCase()); + if (!bucket) return null; + + const path = stripQuery(url); + + for (const route of bucket) { + const result = route.regex.exec(path); + if (!result) continue; + + const [title, , type] = route.meta; + return { + title, + type, + routePath: route.routePath, + // The first path parameter is the affected record in this API's + // conventions (`/api/contracts/:id/...`). Routes with no parameter + // (a create) legitimately have no resource id yet. + resourceId: route.paramNames.length > 0 ? result[1] : null, + }; + } + + return null; + } +} + +/** Strip query string and hash from a URL, leaving the path. */ +export function stripQuery(url: string): string { + const queryIndex = url.indexOf('?'); + const path = queryIndex === -1 ? url : url.slice(0, queryIndex); + const hashIndex = path.indexOf('#'); + return hashIndex === -1 ? path : path.slice(0, hashIndex); +} + +/** Compiled once at module load and shared by the interceptor. */ +export const auditEndpointMatcher = new AuditEndpointMatcher(); diff --git a/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts new file mode 100644 index 000000000..0526636ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-endpoints.ts @@ -0,0 +1,641 @@ +/** + * Freight API — every state-changing endpoint (POST / PUT / PATCH / DELETE). + * + * Shape: " ": [title, method, entity] + * + * Keyed by method + path rather than path alone: 50 paths serve more than one + * method (PATCH and DELETE on /api/contracts/:id, for example), so a path-only + * key would collide and drop those endpoints. + * + * Paths include the global prefix `api` (see app.setGlobalPrefix in src/main.ts). + * Titles come from each route's @ApiOperation summary, falling back to a + * humanized handler name where a route has none. + * + * Excludes the AI Assist and Account entities. + * Generated from the controllers under src/ — 488 endpoints. + */ +/** [title, method, entity] for one auditable route. */ +export type AuditEndpointMeta = readonly [title: string, method: string, entity: string]; + +export const AUDIT_ENDPOINTS: Readonly> = { + // Approval Rule + "POST /api/approval-rules": ["Create an approval rule step", "POST", "Approval Rule"], + "PATCH /api/approval-rules/:id": ["Update an approval rule", "PATCH", "Approval Rule"], + "DELETE /api/approval-rules/:id": ["Soft-delete an approval rule", "DELETE", "Approval Rule"], + "POST /api/approval-rules/:id/move-order": ["Move an approval step up or down within its chain", "POST", "Approval Rule"], + "POST /api/approval-rules/reorder": ["Bulk reorder approval steps within a chain", "POST", "Approval Rule"], + + // Booking + "POST /api/bookings": ["Create a new freight booking (DRAFT)", "POST", "Booking"], + "POST /api/bookings/:bookingId/allocate-containers": ["Allocate containers to vehicles", "POST", "Booking"], + "PATCH /api/bookings/:id": ["Update booking", "PATCH", "Booking"], + "DELETE /api/bookings/:id": ["Soft-delete DRAFT booking", "DELETE", "Booking"], + "POST /api/bookings/:id/cancel": ["Cancel booking", "POST", "Booking"], + "POST /api/bookings/:id/cancel-hold": ["Customer cancels an unpaid hold (SELECTED_FOR_BATCH → CANCELLED);", "POST", "Booking"], + "POST /api/bookings/:id/clearance/declaration": ["GL ET uploads customs declaration on booking (GENERAL customs)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/delivery-order": ["Upload Booking Delivery Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration": ["GL ET sends a draft customs declaration (multi-file) with an estimated price for the customer to review", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/accept": ["Customer accepts the draft customs declaration — unlocks the real customs declaration step for GL Ethiopia", "POST", "Booking"], + "POST /api/bookings/:id/clearance/draft-declaration/change": ["Customer requests a change to the draft customs declaration with a reason — GL Ethiopia sends a corrected draft (repeatable)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty": ["GL ET sets duty/tax on booking with notice attachment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/export-release": ["Confirm Booking Export Release", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize": ["GL finalizes clearance (requires 100% approved) → CLEARANCE_READY", "POST", "Booking"], + "POST /api/bookings/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance on booking", "POST", "Booking"], + "POST /api/bookings/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/proceed": ["Customer requests operation with a schedule day", "POST", "Booking"], + "POST /api/bookings/:id/clearance/release-order": ["Upload Booking Release Order", "POST", "Booking"], + "POST /api/bookings/:id/clearance/review": ["GL reviews a clearance document (Approve | Query)", "POST", "Booking"], + "POST /api/bookings/:id/clearance/ro-amendment": ["Request Booking RO Amendment", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the import customs declaration", "POST", "Booking"], + "POST /api/bookings/:id/clearance/transit-permit": ["Upload Booking Transit Permit", "POST", "Booking"], + "POST /api/bookings/:id/confirm-submit": ["Confirm submit after price change", "POST", "Booking"], + "POST /api/bookings/:id/consolidation": ["Request freight consolidation", "POST", "Booking"], + "DELETE /api/bookings/:id/consolidation": ["Remove consolidation pairing", "DELETE", "Booking"], + "POST /api/bookings/:id/contract/generate": ["Generate contract PDF from template", "POST", "Booking"], + "POST /api/bookings/:id/contract/sign": ["Apply digital signature (customer or staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-cancel": ["Customer cancels their own booking before payment — no cancellation fee", "POST", "Booking"], + "POST /api/bookings/:id/customer-truck-assignment": ["Customer assigns external truck and driver for terminal pickup", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks": ["Add a customer self-haul truck carrying 1–2 of the booking containers", "POST", "Booking"], + "PATCH /api/bookings/:id/customer-trucks/:assignmentId": ["Edit a not-yet-arrived customer truck (plate/driver/type + containers)", "PATCH", "Booking"], + "DELETE /api/bookings/:id/customer-trucks/:assignmentId": ["Remove a not-yet-arrived customer truck from a booking", "DELETE", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/depart": ["Register an import truck leaving: containers loaded + weighed gross (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/:assignmentId/load": ["Truck_dispatch: load selected containers onto a truck (staff)", "POST", "Booking"], + "POST /api/bookings/:id/customer-trucks/bulk": ["Bulk add customer trucks from array payload (Excel parsed)", "POST", "Booking"], + "POST /api/bookings/:id/customer/sign": ["Customer digital signature (deprecated — use POST contract/sign)", "POST", "Booking"], + "POST /api/bookings/:id/documents": ["Upload documents for a booking (DRAFT only)", "POST", "Booking"], + "PATCH /api/bookings/:id/export-handover-mode": ["Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first", "PATCH", "Booking"], + "POST /api/bookings/:id/generate-grn": ["Generate a GRN over the received containers (all received, or a subset) — one GRN per batch", "POST", "Booking"], + "POST /api/bookings/:id/generate-price": ["Generate price preview (DRAFT or CHANGES_REQUESTED)", "POST", "Booking"], + "POST /api/bookings/:id/government-expedite": ["Expedite government booking to PAID / ELIGIBLE for scheduling", "POST", "Booking"], + "POST /api/bookings/:id/marketing/approve": ["Staff contract signature and fully execute (use contract/sign STAFF preferred)", "POST", "Booking"], + "POST /api/bookings/:id/operation/review": ["Operations reviews an operation request: ACCEPT (→ batch pool),", "POST", "Booking"], + "POST /api/bookings/:id/operations/complete": ["Mark completed", "POST", "Booking"], + "POST /api/bookings/:id/operations/start-transit": ["Mark in transit", "POST", "Booking"], + "POST /api/bookings/:id/reject": ["Customer reject price estimate", "POST", "Booking"], + "POST /api/bookings/:id/staff/accept": ["Staff accept intake → set contract validity window + start approval chain", "POST", "Booking"], + "POST /api/bookings/:id/staff/reject": ["Staff final reject", "POST", "Booking"], + "POST /api/bookings/:id/staff/request-changes": ["Staff return booking for customer updates", "POST", "Booking"], + "POST /api/bookings/:id/submit": ["Customer submit booking", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations": ["Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles", "POST", "Booking"], + "POST /api/bookings/:id/wagon-cancellations/preview": ["Preview the fee/credit of a partial wagon cancellation (no writes)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/rebook": ["Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)", "POST", "Booking"], + "POST /api/bookings/wagon-cancellations/:cancellationId/withdraw": ["Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)", "POST", "Booking"], + + // Cargo + "POST /api/cargoes": ["Create a new cargo", "POST", "Cargo"], + "PATCH /api/cargoes/:id": ["Update a cargo", "PATCH", "Cargo"], + "DELETE /api/cargoes/:id": ["Delete a cargo", "DELETE", "Cargo"], + "POST /api/cargoes/:id/deliver": ["Mark cargo as delivered", "POST", "Cargo"], + "POST /api/cargoes/:id/load": ["Load cargo into a container", "POST", "Cargo"], + "POST /api/cargoes/:id/unload": ["Unload cargo from container", "POST", "Cargo"], + + // Cargo Type + "POST /api/cargo-types": ["Create a cargo type", "POST", "Cargo Type"], + "PATCH /api/cargo-types/:id": ["Update a cargo type", "PATCH", "Cargo Type"], + "DELETE /api/cargo-types/:id": ["Soft-delete a cargo type", "DELETE", "Cargo Type"], + "POST /api/cargo-types/:id/move-order": ["Move a cargo type up or down in display order", "POST", "Cargo Type"], + "POST /api/cargo-types/reorder": ["Bulk reorder cargo types by ID list", "POST", "Cargo Type"], + + // Company + "POST /api/companies": ["Create a new company (customer, freight_forwarder, dj_freight_forwarder, transporter)", "POST", "Company"], + "POST /api/companies/:companyId/documents": ["Upload documents for a company (onboarding)", "POST", "Company"], + "POST /api/companies/:companyId/profiles": ["Add a profile (employee) to a company", "POST", "Company"], + "PATCH /api/companies/:id": ["Update a company", "PATCH", "Company"], + "DELETE /api/companies/:id": ["Soft-delete a company", "DELETE", "Company"], + "POST /api/companies/change-requests/:id/approve": ["Approve a pending profile change request (applies the changes)", "POST", "Company"], + "POST /api/companies/change-requests/:id/reject": ["Reject a pending profile change request with a note", "POST", "Company"], + "POST /api/companies/change-requests/:id/request-changes": ["Ask for specific changes on a pending request without rejecting it (row stays open, next edit appends to it)", "POST", "Company"], + "POST /api/companies/company-profile": ["Create a single operational profile for the current user's company. The role starts pending and does not become the active mode", "POST", "Company"], + "POST /api/companies/company-profiles": ["Add operational profile(s) (importer/exporter/forwarder) to the current user's company", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/license": ["Add business-license document(s) to a profile. For an approved company", "POST", "Company"], + "DELETE /api/companies/company-profiles/:profileId/license/:fileId": ["Remove a business-license file (staged for review on an approved company)", "DELETE", "Company"], + "POST /api/companies/company-profiles/:profileId/license/:fileId/replace": ["Replace a business-license file with a newly uploaded one (staged for", "POST", "Company"], + "POST /api/companies/company-profiles/:profileId/reapply": ["Resubmit a rejected operational role for approval (→ pending)", "POST", "Company"], + "PATCH /api/companies/company-profiles/:profileId/status": ["Update a company profile's approval status", "PATCH", "Company"], + "POST /api/companies/create": ["Create a company with its associated external profile (onboarding)", "POST", "Company"], + "POST /api/companies/documents/:fileId/request-change": ["Ask the customer to correct one uploaded document", "POST", "Company"], + "POST /api/companies/fetch-etrade-info": ["Fetch company info from eTrade by TIN", "POST", "Company"], + "POST /api/companies/identity/fayda/complete": ["Bind a completed Fayda verification to the company's owner or Power of Attorney", "POST", "Company"], + "DELETE /api/companies/identity/fayda/poa": ["Remove the company's Power of Attorney — the verified identity, its details and the delegation paper together", "DELETE", "Company"], + "DELETE /api/companies/identity/gm": ["Clear the General Manager's identity — the \\\"same as owner\\\" declaration or a verification, and the details either wrote", "DELETE", "Company"], + "POST /api/companies/identity/gm/same-as-owner": ["Declare the General Manager is the company's owner, copying the owner's verified identity across", "POST", "Company"], + "POST /api/companies/identity/poa/same-as-owner": ["Declare the Power of Attorney is the company's owner, copying the owner's identity across", "POST", "Company"], + "DELETE /api/companies/identity/poa/same-as-owner": ["Undo the Power of Attorney \\\"same as owner\\\" declaration and the identity it copied, leaving the representative open to be verified in their own right", "DELETE", "Company"], + "PATCH /api/companies/onboarding-step": ["Persist the user's current onboarding wizard step", "PATCH", "Company"], + "POST /api/companies/onboarding/complete": ["Mark the current user's onboarding as complete", "POST", "Company"], + "POST /api/companies/onboarding/start": ["Begin onboarding: create a draft company + profile + role(s) so later steps can save incrementally", "POST", "Company"], + "POST /api/companies/poa-delegation": ["Upload the Power of Attorney delegation letter, replacing any existing one", "POST", "Company"], + "DELETE /api/companies/poa-delegation/:fileId": ["Remove the Power of Attorney delegation letter (staged for review on an approved company)", "DELETE", "Company"], + "PATCH /api/companies/profile": ["Update profile (flattened settings page)", "PATCH", "Company"], + + // Compliance + "POST /api/compliance": ["Create a compliance record", "POST", "Compliance"], + "PATCH /api/compliance/:id": ["Update a compliance record", "PATCH", "Compliance"], + "DELETE /api/compliance/:id": ["Soft-delete a compliance record", "DELETE", "Compliance"], + + // Consignment + "POST /api/consignments": ["Create a new consignment", "POST", "Consignment"], + + // Container + "POST /api/containers": ["Create a new container", "POST", "Container"], + "PATCH /api/containers/:id": ["Update a container", "PATCH", "Container"], + "DELETE /api/containers/:id": ["Delete a container", "DELETE", "Container"], + "POST /api/containers/:id/assign-wagon": ["Assign container to a wagon", "POST", "Container"], + "POST /api/containers/:id/unassign-wagon": ["Unassign container from wagon", "POST", "Container"], + + // Container Type + "POST /api/container-types": ["Create a container type", "POST", "Container Type"], + "PATCH /api/container-types/:id": ["Update a container type", "PATCH", "Container Type"], + "DELETE /api/container-types/:id": ["Soft-delete a container type", "DELETE", "Container Type"], + "POST /api/container-types/:id/move-order": ["Move a container type up or down in display order", "POST", "Container Type"], + "POST /api/container-types/reorder": ["Bulk reorder container types by ID list", "POST", "Container Type"], + + // Contract + "POST /api/contracts": ["Create a new contract (DRAFT) with routes + cargo scope", "POST", "Contract"], + "PATCH /api/contracts/:id": ["Update contract", "PATCH", "Contract"], + "DELETE /api/contracts/:id": ["Soft-delete DRAFT contract", "DELETE", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/approve": ["Approve one approval step in sequence", "POST", "Contract"], + "POST /api/contracts/:id/approval-steps/:stepId/reject": ["Reject one approval step — to the customer (terminal → REJECTED) or, via returnToStepId, back to an earlier approver (chain re-runs from there)", "POST", "Contract"], + "POST /api/contracts/:id/booking-requests": ["Customer submits a shipment request on a GENERAL customs contract", "POST", "Contract"], + "POST /api/contracts/:id/bookings": ["Create a shipment booking under a contract — Path A (customer) or Path B (GL Ethiopia)", "POST", "Contract"], + "POST /api/contracts/:id/bookings/:bookingId/complete": ["Complete an initiated booking after Operations finalized its clearance — cargo + binding day, window and departure checks, pricing and invoicing", "POST", "Contract"], + "POST /api/contracts/:id/bookings/initiate": ["Initiate a bare booking instance under an import/export contract (ONE_TIME or GENERAL) — no cargo, no date; enters per-booking clearance (AWAITING_DOCUMENTS). ONE_TIME customs instances are opened by the customer (or GL); GENERAL customs comes from a shipment request", "POST", "Contract"], + "POST /api/contracts/:id/cancel": ["Customer cancels their own contract (blocked while a booking is live)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/declaration": ["GL ET uploads customs declaration documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/delivery-order": ["GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents": ["Customer uploads clearance documents (fieldname = document key)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/documents/:fileKey/replace": ["GL replaces a clearance document in place (reason required) — the previous version is kept in the file history and the new one needs approving", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty": ["GL ET sets duty/tax requirement and advises amount with notice attachment", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty-slip": ["Customer uploads duty/tax payment slip on contract", "POST", "Contract"], + "POST /api/contracts/:id/clearance/duty/dispute": ["Customer disputes the advised duty/tax with a reason — reopens the step so GL Ethiopia can re-advise (repeatable)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/export-release": ["GL ET confirms export release after declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize": ["GL ET finalizes clearance → CLEARANCE_READY_FOR_BOOKING (legacy)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-export-clearance": ["GL ET finalizes export clearance after post-booking transit permit upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/finalize-pre-clearance": ["GL ET finalizes import pre-clearance — unlocks Djibouti DO upload", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-finalize": ["Operations finalizes self-clearance → customer may create the booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ops-review": ["Operations reviews a customer self-clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/output-documents": ["GL uploads customs output documents (IM4/EX3/…) pre-booking", "POST", "Contract"], + "POST /api/contracts/:id/clearance/release-order": ["GL DJ uploads Release Order + vessel departure date (export)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/review": ["GL ET reviews a clearance document (Approve | Query)", "POST", "Contract"], + "POST /api/contracts/:id/clearance/ro-amendment": ["GL DJ requests port amendment when RO vessel window is too short", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/assign": ["GL Djibouti picks the transit officer from the roster — unblocks the customs declaration; calling again reassigns", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-assignee/request": ["GL ET asks GL Djibouti to name the transit officer — required before the customs declaration", "POST", "Contract"], + "POST /api/contracts/:id/clearance/transit-permit": ["GL ET uploads import transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/contracts/:id/confirm-submit": ["Confirm submit after a price change", "POST", "Contract"], + "POST /api/contracts/:id/contract/generate": ["Generate contract document → CONTRACT_READY", "POST", "Contract"], + "POST /api/contracts/:id/contract/send-signing-otp": ["Send the sudo-mode signing OTP to the contract company's registered phone (server picks the number)", "POST", "Contract"], + "POST /api/contracts/:id/contract/sign": ["Apply digital signature (customer or staff/director/ceo)", "POST", "Contract"], + "PUT /api/contracts/:id/document/articles": ["Edit this contract\\'s document articles only (per-contract; never touches the six shared templates)", "PUT", "Contract"], + "POST /api/contracts/:id/documents": ["Upload intake documents for a contract (DRAFT only)", "POST", "Contract"], + "POST /api/contracts/:id/generate-price": ["Generate unit-rate breakdown (no totals at contract phase)", "POST", "Contract"], + "POST /api/contracts/:id/milestones/:code/complete": ["GL marks a pre-booking (contract) milestone complete", "POST", "Contract"], + "POST /api/contracts/:id/renew": ["Create a renewal draft linked via renewalOfId", "POST", "Contract"], + "POST /api/contracts/:id/resume": ["Staff lift a suspension — contract returns to its prior status", "POST", "Contract"], + "POST /api/contracts/:id/staff/accept": ["Staff accept → set validity window + start approval chain", "POST", "Contract"], + "POST /api/contracts/:id/staff/reject": ["Staff reject contract", "POST", "Contract"], + "POST /api/contracts/:id/staff/request-changes": ["Staff return contract for customer updates", "POST", "Contract"], + "POST /api/contracts/:id/submit": ["Customer submit contract (freezes contract_rate_snapshots)", "POST", "Contract"], + "POST /api/contracts/:id/suspend": ["Staff freeze a signed contract (reversible, any post-signature step)", "POST", "Contract"], + "POST /api/contracts/:id/validate-shipment": ["Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created)", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/accept": ["GL marks a shipment request accepted + links the created booking", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/cancel": ["Customer cancels their own pending shipment request", "POST", "Contract"], + "POST /api/contracts/booking-requests/:reqId/reject": ["GL rejects a shipment request", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/documents": ["GL uploads post-booking operational documents (DO/RO/T1/…)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty": ["GL ET advises duty & tax amount + declaration serial", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/duty-slip": ["Customer uploads the duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice": ["GL DJ raises the post-offload final invoice (amount + invoice document)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice-slip": ["Customer attaches the payment slip for the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/approve": ["Customer approves the drafted final invoice — unlocks the payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/final-invoice/confirm": ["GL (ET or DJ) confirms the payment slip — settles the final invoice", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/incidents": ["GL DJ logs a cargo exception with photo evidence", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/milestones/:code/complete": ["GL / Ops / Terminal marks a post-booking milestone complete", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/risk": ["GL ET assigns a customs risk level (GREEN/YELLOW/RED)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty": ["GL ET advises (or skips) the post-arrival additional duty/tax round (import)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/second-duty-slip": ["Customer attaches the additional duty/tax payment slip", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/station-assign": ["GL station manager routes the shipment + binds staff", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-close": ["Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/t1-documents": ["GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs", "POST", "Contract"], + "POST /api/contracts/bookings/:bookingId/transport-document": ["GL ET uploads export transit permit documents (multi-file)", "POST", "Contract"], + "POST /api/gl-exchange/:entityId": ["Share a document with the other GL desk", "POST", "Contract"], + "PATCH /api/gl-exchange/documents/:documentId": ["Uploader edits a shared document (title, visibility, file)", "PATCH", "Contract"], + "DELETE /api/gl-exchange/documents/:documentId": ["Uploader removes a shared document", "DELETE", "Contract"], + + // Contract Template + "POST /api/contract-templates": ["Create a bulk contract template for a (cargo type, customs option) pair", "POST", "Contract Template"], + "PATCH /api/contract-templates/:code": ["Update template metadata (name, title, recitals, active flag)", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code": ["Delete a staff-created bulk template (system templates refuse)", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/articles": ["Add an article to the template", "POST", "Contract Template"], + "PUT /api/contract-templates/:code/articles": ["Replace the full ordered article list (used for reorder)", "PUT", "Contract Template"], + "PATCH /api/contract-templates/:code/articles/:articleId": ["Update an article's title or body", "PATCH", "Contract Template"], + "DELETE /api/contract-templates/:code/articles/:articleId": ["Remove an article from the template", "DELETE", "Contract Template"], + "POST /api/contract-templates/:code/preview": ["Render an HTML preview of the template against mock contract data", "POST", "Contract Template"], + + // Driver + "POST /api/drivers": ["Create a new driver", "POST", "Driver"], + "PATCH /api/drivers/:id": ["Update a driver", "PATCH", "Driver"], + "DELETE /api/drivers/:id": ["Delete a driver", "DELETE", "Driver"], + "POST /api/drivers/:id/documents": ["Upload driver documents (code driver_docs)", "POST", "Driver"], + "DELETE /api/drivers/:id/documents/:fileId": ["Delete a driver document", "DELETE", "Driver"], + + // Dropdown Setting + "POST /api/dropdown-settings": ["Create a new dropdown setting", "POST", "Dropdown Setting"], + "PATCH /api/dropdown-settings/:id": ["Update a dropdown setting's metadata", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/:id": ["Soft-delete a dropdown setting", "DELETE", "Dropdown Setting"], + "POST /api/dropdown-settings/:id/options": ["Append a single option to a setting", "POST", "Dropdown Setting"], + "PUT /api/dropdown-settings/:id/options": ["Replace the full option list for a setting", "PUT", "Dropdown Setting"], + "PATCH /api/dropdown-settings/options/:optionId": ["Update a single option", "PATCH", "Dropdown Setting"], + "DELETE /api/dropdown-settings/options/:optionId": ["Soft-delete a single option", "DELETE", "Dropdown Setting"], + + // EIMS Invoice + "POST /api/invoices/:id/eims/register": ["Register the invoice with MoR EIMS. Idempotent — an invoice that already has an IRN is returned unchanged", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/resolve": ["Resolve an unacknowledged submission: record the IRN confirmed with MoR, or discard it. Clears the system-wide block", "POST", "EIMS Invoice"], + "POST /api/invoices/:id/eims/verify": ["Verify the invoice's stored IRN against EIMS", "POST", "EIMS Invoice"], + + // Exchange Setting + "PATCH /api/exchange-settings": ["Set the USD→ETB fallback by hand (used only while CBE is unreachable)", "PATCH", "Exchange Setting"], + + // Facility + "POST /api/facilities": ["Create a new facility", "POST", "Facility"], + "PATCH /api/facilities/:id": ["Update a facility", "PATCH", "Facility"], + "DELETE /api/facilities/:id": ["Delete a facility (soft delete)", "DELETE", "Facility"], + + // Fayda Verification + "POST /api/fayda/verification/start": ["Start a VeriFayda 2.0 verification session", "POST", "Fayda Verification"], + + // File Upload Setting + "POST /api/file-upload-settings": ["Create a new file upload setting", "POST", "File Upload Setting"], + "PATCH /api/file-upload-settings/:id": ["Update a file upload setting's metadata", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/:id": ["Soft-delete a file upload setting", "DELETE", "File Upload Setting"], + "POST /api/file-upload-settings/:id/fields": ["Append a single field to a setting", "POST", "File Upload Setting"], + "PUT /api/file-upload-settings/:id/fields": ["Replace the full field list for a setting", "PUT", "File Upload Setting"], + "PATCH /api/file-upload-settings/fields/:fieldId": ["Update a single field", "PATCH", "File Upload Setting"], + "DELETE /api/file-upload-settings/fields/:fieldId": ["Soft-delete a single field", "DELETE", "File Upload Setting"], + + // First Mile + "POST /api/first-mile": ["Create a first-mile leg", "POST", "First Mile"], + "PATCH /api/first-mile/:id": ["Update a first-mile leg", "PATCH", "First Mile"], + "DELETE /api/first-mile/:id": ["Soft-delete a first-mile leg", "DELETE", "First Mile"], + "POST /api/first-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "First Mile"], + "POST /api/first-mile/:id/invoice": ["Generate the first-mile delivery-fee invoice", "POST", "First Mile"], + "POST /api/first-mile/:id/vehicles": ["Set the vehicles assigned to a first-mile pickup (multi-truck)", "POST", "First Mile"], + "POST /api/first-mile/accept/:reference": ["Accept a paid booking and create a first-mile leg", "POST", "First Mile"], + + // Fuel + "POST /api/fuel/purchases": ["Record fuel purchase", "POST", "Fuel"], + + // GPS Tracking + "POST /api/gps/devices": ["Register a GPS tracker", "POST", "GPS Tracking"], + "PATCH /api/gps/devices/:id": ["Update a GPS tracker (name / assigned vehicle)", "PATCH", "GPS Tracking"], + "DELETE /api/gps/devices/:id": ["Delete a GPS tracker", "DELETE", "GPS Tracking"], + + // Import Operation + "POST /api/import-operations/customs/:bookingId/declaration": ["Batch 12: record declaration serial number", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/documents": ["Batch 12: upload IM4/IM5/T1/permit/payment-slip documents", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/duties-taxes-paid": ["Batch 12: mark duties and taxes paid", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/notify-duties-taxes": ["Batch 12: notify duties and taxes", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/release-permitted": ["Batch 12: mark import release permitted", "POST", "Import Operation"], + "POST /api/import-operations/customs/:bookingId/risk": ["Batch 12: assign customs risk", "POST", "Import Operation"], + "POST /api/import-operations/djibouti-incidents": ["Batch 8: report a Djibouti import incident / exception", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns": ["Batch 16: create an empty container return record", "POST", "Import Operation"], + "POST /api/import-operations/empty-container-returns/:id/status": ["Batch 16: advance empty container return workflow", "POST", "Import Operation"], + + // Incident + "POST /api/incidents": ["Report an incident", "POST", "Incident"], + "PATCH /api/incidents/:id": ["Update an incident", "PATCH", "Incident"], + "DELETE /api/incidents/:id": ["Delete an incident", "DELETE", "Incident"], + + // Interchange Document + "PATCH /api/interchange-documents/:id/acknowledge": ["Acknowledge an interchange document", "PATCH", "Interchange Document"], + "PATCH /api/interchange-documents/:id/dispute": ["Dispute an interchange document", "PATCH", "Interchange Document"], + "POST /api/interchange-documents/generate-from-schedule": ["Generate interchange document from a train schedule handover", "POST", "Interchange Document"], + + // Last Mile + "POST /api/last-mile": ["Create a last-mile leg", "POST", "Last Mile"], + "PATCH /api/last-mile/:id": ["Update a last-mile leg", "PATCH", "Last Mile"], + "DELETE /api/last-mile/:id": ["Soft-delete a last-mile leg", "DELETE", "Last Mile"], + "POST /api/last-mile/:id/detention-times": ["Set each truck\\'s own detention window (arrived at destination / returned)", "POST", "Last Mile"], + "POST /api/last-mile/:id/distances": ["Set per-vehicle actual distances (does not generate an invoice)", "POST", "Last Mile"], + "POST /api/last-mile/:id/invoice": ["Generate the delivery-fee invoice for a last-mile leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/proof-of-delivery": ["Record proof of delivery (signature + photos) and complete the leg", "POST", "Last Mile"], + "POST /api/last-mile/:id/vehicles": ["Set the vehicles assigned to a last-mile delivery (multi-truck)", "POST", "Last Mile"], + "POST /api/last-mile/:id/warehouse-gate-times": ["Set each truck\\'s warehouse gate arrival/departure times", "POST", "Last Mile"], + "POST /api/last-mile/accept/:reference": ["Accept a paid booking and create a last-mile leg", "POST", "Last Mile"], + + // Last Mile Request + "POST /api/last-mile-requests/:id/approve": ["Truck & Machinery chief approves the request — the advance defaults to the live last-mile rate; LM contract becomes signable and the advance invoice follows the customer signature", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/contract/sign": ["Customer agrees and signs the LM contract — then the advance invoice is issued", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/reject": ["Truck & Machinery chief rejects the request with a reason", "POST", "Last Mile Request"], + "POST /api/last-mile-requests/:id/submit": ["Customer confirms which containers go via EDR last-mile", "POST", "Last Mile Request"], + + // Locomotive + "POST /api/locomotives": ["Create a locomotive", "POST", "Locomotive"], + "PATCH /api/locomotives/:id": ["Update a locomotive", "PATCH", "Locomotive"], + "POST /api/locomotives/:id/decommission": ["Decommission a locomotive", "POST", "Locomotive"], + "DELETE /api/locomotives/:id/permanent": ["Permanently delete a locomotive (irreversible; refused if any train references it)", "DELETE", "Locomotive"], + + // Maintenance + "POST /api/maintenance/costs": ["Record maintenance cost", "POST", "Maintenance"], + "POST /api/maintenance/intervals": ["Define/adjust a service interval (e.g. oil change every 10,000 km)", "POST", "Maintenance"], + "DELETE /api/maintenance/intervals/:id": ["Deactivate a service interval (stops auto-scheduling)", "DELETE", "Maintenance"], + "POST /api/maintenance/parts": ["Create part", "POST", "Maintenance"], + "PATCH /api/maintenance/parts/:id": ["Update part", "PATCH", "Maintenance"], + "DELETE /api/maintenance/parts/:id": ["Delete part", "DELETE", "Maintenance"], + "POST /api/maintenance/schedules": ["Schedule maintenance", "POST", "Maintenance"], + "PATCH /api/maintenance/schedules/:id": ["Update maintenance schedule", "PATCH", "Maintenance"], + "POST /api/maintenance/warranties": ["Create warranty", "POST", "Maintenance"], + "DELETE /api/maintenance/warranties/:id": ["Delete warranty", "DELETE", "Maintenance"], + "POST /api/maintenance/work-orders": ["Create work order", "POST", "Maintenance"], + "PATCH /api/maintenance/work-orders/:id": ["Update work order", "PATCH", "Maintenance"], + "DELETE /api/maintenance/work-orders/:id": ["Delete work order", "DELETE", "Maintenance"], + + // Notification Inbox + "PATCH /api/notifications/:id/read": ["Mark one of my notifications as read", "PATCH", "Notification Inbox"], + "POST /api/notifications/read-all": ["Mark all my notifications as read", "POST", "Notification Inbox"], + + // Organization User + "PUT /api/backoffice/organizations/:orgId/employee-users/:userId/roles": ["Replace org-scoped roles assigned to an employee user", "PUT", "Organization User"], + "POST /api/backoffice/organizations/:orgId/users": ["Create an organization user without assigning positions", "POST", "Organization User"], + + // OTP + "POST /api/otp/send": ["Send OTP", "POST", "OTP"], + "POST /api/otp/verify": ["Verify OTP", "POST", "OTP"], + + // Password Reset + "POST /api/auth/forgot-password/request": ["Send a password-reset code to the account's email AND phone", "POST", "Password Reset"], + "POST /api/auth/forgot-password/resolve-link": ["Validate a staff-issued reset link and return its set-password ticket", "POST", "Password Reset"], + "POST /api/auth/forgot-password/verify": ["Exchange a valid reset code for a single-use set-password ticket", "POST", "Password Reset"], + "POST /api/backoffice/customers/:companyId/reset-password": ["Send a password-reset link to a customer's primary contact", "POST", "Password Reset"], + + // Payment + "POST /api/billing/invoices/:id/confirm-offline": ["Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/confirm": ["Confirm an OTP-debit payment (CAC Bank) for one of the customer's invoices", "POST", "Payment"], + "POST /api/billing/my-invoices/:id/pay": ["Initiate payment for one of the customer's invoices", "POST", "Payment"], + "POST /api/internal/payments/bill-query": ["Live still-payable check + payer name for a CBE bill (called while CBE is on the line)", "POST", "Payment"], + "POST /api/internal/payments/mark-paid": ["Apply a payment.succeeded / payment.failed event from the payment service (idempotent)", "POST", "Payment"], + "POST /api/payments/initiate": ["Initiate payment for an invoice", "POST", "Payment"], + "POST /api/payments/redirect-success/:bookingId": ["Success-redirect ack: mark payment processing + invoice PAYMENT_PROCESSING (webhook remains source of truth)", "POST", "Payment"], + + // Priority Config + "POST /api/priority-configs": ["Create a priority config", "POST", "Priority Config"], + "PATCH /api/priority-configs/:id": ["Update a priority config", "PATCH", "Priority Config"], + "DELETE /api/priority-configs/:id": ["Soft-delete a priority config", "DELETE", "Priority Config"], + "POST /api/priority-configs/:id/move-order": ["Move a priority config up or down in display order", "POST", "Priority Config"], + "POST /api/priority-configs/reorder": ["Bulk reorder priority configs by ID list", "POST", "Priority Config"], + + // Priority Rule Change Request + "POST /api/priority-rule-change-requests": ["Submit a priority-rule change for approval", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/approve": ["Approve and apply a pending change", "POST", "Priority Rule Change Request"], + "POST /api/priority-rule-change-requests/:id/reject": ["Reject a pending change", "POST", "Priority Rule Change Request"], + + // Procurement + "POST /api/procurement/acquisitions": ["Create an asset acquisition", "POST", "Procurement"], + "PATCH /api/procurement/acquisitions/:id": ["Update an asset acquisition", "PATCH", "Procurement"], + "DELETE /api/procurement/acquisitions/:id": ["Delete an asset acquisition", "DELETE", "Procurement"], + "POST /api/procurement/disposals": ["Create an asset disposal", "POST", "Procurement"], + "DELETE /api/procurement/disposals/:id": ["Delete an asset disposal", "DELETE", "Procurement"], + "POST /api/procurement/vendors": ["Create a vendor", "POST", "Procurement"], + "PATCH /api/procurement/vendors/:id": ["Update a vendor", "PATCH", "Procurement"], + "DELETE /api/procurement/vendors/:id": ["Delete a vendor", "DELETE", "Procurement"], + + // Rate + "POST /api/rates": ["Create a rate (DRAFT)", "POST", "Rate"], + "PATCH /api/rates/:id": ["Update a DRAFT rate", "PATCH", "Rate"], + "DELETE /api/rates/:id": ["Soft-delete a rate", "DELETE", "Rate"], + "POST /api/rates/:id/approve": ["CEO approves a rate", "POST", "Rate"], + "POST /api/rates/:id/submit": ["Submit rate for CEO approval", "POST", "Rate"], + + // Rate Change Request + "POST /api/rate-change-requests": ["Propose a change to a LIVE rate", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/approve": ["Approve a rate change and put it into effect", "POST", "Rate Change Request"], + "POST /api/rate-change-requests/:id/reject": ["Reject a rate change — the rate keeps its current value", "POST", "Rate Change Request"], + + // Route + "POST /api/routes": ["Create route", "POST", "Route"], + "PATCH /api/routes/:id": ["Update route", "PATCH", "Route"], + "DELETE /api/routes/:id": ["Deactivate route", "DELETE", "Route"], + "DELETE /api/routes/:id/permanent": ["Permanently delete a route (irreversible; refused while any train schedule references it)", "DELETE", "Route"], + + // Schedule + // NOTE: duplicate route — also declared in modules/scheduling-reschedule/scheduling-reschedule.controller.ts:52. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance": ["Reschedule train for maintenance (new departure + rebalance)", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/execute": ["Execute a confirmed reschedule plan", "POST", "Schedule"], + "POST /api/train-scheduling/schedules/:id/reschedule/preview": ["Preview reschedule / government preempt plan", "POST", "Schedule"], + + // Service Type + "POST /api/service-types": ["Create a service type", "POST", "Service Type"], + "PATCH /api/service-types/:id": ["Update a service type", "PATCH", "Service Type"], + "DELETE /api/service-types/:id": ["Soft-delete a service type", "DELETE", "Service Type"], + "POST /api/service-types/:id/move-order": ["Move a service type up or down in display order", "POST", "Service Type"], + "POST /api/service-types/reorder": ["Bulk reorder service types by ID list", "POST", "Service Type"], + + // Shipping Line + "POST /api/shipping-lines": ["Create a shipping line", "POST", "Shipping Line"], + "PATCH /api/shipping-lines/:id": ["Update a shipping line", "PATCH", "Shipping Line"], + "DELETE /api/shipping-lines/:id": ["Soft-delete a shipping line", "DELETE", "Shipping Line"], + + // Signature + "PUT /api/me/signature": ["Create or update the reusable saved signature", "PUT", "Signature"], + + // Support Chat + "POST /api/support/agent/conversations": ["Start chatting with a company (returns the thread if one exists)", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/messages": ["Reply as an agent, optionally with attachments", "POST", "Support Chat"], + "POST /api/support/agent/conversations/:id/read": ["Mark a thread read (agent side)", "POST", "Support Chat"], + "POST /api/support/conversation/messages": ["Send a message as the customer (optionally with attachments), opening the thread if needed", "POST", "Support Chat"], + "POST /api/support/conversation/read": ["Mark my company's thread read (customer side)", "POST", "Support Chat"], + + // Support Content + "PATCH /api/support-content/documents/:slug": ["Replace a document's payload, recording a new version", "PATCH", "Support Content"], + "POST /api/support-content/documents/:slug/versions/:version/restore": ["Restore a version — re-saves it as a new version, never destructive", "POST", "Support Content"], + "POST /api/support-content/media": ["Upload an image or video for a help section", "POST", "Support Content"], + + // Train + "POST /api/trains": ["Register a new train", "POST", "Train"], + "PATCH /api/trains/:id": ["Update a train", "PATCH", "Train"], + "DELETE /api/trains/:id": ["Delete a train", "DELETE", "Train"], + + // Train Build + "POST /api/train-builder": ["Build a train: code + yard + 2+ locomotives (+ optional wagons)", "POST", "Train Build"], + "DELETE /api/train-builder/:id": ["Disband the train (release wagons and locomotives)", "DELETE", "Train Build"], + "POST /api/train-builder/:id/activate": ["Reactivate a deactivated train back to AVAILABLE", "POST", "Train Build"], + "POST /api/train-builder/:id/deactivate": ["Deactivate the train (park it) — only allowed with no active schedule", "POST", "Train Build"], + "PATCH /api/train-builder/:id/details": ["Edit the train's name and fixed import/export run numbers", "PATCH", "Train Build"], + "PUT /api/train-builder/:id/locomotives": ["Replace the locomotive set (minimum 1, same yard)", "PUT", "Train Build"], + "POST /api/train-builder/:id/reorder-wagons": ["Persist a drag-reorder of the full consist", "POST", "Train Build"], + "POST /api/train-builder/:id/wagons": ["Append AVAILABLE wagons from the train's yard to the consist", "POST", "Train Build"], + "DELETE /api/train-builder/:id/wagons/:wagonId": ["Detach one wagon from the consist", "DELETE", "Train Build"], + "POST /api/train-builder/:id/wagons/:wagonId/maintenance": ["Detach one wagon and move it to MAINTENANCE status", "POST", "Train Build"], + "PATCH /api/train-builder/:id/yard": ["Relocate the train — its locomotives and wagons move to the new yard with it", "PATCH", "Train Build"], + + // Train Schedule + "POST /api/train-scheduling/bookings/:bookingId/allocate": ["Staff: place a paid booking onto a fitting train (notifies customer on date change)", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/expire": ["Staff: expire a reservation and free its capacity", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/mark-paid": ["Staff: mark a reserved booking paid and allocate it now", "POST", "Train Schedule"], + "POST /api/train-scheduling/bookings/:bookingId/move-schedule": ["Re-point a booking to another OPEN same-route schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/preview": ["Preview a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules": ["Create a bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/assign-bookings": ["Assign bulk bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/bulk/schedules/:id/cancel": ["Cancel bulk train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/preview": ["Preview a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules": ["Create a container train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/assign-bookings": ["Assign container bookings to a train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/container/schedules/:id/cancel": ["Cancel container train schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/global-rules": ["Update global train scheduling rules (singleton)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/preview": ["Preview a mixed-capable train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/adjust-consist": ["Permanently trim free wagons off / couple yard wagons onto the schedule's built train (weight & length limits incl. tolerance enforced, every change logged)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/arrive": ["Mark a dispatched train arrived (move assets to destination yard, free assets)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-bookings": ["Assign bookings to a train schedule (mixed-capable)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/assign-unassigned-booking": ["Assign one linked unallocated booking to wagons (preserves existing assignments)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/booking-window": ["Open or close a schedule booking window", "PATCH", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/bookings/:bookingId": ["Unassign a booking from a train schedule", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/load": ["Confirm a booking's cargo loaded at its origin yard (any direction; train must be at that yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/bookings/:bookingId/unload": ["Confirm a booking's cargo unloaded at its destination yard — per-booking arrival, may precede the train's final arrival", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/checkpoints": ["Log the train passing a station (final station triggers arrival)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/confirm-loading": ["Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/container-items/:itemId": ["Update a container number on a wagon slot", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/dispatch": ["Dispatch a scheduled train", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/doc-review-complete": ["Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/finalize": ["Finalize a draft train schedule", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/depart": ["Depart loaded import train from Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/documents": ["Upload/check an import Djibouti-side document", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/gatepass-granted": ["Mark import Djibouti gatepass permission granted", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/load-list": ["Generate import load list / marshalling document summary", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/loaded-on-train": ["Confirm import cargo loaded on train at Djibouti", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/import-djibouti/ready-for-loading": ["Mark import train ready for loading at Djibouti", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/import-loading-status": ["Mark import bookings loaded/unloaded on this schedule (tracking only, does not affect dispatch)", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/load": ["Confirm intercity cargo loaded (train must be at the booking's origin yard)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/:bookingId/unload": ["Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/intercity/accept": ["Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/loading-status": ["Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)", "PATCH", "Train Schedule"], + // NOTE: duplicate route — also declared in modules/train-scheduling/controllers/train-scheduling.controller.ts:798. + // Two controllers register this same path; Nest serves whichever module loads first. + "POST /api/train-scheduling/schedules/:id/maintenance [modules/train-scheduling/controllers/train-scheduling.controller.ts]": ["Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/pin-wagons": ["Pin physical wagons to train set slots", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-allocation": ["Run wagon-level allocation for all eligible linked bookings", "POST", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/run-batch": ["Manually run the batch fill for a schedule", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/schedule-date": ["Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window", "PATCH", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/train-number": ["Edit a departure's train number and voyage number — allowed only until the train is dispatched", "PATCH", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/switch-government-booking": ["Switch out commercial bookings to allocate a government booking in their place", "POST", "Train Schedule"], + "DELETE /api/train-scheduling/schedules/:id/wagons/:trainSetWagonId": ["Remove an empty wagon slot from a train", "DELETE", "Train Schedule"], + "POST /api/train-scheduling/schedules/:id/wagons/:wagonId/move-load": ["Move a wagon's whole load to another wagon (empty → move/repin, loaded → swap loads)", "POST", "Train Schedule"], + "PATCH /api/train-scheduling/schedules/:id/window-rule": ["Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens", "PATCH", "Train Schedule"], + + // Transit Agent + "POST /api/transit-agents": ["Create a transit agent", "POST", "Transit Agent"], + "PATCH /api/transit-agents/:id": ["Update a transit agent", "PATCH", "Transit Agent"], + "DELETE /api/transit-agents/:id": ["Soft-delete a transit agent", "DELETE", "Transit Agent"], + + // Truck Type + "POST /api/truck-types": ["Create a truck type", "POST", "Truck Type"], + "PATCH /api/truck-types/:id": ["Update a truck type", "PATCH", "Truck Type"], + "DELETE /api/truck-types/:id": ["Soft-delete a truck type", "DELETE", "Truck Type"], + + // User Trade Access + "PUT /api/user-trade-access/:userId": ["Set the trade directions a backoffice user may see", "PUT", "User Trade Access"], + + // Vehicle + "POST /api/vehicles": ["Create a new vehicle", "POST", "Vehicle"], + "PATCH /api/vehicles/:id": ["Update a vehicle", "PATCH", "Vehicle"], + "DELETE /api/vehicles/:id": ["Delete a vehicle", "DELETE", "Vehicle"], + + // Wagon + "POST /api/wagons": ["Create a new wagon", "POST", "Wagon"], + "PATCH /api/wagons/:id": ["Update a wagon", "PATCH", "Wagon"], + "DELETE /api/wagons/:id": ["Delete a wagon", "DELETE", "Wagon"], + "POST /api/wagons/:id/assign-train": ["Assign wagon to a train", "POST", "Wagon"], + "DELETE /api/wagons/:id/permanent": ["Permanently delete a wagon (irreversible; refused if it has movements, containers or train-set slots)", "DELETE", "Wagon"], + "POST /api/wagons/:id/unassign-train": ["Unassign wagon from train", "POST", "Wagon"], + "POST /api/wagons/bulk-status": ["Set the status of multiple wagons (audited in wagon_status_logs)", "POST", "Wagon"], + "POST /api/wagons/bulk-transfer": ["Transfer multiple wagons to a destination yard", "POST", "Wagon"], + + // Wagon Transfer Request + "POST /api/wagon-transfer-requests": ["File a count-only wagon-transfer request", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/cancel": ["Withdraw a request that has not moved any wagon yet (use close-short once wagons have moved)", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/close-short": ["OCC: end the request with fewer wagons than asked for — what moved stays, the requester is told the shortfall", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/:id/fulfill": ["OCC: pick wagons and execute the transfer", "POST", "Wagon Transfer Request"], + "POST /api/wagon-transfer-requests/bulk-fulfill": ["OCC: accept-and-execute a subset of pending requests (auto-picks available wagons; the rest stay PENDING)", "POST", "Wagon Transfer Request"], + + // Wagon Type + "POST /api/wagon-types": ["Create a wagon type", "POST", "Wagon Type"], + "PATCH /api/wagon-types/:id": ["Update a wagon type", "PATCH", "Wagon Type"], + "DELETE /api/wagon-types/:id": ["Soft-delete a wagon type", "DELETE", "Wagon Type"], + + // Warehouse + "POST /api/warehouse-allocation-rules": ["Create a warehouse allocation rule", "POST", "Warehouse"], + "PATCH /api/warehouse-allocation-rules/:id": ["Update a warehouse allocation rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-allocation-rules/:id": ["Delete a warehouse allocation rule", "DELETE", "Warehouse"], + "POST /api/warehouse-allocation/preview": ["Preview the yard/warehouse/zone a booking would be allocated to", "POST", "Warehouse"], + "POST /api/warehouse-fee-rules": ["Create a storage / demurrage fee rule", "POST", "Warehouse"], + "PATCH /api/warehouse-fee-rules/:id": ["Update a fee rule", "PATCH", "Warehouse"], + "DELETE /api/warehouse-fee-rules/:id": ["Delete a fee rule", "DELETE", "Warehouse"], + "POST /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Acknowledge / snooze an item fee-accrual alert", "POST", "Warehouse"], + "DELETE /api/warehouse-fees/accrual/:inventoryId/acknowledge": ["Remove an accrual acknowledgement (re-surface for alerts)", "DELETE", "Warehouse"], + "POST /api/warehouses": ["Create warehouse", "POST", "Warehouse"], + "PATCH /api/warehouses/:id": ["Update warehouse", "PATCH", "Warehouse"], + "POST /api/warehouses/:warehouseId/yards": ["Create a yard within a warehouse", "POST", "Warehouse"], + + // Warehouse Fee Invoice + "POST /api/last-mile/:id/generate-truck-detention-invoice": ["Generate a truck-detention invoice for a last-mile leg (per truck per day)", "POST", "Warehouse Fee Invoice"], + "PATCH /api/warehouse-fee-invoices/:id/cancel": ["Cancel a warehouse fee invoice", "PATCH", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay": ["Record a payment against a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-fee-invoices/:id/pay-online": ["Initiate Telebirr/Waafi payment for a warehouse fee invoice", "POST", "Warehouse Fee Invoice"], + "POST /api/warehouse-inventory/:id/generate-fee-invoice": ["Generate a warehouse fee invoice from Batch 5 fee calculation", "POST", "Warehouse Fee Invoice"], + + // Warehouse Inspection Report + "PATCH /api/warehouse-inspection-reports/:id": ["Update an inspection report", "PATCH", "Warehouse Inspection Report"], + "POST /api/warehouse-inspection-reports/:id/attachments": ["Upload inspection images / documents", "POST", "Warehouse Inspection Report"], + "POST /api/warehouse-inventory/:inventoryId/inspection-reports": ["Create an inspection / damage report for an inventory item", "POST", "Warehouse Inspection Report"], + + // Warehouse Inventory + "POST /api/warehouse-inventory/:id/deliver": ["Deliver import goods to the customer + capture proof of delivery", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/:id/dispatch": ["Mark loaded inventory DISPATCHED (left the terminal)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/gate-clearance": ["Final terminal release / gate clearance (blocked while fees unpaid)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/load": ["Load READY_FOR_LOADING inventory onto a wagon", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/move": ["Move inventory to another warehouse/yard/zone", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-loading": ["Mark reserved inventory READY_FOR_LOADING", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/ready-for-pickup": ["Mark inspected IMPORT inventory READY_FOR_PICKUP", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/release": ["Issue a DO / release order for ready-for-pickup inventory", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/:id/store": ["Mark received inventory as STORED (optional explicit warehouse/yard/zone)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-load-ready": ["Auto-load READY_FOR_LOADING inventory with PAID bookings", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/auto-unload-arrived": ["Bulk auto-unload all arrived bookings into the warehouse", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/approve-delivery": ["Approve delivery — customer records their full name (signature optional)", "POST", "Warehouse Inventory"], + "PATCH /api/warehouse-inventory/bookings/:bookingId/double-handling": ["Record Yes/No double handling after unloading (Yes applies the double-handling fee rule)", "PATCH", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/request-handover-signature": ["Ask the customer to sign the handover (creates one if none, then notifies)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bookings/:bookingId/unload": ["Unload a single arrived booking into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-dispatch-export": ["Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/bulk-mark-inspected": ["Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/export/auto-unload-at-djibouti": ["Unload all eligible export items assigned to an arrived Djibouti-side train", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/handovers/:handoverId/sign": ["Customer signs one handover (EDR last-mile: one signature per truck)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/import/auto-unload-arrived-bookings": ["Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive": ["Receive inventory at a warehouse location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/receive-bulk": ["Bulk-receive selected eligible PAID bookings into a location", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/reserve": ["Reserve stored inventory for a PAID booking", "POST", "Warehouse Inventory"], + "POST /api/warehouse-inventory/train/:scheduleId/load": ["Load selected inventory items onto their allocated wagons for a train", "POST", "Warehouse Inventory"], + + // Warehouse Yard + "PATCH /api/warehouse-yards/:id": ["Update warehouse yard", "PATCH", "Warehouse Yard"], + "POST /api/warehouse-yards/:yardId/zones": ["Create a zone within a yard", "POST", "Warehouse Yard"], + + // Warehouse Zone + "PATCH /api/warehouse-zones/:id": ["Update warehouse zone", "PATCH", "Warehouse Zone"], + + // Weight Limit Rule + "POST /api/weight-limit-rules": ["Create a weight limit rule", "POST", "Weight Limit Rule"], + "PATCH /api/weight-limit-rules/:id": ["Update a weight limit rule", "PATCH", "Weight Limit Rule"], + "DELETE /api/weight-limit-rules/:id": ["Soft-delete a weight limit rule", "DELETE", "Weight Limit Rule"], + + // Yard + "POST /api/yards": ["Create a yard", "POST", "Yard"], + "PATCH /api/yards/:id": ["Update a yard", "PATCH", "Yard"], + "DELETE /api/yards/:id": ["Soft-delete a yard", "DELETE", "Yard"], + "POST /api/yards/:id/move-order": ["Move a yard up or down in display order", "POST", "Yard"], + "POST /api/yards/reorder": ["Bulk reorder yards by ID list", "POST", "Yard"], + + // Yard Distance + "POST /api/yard-distances": ["Create a yard distance", "POST", "Yard Distance"], + "PATCH /api/yard-distances/:id": ["Update a yard distance", "PATCH", "Yard Distance"], + "DELETE /api/yard-distances/:id": ["Soft-delete a yard distance", "DELETE", "Yard Distance"], +}; diff --git a/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts new file mode 100644 index 000000000..91d6c9901 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit-log.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Between, FindOptionsWhere, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity'; + +import { AuditLog } from './entities/audit-log.entity'; + +export interface AuditLogQuery { + type?: string; + userId?: string; + method?: string; + isSuccess?: boolean; + resourceId?: string; + from?: Date; + to?: Date; + skip: number; + take: number; +} + +@Injectable() +export class AuditLogRepository extends BaseRepository { + constructor( + @InjectRepository(AuditLog) + private readonly auditLogRepository: Repository, + ) { + super(auditLogRepository); + } + + /** + * Insert one audit row. + * + * `insert` rather than `save`: save would issue a SELECT first to decide + * between insert and update, which is wasted work for a table that is only + * ever appended to. + */ + async record(entry: Partial): Promise { + await this.auditLogRepository.insert( + entry as QueryDeepPartialEntity, + ); + } + + /** + * Paginated, filtered read. Newest first — every index on this table is + * ordered `created_at DESC` to match. + */ + async search(query: AuditLogQuery): Promise<[AuditLog[], number]> { + const where: FindOptionsWhere = {}; + + if (query.type) where.type = query.type; + if (query.userId) where.userId = query.userId; + if (query.method) where.method = query.method; + if (query.resourceId) where.resourceId = query.resourceId; + if (query.isSuccess !== undefined) where.isSuccess = query.isSuccess; + + // Date range: either bound may be supplied alone. + if (query.from && query.to) where.createdAt = Between(query.from, query.to); + else if (query.from) where.createdAt = MoreThanOrEqual(query.from); + else if (query.to) where.createdAt = LessThanOrEqual(query.to); + + return this.auditLogRepository.findAndCount({ + where, + order: { createdAt: 'DESC' }, + skip: query.skip, + take: query.take, + }); + } + + /** Distinct entity types present, for populating a filter dropdown. */ + async distinctTypes(): Promise { + const rows = await this.auditLogRepository + .createQueryBuilder('audit_log') + .select('DISTINCT audit_log.type', 'type') + .orderBy('audit_log.type', 'ASC') + .getRawMany<{ type: string }>(); + + return rows.map((row) => row.type); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.controller.ts b/apps/edr-freight-api/src/modules/audit/audit.controller.ts new file mode 100644 index 000000000..1a07a5fe5 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { PaginatedResponse } from '@edr/types'; + +import { BookingStaff } from '../../common/booking-guards'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; +import { AuditService } from './audit.service'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; + +/** + * Read-only view over the audit trail. + * + * Gated on `edr_freight_app:audit_log:view` — a dedicated view key rather than + * the broad `admin` key, so reading the trail can be granted without also + * granting write access to everything else. + * + * There is deliberately no write, update or delete endpoint here — rows are + * created only by `AuditInterceptor`, and an audit trail that can be edited + * through the API is not an audit trail. + */ +@ApiTags('audit') +@ApiBearerAuth() +@Controller('audit') +export class AuditController { + constructor(private readonly auditService: AuditService) {} + + @Get('logs') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: + 'List backoffice audit logs — filter by entity type, user, method, outcome and date range', + }) + list(@Query() query: AuditLogQueryDto): Promise> { + return this.auditService.search(query); + } + + @Get('types') + @BookingStaff(FREIGHT_PERMS.auditLog.view) + @ApiOperation({ + summary: 'Distinct entity types present in the audit log (filter dropdown)', + }) + types(): Promise { + return this.auditService.listTypes(); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts new file mode 100644 index 000000000..cf12fd4d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.interceptor.ts @@ -0,0 +1,189 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Observable, tap } from 'rxjs'; +import type { Request, Response } from 'express'; + +import { AuditService } from './audit.service'; +import { + auditEndpointMatcher, + type MatchedAuditEndpoint, +} from './audit-endpoint-matcher'; +import { + isAuditableActor, + resolveAuditActor, + type AuditActorSource, +} from './audit-actor'; +import { redactUrlQuery, sanitizeRequestPayload } from './audit.sanitizer'; + +/** Methods that can change state. Everything else is never audited. */ +const AUDITED_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** `error_message` ceiling — stack traces do not belong in this column. */ +const MAX_ERROR_LENGTH = 2_000; + +type RequestWithUser = Request & { + user?: AuditActorSource; + files?: unknown; + file?: unknown; + id?: string; +}; + +/** + * Writes one `audit_logs` row per state-changing backoffice request. + * + * An interceptor rather than the two middlewares originally sketched, for one + * decisive reason: Express middleware runs BEFORE guards, so `req.user` is not + * populated yet. Both the backoffice-only rule and `user_id` would be + * unavailable there. Interceptors run after guards and wrap the handler's + * result, so a single class covers both halves — request context on the way in, + * outcome on the way out — sharing one timer for `duration_ms`. + * + * Registered globally (see `audit.module.ts`), so new routes are covered + * automatically as long as they appear in `AUDIT_ENDPOINTS`. + */ +@Injectable() +export class AuditInterceptor implements NestInterceptor { + constructor(private readonly auditService: AuditService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + // Non-HTTP contexts (the RabbitMQ microservice transport) have no request. + if (context.getType() !== 'http') return next.handle(); + + const httpContext = context.switchToHttp(); + const request = httpContext.getRequest(); + + if (!AUDITED_METHODS.has(request.method)) return next.handle(); + + // Backoffice only. Customers and unauthenticated callers are skipped + // outright — decided in `audit-actor.ts`, which reuses the same + // `userType` discriminator as the permission guards. + if (!isAuditableActor(request.user)) return next.handle(); + + const matched = auditEndpointMatcher.match(request.method, request.originalUrl); + // Not in AUDIT_ENDPOINTS means the route is not a known auditable action; + // recording it would produce rows with no title or entity. + if (!matched) return next.handle(); + + const startedAt = Date.now(); + // The body is captured up front: handlers are free to mutate the DTO they + // are given, so reading it after the fact can record post-mutation values. + const requestPayload = sanitizeRequestPayload( + request.body, + request.files ?? request.file, + ); + + return next.handle().pipe( + tap({ + next: () => { + const response = httpContext.getResponse(); + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: true, + // Nest has not applied the handler's @HttpCode yet at this point + // for some routes; statusCode on the response object is the value + // actually being sent. + statusCode: response.statusCode, + errorMessage: null, + }); + }, + error: (error: unknown) => { + void this.write(request, matched, requestPayload, startedAt, { + isSuccess: false, + statusCode: resolveErrorStatus(error), + errorMessage: resolveErrorMessage(error), + }); + }, + }), + ); + } + + /** + * Build and persist the row. + * + * Deliberately not awaited by `intercept`: the audit write must not add + * latency to the request, and `AuditService.record` already swallows its own + * failures so a rejected promise cannot surface as an unhandled rejection. + */ + private async write( + request: RequestWithUser, + matched: MatchedAuditEndpoint, + requestPayload: Record | null, + startedAt: number, + outcome: { + isSuccess: boolean; + statusCode: number | null; + errorMessage: string | null; + }, + ): Promise { + const actor = resolveAuditActor(request.user as AuditActorSource); + + await this.auditService.record({ + title: matched.title, + method: request.method, + // Full URL including query string, with sensitive query values redacted. + url: redactUrlQuery(request.originalUrl), + routePath: matched.routePath, + type: matched.type, + isSuccess: outcome.isSuccess, + statusCode: outcome.statusCode, + errorMessage: outcome.errorMessage, + userId: actor.userId, + userName: actor.userName, + userRole: actor.userRole, + resourceId: matched.resourceId, + request: requestPayload, + ipAddress: resolveIp(request), + userAgent: request.headers['user-agent'] ?? null, + requestId: resolveRequestId(request), + durationMs: Date.now() - startedAt, + }); + } +} + +/** HTTP status for the failure, falling back to 500 for non-HTTP errors. */ +function resolveErrorStatus(error: unknown): number { + return error instanceof HttpException ? error.getStatus() : 500; +} + +/** Message only — stack traces belong in application logs, not this column. */ +function resolveErrorMessage(error: unknown): string | null { + if (error instanceof HttpException) { + const response = error.getResponse(); + const message = + typeof response === 'string' + ? response + : ((response as { message?: unknown })?.message ?? error.message); + const text = Array.isArray(message) ? message.join('; ') : String(message); + return text.slice(0, MAX_ERROR_LENGTH); + } + + if (error instanceof Error) return error.message.slice(0, MAX_ERROR_LENGTH); + return error ? String(error).slice(0, MAX_ERROR_LENGTH) : null; +} + +/** + * Client IP. The API sits behind a reverse proxy, so `req.ip` is the proxy + * unless `trust proxy` is set; the forwarded header is preferred and its first + * entry (the original client) taken. + */ +function resolveIp(request: Request): string | null { + const forwarded = request.headers['x-forwarded-for']; + const raw = Array.isArray(forwarded) ? forwarded[0] : forwarded; + const candidate = raw?.split(',')[0]?.trim() || request.ip; + if (!candidate) return null; + + // Normalize IPv4-mapped IPv6 (`::ffff:10.0.0.1`), which the `inet` column + // accepts but which reads badly and breaks grouping by address. + return candidate.startsWith('::ffff:') ? candidate.slice(7) : candidate; +} + +/** Correlation id from the proxy/tracing layer, when present. */ +function resolveRequestId(request: RequestWithUser): string | null { + const header = request.headers['x-request-id'] ?? request.headers['x-correlation-id']; + const value = Array.isArray(header) ? header[0] : header; + return (value ?? request.id ?? null)?.toString().slice(0, 64) ?? null; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.module.ts b/apps/edr-freight-api/src/modules/audit/audit.module.ts new file mode 100644 index 000000000..608af3672 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.module.ts @@ -0,0 +1,34 @@ +import { Global, Module } from '@nestjs/common'; +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { AuditController } from './audit.controller'; +import { AuditInterceptor } from './audit.interceptor'; +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditService } from './audit.service'; + +/** + * Backoffice audit trail. + * + * `AuditInterceptor` is bound through `APP_INTERCEPTOR`, so it applies to every + * route in the application without touching the 488 mutating handlers + * individually. Coverage therefore follows `AUDIT_ENDPOINTS`: a new route is + * audited as soon as it appears in that map, and unknown routes are skipped + * rather than recorded with an empty title. + * + * Global so other modules can inject `AuditService` to record domain events + * that do not map cleanly onto an HTTP request. + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([AuditLog])], + controllers: [AuditController], + providers: [ + AuditLogRepository, + AuditService, + { provide: APP_INTERCEPTOR, useClass: AuditInterceptor }, + ], + exports: [AuditService, AuditLogRepository], +}) +export class AuditModule {} diff --git a/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts new file mode 100644 index 000000000..3a934beea --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.sanitizer.ts @@ -0,0 +1,195 @@ +/** + * Redaction and shrinking for anything copied into `audit_logs.request`. + * + * This matters more here than in a typical audit log. `main.ts` raises the JSON + * body ceiling to 100MB so contract signing can post a signature AND a company + * stamp as base64 in one request. Copying a body like that verbatim would put + * both a credential-grade artefact and a 100MB blob into the audit table, on + * the write path of every audited endpoint. + */ + +const REDACTED = '[REDACTED]'; + +/** + * Substring-matched against lower-cased key names, so `newPassword`, + * `otpCode` and `x-authorization` are all caught without enumerating variants. + * + * `signature` and `stamp` are here because contract signing posts both as + * base64 — they are simultaneously the largest and the most sensitive fields + * this API accepts. + */ +const SENSITIVE_KEY_PATTERNS = [ + 'password', + 'otp', + 'token', + 'secret', + 'pin', + 'authorization', + 'signature', + 'stamp', + 'apikey', + 'api_key', + 'credential', + 'ssn', +]; + +/** Serialized `request` ceiling. Beyond this the payload is dropped for a marker. */ +const MAX_REQUEST_BYTES = 64 * 1024; + +/** Depth guard: deep nesting is never worth the recursion cost here. */ +const MAX_DEPTH = 6; + +/** Long strings (base64 blobs) are truncated rather than stored whole. */ +const MAX_STRING_LENGTH = 2_000; + +function isSensitiveKey(key: string): boolean { + const lower = key.toLowerCase(); + return SENSITIVE_KEY_PATTERNS.some((pattern) => lower.includes(pattern)); +} + +/** + * Multer file shape, reduced to a descriptor. The buffer is never stored — + * Postgres is the wrong home for file bytes, and `audit_logs` doubly so. + */ +function isMulterFile(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Record; + return ( + typeof candidate.originalname === 'string' && + (typeof candidate.mimetype === 'string' || typeof candidate.size === 'number') + ); +} + +function describeFile(value: Record): Record { + return { + __file: true, + originalName: value.originalname ?? null, + mimeType: value.mimetype ?? null, + size: typeof value.size === 'number' ? value.size : null, + fieldName: value.fieldname ?? null, + }; +} + +function sanitizeValue(value: unknown, depth: number): unknown { + if (value === null || value === undefined) return value ?? null; + + if (typeof value === 'string') { + return value.length > MAX_STRING_LENGTH + ? `${value.slice(0, MAX_STRING_LENGTH)}…[truncated ${value.length} chars]` + : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') return value; + if (value instanceof Date) return value.toISOString(); + // Buffers are file bytes by definition — never persisted, only described. + if (Buffer.isBuffer(value)) return { __buffer: true, size: value.length }; + + if (depth >= MAX_DEPTH) return '[MAX_DEPTH]'; + + if (Array.isArray(value)) { + // Cap array length: bulk endpoints post large collections. + const capped = value.slice(0, 50).map((item) => sanitizeValue(item, depth + 1)); + if (value.length > 50) capped.push(`…[${value.length - 50} more items]`); + return capped; + } + + if (typeof value === 'object') { + if (isMulterFile(value)) return describeFile(value as Record); + + const out: Record = {}; + for (const [key, nested] of Object.entries(value as Record)) { + out[key] = isSensitiveKey(key) ? REDACTED : sanitizeValue(nested, depth + 1); + } + return out; + } + + // Functions, symbols and anything else are not audit data. + return null; +} + +/** + * Sanitize a request body (or query object) for storage. + * + * Returns null when there is nothing worth keeping, so empty bodies do not + * occupy jsonb rows. + */ +export function sanitizeRequestPayload( + body: unknown, + files?: unknown, +): Record | null { + const payload: Record = {}; + + if (body && typeof body === 'object' && Object.keys(body).length > 0) { + const sanitizedBody = sanitizeValue(body, 0); + if (sanitizedBody && typeof sanitizedBody === 'object') { + Object.assign(payload, sanitizedBody as Record); + } + } + + // Multer puts uploads on `req.files`, outside `req.body`, so they are folded + // in explicitly — otherwise a pure-upload request records an empty payload. + if (files) { + const sanitizedFiles = sanitizeValue(files, 0); + if ( + sanitizedFiles && + (Array.isArray(sanitizedFiles) || typeof sanitizedFiles === 'object') + ) { + const hasEntries = Array.isArray(sanitizedFiles) + ? sanitizedFiles.length > 0 + : Object.keys(sanitizedFiles as object).length > 0; + if (hasEntries) payload.__uploads = sanitizedFiles; + } + } + + if (Object.keys(payload).length === 0) return null; + + // Final size guard. A body can stay under every per-field cap and still be + // enormous in aggregate, so the serialized form is measured before storing. + const serialized = JSON.stringify(payload); + if (serialized && Buffer.byteLength(serialized, 'utf8') > MAX_REQUEST_BYTES) { + return { + __truncated: true, + reason: 'Payload exceeded the audit size limit', + bytes: Buffer.byteLength(serialized, 'utf8'), + keys: Object.keys(payload).slice(0, 50), + }; + } + + return payload; +} + +/** + * Rebuild a URL with sensitive query values redacted. + * + * `url` is stored with its full query string, and query strings are a common + * place for one-time tokens and signed links, so the same deny-list that + * protects the body is applied to the query. + */ +export function redactUrlQuery(url: string): string { + const queryIndex = url.indexOf('?'); + if (queryIndex === -1) return url; + + const path = url.slice(0, queryIndex); + const query = url.slice(queryIndex + 1); + if (!query) return path; + + const redacted = query + .split('&') + .map((pair) => { + const eq = pair.indexOf('='); + if (eq === -1) return pair; + const key = pair.slice(0, eq); + // Keys arrive percent-encoded; decode before matching so `api%2Dkey` + // is not treated as harmless. + let decodedKey = key; + try { + decodedKey = decodeURIComponent(key); + } catch { + /* malformed encoding — fall back to the raw key */ + } + return isSensitiveKey(decodedKey) ? `${key}=${REDACTED}` : pair; + }) + .join('&'); + + return `${path}?${redacted}`; +} diff --git a/apps/edr-freight-api/src/modules/audit/audit.service.ts b/apps/edr-freight-api/src/modules/audit/audit.service.ts new file mode 100644 index 000000000..de7dfd956 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/audit.service.ts @@ -0,0 +1,71 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PaginatedResponse } from '@edr/types'; + +import { AuditLog } from './entities/audit-log.entity'; +import { AuditLogRepository } from './audit-log.repository'; +import { AuditLogQueryDto } from './dto/audit-log-query.dto'; +import { + buildPaginationMeta, + normalizePagination, +} from '../../common/utils/pagination.util'; + +@Injectable() +export class AuditService { + private readonly logger = new Logger(AuditService.name); + + constructor(private readonly auditLogRepository: AuditLogRepository) {} + + /** + * Persist one audit row, swallowing any failure. + * + * An audit write must never turn a successful business action into an error + * for the user: if this table is full, misconfigured or mid-migration, + * contract approvals still need to work. Failures are logged so the gap is + * visible in application logs rather than silent. + */ + async record(entry: Partial): Promise { + try { + await this.auditLogRepository.record(entry); + } catch (error) { + this.logger.error( + `Failed to write audit log for ${entry.method} ${entry.routePath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + } + } + + /** Paginated, filtered audit history, newest first. */ + async search(query: AuditLogQueryDto): Promise> { + const { page, pageSize, skip, take } = normalizePagination(query); + + const from = query.from ? new Date(query.from) : undefined; + const to = query.to ? new Date(query.to) : undefined; + + // A reversed range silently returns zero rows, which reads as "nothing + // happened" rather than "your filter is wrong" — reject it explicitly. + if (from && to && from > to) { + throw new BadRequestException('`from` must be earlier than `to`'); + } + + const [items, total] = await this.auditLogRepository.search({ + type: query.type, + userId: query.userId, + method: query.method, + resourceId: query.resourceId, + isSuccess: + query.isSuccess === undefined ? undefined : query.isSuccess === 'true', + from, + to, + skip, + take, + }); + + return { items, meta: buildPaginationMeta(total, page, pageSize) }; + } + + /** Distinct entity types, for the filter dropdown on the audit screen. */ + async listTypes(): Promise { + return this.auditLogRepository.distinctTypes(); + } +} diff --git a/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts new file mode 100644 index 000000000..5a5199538 --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/dto/audit-log-query.dto.ts @@ -0,0 +1,64 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsBooleanString, IsIn, IsISO8601, IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; + +const AUDITED_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'] as const; + +/** + * Filters for the audit log read endpoint. + * + * Extends the shared pagination DTO so page/pageSize behave (and are capped) + * exactly as they do on every other list endpoint. + */ +export class AuditLogQueryDto extends PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Entity type, e.g. "Contract", "Booking", "Locomotive".', + example: 'Contract', + }) + @IsOptional() + @IsString() + @MaxLength(50) + type?: string; + + @ApiPropertyOptional({ description: 'IAM id of the acting backoffice user.' }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ enum: AUDITED_METHODS }) + @IsOptional() + @Transform(({ value }) => String(value).toUpperCase()) + @IsIn([...AUDITED_METHODS]) + method?: string; + + @ApiPropertyOptional({ description: 'Id of the affected record.' }) + @IsOptional() + @IsString() + @MaxLength(64) + resourceId?: string; + + @ApiPropertyOptional({ + description: 'Filter by outcome: true = succeeded, false = failed.', + }) + @IsOptional() + @IsBooleanString() + isSuccess?: string; + + @ApiPropertyOptional({ + description: 'Inclusive start of the range (ISO 8601).', + example: '2026-01-01T00:00:00.000Z', + }) + @IsOptional() + @IsISO8601() + from?: string; + + @ApiPropertyOptional({ + description: 'Inclusive end of the range (ISO 8601).', + example: '2026-01-31T23:59:59.999Z', + }) + @IsOptional() + @IsISO8601() + to?: string; +} diff --git a/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts new file mode 100644 index 000000000..0ff7727ff --- /dev/null +++ b/apps/edr-freight-api/src/modules/audit/entities/audit-log.entity.ts @@ -0,0 +1,132 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm'; + +/** + * One backoffice action against a state-changing endpoint. + * + * Deliberately does NOT extend `BaseEntity`, which is the repo standard + * everywhere else. `BaseEntity` carries `updatedAt` and `deletedAt`, and both + * are wrong here: + * + * - `updatedAt` implies an audit row can be edited. A record that can be + * rewritten after the fact is not evidence. + * - `deletedAt` (soft delete) would let anyone who can delete erase their own + * trail, and TypeORM would then hide those rows from every default query — + * the failure would be silent, which is the worst property an audit log can + * have. + * + * Rows are insert-only: nothing in this module updates or deletes them. + * + * `userId` is a bare uuid with NO foreign key into the `iam` schema. Two + * reasons: cross-schema FKs are forbidden platform-wide, and a FK would let + * deleting a user cascade away the record of what that user did — exactly + * backwards. `userName` / `userRole` are point-in-time snapshots for the same + * reason: resolving them at read time would rewrite history whenever somebody + * is renamed or changes role. + */ +@Entity({ schema: 'freight', name: 'audit_logs' }) +// Every audit query is time-bounded, so created_at leads most indexes. +@Index('IDX_audit_logs_created_at', ['createdAt']) +@Index('IDX_audit_logs_user_id_created_at', ['userId', 'createdAt']) +@Index('IDX_audit_logs_type_created_at', ['type', 'createdAt']) +@Index('IDX_audit_logs_type_resource_id', ['type', 'resourceId']) +@Index('IDX_audit_logs_route_path_created_at', ['routePath', 'createdAt']) +export class AuditLog { + @PrimaryGeneratedColumn('uuid') + id!: string; + + /** + * Human-readable action, e.g. "Approve contract" — taken from the matched + * entry in `AUDIT_ENDPOINTS`, which sources it from each route's + * `@ApiOperation` summary. + */ + @Column({ name: 'title', type: 'varchar', length: 255 }) + title!: string; + + @Column({ name: 'method', type: 'varchar', length: 10 }) + method!: string; + + /** + * The URL as actually called, real ids and query string included + * (`/api/contracts/abc-123/cancel?force=true`). Query values run through the + * same redaction pass as the body, so a `?token=` never lands here. + */ + @Column({ name: 'url', type: 'text' }) + url!: string; + + /** + * The route template (`/api/contracts/:id/cancel`). + * + * `url` alone cannot be grouped — every contract cancel is a distinct string. + * This column is the join key back to `AUDIT_ENDPOINTS` and makes + * "every contract cancellation" one indexed query instead of a regex scan. + */ + @Column({ name: 'route_path', type: 'varchar', length: 255, nullable: true }) + routePath?: string | null; + + /** Primary entity the action touched: `Contract`, `Booking`, `Locomotive`. */ + @Column({ name: 'type', type: 'varchar', length: 50 }) + type!: string; + + @Column({ name: 'is_success', type: 'boolean' }) + isSuccess!: boolean; + + /** IAM user id. Nullable by design — see the class comment. */ + @Column({ name: 'user_id', type: 'uuid', nullable: true }) + userId?: string | null; + + /** + * Id of the affected record, recovered from the first path parameter of the + * matched template. + * + * `varchar`, not `uuid`: not every identifier is a uuid + * (`/api/contract-templates/:code`), and a create has no id at all until it + * succeeds. A `uuid NOT NULL` column would throw during the write and lose + * the audit row rather than the id. + */ + @Column({ name: 'resource_id', type: 'varchar', length: 64, nullable: true }) + resourceId?: string | null; + + /** + * Sanitized request body. Secrets are replaced with `[REDACTED]` and uploads + * are reduced to `{ __file, originalName, mimeType, size }` descriptors — + * never raw bytes. See `audit.sanitizer.ts`. + */ + @Column({ name: 'request', type: 'jsonb', nullable: true }) + request?: Record | null; + + /** + * `isSuccess` alone cannot separate 403 (denied — the security signal worth + * alerting on) from 500 (broke). Both are simply `false`. + */ + @Column({ name: 'status_code', type: 'smallint', nullable: true }) + statusCode?: number | null; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage?: string | null; + + /** Snapshot of the actor's display name at the time of the action. */ + @Column({ name: 'user_name', type: 'varchar', length: 150, nullable: true }) + userName?: string | null; + + /** Snapshot of the actor's role at the time of the action. */ + @Column({ name: 'user_role', type: 'varchar', length: 100, nullable: true }) + userRole?: string | null; + + /** Non-repudiation: the first thing asked in any incident review. */ + @Column({ name: 'ip_address', type: 'inet', nullable: true }) + ipAddress?: string | null; + + /** Helps separate a real browser session from a script using a stolen token. */ + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent?: string | null; + + /** Correlates this row with application logs/traces for the same request. */ + @Column({ name: 'request_id', type: 'varchar', length: 64, nullable: true }) + requestId?: string | null; + + @Column({ name: 'duration_ms', type: 'integer', nullable: true }) + durationMs?: number | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt!: Date; +} diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index f10b5f021..9c4b69ad6 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -1267,18 +1267,18 @@ export class BookingsController { @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: TCurrentUser, ) { const booking = await this.bookingClearanceService.uploadDeliveryOrder( id, - file, + files ?? [], resolveAuthUserId(user), { vesselArrivalDate, doCollectedDate }, ); @@ -1287,17 +1287,17 @@ export class BookingsController { @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') async uploadBookingReleaseOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: TCurrentUser, ) { const result = await this.bookingClearanceService.uploadReleaseOrder( id, - file, + files ?? [], vesselDepartureDate, resolveAuthUserId(user), ); diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index e2a3d9b0c..121c889bc 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -59,9 +59,11 @@ import { PdfRenderService } from '../billing/documents/pdf-render.service'; import { buildTabularFallbackPdf } from '../billing/documents/styled-pdf.util'; /** - * The allocated train as the backoffice booking detail page needs it: which - * train, its window phase, and both the planned and actual clock. Attached by - * `findById` only when the booking is on a schedule. + * The train as the backoffice booking detail page needs it: which train, its + * window phase, and both the planned and actual clock. Attached by `findById` + * for the allocated train (`train_schedule_id`) or, before the batch engine has + * allocated one, the train the customer picked at day-commit + * (`requested_train_schedule_id`) — see `isRequested`. */ export interface TrainScheduleSummary { id: string; @@ -74,6 +76,12 @@ export interface TrainScheduleSummary { actualArrivalAt: string | null; windowPhase: string | null; paymentPhaseEndsAt: string | null; + /** + * True when this is the customer's requested train rather than a confirmed + * allocation — the state staff review at OPERATION_REQUEST_PENDING, before + * accepting the operation puts the booking into the batch pool. + */ + isRequested: boolean; } /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ @@ -2132,15 +2140,23 @@ export class BookingsService { // Surface the assigned train's operational status so the portal stepper // can show the Arrival stage: the booking status stays IN_TRANSIT from // dispatch until delivery, so arrival is only knowable from the schedule. - if (booking.trainScheduleId) { + // The allocated train, or — before the batch engine has allocated one — the + // train the customer picked at day-commit. Staff reviewing an operation + // request (OPERATION_REQUEST_PENDING) must see which train they are + // accepting onto before they approve, and at that point only the requested + // id is set. + const summarySourceId = booking.trainScheduleId ?? booking.requestedTrainScheduleId; + if (summarySourceId) { const schedule = await this.dataSource .getRepository(TrainSchedule) - .findOne({ where: { id: booking.trainScheduleId } }); + .findOne({ where: { id: summarySourceId } }); + // trainScheduleStatus drives the portal's Arrival stage, so it stays tied + // to a real allocation — a merely requested train has not departed. (booking as Booking & { trainScheduleStatus?: string | null }).trainScheduleStatus = - schedule?.status ?? null; - // Backoffice staff view: the allocated train's identity and clock, so the - // detail page can state which train the booking rides and when it runs - // without a second round-trip to the schedules API. + booking.trainScheduleId ? (schedule?.status ?? null) : null; + // Backoffice staff view: the train's identity and clock, so the detail + // page can state which train the booking rides and when it runs without a + // second round-trip to the schedules API. ( booking as Booking & { trainScheduleSummary?: TrainScheduleSummary | null } ).trainScheduleSummary = schedule @@ -2155,6 +2171,7 @@ export class BookingsService { actualArrivalAt: schedule.actualArrivalAt?.toISOString() ?? null, windowPhase: schedule.windowPhase ?? null, paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null, + isRequested: !booking.trainScheduleId, } : null; } diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts index df55493de..8c127e840 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.spec.ts @@ -291,7 +291,7 @@ describe('BookingClearanceService', () => { const { service, bookingsRepository } = makeService({ booking: generalExportBooking }); const result = await service.uploadReleaseOrder( 'b-export', - { fieldname: 'ro' } as Express.Multer.File, + [{ fieldname: 'ro' } as Express.Multer.File], dateStr, ); diff --git a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts index c9c15f8b0..e0d30a2be 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-clearance.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ContractDocPhase, + isDeliveryOrderFileCode, isDraftDeclarationFileCode, type ClearanceFinalInvoiceSummary, type ClearanceOffloadState, @@ -29,7 +30,7 @@ import { GlOperationsService } from './gl-operations.service'; import { GlExchangeService } from './gl-exchange.service'; import { TransitAgentsService } from '../transit-agents/transit-agents.service'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDraftDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistDeliveryOrderUploads, persistDraftDeclarationUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -813,7 +814,7 @@ export class BookingClearanceService { // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(bookingId, 'bookings'); - if (files.some((f) => f.code === 'delivery_order')) { + if (files.some((f) => isDeliveryOrderFileCode(f.code))) { await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED'); await this.workflowService.markReadyForOperation(bookingId); } @@ -823,7 +824,7 @@ export class BookingClearanceService { async uploadDeliveryOrder( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { @@ -832,19 +833,12 @@ export class BookingClearanceService { throw new BadRequestException('Delivery Order applies only to import bookings.'); } - if (!file) throw new BadRequestException('No Delivery Order uploaded'); - const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and operation readiness) still // waits for GL Ethiopia to finalize pre-clearance so the workflow order holds. - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'delivery_order', - file, - }); + await persistDeliveryOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselArrivalDate, @@ -880,7 +874,7 @@ export class BookingClearanceService { async uploadReleaseOrder( bookingId: string, - file: Express.Multer.File, + files: Express.Multer.File[], vesselDepartureDate: string, userId?: string, ): Promise<{ booking: Booking; hold: boolean; holdReason?: string }> { @@ -894,7 +888,6 @@ export class BookingClearanceService { 'RELEASE_ORDER_SECURED', ); - if (!file) throw new BadRequestException('No Release Order uploaded'); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } @@ -902,12 +895,7 @@ export class BookingClearanceService { const minDays = await this.resolveRoMinDays(); const leadDays = this.daysUntil(vesselDepartureDate); - await this.filesService.upsertByCode({ - resourceId: bookingId, - resource: 'bookings', - code: 'release_order', - file, - }); + await persistReleaseOrderUploads(this.filesService, bookingId, 'bookings', files ?? []); await this.bookingsRepository.update(bookingId, { vesselDepartureDate, diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 2ebd8fc70..76106a9ff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { ContractDocPhase, + isDeliveryOrderFileCode, type ClearanceFinalInvoiceSummary, type ClearanceOffloadState, type ClearanceSecondDuty, @@ -36,7 +37,7 @@ import { Contract } from './entities/contract.entity'; import { ContractDocReviewStatus } from './entities/contract-document-review.entity'; import { FilterContractDto } from './dto/filter-contract.dto'; import { AdviseContractDutyDto } from './dto/phased-clearance.dto'; -import { buildWorkflowFiles, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; +import { buildWorkflowFiles, persistDeclarationUploads, persistDeliveryOrderUploads, persistReleaseOrderUploads, persistTransitPermitUploads, PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES } from './phased-clearance.util'; const RO_VESSEL_MIN_DAYS_CODE = 'ro_vessel_min_days'; @@ -1449,7 +1450,7 @@ export class ContractClearanceService { // GL Djibouti may have uploaded the DO early (un-gated) — count it now. const files = await this.filesService.findByResource(contractId, 'contracts'); - if (files.some((f) => f.code === 'delivery_order')) { + if (files.some((f) => isDeliveryOrderFileCode(f.code))) { await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED'); await this.workflowService.markReadyForBooking(contractId); } @@ -1460,7 +1461,7 @@ export class ContractClearanceService { async uploadDeliveryOrder( contractId: string, - file: Express.Multer.File, + files: Express.Multer.File[], userId?: string, dates?: { vesselArrivalDate?: string; doCollectedDate?: string }, ): Promise { @@ -1470,19 +1471,12 @@ export class ContractClearanceService { throw new BadRequestException('Delivery Order applies only to import contracts.'); } - if (!file) throw new BadRequestException('No Delivery Order uploaded'); - const { vesselArrivalDate, doCollectedDate } = assertDoCollectionDates(dates); // DO upload is deliberately un-gated: GL Djibouti may attach it at any point, // any file type. The DO_COLLECTED milestone (and booking readiness) still waits // for GL Ethiopia to finalize pre-clearance so the workflow order holds. - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: 'delivery_order', - file, - }); + await persistDeliveryOrderUploads(this.filesService, contractId, 'contracts', files ?? []); const cycle = await this.contractsRepository.currentCycle(contractId); if (cycle) { @@ -1520,7 +1514,7 @@ export class ContractClearanceService { async uploadReleaseOrder( contractId: string, - file: Express.Multer.File, + files: Express.Multer.File[], vesselDepartureDate: string, userId?: string, ): Promise<{ contract: Contract; hold: boolean; holdReason?: string }> { @@ -1535,7 +1529,6 @@ export class ContractClearanceService { 'RELEASE_ORDER_SECURED', ); - if (!file) throw new BadRequestException('No Release Order uploaded'); if (!vesselDepartureDate?.trim()) { throw new BadRequestException('Vessel departure date is required'); } @@ -1545,12 +1538,7 @@ export class ContractClearanceService { const cycle = await this.contractsRepository.currentCycle(contractId); if (!cycle) throw new BadRequestException('No clearance cycle found'); - await this.filesService.upsertByCode({ - resourceId: contractId, - resource: 'contracts', - code: 'release_order', - file, - }); + await persistReleaseOrderUploads(this.filesService, contractId, 'contracts', files ?? []); await this.contractsRepository.updateCycle(cycle.id, { vesselDepartureDate, diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ff9b7c24..c9db49b24 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -956,20 +956,20 @@ export class ContractsController { @Post(':id/clearance/delivery-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') @ApiOperation({ summary: - 'GL DJ uploads Delivery Order (import) with vessel arrival + DO collected dates', + 'GL DJ uploads Delivery Order files (import) with vessel arrival + DO collected dates', }) uploadDeliveryOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselArrivalDate') vesselArrivalDate: string | undefined, @Body('doCollectedDate') doCollectedDate: string | undefined, @CurrentUser() user: AuthUserPayload, ) { - return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user), { + return this.clearanceService.uploadDeliveryOrder(id, files ?? [], resolveAuthUserId(user), { vesselArrivalDate, doCollectedDate, }); @@ -977,18 +977,18 @@ export class ContractsController { @Post(':id/clearance/release-order') @BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions) - @UseInterceptors(FileInterceptor('file')) + @UseInterceptors(AnyFilesInterceptor()) @ApiConsumes('multipart/form-data') - @ApiOperation({ summary: 'GL DJ uploads Release Order + vessel departure date (export)' }) + @ApiOperation({ summary: 'GL DJ uploads Release Order files + vessel departure date (export)' }) uploadReleaseOrder( @Param('id', ParseUUIDPipe) id: string, - @UploadedFile() file: Express.Multer.File, + @UploadedFiles() files: Express.Multer.File[], @Body('vesselDepartureDate') vesselDepartureDate: string, @CurrentUser() user: AuthUserPayload, ) { return this.clearanceService.uploadReleaseOrder( id, - file, + files ?? [], vesselDepartureDate, resolveAuthUserId(user), ); diff --git a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts index 78d0d6b47..ab2a2b2d4 100644 --- a/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts +++ b/apps/edr-freight-api/src/modules/contracts/phased-clearance.util.ts @@ -5,6 +5,10 @@ import { draftDeclarationFileLabel, isDeclarationFileCode, isDraftDeclarationFileCode, + isDeliveryOrderFileCode, + isReleaseOrderFileCode, + deliveryOrderFileLabel, + releaseOrderFileLabel, isImportTransitPermitFileCode, isExportTransportFileCode, isT1TransportFileCode, @@ -166,6 +170,98 @@ export async function persistTransitPermitUploads( ); } +/** Require at least one Delivery Order file in the upload batch. */ +export function assertDeliveryOrderFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No Delivery Order uploaded'); + } +} + +/** Assign stable `delivery_order_*` codes for multi-file DO uploads. */ +export function normalizeDeliveryOrderFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `delivery_order_${index}`, + })); +} + +/** Replace all Delivery Order files on a resource with a new multi-file batch. */ +export async function persistDeliveryOrderUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeDeliveryOrderFieldNames(files); + assertDeliveryOrderFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isDeliveryOrderFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `delivery_order_${index}`, + file, + }), + ), + ); +} + +/** Require at least one Release Order file in the upload batch. */ +export function assertReleaseOrderFiles(files: Express.Multer.File[]): void { + if (files.length === 0) { + throw new BadRequestException('No Release Order uploaded'); + } +} + +/** Assign stable `release_order_*` codes for multi-file RO uploads. */ +export function normalizeReleaseOrderFieldNames( + files: Express.Multer.File[], +): Express.Multer.File[] { + return files.map((file, index) => ({ + ...file, + fieldname: `release_order_${index}`, + })); +} + +/** Replace all Release Order files on a resource with a new multi-file batch. */ +export async function persistReleaseOrderUploads( + store: DeclarationFileStore, + resourceId: string, + resource: string, + files: Express.Multer.File[], +): Promise { + const normalized = normalizeReleaseOrderFieldNames(files); + assertReleaseOrderFiles(normalized); + + const existing = await store.findByResource(resourceId, resource); + await Promise.all( + existing + .filter((f) => f.code && isReleaseOrderFileCode(f.code)) + .map((f) => store.deleteByCode(resourceId, resource, f.code!)), + ); + + await Promise.all( + normalized.map((file, index) => + store.upload({ + resourceId, + resource, + code: `release_order_${index}`, + file, + }), + ), + ); +} + /** Require at least one export transport document in the upload batch. */ export function assertExportTransportFiles(files: Express.Multer.File[]): void { if (files.length === 0) { @@ -422,6 +518,22 @@ export function buildWorkflowFiles( }); }); + const extraDeliveryOrders = files + .filter((f) => f.code && isDeliveryOrderFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraDeliveryOrders.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: deliveryOrderFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + const extraT1 = files .filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); @@ -440,6 +552,22 @@ export function buildWorkflowFiles( } if (tradeDirection === 'EXPORT') { + const extraReleaseOrders = files + .filter((f) => f.code && isReleaseOrderFileCode(f.code) && !included.has(f.code)) + .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); + + extraReleaseOrders.forEach((file, index) => { + if (!file.code) return; + included.add(file.code); + out.push({ + code: file.code, + label: releaseOrderFileLabel(file.code, index), + uploadedBy: 'gl_dj', + category: 'djibouti', + file: { id: file.id, name: file.name, url: file.url }, + }); + }); + const extraExportTransport = files .filter((f) => f.code && isExportTransportFileCode(f.code) && !included.has(f.code)) .sort((a, b) => (a.code ?? '').localeCompare(b.code ?? '')); diff --git a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts index 5730120e9..cd8fe1bb9 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/entities/train-schedule.entity.ts @@ -64,6 +64,14 @@ export class TrainSchedule extends BaseEntity { @Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true }) trainNumber?: string | null; + /** + * Voyage (sailing) number for this departure — the identifier yards and + * customs quote alongside the train number. Per-departure, so it lives here + * rather than on the built train. + */ + @Column({ name: 'voyage_number', type: 'varchar', length: 20, nullable: true }) + voyageNumber?: string | null; + // Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule // list, booking windows, and load lists. Assigned at creation from the highest // sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence). diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index c87d118c8..93a187a95 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -15,6 +15,7 @@ import { PortalCustomer, TrainSchedulingCancel, TrainSchedulingCreate, + TrainSchedulingEditTrainNumber, TrainSchedulingReschedule, TrainSchedulingRulesManage, TrainSchedulingUpdate, @@ -52,6 +53,8 @@ import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-q import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; +import { MergeScheduleTrainDto } from "../dto/merge-schedule-train.dto"; +import { UpdateScheduleTrainNumberDto } from "../dto/update-schedule-train-number.dto"; import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto"; import { TrainSchedulingService } from "../services/train-scheduling.service"; import { BookingBatchService } from "../booking-batch.service"; @@ -795,6 +798,47 @@ export class TrainSchedulingController { return this.trainSchedulingService.getContainerTrainScheduleById(id); } + @Patch("schedules/:id/train-number") + @TrainSchedulingEditTrainNumber() + @ApiOperation({ + summary: + "Edit a departure's train number and voyage number — allowed only until the train is dispatched", + }) + async updateScheduleTrainNumber( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleTrainNumberDto, + ) { + await this.trainSchedulingService.updateScheduleTrainNumber(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + + @Get("schedules/:id/merge-preview/:targetTrainId") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "What merging a train into this schedule would do — affected schedules, wagon totals and any blocking reasons. Read-only.", + }) + async previewScheduleMerge( + @Param("id", ParseUUIDPipe) id: string, + @Param("targetTrainId", ParseUUIDPipe) targetTrainId: string, + ) { + return this.trainSchedulingService.previewMerge(id, targetTrainId); + } + + @Post("schedules/:id/merge") + @TrainSchedulingUpdate() + @ApiOperation({ + summary: + "Merge another train into this schedule: its wagons join this consist, a same-day schedule on it is absorbed, and the emptied train is deactivated", + }) + async mergeScheduleTrain( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: MergeScheduleTrainDto, + ) { + await this.trainSchedulingService.mergeScheduleTrain(id, dto); + return this.trainSchedulingService.getContainerTrainScheduleById(id); + } + @Post("schedules/:id/maintenance") @TrainSchedulingReschedule() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts new file mode 100644 index 000000000..8f4c09d43 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/merge-schedule-train.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator'; + +/** + * Merge another train into this schedule's train. The schedule always survives: + * its train set is repointed at `targetTrainId`, that train's wagons join this + * consist, and the source train is left empty and deactivated. + */ +export class MergeScheduleTrainDto { + @ApiProperty({ + description: "The train being merged IN. This schedule's train absorbs it.", + }) + @IsUUID() + targetTrainId!: string; + + @ApiPropertyOptional({ + description: 'Why the trains were merged — kept on the audit trail.', + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts new file mode 100644 index 000000000..5756732c4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/update-schedule-train-number.dto.ts @@ -0,0 +1,40 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * Edit a departure's operational run identifiers. Both fields are optional so + * either can be corrected alone; the service rejects a body carrying neither, + * so an empty request cannot write an audit row for a no-op. + * + * Sending an empty string clears the field; omitting it leaves it unchanged. + */ +export class UpdateScheduleTrainNumberDto { + @ApiPropertyOptional({ + example: '9201', + description: "Run number for this departure. Empty string clears it.", + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + trainNumber?: string; + + @ApiPropertyOptional({ + example: 'V-2026-014', + description: 'Voyage (sailing) number for this departure. Empty string clears it.', + maxLength: 20, + }) + @IsOptional() + @IsString() + @MaxLength(20) + voyageNumber?: string; + + @ApiPropertyOptional({ + description: 'Why the numbers changed — kept on the audit trail.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + reason?: string; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts new file mode 100644 index 000000000..c3acfbecb --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-merge.spec.ts @@ -0,0 +1,399 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; + +/** + * Merging one train into a schedule. The schedule ALWAYS survives: its train + * set is repointed at the target train, that train's wagons join the consist, + * a same-day schedule on the target is absorbed (bookings move here, it is + * soft-deleted), and the emptied source train is deactivated. + * + * Driven against stub repositories — every rule under test is service logic. + */ +describe('TrainSchedulingService — train merge', () => { + const DAY = '2026-08-12T00:00:00.000Z'; + const OTHER_DAY = '2026-08-14T00:00:00.000Z'; + + /** Rows each repository returns, keyed by entity. */ + type Fixture = { + schedule: Record | null; + train?: Record | null; + trainSets?: Record[]; + schedules?: Record[]; + wagons?: Record[]; + wagonTypes?: Record[]; + scheduleBookings?: Record[]; + allocations?: Record[]; + milestones?: Record[]; + setWagons?: Record[]; + }; + + const makeService = (fx: Fixture) => { + const updates: Array<{ entity: string; args: unknown[] }> = []; + const softDeletes: string[] = []; + + const repoFor = (entity: unknown) => { + const name = (entity as { name?: string })?.name ?? String(entity); + const rows = (): Record[] => { + switch (name) { + case 'Train': + return fx.train ? [fx.train] : []; + case 'TrainSet': + return fx.trainSets ?? []; + case 'TrainSchedule': + return fx.schedules ?? []; + case 'Wagon': + return fx.wagons ?? []; + case 'WagonType': + return fx.wagonTypes ?? []; + case 'TrainScheduleBooking': + return fx.scheduleBookings ?? []; + case 'WagonBookingAllocation': + return fx.allocations ?? []; + case 'RouteMilestone': + return fx.milestones ?? []; + case 'TrainSetWagon': + return fx.setWagons ?? []; + default: + return []; + } + }; + return { + find: jest.fn().mockImplementation(async () => rows()), + findOne: jest.fn().mockImplementation(async () => rows()[0] ?? null), + update: jest.fn().mockImplementation(async (...args: unknown[]) => { + updates.push({ entity: name, args }); + }), + softDelete: jest.fn().mockImplementation(async (id: string) => { + softDeletes.push(id); + }), + }; + }; + + const dataSource = { + getRepository: jest.fn().mockImplementation(repoFor), + transaction: jest + .fn() + .mockImplementation(async (cb: (m: unknown) => Promise) => + cb({ getRepository: repoFor }), + ), + }; + + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + dataSource, + trainSchedulesRepository: { + findByIdWithFullGraph: jest.fn().mockResolvedValue(fx.schedule), + findById: jest.fn().mockResolvedValue(fx.schedule), + }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, updates, softDeletes }; + }; + + /** A draft schedule on T1 with 10 wagons and no locomotive caps. */ + const baseSchedule = (over: Record = {}) => ({ + id: 'S1', + reference: 'S-2026-00001', + status: 'DRAFT', + scheduledDepartureDate: DAY, + routeId: null, + maxWagons: 0, + trainSetId: 'TS1', + trainSet: { + id: 'TS1', + trainId: 'T1', + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + ...over, + }); + + const targetWagons = (n: number) => + Array.from({ length: n }, (_, i) => ({ + id: `w-${i}`, + wagonNumber: `200${i}`, + wagonTypeId: 'wt-1', + trainId: 'T2', + })); + + describe('guards', () => { + it('refuses to merge into a dispatched schedule', async () => { + const { service } = makeService({ + schedule: baseSchedule({ status: 'DISPATCHED' }), + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('refuses to merge a train into itself', async () => { + const { service } = makeService({ schedule: baseSchedule() }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T1' }), + ).rejects.toThrow(/already this schedule's train/i); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService({ schedule: null }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('blocks when the target train has no wagons to give', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: [], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/no wagons to merge/i); + }); + }); + + describe('preview', () => { + it('reports the merged wagon total and the emptied source train', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2', trainNumber: '8002' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.canMerge).toBe(true); + expect(preview.wagons).toEqual({ current: 10, incoming: 40, merged: 50 }); + expect(preview.sourceTrainWillDeactivate).toBe(true); + expect(preview.absorbedSchedule).toBeNull(); + }); + + it('names the same-day schedule whose bookings move here', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + { id: 'sb-2', bookingId: 'bk-2', trainScheduleId: 'S2' }, + ], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toMatchObject({ + id: 'S2', + reference: 'S-2026-00002', + bookingsMoving: 2, + }); + }); + + it('lists an other-day schedule as wagons-only, never absorbed', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S3', + reference: 'S-2026-00003', + status: 'DRAFT', + scheduledDepartureDate: OTHER_DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(1); + expect(preview.affectedSchedules[0]).toMatchObject({ id: 'S3' }); + }); + + it('leaves a dispatched schedule on the target untouched', async () => { + const { service } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S4', + status: 'DISPATCHED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + const preview = await service.previewMerge('S1', 'T2'); + + // Same day, but dispatched — its cargo stays put. + expect(preview.absorbedSchedule).toBeNull(); + expect(preview.affectedSchedules).toHaveLength(0); + expect(preview.untouchedSchedules).toHaveLength(1); + }); + }); + + describe('commit', () => { + it('repoints the set, moves the wagons and deactivates the source train', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const setRepoint = updates.find( + (u) => u.entity === 'TrainSet' && u.args[0] === 'TS1', + ); + expect(setRepoint?.args[1]).toMatchObject({ trainId: 'T2' }); + + const wagonMove = updates.find((u) => u.entity === 'Wagon'); + expect(wagonMove?.args[1]).toMatchObject({ trainId: 'T2' }); + + const trainPark = updates.find( + (u) => u.entity === 'Train' && u.args[0] === 'T1', + ); + expect(trainPark?.args[1]).toMatchObject({ status: 'DEACTIVATED' }); + }); + + it('moves the absorbed schedule\'s bookings here and soft-deletes it', async () => { + const { service, updates, softDeletes } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + trainSets: [{ id: 'TS2', trainId: 'T2' }], + schedules: [ + { + id: 'S2', + reference: 'S-2026-00002', + status: 'SCHEDULED', + scheduledDepartureDate: DAY, + trainSetId: 'TS2', + }, + ], + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + scheduleBookings: [ + { id: 'sb-1', bookingId: 'bk-1', trainScheduleId: 'S2' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + const bookingMove = updates.find( + (u) => u.entity === 'TrainScheduleBooking', + ); + expect(bookingMove?.args[0]).toMatchObject({ trainScheduleId: 'S2' }); + expect(bookingMove?.args[1]).toMatchObject({ trainScheduleId: 'S1' }); + + // Soft-deleted, not cancelled — the bookings still exist and still depart. + expect(softDeletes).toEqual(['S2']); + }); + + it('appends merged wagons after the existing consist', async () => { + const { service, updates } = makeService({ + schedule: baseSchedule(), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(2), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + setWagons: [ + { id: 'in-0', trainSetId: 'TS2', physicalWagonId: 'w-0' }, + { id: 'in-1', trainSetId: 'TS2', physicalWagonId: 'w-1' }, + ], + }); + + await service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }); + + // 10 existing wagons occupy 1..10, so the merged pair lands at 11 and 12 + // — staff reorder them in the train builder afterwards. + const seqs = updates + .filter((u) => u.entity === 'TrainSetWagon') + .map((u) => (u.args[1] as { sequenceNo: number }).sequenceNo); + expect(seqs).toEqual([11, 12]); + }); + }); + + describe('capacity', () => { + it('blocks a merge that overruns the locomotive length cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + // A short loco: 100m of train, already 10 × 14m = 140m used. + locomotive: { + maxPullWeightTons: 5000, + maxTrainLengthMeters: 100, + }, + wagons: Array.from({ length: 10 }, (_, i) => ({ + id: `sw-${i}`, + sequenceNo: i + 1, + lengthMeters: 14, + wagonType: { tareWeightTons: 22.4 }, + })), + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max train length/i); + }); + + it('blocks a merge that overruns the pull-weight cap', async () => { + const { service } = makeService({ + schedule: baseSchedule({ + trainSet: { + id: 'TS1', + trainId: 'T1', + locomotive: { + maxPullWeightTons: 300, + maxTrainLengthMeters: 10000, + }, + wagons: [], + }, + }), + train: { id: 'T2', code: 'TR-2' }, + wagons: targetWagons(40), + wagonTypes: [{ id: 'wt-1', lengthMeters: 14, tareWeightTons: 22.4 }], + }); + + await expect( + service.mergeScheduleTrain('S1', { targetTrainId: 'T2' }), + ).rejects.toThrow(/exceeds max pull weight/i); + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts new file mode 100644 index 000000000..d7dae50f4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/schedule-train-number.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +import { TrainSchedulingService } from './services/train-scheduling.service'; +import type { UpdateScheduleTrainNumberDto } from './dto/update-schedule-train-number.dto'; + +/** + * Guards around renumbering a departure. Exercised against a stub repository — + * the rules (dispatch lock, empty-body rejection, clear-vs-leave semantics) are + * pure service logic and need no database. + */ +describe('TrainSchedulingService.updateScheduleTrainNumber', () => { + const makeService = (schedule: Record | null) => { + const update = jest.fn().mockResolvedValue(undefined); + const findById = jest.fn().mockResolvedValue(schedule); + const service = Object.create( + TrainSchedulingService.prototype, + ) as TrainSchedulingService; + Object.assign(service, { + trainSchedulesRepository: { findById, update }, + logger: { log: jest.fn(), warn: jest.fn() }, + }); + return { service, update, findById }; + }; + + const call = (service: TrainSchedulingService, dto: UpdateScheduleTrainNumberDto) => + service.updateScheduleTrainNumber('sched-1', dto); + + it('updates both numbers on a SCHEDULED train', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'SCHEDULED', + trainNumber: '9101', + voyageNumber: null, + }); + + await call(service, { trainNumber: '9201', voyageNumber: 'V-2026-014' }); + + expect(update).toHaveBeenCalledWith('sched-1', { + trainNumber: '9201', + voyageNumber: 'V-2026-014', + }); + }); + + it('refuses to renumber a dispatched train', async () => { + // The numbers are already printed on paperwork that left with the train. + const { service, update } = makeService({ + id: 'sched-1', + status: 'DISPATCHED', + trainNumber: '9101', + }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }); + + it.each(['ARRIVED', 'CANCELLED', 'COMPLETED'])( + 'refuses to renumber a %s schedule', + async (status) => { + const { service, update } = makeService({ id: 'sched-1', status }); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(update).not.toHaveBeenCalled(); + }, + ); + + it('rejects a body carrying neither number before touching the schedule', async () => { + const { service, update, findById } = makeService({ + id: 'sched-1', + status: 'DRAFT', + }); + + await expect(call(service, {})).rejects.toBeInstanceOf(BadRequestException); + expect(findById).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it('leaves an omitted field untouched rather than clearing it', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + trainNumber: '9101', + voyageNumber: 'V-1', + }); + + await call(service, { trainNumber: '9201' }); + + expect(update).toHaveBeenCalledWith('sched-1', { trainNumber: '9201' }); + expect(update.mock.calls[0][1]).not.toHaveProperty('voyageNumber'); + }); + + it('clears a field when an empty string is sent', async () => { + const { service, update } = makeService({ + id: 'sched-1', + status: 'DRAFT', + voyageNumber: 'V-1', + }); + + await call(service, { voyageNumber: ' ' }); + + expect(update).toHaveBeenCalledWith('sched-1', { voyageNumber: null }); + }); + + it('404s on an unknown schedule', async () => { + const { service } = makeService(null); + + await expect(call(service, { trainNumber: '9201' })).rejects.toBeInstanceOf( + NotFoundException, + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 657703ada..8bce5fb80 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -96,6 +96,8 @@ import { } from '../dto/import-djibouti-operation.dto'; import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; +import { MergeScheduleTrainDto } from '../dto/merge-schedule-train.dto'; +import { UpdateScheduleTrainNumberDto } from '../dto/update-schedule-train-number.dto'; import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto'; import { type BookingWindowConfig } from '../booking-window.config'; import { BookingWindowGateway } from '../booking-window.gateway'; @@ -132,14 +134,17 @@ import { } from '../utils/wagon-plan.util'; import { CorridorBudget } from '../corridor-capacity.util'; import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { computeScheduleWagonUsage } from '../utils/schedule-wagon-usage.util'; import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util'; import { bookingCargoTons, bulkItemsFitFor, bulkItemWagonsRequired, bulkTonsPerWagon, + consistViolations, deriveTrainCapacityFromLocomotive, combinedLocomotiveLimits, + trainHardCaps, trainSetLocomotiveLimits, wagonTypeDimensionsFromEntity, LocomotiveLimits, @@ -928,6 +933,68 @@ export class TrainSchedulingService { return fresh ?? schedule; } + /** + * Correct a departure's operational run identifiers — the train number and + * voyage number yards and customs quote. + * + * Editable only until the train leaves: once DISPATCHED (or beyond) the + * numbers are printed on paperwork and quoted downstream, so a late edit would + * desync records that already left with the train. The audit row is written by + * the global AuditInterceptor from the registered route. + */ + async updateScheduleTrainNumber( + id: string, + dto: UpdateScheduleTrainNumberDto, + ): Promise { + if (dto.trainNumber === undefined && dto.voyageNumber === undefined) { + throw new BadRequestException( + 'Provide a train number or a voyage number to update.', + ); + } + + const schedule = await this.trainSchedulesRepository.findById(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + + // Only a train that has not left can be renumbered. CANCELLED is excluded + // too — renumbering a dead schedule has no meaning. + const editable: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + if (!editable.includes(schedule.status)) { + throw new BadRequestException( + `Cannot change the train or voyage number of a ${schedule.status} schedule — ` + + 'the numbers are fixed once the train is dispatched.', + ); + } + + // An empty string clears the field; an omitted field is left untouched. + const patch: Partial = {}; + if (dto.trainNumber !== undefined) { + patch.trainNumber = dto.trainNumber.trim() || null; + } + if (dto.voyageNumber !== undefined) { + patch.voyageNumber = dto.voyageNumber.trim() || null; + } + + await this.trainSchedulesRepository.update(id, patch); + this.logger.log( + `Schedule ${schedule.reference ?? id} renumbered` + + (patch.trainNumber !== undefined + ? ` — train ${schedule.trainNumber ?? '—'} → ${patch.trainNumber ?? '—'}` + : '') + + (patch.voyageNumber !== undefined + ? ` — voyage ${schedule.voyageNumber ?? '—'} → ${patch.voyageNumber ?? '—'}` + : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(id); + return fresh ?? schedule; + } + /** * Reschedule ONE train's departure date (staff action on the ops board). Only * allowed while the booking window has not opened yet — an OPEN/past schedule @@ -4072,7 +4139,15 @@ export class TrainSchedulingService { const [schedules, total] = await this.trainSchedulesRepository.findAndCount({ where, relations: { - trainSet: { locomotive: true, locomotives: { locomotive: true }, train: true }, + trainSet: { + locomotive: true, + locomotives: { locomotive: true }, + train: true, + // Slot allocations back the list's "used wagons" figure — without + // them the row can only report the coupled consist size, which is + // what made the list disagree with the detail page's wagon plan. + wagons: { allocations: true }, + }, // Yards carry the route's display name used by mapScheduleListItem; // milestones (with yards) let it show the full corridor path. route: { originYard: true, destinationYard: true, milestones: { yard: true } }, @@ -5752,12 +5827,22 @@ export class TrainSchedulingService { } private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { + // Wagon figures must match the detail page's wagon plan (WagonPlanGrid) — + // see computeScheduleWagonUsage for why the stored counter cannot be used. + const { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining } = + computeScheduleWagonUsage({ + wagonSlots: schedule.trainSet?.wagons, + storedWagonCount: schedule.trainSet?.wagonCount, + scheduleBookings: schedule.scheduleBookings, + }); + return { id: schedule.id, reference: schedule.reference ?? null, createdAt: schedule.createdAt ?? null, scheduleDate: schedule.scheduledDepartureDate, trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, direction: schedule.direction ?? null, routeName: schedule.route ? formatRouteLabel(schedule.route) : null, origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, @@ -5786,6 +5871,14 @@ export class TrainSchedulingService { currentYardId: loco.currentYardId ?? null, })), wagonCount: schedule.trainSet?.wagonCount ?? 0, + /** Coupled slots carrying a booking allocation — matches the wagon plan. */ + wagonsUsed, + /** Coupled consist size; the denominator of "used". */ + wagonsTotal, + /** Claimed by bookings (incl. unpaid) — not bookable. */ + wagonsReserved, + /** Consist minus what bookings have claimed; what is still bookable. */ + wagonsRemaining, totalWeightTons: roundTons(Number(schedule.trainSet?.totalWeightTons ?? 0)), totalLengthMeters: roundTons(Number(schedule.trainSet?.totalLengthMeters ?? 0)), bookingsCount: schedule.scheduleBookings?.length ?? 0, @@ -7703,6 +7796,7 @@ export class TrainSchedulingService { status: schedule.status, freightType: this.resolveScheduleFreightType(schedule), trainNumber: schedule.trainNumber ?? null, + voyageNumber: schedule.voyageNumber ?? null, maxWagons: schedule.maxWagons ?? null, direction: schedule.direction ?? null, reverseWagonOrder: schedule.reverseWagonOrder ?? false, @@ -9122,4 +9216,376 @@ export class TrainSchedulingService { }); return new Set(allocations.map((a) => a.bookingId)); } + + // ── Train merge ──────────────────────────────────────────────────────────── + // Combine two trains into one departure. The schedule the action is taken + // from ALWAYS survives: its train set is repointed at the target train, the + // target's wagons join this consist, and the source train is emptied and + // deactivated. When the target also runs a schedule on the SAME DAY, that + // schedule's bookings move here and it is soft-deleted; the target's + // other-day schedules contribute wagons only. + + /** Statuses whose schedules may take part in a merge. */ + private static readonly MERGEABLE_STATUSES: string[] = [ + TrainScheduleStatusEnum.Draft, + TrainScheduleStatusEnum.Scheduled, + ]; + + /** + * Everything a merge needs to decide, gathered once. Both `previewMerge` and + * `mergeScheduleTrain` run this so the modal shows exactly what will happen + * and the commit cannot diverge from it. + */ + private async planMerge(scheduleId: string, targetTrainId: string) { + const schedule = + await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); + if (!schedule) { + throw new NotFoundException(`Train schedule ${scheduleId} not found`); + } + if (!TrainSchedulingService.MERGEABLE_STATUSES.includes(schedule.status)) { + throw new BadRequestException( + `Cannot merge into a ${schedule.status} schedule — only draft or scheduled departures can be merged.`, + ); + } + + const sourceTrainId = schedule.trainSet?.trainId ?? null; + if (sourceTrainId && sourceTrainId === targetTrainId) { + throw new BadRequestException( + 'That is already this schedule\'s train — pick a different one to merge in.', + ); + } + + const targetTrain = await this.dataSource + .getRepository(Train) + .findOne({ where: { id: targetTrainId } }); + if (!targetTrain) { + throw new NotFoundException(`Train ${targetTrainId} not found`); + } + + // Every schedule the target train is committed to, via its train sets. + const targetSets = await this.dataSource + .getRepository(TrainSet) + .find({ where: { trainId: targetTrainId } }); + const targetSetIds = targetSets.map((s) => s.id); + const targetSchedules = targetSetIds.length + ? await this.dataSource.getRepository(TrainSchedule).find({ + where: { trainSetId: In(targetSetIds) }, + }) + : []; + + // The same-day schedule is the one whose bookings move here. Only a + // draft/scheduled one qualifies — a dispatched departure keeps its cargo. + const sameDay = (a: Date | string, b: Date | string) => + new Date(a).toISOString().slice(0, 10) === + new Date(b).toISOString().slice(0, 10); + + const absorbed = + targetSchedules.find( + (s) => + s.id !== schedule.id && + sameDay(s.scheduledDepartureDate, schedule.scheduledDepartureDate) && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ) ?? null; + + // Wagons ride with the train, so every OTHER draft/scheduled schedule on it + // is affected too — it gains the merged consist but never the bookings. + const affectedOthers = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + const untouched = targetSchedules.filter( + (s) => + s.id !== schedule.id && + s.id !== absorbed?.id && + !TrainSchedulingService.MERGEABLE_STATUSES.includes(s.status), + ); + + // The wagons joining this consist: whatever physically sits on the target + // train today. + const incomingWagons = await this.dataSource + .getRepository(Wagon) + .find({ where: { trainId: targetTrainId }, order: { wagonNumber: 'ASC' } }); + + const movingBookings = absorbed + ? await this.dataSource.getRepository(TrainScheduleBooking).find({ + where: { trainScheduleId: absorbed.id }, + relations: { booking: true }, + }) + : []; + + return { + schedule, + sourceTrainId, + targetTrain, + absorbed, + affectedOthers, + untouched, + incomingWagons, + movingBookings, + }; + } + + /** + * Blocking checks, run against the plan. Returns human-readable reasons; an + * empty array means the merge may proceed. Kept separate from `planMerge` so + * the preview can SHOW the reasons rather than throwing on them. + */ + private async mergeBlockers( + plan: Awaited>, + ): Promise { + const blockers: string[] = []; + const { schedule, incomingWagons, movingBookings, absorbed } = plan; + + if (incomingWagons.length === 0) { + blockers.push( + `${plan.targetTrain.code} has no wagons to merge — nothing would move.`, + ); + } + + // ── Capacity: the merged consist must fit this schedule's locomotives ──── + const existingSlots = (schedule.trainSet?.wagons ?? []).map((w) => ({ + lengthMeters: Number(w.lengthMeters) || 0, + tareWeightTons: Number(w.wagonType?.tareWeightTons) || 0, + cargoTons: 0, + })); + const wagonTypeIds = [ + ...new Set(incomingWagons.map((w) => w.wagonTypeId).filter(Boolean)), + ]; + const wagonTypes = wagonTypeIds.length + ? await this.dataSource + .getRepository(WagonType) + .find({ where: { id: In(wagonTypeIds) } }) + : []; + const typeById = new Map(wagonTypes.map((t) => [t.id, t])); + const incomingSlots = incomingWagons.map((w) => { + const t = typeById.get(w.wagonTypeId); + return { + lengthMeters: Number(t?.lengthMeters) || 0, + tareWeightTons: Number(t?.tareWeightTons) || 0, + cargoTons: 0, + }; + }); + + const limits = trainSetLocomotiveLimits(schedule.trainSet); + if (limits) { + const rules = await this.dataSource + .getRepository(TrainSchedulingGlobalRules) + .find({ take: 1 }); + const caps = trainHardCaps(limits, { + maxTrainWeightTons: rules[0]?.maxTrainWeightTons ?? undefined, + maxTrainLengthMeters: rules[0]?.maxTrainLengthMeters ?? undefined, + }); + const merged = [...existingSlots, ...incomingSlots]; + // maxWagons is the schedule's own slot ceiling; fall back to the consist + // size when it is unset so the count axis never blocks spuriously. + const violations = consistViolations(merged, { + maxWeightTons: caps.maxWeightTons, + maxLengthMeters: caps.maxLengthMeters, + maxWagonSlots: schedule.maxWagons || merged.length, + }); + blockers.push(...violations); + } + + // ── Legs: an absorbed booking must be servable by THIS schedule's route ── + if (absorbed && movingBookings.length) { + const routeYardIds = await this.routeYardSequence(schedule.routeId ?? null); + if (routeYardIds.length) { + const position = new Map(routeYardIds.map((id, i) => [id, i])); + const slotIds = movingBookings.map((mb) => mb.bookingId); + const allocations = slotIds.length + ? await this.dataSource.getRepository(WagonBookingAllocation).find({ + where: { bookingId: In(slotIds) }, + relations: { trainSetWagon: true }, + }) + : []; + const offRoute = new Set(); + for (const alloc of allocations) { + const board = alloc.trainSetWagon?.boardYardId ?? null; + const alight = alloc.trainSetWagon?.alightYardId ?? null; + // Null on both = rides the whole route; always compatible. + if (!board && !alight) continue; + const from = board ? position.get(board) : 0; + const to = alight ? position.get(alight) : routeYardIds.length - 1; + if (from === undefined || to === undefined || from >= to) { + offRoute.add(alloc.bookingId); + } + } + if (offRoute.size) { + blockers.push( + `${offRoute.size} booking(s) on ${absorbed.reference ?? 'the merged schedule'} ` + + 'travel legs this schedule\'s route does not serve in the same order.', + ); + } + } + } + + return blockers; + } + + /** Ordered yard ids along a route, origin first. Empty when unknown. */ + private async routeYardSequence(routeId: string | null): Promise { + if (!routeId) return []; + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId }, order: { sequenceNo: 'ASC' } }); + return milestones + .map((m) => m.yardId) + .filter((id): id is string => Boolean(id)); + } + + /** + * What a merge WOULD do, without doing it. Drives the confirmation modal: + * which schedules gain wagons, which one is absorbed, and why it is blocked. + */ + async previewMerge(scheduleId: string, targetTrainId: string) { + const plan = await this.planMerge(scheduleId, targetTrainId); + const blockers = await this.mergeBlockers(plan); + + const existingCount = plan.schedule.trainSet?.wagons?.length ?? 0; + return { + canMerge: blockers.length === 0, + blockers, + targetTrain: { + id: plan.targetTrain.id, + code: plan.targetTrain.code, + trainNumber: plan.targetTrain.trainNumber ?? null, + }, + wagons: { + current: existingCount, + incoming: plan.incomingWagons.length, + merged: existingCount + plan.incomingWagons.length, + }, + /** The same-day schedule whose bookings move here and is then removed. */ + absorbedSchedule: plan.absorbed + ? { + id: plan.absorbed.id, + reference: plan.absorbed.reference ?? null, + scheduledDepartureDate: plan.absorbed.scheduledDepartureDate, + status: plan.absorbed.status, + bookingsMoving: plan.movingBookings.length, + } + : null, + /** Other draft/scheduled schedules on the target — wagons only. */ + affectedSchedules: plan.affectedOthers.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + /** On the target train but left alone (dispatched, cancelled, …). */ + untouchedSchedules: plan.untouched.map((s) => ({ + id: s.id, + reference: s.reference ?? null, + scheduledDepartureDate: s.scheduledDepartureDate, + status: s.status, + })), + sourceTrainWillDeactivate: Boolean(plan.sourceTrainId), + }; + } + + /** + * Execute the merge. One transaction: repoint the train set, move the wagons + * (appended last so the builder can reorder them later), carry the absorbed + * schedule's bookings across, soft-delete that schedule, and deactivate the + * emptied source train. + */ + async mergeScheduleTrain( + scheduleId: string, + dto: MergeScheduleTrainDto, + ): Promise { + const plan = await this.planMerge(scheduleId, dto.targetTrainId); + const blockers = await this.mergeBlockers(plan); + if (blockers.length) { + throw new BadRequestException(blockers.join(' ')); + } + + const { + schedule, + sourceTrainId, + targetTrain, + absorbed, + incomingWagons, + movingBookings, + } = plan; + const trainSetId = schedule.trainSetId; + + await this.dataSource.transaction(async (manager) => { + // 1. This schedule's set now runs on the target train. + await manager.getRepository(TrainSet).update(trainSetId, { + trainId: targetTrain.id, + }); + + // 2. The physical wagons follow the train. + if (incomingWagons.length) { + await manager.getRepository(Wagon).update( + { id: In(incomingWagons.map((w) => w.id)) }, + { trainId: targetTrain.id }, + ); + } + + // 3. Carry the target's train-set wagon rows into THIS consist, appended + // after the existing wagons. Sequence is provisional — staff reorder + // in the train builder afterwards. + const existing = schedule.trainSet?.wagons ?? []; + let nextSequence = + existing.reduce((max, w) => Math.max(max, w.sequenceNo ?? 0), 0) + 1; + const incomingSetWagons = await manager.getRepository(TrainSetWagon).find({ + where: { physicalWagonId: In(incomingWagons.map((w) => w.id)) }, + }); + for (const row of incomingSetWagons) { + if (row.trainSetId === trainSetId) continue; + await manager.getRepository(TrainSetWagon).update(row.id, { + trainSetId, + sequenceNo: nextSequence, + }); + nextSequence += 1; + } + + // 4. The absorbed schedule's bookings move here. `bookingId` is uniquely + // indexed, so these rows are UPDATED across rather than re-inserted. + if (absorbed && movingBookings.length) { + await manager + .getRepository(TrainScheduleBooking) + .update( + { trainScheduleId: absorbed.id }, + { trainScheduleId: schedule.id }, + ); + } + + // 5. The absorbed schedule is soft-deleted — its bookings still exist and + // still depart that day, so nobody is notified and nothing is lost. + if (absorbed) { + await manager.getRepository(TrainSchedule).softDelete(absorbed.id); + } + + // 6. The source train is now empty; park it. + if (sourceTrainId) { + await manager.getRepository(Train).update(sourceTrainId, { + status: Freight.TrainStatus.Deactivated, + }); + } + + // 7. Keep the set's cached totals honest. + const mergedCount = + (schedule.trainSet?.wagons?.length ?? 0) + incomingSetWagons.length; + await manager + .getRepository(TrainSet) + .update(trainSetId, { wagonCount: mergedCount }); + }); + + this.logger.log( + `Schedule ${schedule.reference ?? scheduleId} merged with train ${targetTrain.code}` + + ` — ${incomingWagons.length} wagon(s) moved` + + (absorbed + ? `, absorbed ${absorbed.reference ?? absorbed.id} (${movingBookings.length} booking(s))` + : '') + + (sourceTrainId ? ', source train deactivated' : '') + + (dto.reason?.trim() ? ` (${dto.reason.trim()})` : ''), + ); + + const fresh = await this.trainSchedulesRepository.findById(scheduleId); + return fresh ?? schedule; + } } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts new file mode 100644 index 000000000..8fa9c1260 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.spec.ts @@ -0,0 +1,96 @@ +import { computeScheduleWagonUsage } from './schedule-wagon-usage.util'; + +/** A coupled slot; `allocated` = a booking actually sits on it. */ +const slot = (allocated = false) => ({ allocations: allocated ? [{}] : [] }); +const booking = (wagonsRequired: number | null) => ({ booking: { wagonsRequired } }); + +describe('computeScheduleWagonUsage', () => { + it('reports allocated slots as used, not the coupled consist size', () => { + // The reported bug: a 37-wagon consist carrying 3 allocated bookings read + // "37 wgn used" in the list while the detail page read "3 in use". + const slots = [...Array(34).fill(slot(false)), ...Array(3).fill(slot(true))]; + + const usage = computeScheduleWagonUsage({ + wagonSlots: slots, + storedWagonCount: 37, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsTotal).toBe(37); + }); + + it('counts a built train with no bookings as 0 used', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(40).fill(slot(false)), + storedWagonCount: 40, + scheduleBookings: [], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsRemaining).toBe(40); + }); + + it('treats wagons of an unpaid booking as reserved, so they are not bookable', () => { + // Booking claims 5 wagons but has no wagon plan yet: 0 used, still only 5 + // bookable on a 10-wagon train — the reservation is not free space. + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(10).fill(slot(false)), + storedWagonCount: 10, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsUsed).toBe(0); + expect(usage.wagonsReserved).toBe(5); + expect(usage.wagonsRemaining).toBe(5); + }); + + it('does not double-count a booking that is both reserved and allocated', () => { + // 3 allocated slots for a booking that reserved 3 wagons: 7 remain, not 4. + const usage = computeScheduleWagonUsage({ + wagonSlots: [...Array(7).fill(slot(false)), ...Array(3).fill(slot(true))], + storedWagonCount: 10, + scheduleBookings: [booking(3)], + }); + + expect(usage.wagonsUsed).toBe(3); + expect(usage.wagonsReserved).toBe(3); + expect(usage.wagonsRemaining).toBe(7); + }); + + it('never reports negative remaining when claims exceed the consist', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: Array(2).fill(slot(false)), + storedWagonCount: 2, + scheduleBookings: [booking(5)], + }); + + expect(usage.wagonsRemaining).toBe(0); + }); + + it('falls back to the stored counter when slot rows were not loaded', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: [], + storedWagonCount: 12, + scheduleBookings: [], + }); + + expect(usage.wagonsTotal).toBe(12); + expect(usage.wagonsUsed).toBe(0); + }); + + it('tolerates missing relations and null wagonsRequired', () => { + const usage = computeScheduleWagonUsage({ + wagonSlots: null, + storedWagonCount: null, + scheduleBookings: [booking(null)], + }); + + expect(usage).toEqual({ + wagonsUsed: 0, + wagonsTotal: 0, + wagonsReserved: 0, + wagonsRemaining: 0, + }); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts new file mode 100644 index 000000000..f48d60f9a --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/schedule-wagon-usage.util.ts @@ -0,0 +1,58 @@ +/** + * Wagon figures for a train-schedule list row. + * + * The list used to report `trainSet.wagonCount` — the COUPLED CONSIST SIZE — + * under the label "wgn used", so a 37-wagon train carrying 3 allocated bookings + * read "37 wgn used" in the list while its detail page (WagonPlanGrid) read + * "37 wagons · 3 in use". These helpers make the list agree with the detail + * page, which is the figure staff trust. + */ + +/** The shape this math needs — a slot counts as used when it has allocations. */ +export interface WagonSlotLike { + allocations?: unknown[] | null; +} + +export interface ScheduleBookingLike { + booking?: { wagonsRequired?: number | null } | null; +} + +export interface ScheduleWagonUsage { + /** Coupled slots carrying at least one booking allocation. */ + wagonsUsed: number; + /** Coupled consist size — the denominator of `wagonsUsed`. */ + wagonsTotal: number; + /** Wagons claimed by bookings, including bookings that have not paid. */ + wagonsReserved: number; + /** Consist minus what bookings have claimed — what is still bookable. */ + wagonsRemaining: number; +} + +export function computeScheduleWagonUsage(input: { + wagonSlots?: WagonSlotLike[] | null; + /** Stored counter; used only when the slot rows were not loaded. */ + storedWagonCount?: number | null; + scheduleBookings?: ScheduleBookingLike[] | null; +}): ScheduleWagonUsage { + const slots = input.wagonSlots ?? []; + + // Same predicate as the detail page's WagonPlanGrid: a slot is in use only + // when a booking is actually allocated onto it. + const wagonsUsed = slots.filter((slot) => (slot.allocations?.length ?? 0) > 0).length; + + // Prefer live slot rows; the stored counter drifts when a consist is edited + // without a recompute, which is why the list and detail disagreed on totals. + const wagonsTotal = slots.length || (input.storedWagonCount ?? 0); + + // An unpaid booking still holds its wagons, so reserved space is NOT bookable. + const wagonsReserved = (input.scheduleBookings ?? []).reduce( + (sum, link) => sum + (link.booking?.wagonsRequired ?? 0), + 0, + ); + + // Reserved subsumes allocated — an allocated booking still counts its wagons — + // so remaining subtracts whichever claim is larger, never both. + const wagonsRemaining = Math.max(0, wagonsTotal - Math.max(wagonsUsed, wagonsReserved)); + + return { wagonsUsed, wagonsTotal, wagonsReserved, wagonsRemaining }; +} diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 0c9415e42..7b0bdd3bd 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -51,9 +51,9 @@ export class BuildTrainDto { @IsUUID('all', { each: true }) wagonIds?: string[]; - @ApiProperty({ maxLength: 100, description: 'Vogue number' }) + @ApiProperty({ maxLength: 100, description: 'Voyage number' }) @IsString() - @IsNotEmpty({ message: 'Vogue number is required' }) + @IsNotEmpty({ message: 'Voyage number is required' }) @MaxLength(100) trainName!: string; diff --git a/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts new file mode 100644 index 000000000..269c4f9b4 --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/dto/send-wagon-to-maintenance.dto.ts @@ -0,0 +1,15 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class SendWagonToMaintenanceDto { + @ApiPropertyOptional({ + description: + "Why the wagon is going to maintenance. Stored on the wagon's status-history " + + 'log alongside the train it was detached from, matching the fleet desk flow.', + maxLength: 500, + }) + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts index c1b1459a3..248092f95 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.controller.ts @@ -23,6 +23,7 @@ import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto'; import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto'; +import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto'; import { UpdateTrainDetailsDto } from './dto/update-train-details.dto'; import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto'; import { UpdateTrainYardDto } from './dto/update-train-yard.dto'; @@ -135,8 +136,14 @@ export class TrainBuilderController { @Param('id', ParseUUIDPipe) id: string, @Param('wagonId', ParseUUIDPipe) wagonId: string, @CurrentUser() user: AuthUserPayload, + @Body() dto?: SendWagonToMaintenanceDto, ) { - return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user)); + return this.trainBuilderService.sendWagonToMaintenance( + id, + wagonId, + resolveAuthUserId(user), + dto?.note, + ); } @Post(':id/reorder-wagons') diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts new file mode 100644 index 000000000..a6157db7c --- /dev/null +++ b/apps/edr-freight-api/src/modules/trains/train-builder.maintenance.spec.ts @@ -0,0 +1,83 @@ +import { buildMaintenanceNotes, formatTrainRunLabel } from './train-builder.service'; + +describe('formatTrainRunLabel', () => { + it('names the train by its export and import run numbers', () => { + // What staff actually recognise — NOT the internal code (TRN-LEDGER-PW2). + expect( + formatTrainRunLabel({ + exportTrainNumber: '9201', + importTrainNumber: '9202', + trainNumber: 'TRN-7', + code: 'TRN-LEDGER-PW2', + }), + ).toBe('export 9201 / import 9202'); + }); + + it('shows only the run number that is set', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: '9201', code: 'TRN-LEDGER-PW2' }), + ).toBe('export 9201'); + expect( + formatTrainRunLabel({ importTrainNumber: '9202', code: 'TRN-LEDGER-PW2' }), + ).toBe('import 9202'); + }); + + it('falls back to the train number, then the code, when no run is set', () => { + expect(formatTrainRunLabel({ trainNumber: 'TRN-7', code: 'TRN-LEDGER-PW2' })).toBe( + 'TRN-7', + ); + expect(formatTrainRunLabel({ code: 'TRN-LEDGER-PW2' })).toBe('TRN-LEDGER-PW2'); + }); + + it('ignores blank run numbers rather than printing empty labels', () => { + expect( + formatTrainRunLabel({ exportTrainNumber: ' ', importTrainNumber: null, code: 'C-1' }), + ).toBe('C-1'); + }); + + it('never returns an empty label', () => { + expect(formatTrainRunLabel({})).toBe('unknown'); + }); +}); + +describe('buildMaintenanceNotes', () => { + it('records the operator reason together with the train it came off', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', 'Brake shoe worn through'); + + expect(notes.statusLogNote).toBe( + 'Brake shoe worn through (detached from train export 9201 / import 9202)', + ); + expect(notes.movementNote).toBe( + 'Sent to maintenance from train export 9201 / import 9202: Brake shoe worn through', + ); + }); + + it('still records the train number when no reason is given', () => { + // The reason is optional, but which train a wagon left is never optional — + // the history has to answer that on its own. + const notes = buildMaintenanceNotes('export 9201 / import 9202'); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('treats a whitespace-only reason as no reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' '); + + expect(notes.statusLogNote).toBe('Detached from train export 9201 / import 9202'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202'); + }); + + it('trims padding around a real reason', () => { + const notes = buildMaintenanceNotes('export 9201 / import 9202', ' Coupler damage '); + + expect(notes.statusLogNote).toBe('Coupler damage (detached from train export 9201 / import 9202)'); + expect(notes.movementNote).toBe('Sent to maintenance from train export 9201 / import 9202: Coupler damage'); + }); + + it('handles a null reason from an older client', () => { + expect(buildMaintenanceNotes('export 9201 / import 9202', null).statusLogNote).toBe( + 'Detached from train export 9201 / import 9202', + ); + }); +}); diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 9da3cecf0..0f377071c 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -19,6 +19,7 @@ import { TrainSet } from '../train-sets/entities/train-set.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; +import { WagonStatusLog } from '../wagons/entities/wagon-status-log.entity'; import { Wagon } from '../wagons/entities/wagon.entity'; import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto'; import { BuildTrainDto } from './dto/build-train.dto'; @@ -530,7 +531,12 @@ export class TrainBuilderService { * moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until * it clears maintenance. The freed sequence gap is closed. */ - async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) { + async sendWagonToMaintenance( + id: string, + wagonId: string, + userId?: string | null, + note?: string | null, + ) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } }); @@ -542,6 +548,8 @@ export class TrainBuilderService { `Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`, ); } + const previousStatus = wagon.status; + const notes = buildMaintenanceNotes(formatTrainRunLabel(train), note); await manager.getRepository(Wagon).update(wagon.id, { trainId: null, sequenceNumber: null, @@ -549,6 +557,22 @@ export class TrainBuilderService { importTrainNumber: null, exportTrainNumber: null, }); + + // Status-history row, same as the fleet desk's "Send to maintenance" — + // without it a maintenance detach made here is invisible in the wagon's + // status history. The train number is folded into the note so the history + // answers "which train did it come off, and why" in one line. + if (previousStatus !== WagonStatus.Maintenance) { + await manager.getRepository(WagonStatusLog).save( + manager.getRepository(WagonStatusLog).create({ + wagonId: wagon.id, + fromStatus: previousStatus, + toStatus: WagonStatus.Maintenance, + changedByUserId: userId ?? null, + note: notes.statusLogNote, + }), + ); + } // Audit row: which train it came off and when. The wagon does not change // yard here, so from/to are the same — the ledger is the wagon's history // surface, and a maintenance detach has to be in it. @@ -560,7 +584,7 @@ export class TrainBuilderService { fromYardId: yardId, toYardId: yardId, kind: WagonMovementKind.Maintenance, - note: `Sent to maintenance from train ${train.trainNumber ?? train.code}`, + note: notes.movementNote, occurredAt: new Date(), }), ); @@ -1111,6 +1135,48 @@ export class TrainBuilderService { * pinned to a reordered wagon adopt the wagon's new position; unpinned slots * trail behind in their previous relative order. */ +/** + * How a train is named in a wagon's history. Staff identify a train by its + * OPERATIONAL run numbers — the fixed export (odd) and import (even) numbers + * typed at build time — not by its internal code (`TRN-LEDGER-PW2`), which is a + * ledger key and means nothing on the ground. Both runs are shown when set, + * since one built train carries the pair. Falls back to the train number, then + * the code, only when no run number exists. + */ +export function formatTrainRunLabel(train: { + exportTrainNumber?: string | null; + importTrainNumber?: string | null; + trainNumber?: string | null; + code?: string | null; +}): string { + const exportNo = train.exportTrainNumber?.trim(); + const importNo = train.importTrainNumber?.trim(); + const runs = [ + exportNo ? `export ${exportNo}` : null, + importNo ? `import ${importNo}` : null, + ].filter(Boolean); + if (runs.length) return runs.join(' / '); + return train.trainNumber?.trim() || train.code?.trim() || 'unknown'; +} + +/** + * Notes for a maintenance detach. The train's run numbers are always recorded — + * staff need to know which consist a wagon came off — and the operator's reason + * is folded in when given, so the wagon's status history answers "which train, + * and why" in one line (matching the fleet desk's Send-to-maintenance note). + */ +export function buildMaintenanceNotes(trainLabel: string, note?: string | null) { + const reason = note?.trim(); + return { + statusLogNote: reason + ? `${reason} (detached from train ${trainLabel})` + : `Detached from train ${trainLabel}`, + movementNote: reason + ? `Sent to maintenance from train ${trainLabel}: ${reason}` + : `Sent to maintenance from train ${trainLabel}`, + }; +} + export function orderSlotsByWagonSequence< T extends Pick, >(slots: T[], newSeq: Map): T[] { diff --git a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts index 85c879f70..615ef0701 100644 --- a/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts +++ b/apps/edr-freight-api/src/modules/wagons/dto/create-transfer-request.dto.ts @@ -1,5 +1,7 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { + ArrayMaxSize, + IsArray, IsInt, IsNotEmpty, IsOptional, @@ -11,11 +13,14 @@ import { } from 'class-validator'; /** - * A count-only wagon-transfer request. The requester picks source yard, wagon - * type, destination yard and HOW MANY — never the specific wagons; OCC hand-picks - * those at fulfilment. The quantity may not exceed the AVAILABLE wagons of that - * type currently in the source yard (enforced in the service, which is the only - * layer that can count them), and a reason is mandatory. + * A wagon-transfer request. The requester picks source yard, wagon type, + * destination yard and HOW MANY. The quantity may not exceed the AVAILABLE + * wagons of that type currently in the source yard (enforced in the service, + * which is the only layer that can count them), and a reason is mandatory. + * + * The requester may additionally name the specific wagons they want via + * `preferredWagonIds`. That is a preference recorded for OCC, not a + * reservation — the count still drives fulfilment. */ export class CreateTransferRequestDto { @IsUUID() @@ -38,6 +43,17 @@ export class CreateTransferRequestDto { @MaxLength(2000) reason!: string; + @ApiPropertyOptional({ + description: + 'Specific wagons the requester wants, if they picked any. A preference for OCC — the wagons are not reserved.', + type: [String], + }) + @IsOptional() + @IsArray() + @ArrayMaxSize(1000) + @IsUUID('4', { each: true }) + preferredWagonIds?: string[]; + @ApiPropertyOptional({ description: 'Optional note for the fulfilling staff' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts index c39b12b9d..8b1922711 100644 --- a/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts +++ b/apps/edr-freight-api/src/modules/wagons/entities/wagon-transfer-request.entity.ts @@ -56,6 +56,14 @@ export class WagonTransferRequest extends BaseEntity { }) status!: WagonTransferRequestStatus; + /** + * The wagons the requester specifically asked for, when they picked any. A + * preference, not a reservation — the wagons stay AVAILABLE to everyone else, + * and OCC may still send different ones. Null/empty on a plain count request. + */ + @Column({ name: 'preferred_wagon_ids', type: 'uuid', array: true, nullable: true }) + preferredWagonIds?: string[] | null; + @Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true }) requestedByUserId?: string | null; diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts index 8f7457b37..74fb6d6d7 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.spec.ts @@ -149,6 +149,19 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { expect(result.skipped).toHaveLength(0); }); + it('auto-picks the wagons the requester named ahead of the rest', async () => { + // Asked for 2 and named w-3 — the auto-pick must take it even though + // wagon-number order would have sent w-0 and w-1. + build(request({ quantity: 2, preferredWagonIds: ['w-3'] })); + wagonRepo.find.mockResolvedValue(availableWagons(5)); + + await service.bulkFulfill(['req-1']); + + const [{ wagonIds }] = wagonsService.bulkTransfer.mock.calls[0]; + expect(wagonIds).toHaveLength(2); + expect(wagonIds[0]).toBe('w-3'); + }); + it('skips only when the yard has nothing to give', async () => { wagonRepo.find.mockResolvedValue([]); @@ -253,6 +266,84 @@ describe('WagonTransferRequestsService — partial fulfilment', () => { expect(requestRepo.save).not.toHaveBeenCalled(); }); + it('records the wagons the requester hand-picked', async () => { + wagonRepo.count.mockResolvedValue(20); + wagonRepo.find.mockResolvedValue(availableWagons(3)); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 3, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toEqual(['w-0', 'w-1', 'w-2']); + }); + + it('leaves the picks null on a plain count request', async () => { + wagonRepo.count.mockResolvedValue(20); + + await service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 5, + reason: 'Grain campaign', + }, + 'user-1', + ); + + expect(stored.preferredWagonIds).toBeNull(); + }); + + it('refuses picks that are not available in the source yard', async () => { + wagonRepo.count.mockResolvedValue(20); + // Sitting in another yard — the requester's list is stale. + wagonRepo.find.mockResolvedValue([ + { ...availableWagons(1)[0], currentYardId: 'yard-z' }, + ]); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 1, + reason: 'Grain campaign', + preferredWagonIds: ['w-0'], + }, + 'user-1', + ), + ).rejects.toThrow(/no longer available in the source yard/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses more picks than the requested quantity', async () => { + wagonRepo.count.mockResolvedValue(20); + + await expect( + service.createRequest( + { + fromYardId: 'yard-a', + toYardId: 'yard-b', + wagonTypeId: 'type-1', + quantity: 2, + reason: 'Grain campaign', + preferredWagonIds: ['w-0', 'w-1', 'w-2'], + }, + 'user-1', + ), + ).rejects.toThrow(/picked 3 wagon\(s\) but are requesting 2/i); + expect(requestRepo.save).not.toHaveBeenCalled(); + }); + it('still refuses a same-yard move', async () => { await expect( service.createRequest( diff --git a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts index 6b63460ad..dc50a8742 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagon-transfer-requests.service.ts @@ -27,6 +27,15 @@ import { WagonMovement } from './entities/wagon-movement.entity'; import { WagonTransferRequest } from './entities/wagon-transfer-request.entity'; import { WagonsService } from './wagons.service'; +/** + * A request as sent to clients: the entity plus the resolved wagon numbers for + * whatever the requester hand-picked, so the desk can name them without a + * second round trip. + */ +export interface TransferRequestView extends WagonTransferRequest { + preferredWagons?: Array<{ id: string; wagonNumber: string }>; +} + /** Bundled per-user activity: requests they touched + wagons they moved. */ export interface TransferHistory { requests: WagonTransferRequest[]; @@ -75,11 +84,15 @@ export class WagonTransferRequestsService { ) {} /** - * Record a PENDING request. Count-only — no wagons are picked here, but the - * count IS capped by what the source yard can hand over right now: a request - * may not exceed the AVAILABLE, uncoupled wagons of that type in the source - * yard (the same number the yard desk shows). A reason is mandatory and is - * shown on the OCC queue. + * Record a PENDING request. The count is capped by what the source yard can + * hand over right now: a request may not exceed the AVAILABLE, uncoupled + * wagons of that type in the source yard (the same number the yard desk + * shows). A reason is mandatory and is shown on the OCC queue. + * + * The requester may also name the wagons they want (`preferredWagonIds`). + * Those are validated against the source yard here so a bad pick is rejected + * at request time rather than surfacing at fulfilment, but they are only a + * preference — the wagons are not reserved and OCC may send others. */ async createRequest( dto: CreateTransferRequestDto, @@ -104,11 +117,15 @@ export class WagonTransferRequestsService { `Only ${available} wagon(s) of this type are available in the source yard — cannot request ${dto.quantity}`, ); } + + const preferredWagonIds = await this.validatePreferredWagons(dto); + const request = this.requestRepo.create({ fromYardId: dto.fromYardId, toYardId: dto.toYardId, wagonTypeId: dto.wagonTypeId, quantity: dto.quantity, + preferredWagonIds, status: WagonTransferRequestStatus.Pending, requestedByUserId: userId ?? null, reason: dto.reason, @@ -118,6 +135,45 @@ export class WagonTransferRequestsService { return this.findById(saved.id); } + /** + * Check the requester's hand-picked wagons against the source yard: each must + * exist, sit in that yard, match the requested type, be AVAILABLE and be + * uncoupled — the same conditions fulfilment will apply. Returns the + * de-duplicated ids, or null when the requester picked nothing. + */ + private async validatePreferredWagons( + dto: CreateTransferRequestDto, + ): Promise { + const ids = [...new Set(dto.preferredWagonIds ?? [])]; + if (ids.length === 0) return null; + + if (ids.length > dto.quantity) { + throw new BadRequestException( + `You picked ${ids.length} wagon(s) but are requesting ${dto.quantity} — pick at most ${dto.quantity}`, + ); + } + + const wagons = await this.wagonRepo.find({ where: { id: In(ids) } }); + if (wagons.length !== ids.length) { + throw new NotFoundException('One or more selected wagons not found'); + } + const unusable = wagons.filter( + (w) => + w.currentYardId !== dto.fromYardId || + w.wagonTypeId !== dto.wagonTypeId || + w.status !== WagonStatus.Available || + w.trainId != null, + ); + if (unusable.length) { + throw new BadRequestException( + `These wagons are no longer available in the source yard: ${unusable + .map((w) => w.wagonNumber) + .join(', ')}`, + ); + } + return ids; + } + /** * AVAILABLE wagons of `wagonTypeId` currently in `yardId` — what OCC can move * right now. Shown on the desk beside the outstanding count so staff see at a @@ -179,16 +235,51 @@ export class WagonTransferRequestsService { : 'r.createdAt'; qb.orderBy(sortColumn, query.sortOrder ?? 'DESC'); - return paginateQuery(qb, { page: query.page, pageSize: query.pageSize }); + const page = await paginateQuery(qb, { + page: query.page, + pageSize: query.pageSize, + }); + return { ...page, items: await this.withPreferredWagons(page.items) }; } - async findById(id: string): Promise { + /** + * Resolve `preferredWagonIds` into wagon numbers for a page of requests in a + * single query, so the desk can show WHICH wagons were asked for. Ids that no + * longer resolve (purged wagon) simply drop out — the column carries no FK. + */ + private async withPreferredWagons( + requests: WagonTransferRequest[], + ): Promise { + const ids = [ + ...new Set(requests.flatMap((r) => r.preferredWagonIds ?? [])), + ]; + if (ids.length === 0) return requests; + + const wagons = await this.wagonRepo.find({ + where: { id: In(ids) }, + select: { id: true, wagonNumber: true }, + }); + const byId = new Map(wagons.map((w) => [w.id, w.wagonNumber])); + + return requests.map((r) => { + const picked = r.preferredWagonIds ?? []; + if (picked.length === 0) return r; + return Object.assign(r, { + preferredWagons: picked + .filter((id) => byId.has(id)) + .map((id) => ({ id, wagonNumber: byId.get(id)! })), + }); + }); + } + + async findById(id: string): Promise { const request = await this.requestRepo.findOne({ where: { id }, relations: REQUEST_RELATIONS, }); if (!request) throw new NotFoundException(`Transfer request ${id} not found`); - return request; + const [view] = await this.withPreferredWagons([request]); + return view; } /** Wagons still owed on an open request. */ @@ -411,7 +502,7 @@ export class WagonTransferRequestsService { continue; } const remaining = this.remainingOn(request); - const wagons = await this.wagonRepo.find({ + const candidates = await this.wagonRepo.find({ where: { currentYardId: request.fromYardId, wagonTypeId: request.wagonTypeId, @@ -419,8 +510,19 @@ export class WagonTransferRequestsService { trainId: IsNull(), }, order: { wagonNumber: 'ASC' }, - take: remaining, }); + // Honour the requester's picks first — any that are still available in + // the yard go out ahead of the plain wagon-number order, and the rest of + // the instalment is topped up from whatever else is on hand. + const preferred = new Set(request.preferredWagonIds ?? []); + const wagons = ( + preferred.size + ? [ + ...candidates.filter((w) => preferred.has(w.id)), + ...candidates.filter((w) => !preferred.has(w.id)), + ] + : candidates + ).slice(0, remaining); if (wagons.length === 0) { skipped.push({ id, @@ -479,7 +581,7 @@ export class WagonTransferRequestsService { }); return { - requests, + requests: await this.withPreferredWagons(requests), movements, meta: { page: page ?? 1, diff --git a/apps/edr-freight-api/src/seed/edr-freight.seed.ts b/apps/edr-freight-api/src/seed/edr-freight.seed.ts index d90fe7b80..1798a8d5f 100644 --- a/apps/edr-freight-api/src/seed/edr-freight.seed.ts +++ b/apps/edr-freight-api/src/seed/edr-freight.seed.ts @@ -191,6 +191,24 @@ const POSITION_TYPE_PERMISSIONS = [ }, ] as const; +/** + * Audit trail access. + * + * Read-only by design: `audit_logs` rows are written solely by + * `AuditInterceptor` and the module exposes no create/update/delete route, so + * there is deliberately no `:create` / `:update` / `:delete` twin to grant. + * The matching `:read` key is derived automatically by + * `deriveReadPermissions` below. + */ +const AUDIT_LOG_PERMISSIONS = [ + { + id: "52b2cdef-5313-4954-8c69-0f8c58ea2c1e", + key: "edr_freight_app:audit_log:view", + name: { am: "የኦዲት መዝገብ ይመልከቱ", en: "View audit logs" }, + applicationKey: EDR_FREIGHT_APPLICATION.key, + }, +] as const; + const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...EMPLOYEE_REGISTRATION_PERMISSIONS, ...ROLE_ASSIGNMENT_PERMISSIONS, @@ -198,6 +216,7 @@ const EDR_FREIGHT_VIEWABLE_PERMISSIONS = [ ...HIERARCHY_POSITION_PERMISSIONS, ...HIERARCHY_EMPLOYEE_ASSIGNMENT_PERMISSIONS, ...POSITION_TYPE_PERMISSIONS, + ...AUDIT_LOG_PERMISSIONS, ...BOOKING_RULE_ENGINE_PERMISSIONS, ]; diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 4728cf5f3..57bbda267 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1622,11 +1622,24 @@ export const FREIGHT_PERMS = { dispatch: "edr_freight_app:train_scheduling:dispatch", markPaid: "edr_freight_app:train_scheduling:mark_paid", expireBooking: "edr_freight_app:train_scheduling:expire_booking", + /** + * Edit a schedule's operational run numbers (train + voyage) before + * dispatch. Separate from `update`: these numbers are what yards and + * customs quote, so changing them is narrower than general scheduling edits. + */ + editTrainNumber: "edr_freight_app:train_scheduling:edit_train_number", }, fleet: { view: "edr_freight_app:fleet:view", manage: "edr_freight_app:fleet:manage", }, + /** + * Audit trail. View-only: the module has no write routes, so this is the + * only key it needs — see AUDIT_LOG_PERMISSIONS in edr-freight.seed.ts. + */ + auditLog: { + view: "edr_freight_app:audit_log:view", + }, admin: "edr_freight_app:admin", ruleEngine: { view: (slug: RuleEngineResourceSlug) => @@ -2124,6 +2137,7 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.dispatch, FREIGHT_PERMS.trainScheduling.markPaid, FREIGHT_PERMS.trainScheduling.expireBooking, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, @@ -2288,6 +2302,7 @@ export const POSITION_PERMISSION_PRESETS = { FREIGHT_PERMS.trainScheduling.cancel, FREIGHT_PERMS.trainScheduling.reschedule, FREIGHT_PERMS.trainScheduling.rulesManage, + FREIGHT_PERMS.trainScheduling.editTrainNumber, FREIGHT_PERMS.fleet.view, FREIGHT_PERMS.fleet.manage, ...FLEET_GRANULAR_KEYS, diff --git a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts index 09901b502..81df21a17 100644 --- a/apps/edr-freight-api/src/seed/freight-positions.seeder.ts +++ b/apps/edr-freight-api/src/seed/freight-positions.seeder.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { + Application, Organization, Permission, Position, @@ -8,7 +9,23 @@ import { } from '@tria-plc/iamapi-common'; import { DataSource, EntityManager, In } from 'typeorm'; -import { EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; +import { EDR_FREIGHT_APPLICATION, EDR_FREIGHT_POSITIONS } from './edr-freight.seed'; + +/** + * Turn a permission key into a readable fallback name for a row this seeder has + * to mint itself: `edr_freight_app:train_scheduling:edit_train_number` becomes + * "Train scheduling: edit train number". Only used for keys absent from the + * catalog — a key that IS catalogued keeps its curated Amharic/English name. + */ +const nameForKey = (key: string): { en: string } => { + const [, ...rest] = key.split(':'); + const [resource, ...action] = rest; + const humanize = (s: string) => s.replace(/_/g, ' '); + const label = action.length + ? `${humanize(resource)}: ${humanize(action.join(' '))}` + : humanize(resource); + return { en: label.charAt(0).toUpperCase() + label.slice(1) }; +}; const SEED_FLAG = 'SEED_EDR_ORG'; const EDR_ORG_KEY = 'edr_freight'; @@ -83,7 +100,19 @@ export class FreightPositionsSeeder { ); } - /** Resolve every permission key referenced by any position to its id. */ + /** + * Resolve every permission key referenced by any position to its id, minting + * the rows that do not exist yet. + * + * The position presets draw from FREIGHT_PERMS (the registry), which is + * broader than the EDR_FREIGHT_PERMISSIONS catalog EdrOrgSeeder inserts — + * module keys like `train_scheduling:*` live only in the registry. So every + * newly-added preset key would otherwise abort boot with + * `missing_permissions:` until someone hand-inserted it. Ensuring them + * here keeps this seeder self-sufficient: it declares the keys it needs, so + * it is the one that guarantees they exist. Same approach, and the same + * id-less insert reasoning, as FreightNotificationPermissionsSeeder. + */ private async loadPermissionIds( manager: EntityManager, ): Promise> { @@ -91,16 +120,54 @@ export class FreightPositionsSeeder { ...new Set(EDR_FREIGHT_POSITIONS.flatMap((p) => p.permissionKeys)), ]; - const permissions = await manager.getRepository(Permission).find({ - where: { key: In(keys) }, - select: { id: true, key: true }, - }); - - const map = new Map(permissions.map((p) => [p.key, p.id as string])); + const read = async () => { + const rows = await manager.getRepository(Permission).find({ + where: { key: In(keys) }, + select: { id: true, key: true }, + }); + return new Map(rows.map((p) => [p.key, p.id as string])); + }; + let map = await read(); const missing = keys.filter((key) => !map.has(key)); - if (missing.length > 0) { - throw new Error(`missing_permissions:${missing.join(',')}`); + if (missing.length === 0) { + return map; + } + + const application = await manager.getRepository(Application).findOne({ + where: { key: EDR_FREIGHT_APPLICATION.key }, + select: { id: true }, + }); + if (!application?.id) { + throw new Error(`missing_application:${EDR_FREIGHT_APPLICATION.key}`); + } + + // Ids are left to the column default and never sent: iam.permissions has + // two unique columns (PK id, UQ key) and ON CONFLICT can only target one, + // so a hand-minted id already owned by a retired key would slip past + // ON CONFLICT (key) and die on the PK. + await manager + .createQueryBuilder() + .insert() + .into(Permission) + .values( + missing.map((key) => ({ + key, + name: nameForKey(key), + applicationId: application.id as string, + })), + ) + .orIgnore() + .execute(); + + this.logger.log( + `Seeded ${missing.length} permission(s) referenced by positions but absent from the catalog: ${missing.join(', ')}`, + ); + + map = await read(); + const stillMissing = keys.filter((key) => !map.has(key)); + if (stillMissing.length > 0) { + throw new Error(`missing_permissions:${stillMissing.join(',')}`); } return map; diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d221aaad..8c97dcc08 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -41,6 +41,7 @@ import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import ReportsHubPage from "./pages/reports/ReportsHubPage"; import ReportPage from "./pages/reports/ReportPage"; +import AuditLogsPage from "./pages/AuditLogsPage"; import AiBookingMockTestPage from "./pages/ai/AiBookingMockTestPage"; import PaymentsPage from "./pages/payments/PaymentsPage"; //import EmployeesPage from "./pages/dashboard/user-management/EmployeesPage"; @@ -188,6 +189,7 @@ const App = () => { } /> } /> } /> + } /> {/* Dev/testing page for the mock AI booking assistant. */} {trainLabel ? ( ) : ( )} + {isRequested && trainLabel ? ( + + {hasDeparted + ? "This train has already departed — accepting the operation will not place the booking on it." + : "Picked by the customer at day-commit. Accepting the operation releases the booking to the batch pool for this train."} + + ) : null} + {schedule?.status ? ( @@ -231,10 +254,15 @@ export function BookingSchedulingWindowCard({ formatStamp(schedule.scheduledDepartureDate) ?? "—" } + tone={isRequested && hasDeparted ? "danger" : undefined} hint={ schedule.actualDepartureAt ? `Actual · planned ${formatStamp(schedule.scheduledDepartureDate) ?? "—"}` - : "Planned" + : departureIso && !hasDeparted + ? `Planned · departs ${formatRelative(departureIso, nowMs)}` + : hasDeparted + ? "Planned — already past" + : "Planned" } /> void; }) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( clearance.vesselDepartureDate ? new Date(clearance.vesselDepartureDate) : null, ); @@ -1020,7 +1020,14 @@ export function ReleaseOrderCard({ Release Order - + { - if (!file || !vesselDate) return; + if (files.length === 0 || !vesselDate) return; setLoading(true); try { const iso = vesselDate.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx index dfe0bf0e4..95dbc787e 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlClearanceUploadModal.tsx @@ -10,11 +10,10 @@ import { toIsoDate, useDoCollectionDates, } from "@/components/contracts/DoCollectionDateFields"; -import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone"; -import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow"; +import { PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone"; import { contractsService } from "@/services/contracts.service"; import { bookingsService } from "@/services/bookings.service"; -import type { Freight } from "@edr/types"; +import { isDeliveryOrderFileCode, isReleaseOrderFileCode, type Freight } from "@edr/types"; export type GlClearanceUploadKind = "do" | "ro"; @@ -44,9 +43,8 @@ export function GlClearanceUploadModal({ vesselArrivalDate, doCollectedDate, onSuccess, - onPreview, }: GlClearanceUploadModalProps) { - const [file, setFile] = useState(null); + const [files, setFiles] = useState([]); const [vesselDate, setVesselDate] = useState( vesselDepartureDate ? new Date(vesselDepartureDate) : null, ); @@ -66,16 +64,16 @@ export function GlClearanceUploadModal({ const isDo = kind === "do"; const isRo = kind === "ro"; const replaceMode = isDo - ? Boolean(findWorkflowFile(workflowFiles, "delivery_order")) - : Boolean(findWorkflowFile(workflowFiles, "release_order")); + ? workflowFiles.some((f) => isDeliveryOrderFileCode(f.code) && f.file) + : workflowFiles.some((f) => isReleaseOrderFileCode(f.code) && f.file); const close = () => { - setFile(null); + setFiles([]); onClose(); }; const submit = async () => { - if (!file || !kind) return; + if (files.length === 0 || !kind) return; if (isRo && !vesselDate) { toast.error("Vessel departure date is required."); return; @@ -93,23 +91,23 @@ export function GlClearanceUploadModal({ doCollectedDate: toIsoDate(doDates.doCollected)!, }; if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); + await bookingsService.uploadDeliveryOrder(entityId, files, dates); } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); + await contractsService.uploadDeliveryOrder(entityId, files, dates); } toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); } else { const iso = vesselDate!.toISOString().slice(0, 10); const result = isBooking - ? await bookingsService.uploadReleaseOrder(entityId, file, iso) - : await contractsService.uploadReleaseOrder(entityId, file, iso); + ? await bookingsService.uploadReleaseOrder(entityId, files, iso) + : await contractsService.uploadReleaseOrder(entityId, files, iso); if (result.hold) { toast.error(result.holdReason ?? "Vessel date too soon"); } else { toast.success(replaceMode ? "Release Order updated" : "Release Order uploaded"); } } - setFile(null); + setFiles([]); onSuccess?.(); close(); } catch (e) { @@ -152,14 +150,15 @@ export function GlClearanceUploadModal({ )} - @@ -170,7 +169,7 @@ export function GlClearanceUploadModal({ color="edr-green" loading={loading} disabled={ - !file || + files.length === 0 || (isRo && !vesselDate) || (isDo && !doDatesComplete(doDates)) } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx index 926637db2..4b44edda6 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/PhasedClearanceActionPanel.tsx @@ -34,12 +34,15 @@ import { Truck, Upload, } from "lucide-react"; -import type { Freight } from "@edr/types"; +import { + deliveryOrderFileLabel, + isDeliveryOrderFileCode, + type Freight, +} from "@edr/types"; import toast from "react-hot-toast"; import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper"; -import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField"; import { DoCollectionDateFields, doDatesComplete, @@ -2133,56 +2136,82 @@ function DeliveryOrderStep({ onViewFile?: (file: { name: string; url: string }) => void; onDownloadFile?: (file: { id: string; name: string }) => void; }) { - const [files, setFiles] = useState>({ - delivery_order: null, - }); + const [files, setFiles] = useState([]); const [doDates, setDoDates] = useDoCollectionDates({ vesselArrivalDate, doCollectedDate, }); const [loading, setLoading] = useState(false); - const hasFile = Boolean(files.delivery_order); + + const submit = async () => { + if (files.length === 0 || !doDatesComplete(doDates)) return; + const dates = { + vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, + doCollectedDate: toIsoDate(doDates.doCollected)!, + }; + setLoading(true); + try { + if (isBooking) { + await bookingsService.uploadDeliveryOrder(entityId, files, dates); + } else { + await contractsService.uploadDeliveryOrder(entityId, files, dates); + } + setFiles([]); + toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); + onChanged?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Upload failed"); + } finally { + setLoading(false); + } + }; return ( - setFiles((prev) => ({ ...prev, [key]: file }))} - workflowFiles={workflowFiles} - replaceMode={replaceMode} - loading={loading} - disabled={!hasFile || !doDatesComplete(doDates)} - helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected." - submitLabel={replaceMode ? "Replace DO" : "Upload DO"} - extraFields={ - - } - onViewFile={onViewFile} - onDownloadFile={onDownloadFile} - onSubmit={async () => { - const file = files.delivery_order; - if (!file || !doDatesComplete(doDates)) return; - const dates = { - vesselArrivalDate: toIsoDate(doDates.vesselArrival)!, - doCollectedDate: toIsoDate(doDates.doCollected)!, - }; - setLoading(true); - try { - if (isBooking) { - await bookingsService.uploadDeliveryOrder(entityId, file, dates); - } else { - await contractsService.uploadDeliveryOrder(entityId, file, dates); - } - setFiles({ delivery_order: null }); - toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded"); - onChanged?.(); - } catch (e) { - toast.error(e instanceof Error ? e.message : "Upload failed"); - } finally { - setLoading(false); + + + Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the + DO was collected. Add as many files as needed. + + + {workflowFiles + .filter((wf) => isDeliveryOrderFileCode(wf.code) && wf.file) + .map((wf, index) => ( + + ))} + + + + + accept="*/*" + value={files} + onChange={setFiles} + replaceMode={replaceMode} + disabled={loading} + /> + + + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 726711a8b..307cb10aa 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -8,6 +8,7 @@ import { FileSignature, FileText, Hammer, + History, Landmark, LayoutDashboard, LayoutGrid, @@ -500,6 +501,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] FREIGHT_PERMS.settings.supportContent.manage, ], }, + { + label: "Audit logs", + href: "/dashboard/audit-logs", + icon: , + permission: FREIGHT_PERMS.auditLog.view, + }, { label: "Configuration", href: "/dashboard/configuration", diff --git a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx index 21033e7fe..23c6fb220 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainBuilder/BuildTrainModal.tsx @@ -88,7 +88,7 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain const handleBuild = async () => { if (!trainName.trim()) { toast({ - title: "Enter the vogue number", + title: "Enter the voyage number", variant: "destructive", }); return; @@ -150,8 +150,8 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain wagons are attached on the next screen. setTrainName(e.currentTarget.value)} maxLength={100} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx index 27122d68c..641903b58 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/EligibleBookingsPanel.tsx @@ -1,7 +1,9 @@ import { useMemo } from "react"; +import { Link } from "react-router-dom"; import { ArrowRight, Landmark, Package } from "lucide-react"; import { Accordion, + Anchor, Badge, Button, Checkbox, @@ -50,9 +52,20 @@ function EligibleBookingRow({ - + {/* Opens the booking in a new tab: the row is a selection control in + an allocation flow, so navigating away would lose staff's picks. */} + e.stopPropagation()} + > {booking.reference} - + {resolvedFreightType ? ( {resolvedFreightType} diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx new file mode 100644 index 000000000..a713918b4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/MergeScheduleTrainModal.tsx @@ -0,0 +1,344 @@ +import { + Alert, + Badge, + Box, + Button, + Card, + Group, + Loader, + Modal, + Stack, + Text, + Textarea, + TextInput, + ThemeIcon, +} from "@mantine/core"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { + ArrowRight, + Ban, + CircleAlert, + Merge, + Search, + TriangleAlert, +} from "lucide-react"; +import { useMemo, useState } from "react"; + +import { api } from "@/services/api"; +import { useToast } from "@/hooks/use-toast"; + +function parseError(error: unknown, fallback: string): string { + if (isAxiosError(error)) { + const message = error.response?.data?.message; + if (Array.isArray(message)) return message.join(", "); + if (typeof message === "string") return message; + } + return fallback; +} + +const fmtDate = (iso: string) => + new Date(iso).toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + +export interface MergeScheduleTrainModalProps { + scheduleId: string | null; + /** This schedule's current train — excluded from the picker. */ + currentTrainId: string | null; + scheduleReference?: string | null; + opened: boolean; + onClose: () => void; + onMerged?: () => void; +} + +/** + * Merge another train into this schedule. + * + * This schedule always survives: its train set is repointed at the chosen + * train, that train's wagons join this consist, and the emptied train is + * deactivated. When the chosen train also runs a schedule on the SAME DAY, that + * schedule's bookings move here and it is removed — its other-day schedules + * gain the wagons only. The server computes all of that in `previewMerge`, so + * the summary below is exactly what the commit will perform. + */ +export default function MergeScheduleTrainModal({ + scheduleId, + currentTrainId, + scheduleReference, + opened, + onClose, + onMerged, +}: MergeScheduleTrainModalProps) { + const { toast } = useToast(); + const [selectedTrainId, setSelectedTrainId] = useState(null); + const [search, setSearch] = useState(""); + const [reason, setReason] = useState(""); + + const { data: trains = [], isLoading: trainsLoading } = useQuery({ + ...api.trains.list.queryOptions(), + enabled: opened, + }); + + // The schedule's own train cannot be merged into itself. + const options = useMemo(() => { + const q = search.trim().toLowerCase(); + return trains + .filter((t) => t.id !== currentTrainId) + .filter((t) => + q + ? `${t.code} ${t.trainNumber ?? ""} ${t.trainName ?? ""}` + .toLowerCase() + .includes(q) + : true, + ); + }, [trains, currentTrainId, search]); + + const { data: preview, isFetching: previewLoading } = useQuery({ + ...api.trainScheduling.previewScheduleMerge.queryOptions({ + input: { id: scheduleId ?? "", targetTrainId: selectedTrainId ?? "" }, + }), + enabled: opened && Boolean(scheduleId && selectedTrainId), + }); + + const merge = useMutation(api.trainScheduling.mergeScheduleTrain.mutationOptions()); + + const close = () => { + setSelectedTrainId(null); + setSearch(""); + setReason(""); + onClose(); + }; + + const submit = async () => { + if (!scheduleId || !selectedTrainId || !preview?.canMerge) return; + try { + await merge.mutateAsync({ + id: scheduleId, + targetTrainId: selectedTrainId, + ...(reason.trim() ? { reason: reason.trim() } : {}), + }); + toast({ title: "Trains merged" }); + onMerged?.(); + close(); + } catch (err) { + toast({ + title: "Merge failed", + description: parseError(err, "Could not merge the trains"), + variant: "destructive", + }); + } + }; + + return ( + + + + + + + Merge another train into this one + + + {scheduleReference ?? "This departure survives the merge"} + + + + } + > + + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + radius="md" + /> + + {trainsLoading ? ( + + + + ) : options.length === 0 ? ( + + No other trains available to merge. + + ) : ( + + {options.map((t) => { + const on = t.id === selectedTrainId; + return ( + setSelectedTrainId(t.id)} + style={{ + cursor: "pointer", + borderColor: on + ? "var(--mantine-color-edr-green-5)" + : undefined, + background: on + ? "var(--mantine-color-edr-green-0)" + : undefined, + }} + > + + {t.code} + + + {t.trainNumber ? `No. ${t.trainNumber}` : "—"} + + + ); + })} + + )} + + {selectedTrainId && previewLoading ? ( + + + + ) : null} + + {selectedTrainId && preview && !previewLoading ? ( + + {preview.blockers.length ? ( + } + title="This merge is blocked" + > + + {preview.blockers.map((b) => ( + + {b} + + ))} + + + ) : ( + } + > + This cannot be undone. Wagons are appended last — reorder them + afterwards in the train builder. + + )} + + + + + {preview.wagons.current} wagons + + + + {preview.wagons.merged} wagons + + + +{preview.wagons.incoming} from {preview.targetTrain.code} + + + + {preview.absorbedSchedule ? ( + + + {fmtDate(preview.absorbedSchedule.scheduledDepartureDate)} + + + {preview.absorbedSchedule.reference ?? "Same-day schedule"} —{" "} + + {preview.absorbedSchedule.bookingsMoving} booking(s) + {" "} + move here, then it is removed + + + ) : null} + + {preview.affectedSchedules.map((s) => ( + + + {fmtDate(s.scheduledDepartureDate)} + + + {s.reference ?? s.id.slice(0, 8)} — gains the wagons, keeps + its own bookings + + + ))} + + {preview.untouchedSchedules.map((s) => ( + + + {fmtDate(s.scheduledDepartureDate)} + + + {s.reference ?? s.id.slice(0, 8)} — {s.status.toLowerCase()}, + not affected + + + ))} + + {preview.sourceTrainWillDeactivate ? ( + + + + This schedule's current train is emptied and deactivated. + + + ) : null} + + + {preview.canMerge ? ( +