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 56100b4e9..2d4124cef 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -1211,6 +1211,12 @@ export class BookingsService { destinationYardId: filter.destinationYardId, isGovernment: filter.isGovernment, consolidationPaired: filter.consolidationPaired, + // DTO carries 'true'/'false' strings (query params); the repo option is a + // real boolean — convert, preserving "not filtered" when absent. + customsClearingEnabled: + filter.customsClearingEnabled === undefined + ? undefined + : filter.customsClearingEnabled === 'true', search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, diff --git a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts index 2c49ae650..2d3297af8 100644 --- a/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts +++ b/apps/edr-freight-api/src/modules/bookings/dto/filter-booking.dto.ts @@ -106,6 +106,14 @@ export class FilterBookingDto { @IsIn(['true', 'false']) isGovernment?: 'true' | 'false'; + @ApiPropertyOptional({ + enum: ['true', 'false'], + description: 'Filter customs vs self-clearance (non-customs) bookings', + }) + @IsOptional() + @IsIn(['true', 'false']) + customsClearingEnabled?: 'true' | 'false'; + @ApiPropertyOptional({ enum: TRADE_DIRECTIONS }) @IsOptional() @IsIn([...TRADE_DIRECTIONS]) 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 3d41e68a5..d728eff5d 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 @@ -872,6 +872,7 @@ export class ContractClearanceService { pageSize: filter.pageSize ?? 100, statuses: ['CLEARANCE_UNDER_REVIEW'], customsClearingEnabled: false, + search: filter.search, sortBy: filter.sortBy, sortOrder: filter.sortOrder, }); @@ -896,6 +897,7 @@ export class ContractClearanceService { pageSize: filter.pageSize ?? 50, statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'], customsClearingEnabled: false, + search: filter.search, sortBy: filter.sortBy ?? 'createdAt', sortOrder: filter.sortOrder ?? 'DESC', }); diff --git a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts index 6a8aced53..f32f2094e 100644 --- a/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts +++ b/apps/edr-freight-api/src/modules/train-schedules/train-schedules.repository.ts @@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository { }); } + /** + * Light fetch for human-facing labels (notifications): reference, train + * number, departure and the two station names — none of the composition + * graph {@link findByIdWithFullGraph} drags in. + */ + findByIdWithStations(id: string): Promise { + return this.repository.findOne({ + where: { id }, + relations: { originStation: true, destinationStation: true }, + }); + } + async updateStatus( id: string, status: TrainScheduleStatus, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index c25a30f03..0d5acf772 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -2258,7 +2258,7 @@ export class BookingBatchService implements OnModuleInit { this.logger.log( `[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`, ); - this.notifier.secured(booking, reason); + this.notifier.secured(booking, reason, scheduleId); void this.triggerWagonAllocation(scheduleId); void this.markWagonAllocatedMilestone(booking.id); // Customer tracking: freight payment settled (commercial pay-window path). diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts index edd807152..f3d7697f6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-notifier.service.ts @@ -9,6 +9,7 @@ import { import { Booking } from '../bookings/entities/booking.entity'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { BATCH_TIMEZONE } from './booking-batch.constants'; @Injectable() @@ -18,8 +19,37 @@ export class BookingNotifierService { constructor( private readonly notifications: NotificationsService, private readonly inbox: NotificationInboxService, + private readonly trainSchedules: TrainSchedulesRepository, ) {} + /** + * Human-readable description of a train schedule for customer messages: + * reference (or train number) + route + departure date. Never leaks a UUID — + * falls back to a generic phrase when the schedule can't be loaded. + */ + private async scheduleLabel(scheduleId?: string | null): Promise { + const fallback = 'your selected train'; + if (!scheduleId) return fallback; + try { + const s = await this.trainSchedules.findByIdWithStations(scheduleId); + if (!s) return fallback; + const ref = s.reference ?? s.trainNumber ?? null; + const route = + s.originStation?.label && s.destinationStation?.label + ? ` (${s.originStation.label} → ${s.destinationStation.label})` + : ''; + const departure = s.scheduledDepartureDate + ? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}` + : ''; + return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`; + } catch (err) { + this.logger.warn( + `scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`, + ); + return fallback; + } + } + private ref(b: Booking): string { return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`; } @@ -127,12 +157,15 @@ export class BookingNotifierService { }); } - secured(b: Booking, reason: 'paid' | 'gov'): void { - const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${ - reason === 'gov' ? ' (government)' : '' - }.`; - void this.notifyContact(b, msg, 'ALLOCATED'); - this.inApp(b, 'Wagon allocated', msg); + secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void { + void (async () => { + const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId); + const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${ + reason === 'gov' ? ' (government)' : '' + }.`; + void this.notifyContact(b, msg, 'ALLOCATED'); + this.inApp(b, 'Wagon allocated', msg); + })(); } expired(b: Booking): void { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index d529c2817..7ba83e50e 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,6 +52,7 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa import ContractViewPage from "./pages/contracts/ContractViewPage"; import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage"; import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage"; +import ClearanceDocumentsPage from "./pages/contracts/ClearanceDocumentsPage"; import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage"; import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage"; import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage"; @@ -158,6 +159,14 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.contracts.view, }, + // Operations hub: clearance-document review for contracts WITHOUT + // customs clearing (contract-level for one-time, per-booking for general). + { + label: "Clearance Documents", + href: "/dashboard/contracts/clearance-documents", + icon: , + permission: FREIGHT_PERMS.contracts.opsClearanceReview, + }, { label: "Customers", href: "/dashboard/customers", @@ -788,6 +797,18 @@ const App = () => { } /> + {/* Operations hub: clearance documents for non-customs contracts — + Contracts tab (contract-level) + General tab (per-booking). */} + + + + } + /> {/* GL (Path B) contract clearance review hub */} + {statusLabel(status)} + + ); +} + +export default function ClearanceDocumentsPage() { + const navigate = useNavigate(); + const [hubTab, setHubTab] = useState("contracts"); + const [queueTab, setQueueTab] = useState("queue"); + const [query, setQuery] = useState(""); + const [debouncedQuery] = useDebouncedValue(query, 300); + const search = debouncedQuery.trim() || undefined; + + const contractsPager = usePagination({ pageSize: PAGE_SIZE }); + const generalPager = usePagination({ pageSize: PAGE_SIZE }); + + // Any search / queue-history / tab switch restarts both lists from page 1. + useEffect(() => { + contractsPager.setPagination((p) => ({ ...p, pageIndex: 0 })); + generalPager.setPagination((p) => ({ ...p, pageIndex: 0 })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedQuery, queueTab, hubTab]); + + const isHistory = queueTab === "history"; + + const contractsQuery = useQuery({ + queryKey: [ + "clearance-documents", + "contracts", + queueTab, + contractsPager.pagination.pageIndex, + search, + ], + queryFn: () => { + const filter = { + page: contractsPager.pagination.pageIndex + 1, + pageSize: PAGE_SIZE, + search, + }; + return isHistory + ? contractsService.getOpsClearanceHistory(filter) + : contractsService.getOpsClearanceQueue(filter); + }, + enabled: hubTab === "contracts", + placeholderData: keepPreviousData, + }); + + const generalQuery = useQuery({ + queryKey: [ + "clearance-documents", + "general", + queueTab, + generalPager.pagination.pageIndex, + search, + ], + queryFn: () => + bookingsService.list({ + status: isHistory ? BOOKING_HISTORY_STATUS : BOOKING_QUEUE_STATUS, + bookingType: "GENERAL_CONTRACT", + customsClearingEnabled: "false", + page: generalPager.pagination.pageIndex + 1, + pageSize: PAGE_SIZE, + search, + }), + enabled: hubTab === "general", + placeholderData: keepPreviousData, + }); + + const contractColumns = useMemo( + (): ColumnDef[] => [ + { + header: "Reference", + accessorKey: "reference", + }, + { + header: "Customer", + cell: ({ row }) => row.original.company?.name ?? "—", + }, + { + header: "Kind", + cell: ({ row }) => statusLabel(row.original.contractKind), + }, + { + header: "Direction", + cell: ({ row }) => statusLabel(row.original.tradeDirection), + }, + { + header: "Freight", + cell: ({ row }) => statusLabel(row.original.freightType), + }, + { + header: "Status", + cell: ({ row }) => , + }, + { + header: "Created", + cell: ({ row }) => formatDate(row.original.createdAt), + }, + ], + [], + ); + + const bookingColumns = useMemo( + (): ColumnDef[] => [ + { + header: "Reference", + accessorKey: "reference", + }, + { + header: "Customer", + cell: ({ row }) => + row.original.isGovernment + ? (row.original.governmentInstitution ?? "Government") + : (row.original.company?.name ?? "—"), + }, + { + header: "Contract", + cell: ({ row }) => row.original.contractReference ?? "—", + }, + { + header: "Direction", + cell: ({ row }) => statusLabel(row.original.tradeDirection), + }, + { + header: "Freight", + cell: ({ row }) => statusLabel(row.original.freightType), + }, + { + header: "Status", + cell: ({ row }) => , + }, + ], + [], + ); + + const activeQuery = hubTab === "contracts" ? contractsQuery : generalQuery; + const total = activeQuery.data?.total ?? 0; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const tableStatus = activeQuery.isLoading + ? "loading" + : activeQuery.isError + ? "error" + : "success"; + + return ( + + + + + + setHubTab((v as HubTab) ?? "contracts")} + > + + Contracts + General + + + + setQueueTab(v as QueueTab)} + data={[ + { value: "queue", label: "Queue" }, + { value: "history", label: "History" }, + ]} + size="xs" + /> + setQuery(e.currentTarget.value)} + placeholder={ + hubTab === "contracts" + ? "Search reference or customer…" + : "Search booking, customer or contract…" + } + leftSection={} + w={260} + /> + + + + {hubTab === "contracts" ? ( + + columns={contractColumns} + data={contractsQuery.data?.items ?? []} + status={tableStatus} + onRowClick={(row) => + navigate(`/dashboard/contracts/clearance/${row.id}`) + } + pagination={{ + pageIndex: contractsPager.pagination.pageIndex, + pageSize: PAGE_SIZE, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination: contractsPager.pagination }, + onPaginationChange: contractsPager.setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + ) : ( + + columns={bookingColumns} + data={generalQuery.data?.items ?? []} + status={tableStatus} + onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)} + pagination={{ + pageIndex: generalPager.pagination.pageIndex, + pageSize: PAGE_SIZE, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination: generalPager.pagination }, + onPaginationChange: generalPager.setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 9ba8efc75..db7441721 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -19,6 +19,8 @@ export interface BookingListFilter { freightType?: string; /** ONE_TIME | GENERAL_CONTRACT — the booking-kind tab filter. */ bookingType?: string; + /** 'true' → customs bookings, 'false' → self-clearance (non-customs). */ + customsClearingEnabled?: "true" | "false"; tradeDirection?: string; paymentCurrency?: string; paymentStatus?: string; @@ -185,6 +187,8 @@ export const bookingsService = { if (filter.originYardId) params.originYardId = filter.originYardId; if (filter.destinationYardId) params.destinationYardId = filter.destinationYardId; if (filter.isGovernment) params.isGovernment = filter.isGovernment; + if (filter.customsClearingEnabled) + params.customsClearingEnabled = filter.customsClearingEnabled; if (filter.search) params.search = filter.search; } const response = await client.get(B.BASE, { diff --git a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts index 43c630f93..0c01040f3 100644 --- a/apps/edr-freight-web/backoffice/src/services/contracts.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/contracts.service.ts @@ -457,9 +457,14 @@ export const contractsService = { }, // ── Path A self-clearance (Operations review) ── - getOpsClearanceQueue: async (): Promise => { + getOpsClearanceQueue: async (filter?: { + page?: number; + pageSize?: number; + search?: string; + }): Promise => { const response = await client.get( C.OPS_CLEARANCE_QUEUE, + { params: filter }, ); const data = unwrap(response.data); return { @@ -474,8 +479,15 @@ export const contractsService = { return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 }; }, - getOpsClearanceHistory: async (): Promise => { - const response = await client.get(C.OPS_CLEARANCE_HISTORY); + getOpsClearanceHistory: async (filter?: { + page?: number; + pageSize?: number; + search?: string; + }): Promise => { + const response = await client.get( + C.OPS_CLEARANCE_HISTORY, + { params: filter }, + ); const data = unwrap(response.data); return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 }; },