From e37f1e0807b1f1c9b8a0278b307e8cfd5482af93 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 12 Aug 2026 17:19:06 +0000 Subject: [PATCH 1/7] fix issue --- apps/edr-freight-api/src/app.module.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 3bab613fb..1efe750fc 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -112,6 +112,7 @@ 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 { RequestLogMiddleware } from "@edr/api-common"; import { LoginAudienceMiddleware } from "./modules/auth/login-audience.middleware"; import { PositionTypePermissionsCache } from "./common/position-type-permissions.cache"; @@ -244,6 +245,7 @@ if (!process.env.APPLICATION_NAME) { EimsModule, FleetHistoryModule, AiModule, + AuditModule, ], providers: [ EdrOrgSeeder, From 833e62990e429b2ac4a79e05b746fffe88d1ace7 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 08:22:51 +0000 Subject: [PATCH 2/7] feat(train-scheduling): add wagon type, tare, equated length, station, seal no and note columns to import marshalling doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Import Load List / Marshalling Document only rendered Seq, Wagon, Booking, Company, Load, Container numbers, Weight T — missing fields present on the physical marshaling sheet (wagon type, tare, equated length, departure/arrival station, seal no) and a blank note column for yard staff. Export marshalling doc already had most of these; import doc now matches. Existing columns kept in place, unchanged. --- .../last-mile-requests.controller.ts | 13 +++ .../last-mile-requests.service.ts | 22 +++++ .../services/train-scheduling.service.ts | 27 +++++- .../detail/BookingMileServicesCard.tsx | 60 +++++++++++- .../portal/src/constants/URLS.ts | 1 + .../components/MileSummaryCard.tsx | 97 +++++++++++++++++-- .../services/last-mile-requests.service.ts | 7 ++ 7 files changed, 215 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts index 3d9fa4254..ff52ff9d7 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.controller.ts @@ -48,6 +48,19 @@ export class LastMileRequestsController { return this.requestsService.freeTruckCount().then((count) => ({ count })); } + // Customer-facing like :id — booking detail (portal + backoffice) lists the + // booking's requests to link the stored LM contract. Ownership-checked in + // the service for portal callers. + @Get('by-booking/:bookingId') + @MixedAudience(FREIGHT_PERMS.lastMile.requestView) + @ApiOperation({ summary: "A booking's last-mile requests, newest first — LM contract reference" }) + findForBooking( + @Param('bookingId', ParseUUIDPipe) bookingId: string, + @CurrentUser() user: TCurrentUser, + ) { + return this.requestsService.findForBooking(bookingId, user?.id ?? null); + } + @Get(':id/price-estimate') @BookingStaff(FREIGHT_PERMS.lastMile.requestView) @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts index ebdc93746..37612d318 100644 --- a/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts +++ b/apps/edr-freight-api/src/modules/last-mile-requests/last-mile-requests.service.ts @@ -221,6 +221,28 @@ export class LastMileRequestsService { return record; } + /** + * Every request on a booking, newest first — the booking-detail pages + * (portal + backoffice) use this to surface the LM contract later. Portal + * callers pass their userId and are ownership-checked against the booking's + * company, mirroring findById. + */ + async findForBooking(bookingId: string, userId?: string | null): Promise { + if (userId) { + const companyId = await this.bookingsService.resolveCustomerCompanyId(userId); + if (companyId) { + const booking = await this.bookingsRepository.findById(bookingId); + if (booking?.companyId && booking.companyId !== companyId) { + throw new BadRequestException('This booking does not belong to your company'); + } + } + } + return this.requestsRepository.findAll({ + where: { bookingId }, + order: { createdAt: 'DESC' }, + }); + } + /** * Rule-based price estimate for the approval dialog: estimated km (yard GPS → * delivery point, straight-line) × the LIVE last-mile rate rules against the 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 fc9395ffb..6c89c6980 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 @@ -3002,6 +3002,9 @@ export class TrainSchedulingService { .map((wagon) => ({ sequenceNo: wagon.sequenceNo, wagonNumber: wagon.physicalWagon?.wagonNumber ?? null, + wagonType: wagon.wagonType?.code ?? wagon.wagonType?.name ?? null, + tareWeightTons: wagon.wagonType?.tareWeightTons ?? null, + equatedLengthM: wagon.wagonType?.equatedLengthM ?? null, allocations: (wagon.allocations ?? []).map((allocation) => ({ bookingId: allocation.bookingId, bookingReference: allocation.booking?.reference ?? null, @@ -3501,26 +3504,37 @@ export class TrainSchedulingService { const allocationRows = loadList.wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} - ${esc(wagon.wagonNumber)}`; + ${esc(wagon.wagonNumber)} + ${esc(wagon.wagonType)} + ${wagon.tareWeightTons == null ? '-' : esc(Number(wagon.tareWeightTons).toFixed(2))} + ${wagon.equatedLengthM == null ? '-' : esc(Number(wagon.equatedLengthM).toFixed(3))} + ${esc(loadList.origin)} + ${esc(loadList.destination)}`; // An empty wagon still runs in the consist, so it still gets a line — see // buildExportLoadListHtml. if (wagon.allocations.length === 0) { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } return wagon.allocations.map( (allocation) => { const companyName = (allocation.booking as unknown as { company?: { name?: string } } | undefined)?.company?.name ?? '-'; + const sealNumbers = (allocation.containerItems ?? []) + .map((item) => item.sealNumber) + .filter(Boolean) + .join(', '); return ` ${wagonCells} ${esc(allocation.bookingReference ?? allocation.bookingId)} ${esc(companyName)} ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} + ${esc(sealNumbers || '-')} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -3609,15 +3623,22 @@ export class TrainSchedulingService { Seq Wagon + Wagon Type + Tare + Equated + Departure Station + Arrival Station Booking Company Load Container numbers + Seal No + Note Weight T - ${allocationRows || 'No wagons on this train set.'} + ${allocationRows || 'No wagons on this train set.'} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx index 3236e5cae..0a079bb4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingMileServicesCard.tsx @@ -1,6 +1,9 @@ -import { Truck } from "lucide-react"; -import { SimpleGrid } from "@mantine/core"; +import { Download, Truck } from "lucide-react"; +import { Button, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { useQuery } from "@tanstack/react-query"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import type { BookingDetail } from "@/types/booking"; import { SectionCard } from "./SectionCard"; @@ -10,12 +13,37 @@ export interface BookingMileServicesCardProps { booking: BookingDetail; } -/** First / last mile addresses. Renders nothing when neither is present. */ +/** + * First / last mile addresses, plus the stored last-mile contract reference + * (signed status + PDF download) for Truck & Machinery once a request on this + * booking is approved. Renders nothing when neither address is present. + */ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProps) { + const { data: requestsResponse } = useQuery({ + queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list({ bookingId: booking.id }), + queryFn: async () => + (await lastMileRequestsService.list({ bookingId: booking.id })).data, + enabled: Boolean(booking.lastMileDeliveryAddress), + }); + const approvedRequest = (requestsResponse?.data ?? []).find( + (r) => r.status === "APPROVED", + ); + if (!booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress) { return null; } + const downloadContract = async () => { + if (!approvedRequest) return; + const blob = (await lastMileRequestsService.contractDocument(approvedRequest.id)).data; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `last-mile-contract-${booking.reference ?? booking.id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + }; + return ( @@ -26,6 +54,32 @@ export function BookingMileServicesCard({ booking }: BookingMileServicesCardProp )} + {approvedRequest && ( + + + + Last-mile contract + + + {approvedRequest.customerSignedAt + ? `Signed ${new Date(approvedRequest.customerSignedAt).toLocaleDateString()}${ + approvedRequest.signerDisplayName + ? ` by ${approvedRequest.signerDisplayName}` + : "" + }` + : "Awaiting customer signature"} + + + + + )} ); } diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index a0d33f5ba..c9576d192 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -224,6 +224,7 @@ export const URL_CONSTANTS = { }, LAST_MILE_REQUESTS: { + BY_BOOKING: (bookingId: string) => `/api/last-mile-requests/by-booking/${bookingId}`, BY_ID: (id: string) => `/api/last-mile-requests/${id}`, SUBMIT: (id: string) => `/api/last-mile-requests/${id}/submit`, CONTRACT_VIEW: (id: string) => `/api/last-mile-requests/${id}/contract/view`, diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx index bfb57529f..6619dbd06 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/MileSummaryCard.tsx @@ -1,5 +1,6 @@ -import { Box, Group, Stack, Text } from "@mantine/core"; +import { Box, Button, Group, Stack, Text } from "@mantine/core"; import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "react-router-dom"; import type { Freight } from "@edr/types"; @@ -8,6 +9,7 @@ import type { MileLegSummary, MileVehicleSummary, } from "@/services/bookings.service"; +import { lastMileRequestsService } from "@/services/last-mile-requests.service"; import { CardTitle, SectionCard } from "./layout"; @@ -171,12 +173,85 @@ function LegBlock({ ); } +/** + * Reference row for the stored last-mile contract: signed status, open the + * contract page (view / sign), download the PDF. + */ +function LastMileContractRow({ + bookingId, + requestId, + signedAt, + signerDisplayName, +}: { + bookingId: string; + requestId: string; + signedAt?: string | null; + signerDisplayName?: string | null; +}) { + const navigate = useNavigate(); + const download = async () => { + const blob = await lastMileRequestsService.downloadContractDocument(requestId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "last-mile-contract.pdf"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + Last-mile contract + + + {signedAt + ? `Signed ${new Date(signedAt).toLocaleDateString()}${ + signerDisplayName ? ` by ${signerDisplayName}` : "" + }` + : "Awaiting your signature"} + + + + + + + + ); +} + export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { const { data } = useQuery({ queryKey: ["booking-mile-summary", booking.id], queryFn: () => bookingsService.mileSummary(booking.id), }); + // The stored LM contract lives on the booking's approved last-mile request. + const { data: lmRequests } = useQuery({ + queryKey: ["booking-last-mile-requests", booking.id], + queryFn: () => lastMileRequestsService.listForBooking(booking.id), + enabled: !!booking.lastMileDeliveryAddress, + }); + const approvedRequest = (lmRequests ?? []).find((r) => r.status === "APPROVED"); + const firstLeg = data?.firstMile ?? null; const lastLeg = data?.lastMile ?? null; @@ -202,11 +277,21 @@ export function MileSummaryCard({ booking }: { booking: Freight.IBooking }) { /> )} {showLast && ( - + + + {approvedRequest && ( + + )} + )} diff --git a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts index 5069eafe3..08133ba08 100644 --- a/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts +++ b/apps/edr-freight-web/portal/src/services/last-mile-requests.service.ts @@ -12,6 +12,7 @@ export interface LastMileRequest { requestedContainerNumbers?: string[] | null; requestedDeliveryDate?: string | null; customerSignedAt?: string | null; + signerDisplayName?: string | null; rejectionReason?: string | null; createdAt: string; updatedAt: string; @@ -46,6 +47,12 @@ export const lastMileRequestsService = { return data.data ?? data; }, + /** The booking's requests, newest first — links the stored LM contract. */ + listForBooking: async (bookingId: string): Promise => { + const { data } = await client.get(L.BY_BOOKING(bookingId)); + return data.data ?? data; + }, + /** Confirm which containers go via EDR last-mile and the requested delivery date. */ submit: async ( id: string, From 58cb71a9cf6872e6dde8791a5f96d75e5bbec6e8 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 08:55:17 +0000 Subject: [PATCH 3/7] feat(train-scheduling): add direct truck-to-train load action beside Load Export bookings whose cargo goes straight from customer truck to wagon never get a warehouse GRN, so the load gate (assertExportReceivedWithGrn) rejects them forever unless staff first flip exportHandoverMode on the booking detail page, then come back and retry Load. Add a Truck to Train button next to Load in the schedule workspace's on-train list, shown for EXPORT bookings at the boarding yard. One click sets exportHandoverMode=DIRECT_TO_TRAIN then loads the booking. --- .../ScheduleWorkspacePanel.tsx | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx index de954742e..25c20c8db 100644 --- a/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/ScheduleWorkspacePanel.tsx @@ -29,6 +29,7 @@ import { // Repeat, // used by the hidden Move (reassign) button Train, TrainFront, + Truck, Weight, X, } from "lucide-react"; @@ -37,6 +38,7 @@ import { CountdownTimer } from "@edr/ui-common"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import { api } from "@/services/api"; +import { bookingsService } from "@/services/bookings.service"; import { useToast } from "@/hooks/use-toast"; import type { EligibleContainerBooking, @@ -411,6 +413,31 @@ export function ScheduleWorkspacePanel({ ); }; + // Export cargo that skipped the warehouse (customer truck straight onto the + // wagon) has no GRN and never will — loadBooking's GRN gate would keep + // rejecting it forever. Setting DIRECT_TO_TRAIN tells that gate the carriage + // acceptance sheet is the handover document instead, then loads in one click. + const [truckToTrainPending, setTruckToTrainPending] = useState(null); + const doTruckToTrain = (bookingId: string, ref: string) => { + setTruckToTrainPending(bookingId); + bookingsService + .setExportHandoverMode(bookingId, "DIRECT_TO_TRAIN") + .then(() => loadJourney.mutateAsync({ scheduleId: schedule.id, bookingId })) + .then(() => { + toast({ title: `${ref} loaded — direct truck-to-train handover` }); + onChanged(); + void yardWorkQuery.refetch(); + }) + .catch((error) => + toast({ + title: "Could not load as direct truck-to-train", + description: apiErrorMessage(error, "Please try again."), + variant: "destructive", + }), + ) + .finally(() => setTruckToTrainPending(null)); + }; + const doUnload = (bookingId: string, ref: string) => { unloadJourney .mutateAsync({ scheduleId: schedule.id, bookingId }) @@ -708,6 +735,12 @@ export function ScheduleWorkspacePanel({ const alightHere = trainAtYardId != null && b.destinationYardId === trainAtYardId; const showLoad = canWork && !riding && !done && (journey?.canLoad ?? false); const showUnload = canWork && riding && (journey?.canUnload ?? false); + const showTruckToTrain = + canWork && + !riding && + !done && + boardHere && + b.tradeDirection === "EXPORT"; return ( ) : null} + {showTruckToTrain ? ( + + + + ) : null} {showUnload ? ( Date: Thu, 13 Aug 2026 12:20:27 +0300 Subject: [PATCH 4/7] fix: ( notifications ) stop group-booking SMS greeting the wrong passenger --- .../common/utils/booking-sms.utils.spec.ts | 153 ++++++++++++++++++ .../src/common/utils/booking-sms.utils.ts | 115 +++++++++++++ .../notifications/notifications.service.ts | 24 +-- 3 files changed, 275 insertions(+), 17 deletions(-) create mode 100644 apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts create mode 100644 apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts new file mode 100644 index 000000000..93fa94b50 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.spec.ts @@ -0,0 +1,153 @@ +import { buildSeatSummary } from './booking-sms.utils'; + +const seat = (passengerName: string, seatNumber: string, leg = 1, coachType = 'VIP Bed') => ({ + passengerName, + leg, + seat: { seatNumber, coach: { number: 'VIP-0001 (DJ)', coachType: { name: coachType } } }, +}); + +describe('buildSeatSummary', () => { + it('greets a solo traveller by name and omits the name from the seat line', () => { + const { passengerName, trainSeatLines } = buildSeatSummary([seat('Yanet', '9')], 'ONE_WAY'); + + expect(passengerName).toBe('Yanet'); + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + expect(trainSeatLines).not.toContain('Train/Seat'); + expect(trainSeatLines).not.toContain('Yanet'); + }); + + it('greets a group collectively and names each seat', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '4'), seat('Abebe', '6'), seat('Sara', '9'), seat('Helen', '10')], + 'ONE_WAY', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + // The reported bug: the seats query had no orderBy, so Postgres heap order put the LAST + // passenger first and the SMS greeted them while texting the first passenger's phone. + it('is immune to seat rows arriving in an arbitrary order', () => { + const rows = [seat('Yanet', '9'), seat('Helen', '10'), seat('Abebe', '6'), seat('Sara', '4')]; + + const { passengerName, trainSeatLines } = buildSeatSummary(rows, 'ONE_WAY'); + + expect(passengerName).toBe('Passengers'); + // Every line pairs the right person with their own seat, regardless of input order. + expect(trainSeatLines).toBe( + [ + 'Sara, VIP-0001 (DJ) VIP Bed, seat no. 4', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 6', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Helen, VIP-0001 (DJ) VIP Bed, seat no. 10', + ].join('\n'), + ); + }); + + it('sorts seat numbers numerically, not lexicographically', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('A', '9'), seat('B', '10'), seat('C', '6'), seat('D', '4')], + 'ONE_WAY', + ); + + expect(trainSeatLines.match(/seat no\. \d+/g)).toEqual([ + 'seat no. 4', + 'seat no. 6', + 'seat no. 9', + 'seat no. 10', + ]); + }); + + it('labels round-trip legs as Outbound/Return, listing each passenger once per leg', () => { + const { passengerName, trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Passengers'); + expect(trainSeatLines).toBe( + [ + 'Outbound:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 9', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 10', + 'Return:', + 'Yanet, VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Abebe, VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + // TRANSIT leg 2 is a connecting segment of the same outbound journey — never a return. + it('labels transit legs as Leg 1/Leg 2, never Return', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Abebe', '10', 1), seat('Yanet', '3', 2), seat('Abebe', '4', 2)], + 'TRANSIT', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + expect(trainSeatLines).not.toContain('Return'); + expect(trainSeatLines).not.toContain('Outbound'); + }); + + it('labels all four round-trip-transit legs', () => { + const { trainSeatLines } = buildSeatSummary( + [1, 2, 3, 4].map((leg) => seat('Yanet', String(leg), leg)), + 'ROUND_TRIP_TRANSIT', + ); + + expect(trainSeatLines).toBe( + [ + 'Outbound leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 1', + 'Outbound leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 2', + 'Return leg 1:', + 'VIP-0001 (DJ) VIP Bed, seat no. 3', + 'Return leg 2:', + 'VIP-0001 (DJ) VIP Bed, seat no. 4', + ].join('\n'), + ); + }); + + it('greets a solo round-trip traveller by name (same person on both legs)', () => { + const { passengerName } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'ROUND_TRIP', + ); + + expect(passengerName).toBe('Yanet'); + }); + + it('trims a trailing space on the coach type instead of emitting "Bed , seat"', () => { + const { trainSeatLines } = buildSeatSummary([seat('Yanet', '9', 1, 'VIP Bed ')], 'ONE_WAY'); + + expect(trainSeatLines).toBe('VIP-0001 (DJ) VIP Bed, seat no. 9'); + }); + + it('falls back safely on empty or malformed input', () => { + expect(buildSeatSummary([], 'ONE_WAY')).toEqual({ passengerName: 'Passenger', trainSeatLines: '' }); + + const { passengerName, trainSeatLines } = buildSeatSummary([{ leg: 1 }], 'ONE_WAY'); + expect(passengerName).toBe('Passenger'); + expect(trainSeatLines).toBe('-, seat no. -'); + }); + + it('falls back to a generic leg heading for an unknown booking type', () => { + const { trainSeatLines } = buildSeatSummary( + [seat('Yanet', '9', 1), seat('Yanet', '3', 2)], + 'SOMETHING_NEW', + ); + + expect(trainSeatLines).toContain('Leg 1:'); + expect(trainSeatLines).toContain('Leg 2:'); + }); +}); diff --git a/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts new file mode 100644 index 000000000..18b1db9ac --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/booking-sms.utils.ts @@ -0,0 +1,115 @@ +/** + * Builds the two passenger-facing values the `booking.created` SMS/email template needs: + * the `{{passengerName}}` salutation and the `{{trainSeatLines}}` block. + * + * Why this is a shared pure helper rather than inline logic: the salutation used to be + * `seats[0]?.passengerName`, and the query loading those seats had no `orderBy`. Postgres + * returns heap order for an unordered SELECT, and an UPDATE relocates a row to the end of + * the heap — so a group booking regularly greeted the LAST passenger while texting the + * first one's phone. Deriving both values from the whole seat set, sorted deterministically, + * removes the dependency on row order entirely, and keeps the formatting unit-testable + * without a Nest testing module. + * + * Group bookings send ONE SMS to Booking.contactPhone by design — BookingSeat has no + * phone/email column, so there is no per-passenger recipient. Hence 2+ passengers are + * greeted collectively and each seat line names its own occupant. + */ + +export interface SeatSummary { + /** Salutation: the traveller's name when solo, otherwise 'Passengers'. */ + passengerName: string; + /** One line per booked seat, newline-joined, with a heading per leg on multi-leg bookings. */ + trainSeatLines: string; +} + +/** + * Seat numbers are stored as strings of digits (Seat.seatNumber), so they must be compared + * numerically — a plain string compare orders '10' before '9'. Non-numeric labels sort last, + * then alphabetically among themselves. + */ +function compareSeatNumber(a: string, b: string): number { + const na = Number.parseInt(a, 10); + const nb = Number.parseInt(b, 10); + const aNum = Number.isNaN(na); + const bNum = Number.isNaN(nb); + if (aNum && bNum) return a.localeCompare(b); + if (aNum) return 1; + if (bNum) return -1; + return na - nb || a.localeCompare(b); +} + +const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : v == null ? '' : String(v).trim()); + +const ROUND_TRIP_TRANSIT_LEGS: Record = { + 1: 'Outbound leg 1', + 2: 'Outbound leg 2', + 3: 'Return leg 1', + 4: 'Return leg 2', +}; + +/** + * Leg numbering means different things per booking type — see the enum documented on + * TicketsController.validate. TRANSIT's leg 2 is a connecting segment of the SAME outbound + * journey, so it must never be labelled 'Return'. + */ +function legLabel(bookingType: string | undefined, leg: number): string { + switch (bookingType) { + case 'ROUND_TRIP': + return leg === 1 ? 'Outbound' : leg === 2 ? 'Return' : `Leg ${leg}`; + case 'TRANSIT': + return `Leg ${leg}`; + case 'ROUND_TRIP_TRANSIT': + return ROUND_TRIP_TRANSIT_LEGS[leg] ?? `Leg ${leg}`; + default: + // Unknown or newly added booking type — degrade to a generic heading rather than guessing. + return `Leg ${leg}`; + } +} + +export function buildSeatSummary(seats: any[], bookingType?: string): SeatSummary { + const rows = [...(seats ?? [])].sort( + (a, b) => + (a?.leg ?? 1) - (b?.leg ?? 1) || + str(a?.seat?.coach?.number).localeCompare(str(b?.seat?.coach?.number)) || + compareSeatNumber(str(a?.seat?.seatNumber), str(b?.seat?.seatNumber)), + ); + + // Distinct travellers. A round-trip/transit booking has one row per passenger PER LEG, so + // the same name legitimately repeats — count people, not rows. + const names: string[] = []; + for (const row of rows) { + const name = str(row?.passengerName); + if (name && !names.includes(name)) names.push(name); + } + const isGroup = names.length > 1; + + const line = (row: any): string => { + const coach = str(row?.seat?.coach?.number) || '-'; + const coachType = str(row?.seat?.coach?.coachType?.name); + const seatNo = str(row?.seat?.seatNumber) || '-'; + // Trim each part before joining: the coach-type name carries a trailing space in some + // records, which a `.replace(/ +/g, ' ')` collapse cannot remove (it shrinks runs of + // spaces but leaves a single one), and it surfaced as 'VIP Bed , seat no. 9'. + const where = [coach, coachType].filter(Boolean).join(' '); + const who = isGroup ? `${str(row?.passengerName) || 'Passenger'}, ` : ''; + return `${who}${where}, seat no. ${seatNo}`; + }; + + const legs = [...new Set(rows.map((row) => row?.leg ?? 1))]; + const trainSeatLines = + legs.length > 1 + ? legs + .map((leg) => + [ + `${legLabel(bookingType, leg)}:`, + ...rows.filter((row) => (row?.leg ?? 1) === leg).map(line), + ].join('\n'), + ) + .join('\n') + : rows.map(line).join('\n'); + + return { + passengerName: isGroup ? 'Passengers' : (names[0] || 'Passenger'), + trainSeatLines, + }; +} diff --git a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts index 9ef922bb3..3b7e7be2f 100644 --- a/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts +++ b/apps/edr-passenger-api/src/modules/notifications/notifications.service.ts @@ -8,6 +8,7 @@ import { EmailClientService } from './email-client.service'; import { SmsClientService } from './sms-client.service'; import { CreateTemplateDto, UpdateTemplateDto } from './notifications.dto'; import { resolveBookingSegment } from '../../common/utils/segment-resolver.utils'; +import { buildSeatSummary } from '../../common/utils/booking-sms.utils'; export type NotificationChannelType = 'EMAIL' | 'SMS' | 'PUSH' | 'IN_APP'; @@ -305,7 +306,7 @@ export class NotificationsService { where: { id: bookingId }, include: { schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } }, - seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } }, + seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } }, orderBy: { leg: 'asc' } }, }, }); @@ -367,30 +368,19 @@ export class NotificationsService { } /** - * Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a - * pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get - * several lines). + * Builds the interpolation context for the `booking.created` template. `passengerName` and + * `trainSeatLines` both come from buildSeatSummary — a solo booking is greeted by name with + * bare "coach, seat no." lines, while a group is greeted as "Passengers" and each line names + * its own occupant (one SMS goes to Booking.contactPhone for the whole party). */ private buildBookingCreatedContext(booking: any, ref: string): Record { const s = booking?.schedule ?? {}; - const trainName = s.train?.name ?? s.train?.number ?? ''; const fmtDate = (d: any) => d ? new Date(d).toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }) : 'TBD'; const fmtTime = (d: any) => d ? new Date(d).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }) : 'TBD'; - const seats = booking?.seats ?? []; - const trainSeatLines = seats - .map((bs: any) => { - const coach = bs.seat?.coach?.number ?? '-'; - const cls = bs.seat?.coach?.coachType?.name ?? ''; - const seatNo = bs.seat?.seatNumber ?? '-'; - return `Train/Seat: Train ${trainName}, ${coach} ${cls}, seat no. ${seatNo}`.replace(/ +/g, ' ').trim(); - }) - .join('\n'); - - // Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat. - const passengerName = seats[0]?.passengerName ?? 'Passenger'; + const { passengerName, trainSeatLines } = buildSeatSummary(booking?.seats ?? [], booking?.bookingType); const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`; const segment = resolveBookingSegment(s, booking?.originStationId, booking?.destinationStationId); From 89dba01cd797ed561341195fccac1c449e8957fd Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 09:27:01 +0000 Subject: [PATCH 5/7] feat(train-scheduling): notify customer of CAS on direct-to-train load Direct truck-to-train export cargo skips the warehouse, so the existing carriage acceptance sheet ready notice (fired on warehouse receive) never reached these bookings. Their handover moment is the load itself. Extract notifyCarriageAcceptanceReady into a shared notifications util (was private to WarehouseInventoryService) and call it from BookingJourneyService.loadBooking for EXPORT + DIRECT_TO_TRAIN bookings, right after the GRN gate, before the load transaction proceeds. --- .../notifications/notify-company.util.ts | 41 +++++++++++++++++++ .../booking-journey.service.spec.ts | 2 + .../booking-journey.service.ts | 22 +++++++++- .../warehouses/warehouse-inventory.service.ts | 32 +++++---------- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index e3b2fd80a..467b172ba 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -1,6 +1,9 @@ import { DataSource } from 'typeorm'; +import { Logger } from '@nestjs/common'; +import { NotificationAudience, NotificationType } from '@edr/types'; import { NotificationsService } from './notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { companyNotifyEmailExpr, companyNotifyPhoneExpr, @@ -42,3 +45,41 @@ export async function sendCompanyChannels( } } } + +/** + * Tell the customer their export carriage acceptance sheet is ready to + * download from the portal — the sheet itself is generated on demand by + * BookingsService.carriageAcceptanceSheet, never stored, so this is a + * "ready" notice + link, not an attachment (the email pipeline carries text + * only). Shared by every path that makes a booking's handover final: the + * warehouse gate on receive, and direct truck-to-train on load (that cargo + * never sees a warehouse, so its handover moment IS the load). + */ +export async function notifyCarriageAcceptanceReady( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + logger: Logger, +): Promise { + try { + const [b]: Array<{ companyId: string | null; reference: string }> = await dataSource.query( + `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!b?.companyId) return; + const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; + await inbox.notify({ + recipients: { companyId: b.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Carriage acceptance sheet ready', + body, + link: `/bookings/${bookingId}`, + data: { bookingId, reference: b.reference }, + }); + await sendCompanyChannels(dataSource, notifications, b.companyId, body); + } catch (err) { + logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); + } +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts index 0a8cc99fe..ea936465e 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.spec.ts @@ -11,6 +11,8 @@ describe('BookingJourneyService.autoPlaceOnFreedWagons', () => { {} as never, // yardFacilities {} as never, // facilityHandling { emit: jest.fn() } as never, // events + {} as never, // notifications + {} as never, // inbox ); const schedule = { id: 'sched-1', trainSetId: 'ts-1' }; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index bf6ab5069..56b28fb7f 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -24,7 +24,10 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin import { Wagon } from '../wagons/entities/wagon.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; +import { NotificationsService } from '../notifications/notifications.service'; +import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; +import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -52,6 +55,8 @@ export class BookingJourneyService { private readonly yardFacilities: YardFacilitiesService, private readonly facilityHandling: FacilityHandlingService, private readonly events: EventEmitter2, + private readonly notifications: NotificationsService, + private readonly inbox: NotificationInboxService, @Optional() private readonly milestoneService?: ClearanceMilestoneService, ) {} @@ -76,6 +81,21 @@ export class BookingJourneyService { // Export cargo must be in the warehouse with a GRN before it can be loaded, // however it arrived and whatever it is allocated to. await assertExportReceivedWithGrn(this.dataSource, booking); + // Direct truck-to-train cargo never sees the warehouse, so loading IS its + // handover moment — the carriage acceptance sheet must go out to the + // customer right here, not on a receive event that will never fire. + if ( + booking.tradeDirection === 'EXPORT' && + booking.exportHandoverMode === DIRECT_TO_TRAIN + ) { + await notifyCarriageAcceptanceReady( + this.dataSource, + this.notifications, + this.inbox, + booking.id, + this.logger, + ); + } const now = new Date(); await this.dataSource.transaction(async (manager) => { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 239485d3c..163b5bcc1 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -28,7 +28,10 @@ import type { InterchangeDocument } from '../interchange-documents/entities/inte import { LastMileService } from '../last-mile/last-mile.service'; import { UpdateLastMileDto } from '../last-mile/dto/update-last-mile.dto'; import { NotificationsService } from '../notifications/notifications.service'; -import { sendCompanyChannels } from '../notifications/notify-company.util'; +import { + sendCompanyChannels, + notifyCarriageAcceptanceReady as notifyCarriageAcceptanceReadyShared, +} from '../notifications/notify-company.util'; import { companyNotifyPhoneExpr, primaryContactUserJoin, @@ -6167,26 +6170,13 @@ export class WarehouseInventoryService { * fires right after receive, not at marshalling. */ private async notifyCarriageAcceptanceReady(bookingId: string): Promise { - try { - const [b]: Array<{ companyId: string | null; reference: string }> = await this.dataSource.query( - `SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`, - [bookingId], - ); - if (!b?.companyId) return; - const body = `Your carriage acceptance sheet for booking ${b.reference} is ready to download from the portal.`; - await this.inbox.notify({ - recipients: { companyId: b.companyId }, - audience: NotificationAudience.PORTAL, - type: NotificationType.DOCUMENT_ACTION, - title: 'Carriage acceptance sheet ready', - body, - link: `/bookings/${bookingId}`, - data: { bookingId, reference: b.reference }, - }); - await sendCompanyChannels(this.dataSource, this.notifications, b.companyId, body); - } catch (err) { - this.logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); - } + await notifyCarriageAcceptanceReadyShared( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + this.logger, + ); } private async notifyOwnerInventoryReceived(params: { From e66e622ededffd804fe61f840824a914309ee696 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 09:52:43 +0000 Subject: [PATCH 6/7] eims cancellation --- .../eims/eims-cancellation.service.spec.ts | 14 ++++++++------ .../modules/eims/eims-cancellation.service.ts | 17 ++++++++++++----- .../src/modules/eims/eims-invoice.controller.ts | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts index ab5ea2548..6bd9b04b3 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.spec.ts @@ -1,4 +1,4 @@ -import { BadRequestException } from "@nestjs/common"; +import { BadRequestException, ConflictException } from "@nestjs/common"; import { DataSource } from "typeorm"; import { Invoice } from "../billing/entities/invoice.entity"; @@ -96,14 +96,16 @@ describe("EimsCancellationService.cancelInvoiceWithEims", () => { expect(postBearer).toHaveBeenCalledWith("/v1/cancel", { Irn: IRN, ReasonCode: "1", Remark: "" }); }); - it("is idempotent — an already-cancelled invoice returns unchanged, no HTTP call", async () => { - const db = new FakeDb([invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled })]); + it("refuses re-cancelling an already-cancelled invoice, per IRC-N010 — no silent no-op", async () => { + const db = new FakeDb([ + invoiceRow({ eimsStatus: EimsInvoiceStatus.Cancelled, eimsCancellationDate: "Sun Dec 22 2024" }), + ]); const postBearer = jest.fn(); - const view = await build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1"); - + await expect(build(db, postBearer).cancelInvoiceWithEims(INVOICE_ID, "1")).rejects.toBeInstanceOf( + ConflictException, + ); expect(postBearer).not.toHaveBeenCalled(); - expect(view.eimsStatus).toBe(EimsInvoiceStatus.Cancelled); }); it("refuses to cancel an invoice that was never registered", async () => { diff --git a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts index 83ff16704..73c3e87f4 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-cancellation.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common"; import { InjectDataSource } from "@nestjs/typeorm"; import { DataSource, EntityManager } from "typeorm"; @@ -38,8 +38,11 @@ export class EimsCancellationService { ) {} /** - * Idempotent: an already-cancelled invoice returns unchanged, no HTTP call. Refuses an invoice - * that was never registered — there is no IRN to cancel. + * Refuses an already-cancelled invoice with a 409, rather than a silent no-op — IRC-N010 in + * MoR's Master Compliance Checklist requires "an appropriate error or rejection message" for a + * repeat cancellation, not a quiet success. No HTTP call either way: this is a local check, not + * a retry against MoR. Also refuses an invoice that was never registered — there is no IRN to + * cancel. */ async cancelInvoiceWithEims( invoiceId: string, @@ -48,7 +51,12 @@ export class EimsCancellationService { ): Promise { const eligible = await this.dataSource.transaction(async (manager) => { const invoice = await this.lockInvoice(manager, invoiceId); - if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) return null; + if (invoice.eimsStatus === EimsInvoiceStatus.Cancelled) { + throw new ConflictException({ + code: "EIMS_ALREADY_CANCELLED", + message: `Invoice ${invoice.invoiceNumber} was already cancelled with EIMS${invoice.eimsCancellationDate ? ` (${invoice.eimsCancellationDate})` : ""}.`, + }); + } if (!invoice.eimsIrn) { throw new BadRequestException({ code: "EIMS_NOT_REGISTERED", @@ -57,7 +65,6 @@ export class EimsCancellationService { } return invoice; }); - if (!eligible) return this.getEimsCancellationStatus(invoiceId); const request: EimsCancelRequest = { Irn: eligible.eimsIrn!, ReasonCode: reasonCode, Remark: remark ?? "" }; // Outside any transaction — no DB lock is held across the wire. diff --git a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts index 5db756329..c02c7870e 100644 --- a/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts +++ b/apps/edr-freight-api/src/modules/eims/eims-invoice.controller.ts @@ -81,7 +81,7 @@ export class EimsInvoiceController { @BookingStaff(FREIGHT_PERMS.invoices.eimsCancel) @ApiOperation({ summary: - "Cancel the invoice's registered EIMS document. Idempotent — an already-cancelled invoice is returned unchanged.", + "Cancel the invoice's registered EIMS document. Refuses (409) an already-cancelled invoice rather than a silent no-op — see IRC-N010.", }) cancel(@Param("id", ParseUUIDPipe) id: string, @Body() dto: CancelEimsRegistrationDto) { return this.cancellation.cancelInvoiceWithEims(id, dto.reasonCode, dto.remark); From c4bf3c947954b5f386314976349b92414b99368e Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Thu, 13 Aug 2026 10:53:42 +0000 Subject: [PATCH 7/7] feat(freight): add company logo setting applied to every generated document New logo-settings module (mirrors stamp-settings): single uploaded logo, stored via FilesService/MinIO, injected as a data URL into invoice/receipt, contract, warehouse, train-scheduling, and payment-receipt PDFs. Adds a matching backoffice settings page and settings:logo:view/manage permissions. > --- apps/edr-freight-api/src/app.module.ts | 2 + .../contracts/contract-view-model.builder.ts | 6 + .../contracts/templates/_partials/styles.hbs | 6 + .../src/contracts/templates/edr-dynamic.hbs | 2 +- .../src/contracts/templates/generic.hbs | 2 +- .../src/contracts/templates/last-mile.hbs | 3 +- .../migrations/3500000000000-LogoSettings.ts | 27 +++ .../invoice-document.service.spec.ts | 2 +- .../documents/invoice-document.service.ts | 13 +- .../billing/documents/logo-markup.util.ts | 35 ++++ .../last-mile-contract.service.ts | 3 + .../dto/update-logo-setting.dto.ts | 9 + .../entities/logo-setting.entity.ts | 25 +++ .../logo-settings/logo-settings.controller.ts | 39 ++++ .../logo-settings/logo-settings.module.ts | 24 +++ .../logo-settings/logo-settings.repository.ts | 21 +++ .../logo-settings/logo-settings.service.ts | 161 ++++++++++++++++ .../modules/payment/payment.service.spec.ts | 2 + .../src/modules/payment/payment.service.ts | 3 + .../src/modules/payment/templates/receipt.hbs | 9 + .../services/train-scheduling.service.spec.ts | 1 + .../services/train-scheduling.service.ts | 20 +- .../warehouses/warehouse-inventory.service.ts | 18 ++ .../src/seed/freight-permissions.registry.ts | 16 ++ apps/edr-freight-web/backoffice/src/App.tsx | 10 + .../src/components/contracts/LogoUpload.tsx | 172 ++++++++++++++++++ .../components/layout/sidebar-sections.tsx | 7 + .../backoffice/src/hooks/useLogoSettings.ts | 45 +++++ .../backoffice/src/lib/permissions.ts | 6 + .../src/pages/settings/LogoSettingsPage.tsx | 88 +++++++++ .../src/services/logoSettings.service.ts | 31 ++++ 31 files changed, 800 insertions(+), 8 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts create mode 100644 apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/dto/update-logo-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/entities/logo-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/logo-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/logo-settings.module.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/logo-settings.repository.ts create mode 100644 apps/edr-freight-api/src/modules/logo-settings/logo-settings.service.ts create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/LogoUpload.tsx create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index 1efe750fc..c106ffff2 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -49,6 +49,7 @@ import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-up import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; +import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; import { SupportContentModule } from "./modules/support-content/support-content.module"; import { OtpModule } from "./modules/otp/otp.module"; @@ -209,6 +210,7 @@ if (!process.env.APPLICATION_NAME) { DropdownSettingsModule, ExchangeSettingsModule, StampSettingsModule, + LogoSettingsModule, ContractTemplatesModule, SupportContentModule, OtpModule, diff --git a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts index 94a6809e6..517934bb9 100644 --- a/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-view-model.builder.ts @@ -10,6 +10,7 @@ import { ContractPricingScheduleBuilder, PricingSchedule } from './contract-pric import { ContractRateScheduleBuilder, RateSchedule } from './contract-rate-schedule.builder'; import { ContractTemplateResolver } from './contract-template.resolver'; import { StampSettingsService } from '../modules/stamp-settings/stamp-settings.service'; +import { LogoSettingsService } from '../modules/logo-settings/logo-settings.service'; import { ContractTemplateMeta, getTemplateMeta } from './contract-template.registry'; export interface ContractSignatureView { @@ -111,6 +112,8 @@ export interface ContractViewModel { hasCustomerSignature: boolean; hasStaffSignature: boolean; dynamicTemplate?: ContractDynamicTemplateView; + /** Company logo for the cover-page header (LogoSettingsService); null renders the "EDR" mark. */ + logoImageUrl?: string | null; } @Injectable() @@ -121,6 +124,7 @@ export class ContractViewModelBuilder { private readonly pricingBuilder: ContractPricingScheduleBuilder, private readonly rateScheduleBuilder: ContractRateScheduleBuilder, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async build(bookingId: string): Promise<{ booking: Booking; view: ContractViewModel }> { @@ -138,6 +142,7 @@ export class ContractViewModelBuilder { template.freight, ); const signatures = await this.loadSignatures(bookingId); + const logoImageUrl = await this.logoSettings.getLogoImageUrl(); const hasCustomer = signatures.some((s) => s.role === 'CUSTOMER'); const hasStaff = signatures.some((s) => s.role === 'STAFF'); @@ -194,6 +199,7 @@ export class ContractViewModelBuilder { hasContractDocument: hasContractFile, hasCustomerSignature: hasCustomer, hasStaffSignature: hasStaff, + logoImageUrl, }; return { booking, view }; diff --git a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs index d9bc9927f..09bf3031e 100644 --- a/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs +++ b/apps/edr-freight-api/src/contracts/templates/_partials/styles.hbs @@ -77,6 +77,12 @@ letter-spacing: 0.08em; width: 72px; } + .logo-mark img { + display: block; + max-height: 100%; + max-width: 100%; + object-fit: contain; + } .kicker { color: #0e5b45; font-family: Arial, sans-serif; diff --git a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs index 6ba7c1610..81631e5d2 100644 --- a/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/edr-dynamic.hbs @@ -11,7 +11,7 @@ {{!-- ─────────────────────────── Cover page ─────────────────────────── --}}
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Services

diff --git a/apps/edr-freight-api/src/contracts/templates/generic.hbs b/apps/edr-freight-api/src/contracts/templates/generic.hbs index 75f795bce..f576c99ce 100644 --- a/apps/edr-freight-api/src/contracts/templates/generic.hbs +++ b/apps/edr-freight-api/src/contracts/templates/generic.hbs @@ -9,7 +9,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Freight Transport Contract

diff --git a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs index 9437bb18b..0cfef51d5 100644 --- a/apps/edr-freight-api/src/contracts/templates/last-mile.hbs +++ b/apps/edr-freight-api/src/contracts/templates/last-mile.hbs @@ -9,6 +9,7 @@ main { padding: 32px 40px; } .brand-row { display: flex; align-items: center; gap: 14px; border-bottom: 3px solid #1a5632; padding-bottom: 14px; } .logo-mark { background: #1a5632; color: #fff; font-weight: 700; font-size: 18px; padding: 10px 14px; border-radius: 6px; } + .logo-mark img { display: block; max-height: 32px; max-width: 100px; object-fit: contain; } .kicker { margin: 0; font-weight: 700; } .muted { margin: 0; color: #666; } h1 { font-size: 20px; margin: 24px 0 4px; } @@ -32,7 +33,7 @@
-
EDR
+
{{#if logoImageUrl}}Company logo{{else}}EDR{{/if}}

Ethio-Djibouti Standard Gauge Railway Share Company

Last-Mile Delivery Contract

diff --git a/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts new file mode 100644 index 000000000..36d70906f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3500000000000-LogoSettings.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table holding the one company logo image stamped onto every + * generated document (see LogoSettingsService). Same single-row shape as + * stamp_settings; the app never inserts more than one row. + */ +export class LogoSettings3500000000000 implements MigrationInterface { + name = "LogoSettings3500000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.logo_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + logo_file_id uuid REFERENCES freight.files(id), + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.logo_settings;`); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts index 00e590e17..bc5250888 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.spec.ts @@ -14,7 +14,7 @@ const model = (over: Partial = {}): InvoiceDocumentModel = }); describe("InvoiceDocumentService.buildHtml — EIMS QR", () => { - const service = new InvoiceDocumentService({} as never, {} as never); + const service = new InvoiceDocumentService({} as never, {} as never, {} as never); it("renders no QR block when qrImageUrl is unset", () => { const html = service.buildHtml(model()); diff --git a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts index d83b7e2f0..f6b264636 100644 --- a/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts +++ b/apps/edr-freight-api/src/modules/billing/documents/invoice-document.service.ts @@ -1,8 +1,10 @@ import { Injectable } from "@nestjs/common"; import { StampSettingsService } from "../../stamp-settings/stamp-settings.service"; +import { LogoSettingsService } from "../../logo-settings/logo-settings.service"; import { PdfRenderService } from "./pdf-render.service"; import { sealClass, sealImageCss, sealMarkup } from "./seal-markup.util"; +import { logoImageCss, logoMarkup } from "./logo-markup.util"; import { PdfColor, assembleSinglePagePdf, @@ -62,6 +64,7 @@ export interface InvoiceDocumentModel { * explicitly only to override that default for one document. */ stampImageUrl?: string | null; + logoImageUrl?: string | null; /** * MoR EIMS verification QR (data URL, pre-rendered by the caller from `Invoice.eimsSignedQr` — * see that column's comment). Set only once an invoice is actually registered; the IRN text @@ -81,6 +84,7 @@ export class InvoiceDocumentService { constructor( private readonly pdf: PdfRenderService, private readonly stampSettings: StampSettingsService, + private readonly logoSettings: LogoSettingsService, ) {} async render( @@ -90,7 +94,11 @@ export class InvoiceDocumentService { model.stampImageUrl !== undefined ? model.stampImageUrl : await this.stampSettings.getStampImageUrl(); - const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl }; + const logoImageUrl = + model.logoImageUrl !== undefined + ? model.logoImageUrl + : await this.logoSettings.getLogoImageUrl(); + const resolvedModel: InvoiceDocumentModel = { ...model, stampImageUrl, logoImageUrl }; const html = this.buildHtml(resolvedModel); const kindLabel = model.kind === "RECEIPT" ? "receipt" : "invoice"; @@ -250,6 +258,7 @@ export class InvoiceDocumentService { model.sealText ?? (model.kind === "RECEIPT" || model.status === "PAID" ? "EDR PAID" : "EDR"); const sealInner = sealMarkup(model.stampImageUrl, sealText); const sealCssClass = sealClass(model.stampImageUrl); + const logoInner = logoMarkup(model.logoImageUrl); const qrMarkup = model.qrImageUrl ? `
EIMS verification QRScan to verify (MoR EIMS)
` @@ -293,6 +302,7 @@ export class InvoiceDocumentService { .meta strong { display: block; color: #0f172a; font-size: 17px; margin-top: 5px; } .seal { position: absolute; right: 28px; top: 118px; width: 116px; height: 116px; border: 4px double #0f766e; border-radius: 999px; color: #0f766e; display: flex; align-items: center; justify-content: center; text-align: center; font-weight: 800; font-size: 18px; transform: rotate(-14deg); opacity: .82; } ${sealImageCss()} + ${logoImageCss()} .qr { position: absolute; right: 160px; top: 118px; width: 90px; text-align: center; } .qr img { width: 90px; height: 90px; } .qr span { display: block; font-size: 7px; color: #64748b; margin-top: 3px; } @@ -322,6 +332,7 @@ export class InvoiceDocumentService {
+ ${logoInner}
Ethio-Djibouti Railway S.C.

${esc(model.title)} ${model.kind === "RECEIPT" ? "Receipt" : "Invoice"}

diff --git a/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts new file mode 100644 index 000000000..f78109855 --- /dev/null +++ b/apps/edr-freight-api/src/modules/billing/documents/logo-markup.util.ts @@ -0,0 +1,35 @@ +/** + * The single decision every EDR document makes about its header logo: draw + * the one uploaded company logo when configured (LogoSettingsService), or + * render nothing — the existing "Ethio-Djibouti Railway S.C." text brand next + * to it already covers the no-logo case, so there is no text fallback here + * (contrast seal-markup.util.ts, whose seal has no text of its own). + */ + +function escapeHtml(value: unknown): string { + return String(value ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * `` markup for the header logo, or "" when unset. `logoImageUrl` is + * expected to be a data URL from LogoSettingsService.getLogoImageUrl(). + * `className` defaults to "doc-logo" — each document supplies that class's + * sizing in its own
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Warehouse Release / Exit Paper

Official gate clearance and warehouse exit authorization
@@ -5775,6 +5789,8 @@ export class WarehouseInventoryService { } | null; /** The one global company stamp; null falls back to the drawn text seal. */ stampImageUrl?: string | null; + /** The one global company logo; null renders the plain text brand. */ + logoImageUrl?: string | null; }): string { const esc = (value: unknown) => String(value ?? '-') @@ -5853,11 +5869,13 @@ export class WarehouseInventoryService { .seal::before { content: ""; position: absolute; width: 78px; height: 78px; border: 1px solid #17633a; border-radius: 999px; } .seal span { position: relative; } ${sealImageCss()} + ${logoImageCss()}
+ ${logoMarkup(data.logoImageUrl)}
Ethio-Djibouti Railway S.C.

Import Goods Handover Document

EDR to customer warehouse handover
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 cdbffd961..f0b71a056 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1211,6 +1211,16 @@ export const CONFIG_SETTINGS_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:stamp:manage", "Manage the company stamp", ), + perm( + "b4b00004-0001-4000-8000-000000000001", + "edr_freight_app:settings:logo:view", + "View the company logo", + ), + perm( + "b4b00004-0001-4000-8000-000000000002", + "edr_freight_app:settings:logo:manage", + "Manage the company logo", + ), // The per-officer approval teeter (ማህተም) — an individual's own stamp + // signature, not the company seal. It used to ride on settings:stamp:*, which // now gates the ONE company stamp; this key was split out when the two were @@ -1935,6 +1945,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, // and NOT the company seal above. Retired: `invoiceStamp`, which used to // gate the company stamp before the two were untangled. diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 64f91da72..37795b2f2 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -52,6 +52,7 @@ import NoAccessPage from "./pages/NoAccessPage"; import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"; +import LogoSettingsPage from "./pages/settings/LogoSettingsPage"; import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage"; import PortalContentPage from "./pages/portal_content/PortalContentPage"; import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage"; @@ -807,6 +808,15 @@ const App = () => { path="invoice-stamp-settings" element={} /> + {/* The ONE company logo, shown in the header of every generated document. */} + + + + } + /> void; + label?: string; + description?: string; +} + +/** + * Company logo picker — reads the picked image straight into a data URL, + * same transport as {@link StampUpload}. Kept as its own component (not a + * generalized image-upload) matching how stamp/teeter are already separate + * files here despite the near-identical shape. + */ +export function LogoUpload({ + value, + onChange, + label = "Company logo", + description = "Attach the official company logo.", +}: LogoUploadProps) { + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [error, setError] = useState(null); + const [fileName, setFileName] = useState(null); + + const readFile = (file: File | undefined | null) => { + if (!file) return; + if (!file.type.startsWith("image/")) { + setError("The logo must be an image file (PNG or JPG)."); + return; + } + if (file.size > MAX_LOGO_MB * 1024 * 1024) { + setError(`The logo image must be under ${MAX_LOGO_MB} MB.`); + return; + } + const reader = new FileReader(); + reader.onload = () => { + setError(null); + setFileName(file.name); + onChange(typeof reader.result === "string" ? reader.result : null); + }; + reader.onerror = () => setError("Could not read that file. Try another."); + reader.readAsDataURL(file); + }; + + const openPicker = () => inputRef.current?.click(); + + const clear = () => { + setFileName(null); + setError(null); + onChange(null); + if (inputRef.current) inputRef.current.value = ""; + }; + + return ( + + + {label} + + + readFile(e.currentTarget.files?.[0])} + /> + + {value ? ( + + + + Company logo + + + + {fileName ?? "Logo attached"} + + + Shown in the header of every generated document. + + + + + + + + + ) : ( + { + e.preventDefault(); + setDragging(true); + }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { + e.preventDefault(); + setDragging(false); + readFile(e.dataTransfer.files?.[0]); + }} + style={{ + borderColor: dragging + ? "var(--mantine-color-edr-green-6)" + : undefined, + borderStyle: "dashed", + backgroundColor: dragging + ? "var(--mantine-color-edr-green-0)" + : undefined, + cursor: "pointer", + }} + > + + + + Upload company logo + + + {description} Drop an image here or click to browse — PNG or JPG, + up to {MAX_LOGO_MB} MB. + + + + )} + + {error && ( + + {error} + + )} + + ); +} 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 95608b6db..08d81da5a 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 @@ -9,6 +9,7 @@ import { FileText, Hammer, History, + Image as ImageIcon, Landmark, LayoutDashboard, LayoutGrid, @@ -495,6 +496,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.settings.stamp.view, }, + { + label: "Company logo", + href: "/dashboard/logo-settings", + icon: , + permission: FREIGHT_PERMS.settings.logo.view, + }, { label: "Contract templates", href: "/dashboard/contract-templates", diff --git a/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts b/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts new file mode 100644 index 000000000..662906f42 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/hooks/useLogoSettings.ts @@ -0,0 +1,45 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; + +import { logoSettingsService } from "@/services/logoSettings.service"; +import { useErrorHandler } from "@/shared/hooks/useErrorHandler"; + +const QUERY_KEY = ["logoSettings"]; + +export const useLogoSettingsQuery = () => + useQuery({ + queryKey: QUERY_KEY, + queryFn: () => logoSettingsService.get(), + }); + +export const useSetLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: (logoImageBase64: string) => + logoSettingsService.set(logoImageBase64), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.updated", "Company logo updated")); + }, + onError: handleError, + }); +}; + +export const useClearLogo = () => { + const queryClient = useQueryClient(); + const { t } = useTranslation(); + const { handleError } = useErrorHandler(t); + + return useMutation({ + mutationFn: () => logoSettingsService.clear(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEY }); + toast.success(t("logoSettings.cleared", "Company logo removed")); + }, + onError: handleError, + }); +}; diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index c69aa5d1b..06f1f45da 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -334,6 +334,12 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:stamp:view", manage: "edr_freight_app:settings:stamp:manage", }, + // The ONE company logo, applied to every generated document (invoices, + // receipts, contracts, warehouse papers, train-scheduling manifests). + logo: { + view: "edr_freight_app:settings:logo:view", + manage: "edr_freight_app:settings:logo:manage", + }, // The per-officer approval teeter (ማህተም) + signature — genuinely per-person, // and NOT the company seal above. Retired: `invoiceStamp`, which used to // gate the company stamp before the two were untangled. diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx new file mode 100644 index 000000000..d2f09cd48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/settings/LogoSettingsPage.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/common/ui/card"; +import { Button } from "@/shared/common/ui/button"; +import { Save, Trash2 } from "lucide-react"; + +import { LogoUpload } from "@/components/contracts/LogoUpload"; +import { + useClearLogo, + useSetLogo, + useLogoSettingsQuery, +} from "@/hooks/useLogoSettings"; + +/** + * The ONE company logo, read by every document path server-side via + * LogoSettingsService: invoices and receipts, contract cover pages, warehouse + * GRN/release/handover papers, train-scheduling manifests, and the payment + * receipt. Single global image — no per-document choice. + */ +export default function LogoSettingsPage() { + const { data, isLoading } = useLogoSettingsQuery(); + const setLogo = useSetLogo(); + const clearLogo = useClearLogo(); + const [draft, setDraft] = useState(null); + + useEffect(() => { + setDraft(null); + }, [data?.logoImageUrl]); + + const value = draft !== null ? draft : (data?.logoImageUrl ?? null); + const dirty = draft !== null && draft !== data?.logoImageUrl; + + const handleSave = async () => { + if (!draft) return; + await setLogo.mutateAsync(draft); + }; + + const handleClear = async () => { + if (!data?.logoImageUrl) return; + await clearLogo.mutateAsync(); + }; + + return ( +
+ + + Company logo + + The single EDR logo, applied to every generated document — + invoices and receipts, contracts, warehouse papers, train-scheduling + manifests, and payment receipts. Replacing it here changes it + everywhere at once. + + + + + +
+ + {data?.logoImageUrl && !dirty && ( + + )} +
+
+
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts b/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts new file mode 100644 index 000000000..fd89f2ed3 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/services/logoSettings.service.ts @@ -0,0 +1,31 @@ +import { api as client } from "../auth/http"; +import { unwrap } from "@/utils/endpoint"; +import type { ApiResponse } from "@/types/apiResponse"; + +const BASE = "/logo-settings"; + +/** Company logo used on every generated document. */ +export interface LogoSettings { + logoImageUrl: string | null; + updatedById: string | null; + updatedAt: string | null; +} + +export const logoSettingsService = { + get: async (): Promise => { + const response = await client.get>(BASE); + return unwrap(response.data); + }, + + set: async (logoImageBase64: string): Promise => { + const response = await client.put>(BASE, { + logoImageBase64, + }); + return unwrap(response.data); + }, + + clear: async (): Promise => { + const response = await client.delete>(BASE); + return unwrap(response.data); + }, +};