From 5ce5cbe29dad4749ee0a05420e875380dab6396c Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 16 Aug 2026 08:18:40 +0000 Subject: [PATCH 1/4] feat: enhance booking and scheduling features with shipping line support and improved search functionality --- .../modules/bookings/bookings.repository.ts | 13 +- .../bookings/dto/filter-booking.dto.ts | 2 +- .../scheduling-reschedule.service.spec.ts | 18 +- .../scheduling-reschedule.service.ts | 21 ++- .../services/train-scheduling.service.spec.ts | 26 +++ .../services/train-scheduling.service.ts | 168 ++++++++++++------ .../pages/bookings/BookingRequestsPage.tsx | 2 +- .../contracts/ClearanceDocumentsPage.tsx | 50 +++++- 8 files changed, 225 insertions(+), 75 deletions(-) diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts index 379449f4f..310364927 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.repository.ts @@ -16,6 +16,7 @@ import { computeFacets, FacetBucket } from '../../common/utils/facets.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Contract } from '../contracts/entities/contract.entity'; +import { ShippingLineCompany } from '../shipping-lines/entities/shipping-line-company.entity'; import { ContractRateSnapshot } from '../contracts/entities/contract-rate-snapshot.entity'; import { ContractRoute } from '../contracts/entities/contract-route.entity'; import { applyDirectionScope } from '../user-trade-access/trade-scope.util'; @@ -783,16 +784,20 @@ export class BookingsRepository extends BaseRepository { // by TypeORM and crashes). .leftJoin(Contract, 'contract', 'contract.id = booking.contract_id') .addSelect('contract.reference', 'contract_reference') + // Shipping-line owner name for search only (no relation, see entity) — + // the list rows get `shippingLineCompany` hydrated by the service. + .leftJoin(ShippingLineCompany, 'slc', 'slc.id = booking.shipping_line_company_id') .where('booking.deleted_at IS NULL'); this.applyListFilters(qb, options); - // Free-text search spans joined columns (company, contract) that only this - // list query joins — so it lives here, not in applyListFilters (shared - // with getListSummaryMetrics, whose query builder has no joins). + // Free-text search spans joined columns (company, shipping line, contract) + // that only this list query joins — so it lives here, not in + // applyListFilters (shared with getListSummaryMetrics, whose query builder + // has no joins). if (options.search) { qb.andWhere( - '(booking.reference ILIKE :search OR company.name ILIKE :search OR contract.reference ILIKE :search)', + '(booking.reference ILIKE :search OR company.name ILIKE :search OR slc.name ILIKE :search OR contract.reference ILIKE :search)', { search: `%${options.search}%` }, ); } 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 b9cf58da0..4ffde3d0d 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 @@ -148,7 +148,7 @@ export class FilterBookingDto { @ApiPropertyOptional({ description: - 'Free-text search across booking reference, company name, and contract reference.', + 'Free-text search across booking reference, customer / shipping-line company name, and contract reference.', }) @IsOptional() @Transform(({ value }) => diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts index 4a5fc86bb..2d1bbf6f4 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.spec.ts @@ -72,6 +72,8 @@ describe('SchedulingRescheduleService', () => { previewTrainSchedule: jest.fn(), unassignBooking: jest.fn(), assignBookingsToSchedule: jest.fn(), + windowFieldsForNewDeparture: jest.fn().mockResolvedValue({}), + emitWindowState: jest.fn().mockResolvedValue(undefined), }; schedulingRescheduleRepository = { createEvent: jest.fn().mockResolvedValue({ id: 'event-1' }), @@ -213,6 +215,13 @@ describe('SchedulingRescheduleService', () => { }); trainSchedulesRepository.updateStatus.mockResolvedValue(undefined); trainSchedulingService.assignBookingsToSchedule.mockResolvedValue({ id: 'sched-1' }); + // An OPEN window's close must follow the new departure (this is the + // portal's "closes in" countdown) — the derived fields ride along with the + // date write. + const newCloses = new Date('2099-06-22T08:00:00.000Z'); + trainSchedulingService.windowFieldsForNewDeparture.mockResolvedValue({ + windowClosesAt: newCloses, + }); const result = await service.maintenanceReschedule( 'sched-1', @@ -228,9 +237,16 @@ describe('SchedulingRescheduleService', () => { expect(trainSchedulesRepository.updateStatus).toHaveBeenCalledWith( 'sched-1', 'DRAFT', - { scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z') }, + { + scheduledDepartureDate: new Date('2099-06-22T10:00:00.000Z'), + windowClosesAt: newCloses, + }, txManager, ); + expect(trainSchedulingService.windowFieldsForNewDeparture).toHaveBeenCalledWith( + schedule, + new Date('2099-06-22T10:00:00.000Z'), + ); expect(schedulingRescheduleRepository.createEvent).toHaveBeenCalledWith( expect.objectContaining({ trigger: 'TRAIN_MAINTENANCE', diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index 7283c874a..7c41ee43a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -227,17 +227,25 @@ export class SchedulingRescheduleService { // through this manager without editing TrainSchedulingService. A failure // between those steps and this block can still leave partial state; a human // must finish the full cross-service transaction threading. + // The booking window must follow the new departure (an OPEN window's + // "closes in" countdown is capped at departure − close offset; PRE_WINDOW / + // DONE re-derive their open/close). Same math as maintenanceReschedule. + const windowFields = newDeparture + ? await this.trainSchedulingService.windowFieldsForNewDeparture( + schedule, + newDeparture, + ) + : {}; + await this.dataSource.transaction(async (manager) => { if (newDeparture) { - // M7: raw write of scheduledDepartureDate. We deliberately do NOT - // delegate to TrainSchedulingService.updateScheduleDate, which only - // permits a date change while windowPhase === 'PRE_WINDOW' and would - // reject reschedules of already-open (SCHEDULED) trains. Consequence: - // the booking-window fields are NOT re-derived for the new date here. + // Raw write of scheduledDepartureDate: updateScheduleDate only permits a + // date change while windowPhase === 'PRE_WINDOW' and would reject + // reschedules of already-open (SCHEDULED) trains. await this.trainSchedulesRepository.updateStatus( scheduleId, schedule.status as TrainScheduleStatus, - { scheduledDepartureDate: newDeparture }, + { scheduledDepartureDate: newDeparture, ...windowFields }, manager, ); } @@ -263,6 +271,7 @@ export class SchedulingRescheduleService { // `newDeparture` is null when the date was unchanged, so retained customers // are not falsely told the train was rescheduled. await this.notifyRescheduleOutcome(dto, newDeparture); + if (newDeparture) void this.trainSchedulingService.emitWindowState(scheduleId); return { plan, schedule: assignResult }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index ed099ad72..15cf4f592 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1821,5 +1821,31 @@ describe('TrainSchedulingService', () => { expect(written.windowPhase).toBeUndefined(); expect(written.windowOpensAt).toBeUndefined(); }); + + it('moves an OPEN export close to the new departure but keeps the open', async () => { + const opensAt = new Date('2027-06-19T03:00:00.000Z'); + trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue( + doneExportSchedule({ + windowPhase: 'OPEN', + bookingWindowStatus: 'OPEN', + windowOpensAt: opensAt, + windowClosesAt: new Date('2027-06-20T03:00:00.000Z'), + }), + ); + + // Departure pushed 3 days later → close = new departure − 120min; the + // open customers already booked against stays untouched. + await service.maintenanceReschedule('sch-done', { + newDepartureDate: '2027-06-23T05:00:00.000Z', + } as never); + + const written = scheduleUpdate.mock.calls[0][1]; + expect(written.scheduledDepartureDate).toEqual( + new Date('2027-06-23T05:00:00.000Z'), + ); + expect(written.windowClosesAt).toEqual(new Date('2027-06-23T03:00:00.000Z')); + expect(written.windowOpensAt).toBeUndefined(); + expect(written.windowPhase).toBeUndefined(); + }); }); }); 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 2effe6a37..0fb6e16ab 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 @@ -164,6 +164,8 @@ import { } from '../booking-batch.constants'; import { orderConsistWagons } from '../consist-order.util'; import { + bookingCloseCutoff, + clampCloseToOfficeHours, computeExportWindowTimes, computeImportWindowTimes, earliestSchedulableDeparture, @@ -473,7 +475,7 @@ export class TrainSchedulingService { * used for lifecycle changes outside the window tick (create, cancel, * finalize, restamp). A push failure must never break the mutation. */ - private async emitWindowState(scheduleId: string): Promise { + async emitWindowState(scheduleId: string): Promise { try { const fresh = await this.trainSchedulesRepository.findById(scheduleId); // Dedicated shipping-line departures are never announced to the portal — @@ -1159,66 +1161,73 @@ export class TrainSchedulingService { } /** - * Maintenance reschedule: the admin moves a train (with everything aboard) to - * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window - * phase and inside the booking lead window — a maintenance move is an - * operational fact, not a planning choice. What moves and what stays: + * Booking-window fields that must follow a train's departure moving to + * `departure` (any window phase). Shared by every reschedule path so the + * "closes in" countdown always tracks the real departure. * - * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every - * aboard/targeted booking's scheduledDate (the day-pool queries key on it, - * so a booking left on the old day would fall out of its own train's pool). - * - STAYS: train set, wagon assignments, schedule↔booking links, route, - * maxWagons, and the window RULE snapshot. Stamped window times are only - * re-derived for PRE_WINDOW schedules (their window hasn't run yet); a - * schedule mid- or post-window keeps its timeline untouched. + * PRE_WINDOW: the stamped open/close were derived from the old departure + * and the window hasn't opened yet, so re-derive them from the schedule's + * own rule snapshot against the new date (joining the target day's route + * group timeline when one exists, exactly like updateScheduleDate). * - * Customers of every moved booking are notified (maintenanceMoved). + * DONE: the window already finished (e.g. the close offset hit and then the + * train was moved to a later departure). The window must follow the new + * departure, so it REOPENS: re-derive open/close the same way, reset the + * phase to PRE_WINDOW and clamp a past open into the present so the tick + * opens it immediately. A FULL train stays closed — there is nothing left + * to sell — and so does one whose re-derived window would already be over. + * + * OPEN: customers are already booking against the open they were shown, so + * the open stays put — but the close was capped at the OLD departure's + * cutoff, so it must follow the new one (import: open + duration under + * office hours, capped at the cutoff; export: the cutoff itself). Moving + * the train later extends the "closes in" countdown, moving it earlier + * shortens it (a close now in the past is picked up by the next tick). + * + * DOC_REVIEW/PAYMENT keep their running timeline. */ - async maintenanceReschedule( - id: string, - dto: MaintenanceRescheduleDto, - ): Promise { - const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); - if (!schedule) { - throw new NotFoundException(`Train schedule ${id} not found`); - } - if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { - throw new BadRequestException( - `Cannot reschedule a ${schedule.status.toLowerCase()} train`, - ); - } - - const departure = new Date(dto.newDepartureDate); - if (Number.isNaN(departure.getTime())) { - throw new BadRequestException('Invalid departure date.'); - } - if (departure.getTime() <= Date.now()) { - throw new BadRequestException('New departure must be in the future.'); - } - - const deltaMs = - departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); - const scheduledArrivalDate = schedule.scheduledArrivalDate - ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) - : undefined; - - // PRE_WINDOW: the stamped open/close were derived from the old departure - // and the window hasn't opened yet, so re-derive them from the schedule's - // own rule snapshot against the new date (joining the target day's route - // group timeline when one exists, exactly like updateScheduleDate). - // - // DONE: the window already finished (e.g. the close offset hit and then the - // train was moved to a later departure). The window must follow the new - // departure, so it REOPENS: re-derive open/close the same way, reset the - // phase to PRE_WINDOW and clamp a past open into the present so the tick - // opens it immediately. A FULL train stays closed — there is nothing left - // to sell — and so does one whose re-derived window would already be over. - // - // Mid-window phases (OPEN/DOC_REVIEW/PAYMENT) keep their running timeline. + async windowFieldsForNewDeparture( + schedule: TrainSchedule, + departure: Date, + ): Promise< + Partial< + Pick< + TrainSchedule, + | 'windowOpensAt' + | 'windowClosesAt' + | 'windowPhase' + | 'bookingWindowStatus' + | 'docReviewCompletedAt' + | 'docReviewEndsAt' + | 'paymentPhaseEndsAt' + > + > + > { const reopenFromDone = schedule.windowPhase === 'DONE' && schedule.bookingWindowStatus !== 'FULL'; + const shiftOpenClose = + schedule.windowPhase === 'OPEN' && schedule.windowOpensAt != null; const windowFields = - schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone + shiftOpenClose + ? await (async () => { + const merged = effectiveWindowConfig( + schedule, + await this.getWindowConfig(), + ); + const opensAt = schedule.windowOpensAt!; + const cutoff = bookingCloseCutoff(departure, schedule.direction, merged); + let closesAt = cutoff; + if (schedule.direction !== 'EXPORT') { + closesAt = clampCloseToOfficeHours( + opensAt, + new Date(opensAt.getTime() + merged.windowDurationHours * 3_600_000), + merged, + ); + if (closesAt.getTime() > cutoff.getTime()) closesAt = cutoff; + } + return { windowClosesAt: closesAt }; + })() + : schedule.windowPhase === 'PRE_WINDOW' || reopenFromDone ? await (async () => { const merged = effectiveWindowConfig( schedule, @@ -1266,6 +1275,55 @@ export class TrainSchedulingService { }; })() : {}; + return windowFields; + } + + /** + * Maintenance reschedule: the admin moves a train (with everything aboard) to + * a new departure. Unlike {@link updateScheduleDate} this runs at ANY window + * phase and inside the booking lead window — a maintenance move is an + * operational fact, not a planning choice. What moves and what stays: + * + * - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every + * aboard/targeted booking's scheduledDate (the day-pool queries key on it, + * so a booking left on the old day would fall out of its own train's pool). + * - STAYS: train set, wagon assignments, schedule↔booking links, route, + * maxWagons, and the window RULE snapshot. Stamped window times are + * re-derived for PRE_WINDOW schedules (their window hasn't run yet); an + * OPEN schedule keeps its open but its close follows the new departure; + * DOC_REVIEW/PAYMENT keep their timeline untouched. + * + * Customers of every moved booking are notified (maintenanceMoved). + */ + async maintenanceReschedule( + id: string, + dto: MaintenanceRescheduleDto, + ): Promise { + const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id); + if (!schedule) { + throw new NotFoundException(`Train schedule ${id} not found`); + } + if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) { + throw new BadRequestException( + `Cannot reschedule a ${schedule.status.toLowerCase()} train`, + ); + } + + const departure = new Date(dto.newDepartureDate); + if (Number.isNaN(departure.getTime())) { + throw new BadRequestException('Invalid departure date.'); + } + if (departure.getTime() <= Date.now()) { + throw new BadRequestException('New departure must be in the future.'); + } + + const deltaMs = + departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime(); + const scheduledArrivalDate = schedule.scheduledArrivalDate + ? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs) + : undefined; + + const windowFields = await this.windowFieldsForNewDeparture(schedule, departure); await this.dataSource.getRepository(TrainSchedule).update(id, { scheduledDepartureDate: departure, diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index 0c6a3553a..8c2a3a912 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -500,7 +500,7 @@ export default function BookingRequestsPage() { diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx index 029c53dd2..db57819d5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ClearanceDocumentsPage.tsx @@ -14,7 +14,7 @@ import { DatePickerInput } from "@mantine/dates"; import { getDateRangePresets } from "@/components/common/dateRangePresets"; import { useDebouncedValue } from "@mantine/hooks"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { FileText, Inbox, RefreshCw, Search, User, X } from "lucide-react"; +import { FileText, Inbox, RefreshCw, Search, Ship, User, X } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -75,6 +75,12 @@ const OWNERSHIP_OPTIONS = [ { value: "false", label: "Private" }, ]; +/** Who booked: shipping lines own bookings via `shippingLineCompanyId`, not a customer company. */ +const CUSTOMER_KIND_OPTIONS = [ + { value: "SHIPPING_LINE", label: "Shipping line" }, + { value: "CUSTOMER", label: "Customer" }, +]; + function startOfDayIso(d: Date): string { const x = new Date(d); x.setHours(0, 0, 0, 0); @@ -98,6 +104,7 @@ export default function ClearanceDocumentsPage() { const [directionFilter, setDirectionFilter] = useState(null); const [freightTypeFilter, setFreightTypeFilter] = useState(null); const [ownershipFilter, setOwnershipFilter] = useState(null); + const [customerKindFilter, setCustomerKindFilter] = useState(null); const [createdFrom, setCreatedFrom] = useState(null); const [createdTo, setCreatedTo] = useState(null); const { pagination, setPagination } = usePagination({ pageSize: PAGE_SIZE }); @@ -118,6 +125,7 @@ export default function ClearanceDocumentsPage() { directionFilter, freightTypeFilter, ownershipFilter, + customerKindFilter, createdFrom, createdTo, page, @@ -138,6 +146,9 @@ export default function ClearanceDocumentsPage() { ...(ownershipFilter ? { isGovernment: ownershipFilter as "true" | "false" } : {}), + ...(customerKindFilter + ? { customerKind: customerKindFilter as "SHIPPING_LINE" | "CUSTOMER" } + : {}), ...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}), ...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}), }), @@ -153,17 +164,29 @@ export default function ClearanceDocumentsPage() { header: () => Customer, cell: ({ row }) => { const b = row.original; - const customer = b.isGovernment - ? (b.governmentInstitution ?? "Government") - : (b.company?.name ?? "—"); + const isShippingLine = Boolean(b.shippingLineCompany ?? b.shippingLineCompanyId); + const customer = isShippingLine + ? (b.shippingLineCompany?.name ?? "Shipping line") + : b.isGovernment + ? (b.governmentInstitution ?? "Government") + : (b.company?.name ?? "—"); return (
- + {isShippingLine ? ( + + ) : ( + + )}
-

+

{customer} + {isShippingLine ? ( + + Shipping line + + ) : null}

@@ -267,7 +290,7 @@ export default function ClearanceDocumentsPage() { } value={query} onChange={(e) => { @@ -333,6 +356,19 @@ export default function ClearanceDocumentsPage() { style={{ minWidth: 140 }} aria-label="Filter by freight type" /> + Date: Mon, 17 Aug 2026 09:47:36 +0300 Subject: [PATCH 2/4] fix: ( payments ) restrict force-confirm to tickets:generate permission --- .../src/modules/payments/payments.controller.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 9c1bfa65d..858d108fe 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -211,17 +211,14 @@ export class PaymentsController { } @Post(":bookingId/force-confirm") - @PassengerStaff([ - PASSENGER_PERMS.payments.manage, - PASSENGER_PERMS.payments.manageMethods, - PASSENGER_PERMS.admin, - ]) + @PassengerStaff([PASSENGER_PERMS.tickets.generate, PASSENGER_PERMS.admin]) @ApiBearerAuth("IAM-auth") @ApiOperation({ - summary: "Force-confirm payment & generate ticket (back-office only)", + summary: "Force-confirm payment & generate ticket (ticket-generate permission)", description: "Marks the payment as SUCCEEDED, confirms the booking, and generates the ticket. " + - "Use when a vendor payment completed but the webhook was never delivered. Idempotent.", + "Use when a vendor payment completed but the webhook was never delivered. Idempotent. " + + "Requires `edr_passenger_app:tickets:generate` (admins bypass).", }) forceConfirm( @Param("bookingId") bookingId: string, From b453b81ff530ee6536ee49451094c42fc905a909 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 09:55:02 +0300 Subject: [PATCH 3/4] feat: ( backoffice ) hide Generate Ticket action without tickets:generate --- apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx index a1909d6e8..fffe3722c 100644 --- a/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/bookings/page.tsx @@ -31,6 +31,9 @@ const SectionHeader = ({ title }: { title: string }) => ( function BookingsPageContent() { const canManage = usePermission(PERMS.bookings.manage); + // Mirrors the API guard on POST /payments/:bookingId/force-confirm — + // tickets:generate, with the usual super-admin / org-admin bypass. + const canGenerateTicket = usePermission(PERMS.tickets.generate); const [filters, setFilters] = useState({ page: 1, pageSize: 20, search: '', status: '' }); const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' }); const [showExtraFilters, setShowExtraFilters] = useState(false); @@ -298,7 +301,7 @@ function BookingsPageContent() { const actions = [ { label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye }, - { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, + { label: 'Generate Ticket', onClick: (b: any) => { setGenerateTicketForm({ paymentReference: '', paymentMethod: '', notes: '' }); setGenerateTicketTouched({ paymentReference: false, paymentMethod: false }); setGenerateTicketBooking(b); }, variant: 'secondary' as const, icon: Ticket, show: (b: any) => canGenerateTicket && !(b.status === 'CONFIRMED' && b.paymentIntent?.status === 'SUCCEEDED') }, { label: 'Delete', onClick: (b: any) => { setDeleteError(null); setDeleteCascade(false); setDeleteCascadeChecked(false); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 }, ]; From 41080c1650f88bd9ca64fedb8b91686d7a7d12cc Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Mon, 17 Aug 2026 10:22:52 +0300 Subject: [PATCH 4/4] feat: ( backoffice ) gate Master Data pages and sidebar on view permissions --- .../backoffice/src/app/classes/page.tsx | 12 +++++++++++- .../backoffice/src/app/coaches/page.tsx | 12 +++++++++++- .../backoffice/src/app/routes/page.tsx | 12 +++++++++++- .../backoffice/src/app/schedules/page.tsx | 12 +++++++++++- .../backoffice/src/app/seats/page.tsx | 11 ++++++++++- .../backoffice/src/app/stations/page.tsx | 12 +++++++++++- .../backoffice/src/app/trains/page.tsx | 12 +++++++++++- .../backoffice/src/components/layout/Sidebar.tsx | 14 +++++++------- 8 files changed, 83 insertions(+), 14 deletions(-) diff --git a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx index af2cab29b..ed6f71acb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/classes/page.tsx @@ -10,8 +10,10 @@ import Modal from '@/components/ui/Modal'; import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { seatClassesApi, apiClient } from '@/lib/api'; import { formatCurrency } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function ClassesPage() { +function ClassesPageContent() { const [filters, setFilters] = useState({ search: '' }); const [showModal, setShowModal] = useState(false); const [editingClass, setEditingClass] = useState(null); @@ -352,3 +354,11 @@ export default function ClassesPage() {

); } + +export default function ClassesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx index acb0deefa..408ebb352 100644 --- a/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/coaches/page.tsx @@ -10,6 +10,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { fleetApi, apiClient } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; type Tab = 'types' | 'coaches' | 'utilization'; @@ -142,7 +144,7 @@ const renderBedVisualization = (coach: any) => { ); }; -export default function CoachesPage() { +function CoachesPageContent() { const [activeTab, setActiveTab] = useState('coaches'); const [search, setSearch] = useState(''); const [showModal, setShowModal] = useState(false); @@ -929,3 +931,11 @@ export default function CoachesPage() {
); } + +export default function CoachesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx index 251d034b1..a44d78546 100644 --- a/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/routes/page.tsx @@ -11,6 +11,8 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { routesApi } from '@/lib/api/routes'; import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface RouteStop { stationId: string; @@ -166,7 +168,7 @@ function RouteCoachesTab({ routes }: { routes: any[] }) { ); } -export default function RoutesPage() { +function RoutesPageContent() { const [activeTab, setActiveTab] = useState('routes'); const [showModal, setShowModal] = useState(false); const [editingRoute, setEditingRoute] = useState(null); @@ -922,3 +924,11 @@ export default function RoutesPage() { ); } + +export default function RoutesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 35d0290ea..7738f4b71 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -14,6 +14,8 @@ import { usePagination } from '@/lib/use-pagination'; import { formatDateTime } from '@/lib/utils'; import { eatLocalToISO, isoToEATLocal } from '@/lib/timezone'; import DateTimePicker from '@/components/ui/DateTimePicker'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; interface Schedule { id: string; @@ -52,7 +54,7 @@ interface Coach { coachType?: { name: string }; } -export default function SchedulesPage() { +function SchedulesPageContent() { const [showModal, setShowModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -1248,3 +1250,11 @@ export default function SchedulesPage() { ); } + +export default function SchedulesPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx index c2d3ef2db..86d61eff0 100644 --- a/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/seats/page.tsx @@ -6,6 +6,7 @@ import { seatsApi, schedulesApi, fleetApi, routeCoachTemplatesApi, bookingsApi } import { routesApi } from '@/lib/api/routes'; import { usePermissionStrict } from '@/lib/use-permission'; import { PERMS } from '@/lib/permissions'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; import Modal from '@/components/ui/Modal'; import ActionButton from '@/components/ui/ActionButton' import { Armchair, Lock, Unlock, Bed, X, RotateCcw, ChevronDown, Train, Wrench, Ticket as TicketIcon } from 'lucide-react'; @@ -15,7 +16,7 @@ import { SeatBlockReasonCategory, } from '@edr/types'; -export default function SeatsPage() { +function SeatsPageContent() { const [activeTab, setActiveTab] = useState<'route' | 'schedule'>('route'); const [selectedSchedule, setSelectedSchedule] = useState(''); const [selectedRoute, setSelectedRoute] = useState(''); @@ -1491,3 +1492,11 @@ function SeatIcon({ ); } + +export default function SeatsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx index 032a978a4..420bc3434 100644 --- a/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/stations/page.tsx @@ -11,8 +11,10 @@ import ConfirmDialog from '@/components/ui/ConfirmDialog'; import { stationsApi } from '@/lib/api'; import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function StationsPage() { +function StationsPageContent() { const [filters, setFilters] = useState({ search: '', country: '', operational: '' }); const [showModal, setShowModal] = useState(false); const [editingStation, setEditingStation] = useState(null); @@ -379,3 +381,11 @@ export default function StationsPage() { ); } + +export default function StationsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx index 527aebdc7..e1be87ff3 100644 --- a/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/trains/page.tsx @@ -13,8 +13,10 @@ import Pagination from '@/components/ui/Pagination'; import { usePagination } from '@/lib/use-pagination'; import { Train as TrainType } from '@/types'; import { formatDate } from '@/lib/utils'; +import { PermissionGuard } from '@/components/layout/PermissionGuard'; +import { PERMS } from '@/lib/permissions'; -export default function TrainsPage() { +function TrainsPageContent() { const [showModal, setShowModal] = useState(false); const [editingTrain, setEditingTrain] = useState(null); const [search, setSearch] = useState(''); @@ -366,3 +368,11 @@ export default function TrainsPage() { ); } + +export default function TrainsPage() { + return ( + + + + ); +} diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 616488917..1f33c417e 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -81,13 +81,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Master Data', items: [ - { name: 'Stations', href: '/stations', icon: MapPin }, - { name: 'Trains', href: '/trains', icon: Train }, - { name: 'Coaches', href: '/coaches', icon: Grid3x3 }, - { name: 'Seats', href: '/seats', icon: Armchair }, - { name: 'Classes', href: '/classes', icon: Settings }, - { name: 'Routes', href: '/routes', icon: Route }, - { name: 'Schedules', href: '/schedules', icon: Calendar }, + { name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view }, + { name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view }, + { name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view }, + { name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view }, + { name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view }, + { name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view }, + { name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view }, ] }, {