From 69df391f9052c572de02e4fd77c6d1f802ed8c58 Mon Sep 17 00:00:00 2001 From: marshal Date: Mon, 29 Jun 2026 16:03:53 +0300 Subject: [PATCH 01/79] Update payment service and API base URL for local development --- .../modules/payment/payment-client.service.ts | 4 +- .../src/modules/payment/payment.service.ts | 75 +++++++------------ .../backoffice/src/constants/apiConfig.ts | 4 +- .../portal/src/constants/apiConfig.ts | 4 +- 4 files changed, 35 insertions(+), 52 deletions(-) diff --git a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts index 4c2ebe971..68ed249a5 100644 --- a/apps/edr-freight-api/src/modules/payment/payment-client.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment-client.service.ts @@ -19,8 +19,8 @@ export class PaymentClientService { private readonly logger = new Logger(PaymentClientService.name); private readonly baseUrl = ( // process.env.PAYMENT_API_URL ?? - "https://paymentcallback.triaplc.com" - // "http://localhost:3003" + // "https://paymentcallback.triaplc.com" + "http://localhost:3003" ).replace(/\/$/, ""); private readonly serviceToken = process.env.SERVICE_AUTH_TOKEN ?? ""; diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 9f216bc60..dbd635d86 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -163,10 +163,6 @@ export class PaymentService { failureUrl: 'https://edrfreight.triaplc.com/payment/failure', }); - await this.datasource.getRepository(Booking).update( - { id: dto.bookingId }, - { paymentStatus: "PAID", status: "PAID" }, - ); const intent = await this.syncIntentProjection(booking.id, booking, snapshot); if (snapshot.status === ProviderPaymentStatus.SUCCEEDED) { @@ -293,21 +289,20 @@ export class PaymentService { providerTxnId?: string; paidAt?: Date; }): Promise<{ alreadyFinalized: boolean }> { - // const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); - // if (!intent) throw new NotFoundException("PaymentIntent not found"); - // if (intent.status === "success") return { alreadyFinalized: true }; + const intent = await this.paymentRepo.findOneBy({ id: input.intentId }); + if (!intent) throw new NotFoundException("PaymentIntent not found"); + if (intent.status === "success") return { alreadyFinalized: true }; const paidAt = input.paidAt ?? new Date(); // Every booking is a real shipment now (contracts are a separate aggregate), // so payment always settles the booking to PAID and enters allocation. await this.datasource.transaction(async (mg) => { - // await mg.update( - // PaymentEntity, - // // { id: intent.id }, - // {id:input.intentId}, - // { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, - // ); + await mg.update( + PaymentEntity, + { id: intent.id }, + { status: "success", paidAt, transactionId: input.providerTxnId ?? intent.transactionId }, + ); await mg.update( Booking, { id: input.bookingId }, @@ -403,46 +398,34 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - const { alreadyFinalized } = await this.finalizePaymentSuccess({ - intentId:event.intentId, + if (event.eventType === "payment.succeeded") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + const { alreadyFinalized } = await this.finalizePaymentSuccess({ + intentId: intent.id, bookingId: event.referenceId, providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, }); - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); return { processed: true, alreadyFinalized }; - // console.log(`Received payment event: ${JSON.stringify(event)}`); - // if (event.eventType === "payment.succeeded") { - // console.log(`Received payment.succeeded event for booking ${event.referenceId}, intent ${event.intentId}`); - // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - // if (!intent) { - // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - // } - // console.log(`Processing payment.succeeded event for booking ${event.referenceId}, intent ${intent.id}`); - // const { alreadyFinalized } = await this.finalizePaymentSuccess({ - // intentId: intent.id, - // bookingId: event.referenceId, - // providerTxnId: event.providerTxnId, - // paidAt: event.paidAt ? new Date(event.paidAt) : undefined, - // }); - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); - // return { processed: true, alreadyFinalized }; - // } + } - // if (event.eventType === "payment.failed") { - // const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); - // if (!intent) { - // return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; - // } - // await this.markPaymentFailed({ - // intentId: intent.id, - // failureCode: event.failureCode, - // failureMessage: event.failureMessage, - // }); - // return { processed: true }; - // } + if (event.eventType === "payment.failed") { + const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId, type: "booking" }); + if (!intent) { + return { processed: false, reason: `No local intent for booking ${event.referenceId}` }; + } + await this.markPaymentFailed({ + intentId: intent.id, + failureCode: event.failureCode, + failureMessage: event.failureMessage, + }); + return { processed: true }; + } - // return { processed: false, reason: `Unknown event type: ${event.eventType}` }; + return { processed: false, reason: `Unknown event type: ${event.eventType}` }; } private toLocalStatus(status: ProviderPaymentStatus): PaymentEntity["status"] { diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the diff --git a/apps/edr-freight-web/portal/src/constants/apiConfig.ts b/apps/edr-freight-web/portal/src/constants/apiConfig.ts index 1b070d87d..a24cb4a6d 100644 --- a/apps/edr-freight-web/portal/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/portal/src/constants/apiConfig.ts @@ -1,5 +1,5 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From a072f0445068263a5e212b238514775f4c1e85d6 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 02:20:32 +0000 Subject: [PATCH 02/79] Enhance contract and booking request handling --- .../contracts/booking-request.repository.ts | 12 +- .../src/modules/payment/payment.service.ts | 25 +- .../contracts/detail/RequestDetailCards.tsx | 275 ++++++++++++++++ .../bookings/BookingRequestDetailPage.tsx | 22 +- .../contracts/ShipmentRequestDetailPage.tsx | 111 ++++--- .../ContractChangesRequestedView.tsx | 309 ++++++++++++++++++ .../pages/contracts/ContractDetailPage.tsx | 7 + packages/types/src/freight/contracts.ts | 36 ++ 8 files changed, 735 insertions(+), 62 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx create mode 100644 apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts index 9529cabbe..7a3695768 100644 --- a/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/booking-request.repository.ts @@ -34,7 +34,17 @@ export class BookingRequestRepository extends BaseRepository { async findById(id: string): Promise { return this.repository.findOne({ where: { id }, - relations: { contract: true }, + // Load the contract with the bits the detail page surfaces: customer + // (company), service type (mile/customs flags), routes (with yard labels) + // and cargo scope. + relations: { + contract: { + company: true, + serviceType: true, + routes: { originYard: true, destinationYard: true }, + cargoScope: true, + }, + }, }); } diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 347fedc1e..23376820f 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -17,12 +17,14 @@ import * as fs from "fs"; import * as path from "path"; import * as Handlebars from "handlebars"; import { Booking } from "../bookings/entities/booking.entity"; +import { Invoice } from "../billing/entities/invoice.entity"; import { ClientAction, ProviderPaymentStatus, } from "@edr/payment-providers"; import { + Freight, PaymentService as PaymentServiceEnum, PaymentReferenceType, PaymentIntentSnapshot, @@ -462,30 +464,37 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { - console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { + console.log(`Payment succeeded event received for reference ${event.referenceId}`); const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); if (!intent) { + console.warn(`No local intent found for reference ${event.referenceId}`); return { processed: false, reason: `No local intent for reference ${event.referenceId}` }; } - console.log(`Processing payment succeeded event for intent: }`,intent); + const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, { providerTxnId: event.providerTxnId, paidAt: event.paidAt ? new Date(event.paidAt) : undefined, notify: true, }); - console.log(`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + console.log(`Payment intent ${intent.id} marked as succeeded (alreadyFinalized=${alreadyFinalized})`); - // When the intent references a booking, flip the booking itself paid. - // refId holds the booking id (the domain reference the intent opened with). - if (intent.referenceType === PaymentReferenceType.BOOKING) { + // The invoice the intent settled is the authority on what was paid for. + // Its `paymentId` links 1:1 to this intent; when its source is a booking, + // `sourceId` holds that booking id — flip the booking itself paid. + const invoice = await this.datasource.manager.findOneBy(Invoice, { + paymentId: intent.id, + }); + console.log(`Invoice lookup for payment intent ${intent.id} returned invoice ${invoice?.id} (source=${invoice?.source}, sourceId=${invoice?.sourceId})`); + if (invoice?.source === Freight.InvoiceSource.Booking) { + console.log(`Marking booking ${invoice.sourceId} as PAID due to invoice ${invoice.id} settlement`); await this.datasource.manager.update( Booking, - { id: intent.refId }, + { id: invoice.sourceId }, { status: "PAID", paymentStatus: "PAID" }, ); } - // console.log(`Payment finalized for booking ${event.referenceId}, intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`); + return { processed: true, alreadyFinalized }; } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx new file mode 100644 index 000000000..595252cbf --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/contracts/detail/RequestDetailCards.tsx @@ -0,0 +1,275 @@ +import type { LucideIcon } from "lucide-react"; +import { + Building2, + FileCheck, + FileText, + Mail, + MapPin, + Package, + Phone, + Ship, + Truck, + User, + Warehouse, +} from "lucide-react"; +import { Badge, Box, Divider, Group, Stack, Text } from "@mantine/core"; +import type { Freight } from "@edr/types"; + +import { SectionCard } from "@/components/bookings/detail/SectionCard"; + +type ReqContract = NonNullable; + +interface InfoRowProps { + icon: LucideIcon; + label: string; + value?: string | null; +} + +function InfoRow({ icon: Icon, label, value }: InfoRowProps) { + return ( + + + + + {label} + + + + {value || "—"} + + + ); +} + +function InfoRows({ rows }: { rows: InfoRowProps[] }) { + const visible = rows.filter((r) => r.value); + if (visible.length === 0) { + return ( + + No details available. + + ); + } + return ( + + {visible.map((row, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} + +/** Customer (company) on the request's contract. */ +export function RequestCustomerCard({ contract }: { contract?: ReqContract | null }) { + const company = contract?.company; + if (!company) { + return ( + + + No customer linked to this request. + + + ); + } + return ( + + + + ); +} + +const fmtDate = (iso?: string | null) => + iso + ? new Intl.DateTimeFormat("en-GB", { + day: "2-digit", + month: "short", + year: "numeric", + }).format(new Date(iso)) + : "—"; + +const titleCase = (s?: string | null) => + s ? s.charAt(0) + s.slice(1).toLowerCase() : "—"; + +/** Contract identity + commercial terms. */ +export function RequestContractSummaryCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + if (!contract) return null; + return ( + + + + ); +} + +/** Routes + cargo scope of the contract. */ +export function RequestRouteCargoCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const routes = contract?.routes ?? []; + const cargo = contract?.cargoScope ?? []; + const isContainer = contract?.freightType === "CONTAINER"; + return ( + + + + + Routes + + {routes.length === 0 ? ( + + No routes recorded. + + ) : ( + + {routes.map((r) => ( + + + + {r.originYard?.label ?? r.originYardId} →{" "} + {r.destinationYard?.label ?? r.destinationYardId} + + + ))} + + )} + + + + Cargo scope + + {cargo.length === 0 ? ( + + No cargo scope recorded. + + ) : ( + + {cargo.map((c) => ( + } + > + {c.containerSize ?? + c.cargoFreeText ?? + (isContainer ? "Container" : "Bulk commodity")} + + ))} + + )} + + + + ); +} + +/** Service type — what the contracted service bundles (rail-only vs logistics/customs). */ +export function RequestServiceTypeCard({ + contract, +}: { + contract?: ReqContract | null; +}) { + const st = contract?.serviceType; + if (!st) return null; + + const firstMile = st.includesFirstMile ?? false; + const lastMile = st.includesLastMile ?? false; + const customs = st.includesCustoms ?? false; + const railOnly = !firstMile && !lastMile && !customs; + + const chips: Array<{ label: string; color: string; icon: LucideIcon }> = []; + if (railOnly) chips.push({ label: "Rail only", color: "blue", icon: Ship }); + if (firstMile) + chips.push({ label: "First-mile pickup", color: "teal", icon: Truck }); + if (lastMile) + chips.push({ label: "Last-mile delivery", color: "teal", icon: Warehouse }); + if (customs) + chips.push({ label: "Customs clearance (GL)", color: "grape", icon: FileCheck }); + + return ( + + + + {chips.map((c) => ( + } + > + {c.label} + + ))} + + {st.description ? ( + + {st.description} + + ) : null} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index 75badc5b3..8cc6655dd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -289,16 +289,18 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> - + {booking.customsClearingEnabled && ( + + )} {showContractButton && ( +
+ + {contract.reference} + + + Changes requested — update your documents and resubmit. + +
+ + + } + title="A reviewer asked for changes" + > + Update the documents below — replace anything that needs to change and + attach any required document that isn't on file yet — then resubmit the + contract for review. + + + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + Update documents + + + Replace any document you need to change. Documents marked required + must be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + {error && ( + } + mt="md" + > + {error} + + )} + {(updateMutation.isError || submitMutation.isError) && ( + } + mt="md" + > + Couldn't resubmit. Please try again. + + )} + + + + + + ); +} + +export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 4e40313c9..bd7e1bafe 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -46,6 +46,7 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; +import { ContractChangesRequestedView } from "./ContractChangesRequestedView"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -193,6 +194,12 @@ export default function ContractDetailPage() { ); } + // Staff returned the contract for changes — show the edit-and-resubmit view + // (update documents → resubmit) instead of the read-only detail. + if (contract.status === "CHANGES_REQUESTED") { + return ; + } + const isContainer = contract.freightType === "CONTAINER"; const isGeneral = contract.contractKind === "GENERAL"; const routes = contract.routes ?? []; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index 0dbadc50f..197df32a7 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -411,7 +411,10 @@ export interface IContract extends BaseEntity { id: string; code: string; serviceName: string; + description?: string | null; canBeBookedAlone: boolean; + includesFirstMile?: boolean; + includesLastMile?: boolean; includesCustoms: boolean; } | null; paymentCurrency: string; @@ -614,6 +617,39 @@ export interface IBookingRequest extends BaseEntity { reviewedByStaffId?: string | null; reviewedAt?: string | null; reviewNote?: string | null; + /** Loaded contract relation (request detail response includes it). */ + contract?: BookingRequestContract | null; +} + +/** Customer (company) summary carried on a request's contract. */ +export interface BookingRequestCompany { + id: string; + name?: string | null; + tin?: string | null; + email?: string | null; + phone?: string | null; + address?: string | null; + contactPersonName?: string | null; + contactPersonPhone?: string | null; +} + +/** + * The slice of the contract surfaced on the shipment-request detail page: + * identity, service type (mile/customs flags), customer, routes and cargo scope. + */ +export interface BookingRequestContract { + id: string; + reference: string; + contractKind: ContractKind; + tradeDirection: ContractTradeDirection; + freightType: ContractFreightType; + customsClearingEnabled: boolean; + paymentCurrency: string; + contractValidUntil?: string | null; + company?: BookingRequestCompany | null; + serviceType?: IContract["serviceType"]; + routes?: IContractRoute[]; + cargoScope?: IContractCargoScope[]; } export interface CreateBookingRequestDto { From 9674c9394d91a951d80f27ee55cec48b89a83fe8 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 02:52:19 +0000 Subject: [PATCH 03/79] Update logging configuration to use TYPEORM_LOGGING environment variable --- apps/edr-freight-api/src/config/database.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index 0e7375b19..a75731cfd 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,6 +118,6 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.NODE_ENV === "development", + logging: process.env.TYPEORM_LOGGING === "true", }; }); From f51c0158141c839b98e94836af4542572522678c Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 03:13:18 +0000 Subject: [PATCH 04/79] Enhance payment module --- .../src/config/database.config.ts | 3 ++- .../src/modules/payment/payment.module.ts | 4 ++++ .../src/modules/payment/payment.service.ts | 19 +++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/config/database.config.ts b/apps/edr-freight-api/src/config/database.config.ts index a75731cfd..c27d381e2 100644 --- a/apps/edr-freight-api/src/config/database.config.ts +++ b/apps/edr-freight-api/src/config/database.config.ts @@ -118,6 +118,7 @@ export default registerAs("database", (): TypeOrmModuleOptions => { migrationsRun: true, // Schema changes via migrations only (synchronize breaks ITMLS backfill on existing rows). synchronize: false, - logging: process.env.TYPEORM_LOGGING === "true", + logging: + process.env.TYPEORM_LOGGING === "true" ? true : ["error", "warn"], }; }); diff --git a/apps/edr-freight-api/src/modules/payment/payment.module.ts b/apps/edr-freight-api/src/modules/payment/payment.module.ts index 330521218..532e1c06b 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.module.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.module.ts @@ -13,6 +13,8 @@ import { import { ServiceAuthGuard } from "../../common/guards/service-auth.guard"; import { BillingModule } from "../billing/billing.module"; +import { FirstMileModule } from "../first-mile/first-mile.module"; +import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module"; import { PaymentRefundEntity } from "./entities/payment-refund.entity"; import { PaymentWebhookEventEntity } from "./entities/payment-webhook-event.entity"; import { PaymentEntity } from "./entities/payment.entity"; @@ -57,6 +59,8 @@ function rabbitMQImport(): DynamicModule[] { HttpModule.register({ timeout: 10_000 }), ConfigModule, forwardRef(() => BillingModule), + forwardRef(() => TrainSchedulingModule), + FirstMileModule, TypeOrmModule.forFeature([ PaymentEntity, PaymentWebhookEventEntity, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 23376820f..e03d15d83 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -12,6 +12,8 @@ import { PaymentEntity } from "./entities/payment.entity"; import { PaymentRepository } from "./payment.repository"; import { PaymentClientService } from "./payment-client.service"; import { BillingService } from "../billing/billing.service"; +import { FirstMileService } from "../first-mile/first-mile.service"; +import { BookingBatchService } from "../train-scheduling/booking-batch.service"; import * as fs from "fs"; import * as path from "path"; @@ -106,6 +108,9 @@ export class PaymentService { private readonly paymentClient: PaymentClientService, @Inject(forwardRef(() => BillingService)) private readonly billing: BillingService, + @Inject(forwardRef(() => BookingBatchService)) + private readonly bookingBatchService: BookingBatchService, + private readonly firstMileService: FirstMileService, ) { } async getAll(filters: { @@ -212,6 +217,20 @@ export class PaymentService { failureUrl: input.failureUrl ?? "https://edrfreight.triaplc.com/payment/failure", }); + //////////////// fake + + await this.datasource.manager.update( + Booking, + { id: input.referenceId }, + { status: "PAID", paymentStatus: "PAID" }, + ); + await this.firstMileService.acceptBooking(input.referenceId); + await this.bookingBatchService.ensurePaidBookingAllocated(input.referenceId); + + + //////////////// fake + + //update the booking heer for now the staus anf const immediateSuccess = snapshot.status === ProviderPaymentStatus.SUCCEEDED; const paidAt = snapshot.paidAt ? new Date(snapshot.paidAt) : undefined; From dd9a7e639aa4ea8c2d33a34263f04757b8986e07 Mon Sep 17 00:00:00 2001 From: marshal Date: Tue, 30 Jun 2026 06:15:57 +0300 Subject: [PATCH 05/79] console --- apps/edr-freight-api/src/modules/billing/billing.service.ts | 3 ++- apps/edr-freight-api/src/modules/payment/payment.service.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 01b057a76..fa5681b06 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -492,7 +492,8 @@ export class BillingService { // the domain never supplies it. New sources add their uppercased value to // the PaymentReferenceType enum. referenceType: invoice.source.toUpperCase() as PaymentReferenceType, - orderRef: invoice.invoiceNumber, + // orderRef: invoice.invoiceNumber, + orderRef:Date.now().toString(), amountMinor: Math.round(Number(invoice.totalAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, diff --git a/apps/edr-freight-api/src/modules/payment/payment.service.ts b/apps/edr-freight-api/src/modules/payment/payment.service.ts index 1ea41cee9..a6bd457fb 100644 --- a/apps/edr-freight-api/src/modules/payment/payment.service.ts +++ b/apps/edr-freight-api/src/modules/payment/payment.service.ts @@ -465,6 +465,7 @@ export class PaymentService { failureCode?: string; failureMessage?: string; }): Promise<{ processed: boolean; alreadyFinalized?: boolean; reason?: string }> { + console.log(`Received payment event: ${JSON.stringify(event)}`); if (event.eventType === "payment.succeeded") { console.log(`Payment succeeded event received for reference ${event.referenceId}`); const intent = await this.paymentRepo.findOneBy({ refId: event.referenceId }); From 14dde418f7ab43802b46c765a9400cb09b3ac159 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 03:55:57 +0000 Subject: [PATCH 06/79] fix(migrations): check if tables exist before CREATE TABLE Make invoices migration idempotent - skip table/index creation if they already exist. Prevents 'relation already exists' errors on redeployment. Co-Authored-By: Claude Haiku 4.5 --- .../1821000000002-CreateInvoices.ts | 134 ++++++++++-------- 1 file changed, 73 insertions(+), 61 deletions(-) diff --git a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts index 93196578d..a36ffa5da 100644 --- a/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts +++ b/apps/edr-freight-api/src/migrations/1821000000002-CreateInvoices.ts @@ -32,71 +32,83 @@ export class CreateInvoices1821000000002 implements MigrationInterface { `); } - await queryRunner.query(` - CREATE TABLE freight.invoices ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_number varchar(64) NOT NULL, - company_id uuid NOT NULL, - company_profile_id uuid NOT NULL, - total_amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', - source varchar(255) NOT NULL, - source_id varchar(255) NOT NULL, - type varchar(255) NOT NULL, - issued_at timestamptz, - payment_id uuid, - due_at timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoices PRIMARY KEY (id), - CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), - CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) - REFERENCES freight.companies (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) - REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, - CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) - REFERENCES freight.payments (id) ON DELETE SET NULL + const invoicesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoices';`, + ); + + if (!invoicesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoices ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_number varchar(64) NOT NULL, + company_id uuid NOT NULL, + company_profile_id uuid NOT NULL, + total_amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT', + source varchar(255) NOT NULL, + source_id varchar(255) NOT NULL, + type varchar(255) NOT NULL, + issued_at timestamptz, + payment_id uuid, + due_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoices PRIMARY KEY (id), + CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number), + CONSTRAINT fk_invoices_company FOREIGN KEY (company_id) + REFERENCES freight.companies (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_company_profile FOREIGN KEY (company_profile_id) + REFERENCES freight.company_profiles (id) ON DELETE RESTRICT, + CONSTRAINT fk_invoices_payment FOREIGN KEY (payment_id) + REFERENCES freight.payments (id) ON DELETE SET NULL + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, ); - `); - - await queryRunner.query( - `CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, - ); - await queryRunner.query( - `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, - ); - - await queryRunner.query(` - CREATE TABLE freight.invoice_lines ( - id uuid NOT NULL DEFAULT uuid_generate_v4(), - invoice_id uuid NOT NULL, - charge_type varchar NOT NULL, - description varchar(255), - quantity numeric(12, 2) NOT NULL DEFAULT 1, - unit_rate numeric(14, 2) NOT NULL DEFAULT 0, - amount numeric(14, 2) NOT NULL, - currency varchar(8) NOT NULL DEFAULT 'ETB', - metadata jsonb, - created_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now(), - deleted_at timestamptz, - CONSTRAINT pk_invoice_lines PRIMARY KEY (id), - CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) - REFERENCES freight.invoices (id) ON DELETE CASCADE + await queryRunner.query( + `CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, ); - `); + await queryRunner.query( + `CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_invoices_status ON freight.invoices (status);`, + ); + } - await queryRunner.query( - `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + const invoiceLinesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'invoice_lines';`, ); + + if (!invoiceLinesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.invoice_lines ( + id uuid NOT NULL DEFAULT uuid_generate_v4(), + invoice_id uuid NOT NULL, + charge_type varchar NOT NULL, + description varchar(255), + quantity numeric(12, 2) NOT NULL DEFAULT 1, + unit_rate numeric(14, 2) NOT NULL DEFAULT 0, + amount numeric(14, 2) NOT NULL, + currency varchar(8) NOT NULL DEFAULT 'ETB', + metadata jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT pk_invoice_lines PRIMARY KEY (id), + CONSTRAINT fk_invoice_lines_invoice FOREIGN KEY (invoice_id) + REFERENCES freight.invoices (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, + ); + } } public async down(queryRunner: QueryRunner): Promise { From a3741c45bf8522b3596a5adc17d865e0d9c1a031 Mon Sep 17 00:00:00 2001 From: yaschalew Date: Tue, 30 Jun 2026 07:01:32 +0300 Subject: [PATCH 07/79] fix --- apps/edr-freight-web/backoffice/src/constants/apiConfig.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts index 1217b8762..7a7604cc7 100644 --- a/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts +++ b/apps/edr-freight-web/backoffice/src/constants/apiConfig.ts @@ -1,6 +1,6 @@ -export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; +// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; -// export const API_BASE_URL = 'http://localhost:3001'; +export const API_BASE_URL = 'http://localhost:3001'; /** * URL that streams an uploaded file through the API by its UUID. Routes the From 0bc95341720c116bd962e94f703e3f1c949f47de Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:03:29 +0000 Subject: [PATCH 08/79] fix: replace apiClient with api in FirstMilePage Use correct import 'api' from '@/auth/http' instead of undefined 'apiClient'. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 569511d30..164d88f0a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -441,7 +441,7 @@ const FirstMilePage = () => { }); const allocateMutation = useMutation({ - mutationFn: (data) => apiClient.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), + mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); From 360e61ac15b07ab5da2691d11dd776fcd5030e71 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 04:04:56 +0000 Subject: [PATCH 09/79] fix: use byId() instead of detail() in QUERY_KEYS QUERY_KEYS.FIRST_MILE and QUERY_KEYS.LAST_MILE have byId() not detail(). Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/operations/FirstMilePage.tsx | 2 +- .../backoffice/src/pages/operations/LastMilePage.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 164d88f0a..52b3d7139 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -444,7 +444,7 @@ const FirstMilePage = () => { mutationFn: (data) => api.post(`/first-mile/${containerAllocationFirstMileId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.detail(containerAllocationFirstMileId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.byId(containerAllocationFirstMileId ?? "") }); setContainerAllocationOpen(false); setContainerAllocationFirstMileId(null); }, diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 3de5e578e..a40721a1c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -395,7 +395,7 @@ const LastMilePage = () => { api.post(`/last-mile/${activeId}/allocate-containers`, data), onSuccess: () => { toast({ title: "Containers allocated", variant: "default" }); - void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.detail(activeId ?? "") }); + void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.byId(activeId ?? "") }); closeAllocation(); }, onError: () => { From df6e417a56405221da559af0d34821a7bee12a43 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:07:13 +0000 Subject: [PATCH 10/79] feat: add fuel management system backend - FuelPurchase entity: record fuel purchases with cost tracking - FuelConsumption entity: monthly aggregation of fuel metrics - FuelService: record purchases, calculate stats, efficiency - FuelController: REST API for fuel operations - FuelModule: integrated into app - Migration: create fuel_purchases and fuel_consumption tables Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1840000000000-CreateFuelTables.ts | 79 +++++++++++++++++ .../fuel/dto/create-fuel-purchase.dto.ts | 40 +++++++++ .../fuel/entities/fuel-consumption.entity.ts | 35 ++++++++ .../fuel/entities/fuel-purchase.entity.ts | 51 +++++++++++ .../src/modules/fuel/fuel.controller.ts | 48 ++++++++++ .../src/modules/fuel/fuel.module.ts | 15 ++++ .../src/modules/fuel/fuel.repository.ts | 68 +++++++++++++++ .../src/modules/fuel/fuel.service.ts | 87 +++++++++++++++++++ 9 files changed, 425 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.controller.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.module.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.repository.ts create mode 100644 apps/edr-freight-api/src/modules/fuel/fuel.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index db05ae26f..cbc8ce22a 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -69,6 +69,7 @@ import { WarehousesModule } from './modules/warehouses/warehouses.module'; import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; +import { FuelModule } from './modules/fuel/fuel.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -133,6 +134,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera OverviewModule, VehiclesModule, DriversModule, + FuelModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts new file mode 100644 index 000000000..a74a58f8e --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1840000000000-CreateFuelTables.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CreateFuelTables1840000000000 implements MigrationInterface { + name = "CreateFuelTables1840000000000"; + + public async up(queryRunner: QueryRunner): Promise { + const fuelPurchasesExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_purchases';`, + ); + + if (!fuelPurchasesExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_purchases ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + purchase_date timestamptz NOT NULL, + liters numeric(10, 2) NOT NULL, + cost_per_liter numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + fuel_station varchar(255) NULL, + payment_method varchar(50) DEFAULT 'CASH', + odometer_reading numeric(10, 2) NULL, + driver_id uuid NULL, + receipt_number varchar(255) NULL, + notes text NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_purchases PRIMARY KEY (id), + CONSTRAINT fk_fuel_purchases_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_vehicle ON freight.fuel_purchases (vehicle_id);`, + ); + await queryRunner.query( + `CREATE INDEX idx_fuel_purchases_date ON freight.fuel_purchases (purchase_date);`, + ); + } + + const fuelConsumptionExists = await queryRunner.query( + `SELECT 1 FROM information_schema.tables WHERE table_schema = 'freight' AND table_name = 'fuel_consumption';`, + ); + + if (!fuelConsumptionExists.length) { + await queryRunner.query(` + CREATE TABLE freight.fuel_consumption ( + id uuid NOT NULL DEFAULT gen_random_uuid(), + vehicle_id uuid NOT NULL, + month date NOT NULL, + total_liters numeric(10, 2) NOT NULL, + total_cost numeric(14, 2) NOT NULL, + total_distance_km numeric(10, 2) NOT NULL, + fuel_efficiency_km_per_l numeric(10, 2) NULL, + number_of_purchases integer DEFAULT 0, + average_cost_per_liter numeric(10, 2) NULL, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz NULL, + CONSTRAINT pk_fuel_consumption PRIMARY KEY (id), + CONSTRAINT fk_fuel_consumption_vehicle FOREIGN KEY (vehicle_id) + REFERENCES freight.vehicles (id) ON DELETE CASCADE, + CONSTRAINT uq_fuel_consumption_vehicle_month UNIQUE (vehicle_id, month) + ); + `); + + await queryRunner.query( + `CREATE INDEX idx_fuel_consumption_vehicle_month ON freight.fuel_consumption (vehicle_id, month);`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_consumption;`); + await queryRunner.query(`DROP TABLE IF EXISTS freight.fuel_purchases;`); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts new file mode 100644 index 000000000..254af7104 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/dto/create-fuel-purchase.dto.ts @@ -0,0 +1,40 @@ +import { IsUUID, IsNumber, IsDateString, IsString, IsOptional, IsEnum } from 'class-validator'; +import { PaymentMethod } from '../entities/fuel-purchase.entity'; + +export class CreateFuelPurchaseDto { + @IsUUID() + vehicleId!: string; + + @IsDateString() + purchaseDate!: string; + + @IsNumber() + liters!: number; + + @IsNumber() + costPerLiter!: number; + + @IsOptional() + @IsString() + fuelStation?: string; + + @IsEnum(PaymentMethod) + @IsOptional() + paymentMethod?: PaymentMethod; + + @IsOptional() + @IsNumber() + odometerReading?: number; + + @IsOptional() + @IsUUID() + driverId?: string; + + @IsOptional() + @IsString() + receiptNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts new file mode 100644 index 000000000..002eca861 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -0,0 +1,35 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +@Entity({ name: 'fuel_consumption', schema: 'freight' }) +@Index(['vehicleId', 'month']) +export class FuelConsumption extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'month', type: 'date' }) + month!: Date; + + @Column({ name: 'total_liters', type: 'numeric', precision: 10, scale: 2 }) + totalLiters!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 }) + totalDistanceKm!: number; + + @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) + fuelEfficiencyKmPerL?: number; + + @Column({ name: 'number_of_purchases', type: 'integer', default: 0 }) + numberOfPurchases!: number; + + @Column({ name: 'average_cost_per_liter', type: 'numeric', precision: 10, scale: 2, nullable: true }) + averageCostPerLiter?: number; +} diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts new file mode 100644 index 000000000..163618d0b --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-purchase.entity.ts @@ -0,0 +1,51 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum PaymentMethod { + CASH = 'CASH', + CARD = 'CARD', + FUEL_CARD = 'FUEL_CARD', + TRANSFER = 'TRANSFER', + CHEQUE = 'CHEQUE', +} + +@Entity({ name: 'fuel_purchases', schema: 'freight' }) +export class FuelPurchase extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'purchase_date', type: 'timestamptz' }) + purchaseDate!: Date; + + @Column({ name: 'liters', type: 'numeric', precision: 10, scale: 2 }) + liters!: number; + + @Column({ name: 'cost_per_liter', type: 'numeric', precision: 10, scale: 2 }) + costPerLiter!: number; + + @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) + totalCost!: number; + + @Column({ name: 'fuel_station', nullable: true }) + fuelStation?: string; + + @Column({ name: 'payment_method', type: 'varchar', default: PaymentMethod.CASH }) + paymentMethod!: PaymentMethod; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'driver_id', type: 'uuid', nullable: true }) + driverId?: string; + + @Column({ name: 'receipt_number', nullable: true }) + receiptNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts new file mode 100644 index 000000000..b9d19626a --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.controller.ts @@ -0,0 +1,48 @@ +import { Controller, Post, Get, Body, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { FuelService } from './fuel.service'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@ApiTags('Fuel Management') +@Controller('fuel') +export class FuelController { + constructor(private readonly fuelService: FuelService) {} + + @Post('purchases') + @ApiOperation({ summary: 'Record fuel purchase' }) + async recordFuelPurchase(@Body() dto: CreateFuelPurchaseDto) { + return this.fuelService.recordFuelPurchase(dto); + } + + @Get('purchases/:vehicleId') + @ApiOperation({ summary: 'Get fuel purchases for vehicle' }) + async getFuelPurchases( + @Param('vehicleId') vehicleId: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + return this.fuelService.getFuelPurchases( + vehicleId, + new Date(startDate), + new Date(endDate), + ); + } + + @Get('consumption/:vehicleId/:month') + @ApiOperation({ summary: 'Get monthly fuel consumption' }) + async getMonthlyConsumption( + @Param('vehicleId') vehicleId: string, + @Param('month') month: string, + ) { + return this.fuelService.getMonthlyConsumption(vehicleId, new Date(month)); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get fuel statistics for vehicle' }) + async getVehicleFuelStats( + @Param('vehicleId') vehicleId: string, + @Query('months') months: number = 12, + ) { + return this.fuelService.getVehicleFuelStats(vehicleId, months); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.module.ts b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts new file mode 100644 index 000000000..258350f1c --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FuelController } from './fuel.controller'; +import { FuelService } from './fuel.service'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Module({ + imports: [TypeOrmModule.forFeature([FuelPurchase, FuelConsumption])], + controllers: [FuelController], + providers: [FuelService, FuelRepository], + exports: [FuelService], +}) +export class FuelModule {} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts new file mode 100644 index 000000000..6e7c7901e --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -0,0 +1,68 @@ +import { Injectable } from '@nestjs/common'; +import { BaseRepository } from '@edr/api-common'; +import { DataSource } from 'typeorm'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; + +@Injectable() +export class FuelRepository extends BaseRepository { + constructor(dataSource: DataSource) { + super(FuelPurchase, dataSource.createEntityManager()); + } + + async findByVehicleAndDateRange( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.find({ + where: { + vehicleId, + purchaseDate: { + $gte: startDate, + $lte: endDate, + }, + }, + order: { purchaseDate: 'DESC' }, + }); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + return consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + } + + async updateMonthlyConsumption( + vehicleId: string, + month: Date, + data: Partial, + ): Promise { + const consumptionRepository = this.manager.getRepository(FuelConsumption); + let consumption = await consumptionRepository.findOne({ + where: { + vehicleId, + month, + }, + }); + + if (!consumption) { + consumption = consumptionRepository.create({ + vehicleId, + month, + ...data, + }); + } else { + Object.assign(consumption, data); + } + + return consumptionRepository.save(consumption); + } +} diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts new file mode 100644 index 000000000..e8d0f6d04 --- /dev/null +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -0,0 +1,87 @@ +import { Injectable } from '@nestjs/common'; +import { FuelRepository } from './fuel.repository'; +import { FuelPurchase } from './entities/fuel-purchase.entity'; +import { FuelConsumption } from './entities/fuel-consumption.entity'; +import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; + +@Injectable() +export class FuelService { + constructor(private readonly fuelRepository: FuelRepository) {} + + async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { + const totalCost = dto.liters * dto.costPerLiter; + + const purchase = this.fuelRepository.create({ + ...dto, + totalCost, + }); + + const saved = await this.fuelRepository.save(purchase); + + // Update monthly consumption + await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); + + return saved; + } + + async getFuelPurchases( + vehicleId: string, + startDate: Date, + endDate: Date, + ): Promise { + return this.fuelRepository.findByVehicleAndDateRange(vehicleId, startDate, endDate); + } + + async getMonthlyConsumption( + vehicleId: string, + month: Date, + ): Promise { + return this.fuelRepository.getMonthlyConsumption(vehicleId, month); + } + + async getVehicleFuelStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); + + const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; + + return { + vehicleId, + totalPurchases: purchases.length, + totalLiters, + totalCost, + averagePricePerLiter: averagePrice, + dateRange: { startDate, endDate }, + }; + } + + private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { + const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + + const purchases = await this.fuelRepository.find({ + where: { + vehicleId, + purchaseDate: { + $gte: monthStart, + $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), + }, + }, + }); + + const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const numberOfPurchases = purchases.length; + const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; + + await this.fuelRepository.updateMonthlyConsumption(vehicleId, monthStart, { + totalLiters, + totalCost, + numberOfPurchases, + averageCostPerLiter, + } as Partial); + } +} From 7b9cd8871298b29805062113e72dc5d1a0f4dab5 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:08:56 +0000 Subject: [PATCH 11/79] fix: correct fuel module TypeScript errors - Use @InjectRepository decorators for proper dependency injection - Fix BaseRepository initialization with Repository instance - Remove unnecessary DataSource references - Add proper type annotations to reduce handlers Co-Authored-By: Claude Haiku 4.5 --- .../src/modules/fuel/fuel.repository.ts | 36 +++++++++++------- .../src/modules/fuel/fuel.service.ts | 37 ++++++++++--------- 2 files changed, 42 insertions(+), 31 deletions(-) diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts index 6e7c7901e..d062c38e1 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.repository.ts @@ -1,13 +1,19 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; import { BaseRepository } from '@edr/api-common'; -import { DataSource } from 'typeorm'; +import { Repository, Between } from 'typeorm'; import { FuelPurchase } from './entities/fuel-purchase.entity'; import { FuelConsumption } from './entities/fuel-consumption.entity'; @Injectable() export class FuelRepository extends BaseRepository { - constructor(dataSource: DataSource) { - super(FuelPurchase, dataSource.createEntityManager()); + constructor( + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + @InjectRepository(FuelConsumption) + private readonly consumptionRepository: Repository, + ) { + super(purchaseRepository); } async findByVehicleAndDateRange( @@ -15,13 +21,10 @@ export class FuelRepository extends BaseRepository { startDate: Date, endDate: Date, ): Promise { - return this.find({ + return this.purchaseRepository.find({ where: { vehicleId, - purchaseDate: { - $gte: startDate, - $lte: endDate, - }, + purchaseDate: Between(startDate, endDate), }, order: { purchaseDate: 'DESC' }, }); @@ -31,8 +34,7 @@ export class FuelRepository extends BaseRepository { vehicleId: string, month: Date, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - return consumptionRepository.findOne({ + return this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -45,8 +47,7 @@ export class FuelRepository extends BaseRepository { month: Date, data: Partial, ): Promise { - const consumptionRepository = this.manager.getRepository(FuelConsumption); - let consumption = await consumptionRepository.findOne({ + let consumption = await this.consumptionRepository.findOne({ where: { vehicleId, month, @@ -54,7 +55,7 @@ export class FuelRepository extends BaseRepository { }); if (!consumption) { - consumption = consumptionRepository.create({ + consumption = this.consumptionRepository.create({ vehicleId, month, ...data, @@ -63,6 +64,13 @@ export class FuelRepository extends BaseRepository { Object.assign(consumption, data); } - return consumptionRepository.save(consumption); + return this.consumptionRepository.save(consumption); + } + + async findPurchasesByVehicle(vehicleId: string): Promise { + return this.purchaseRepository.find({ + where: { vehicleId }, + order: { purchaseDate: 'DESC' }, + }); } } diff --git a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts index e8d0f6d04..9241f9975 100644 --- a/apps/edr-freight-api/src/modules/fuel/fuel.service.ts +++ b/apps/edr-freight-api/src/modules/fuel/fuel.service.ts @@ -1,4 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; import { FuelRepository } from './fuel.repository'; import { FuelPurchase } from './entities/fuel-purchase.entity'; import { FuelConsumption } from './entities/fuel-consumption.entity'; @@ -6,17 +8,21 @@ import { CreateFuelPurchaseDto } from './dto/create-fuel-purchase.dto'; @Injectable() export class FuelService { - constructor(private readonly fuelRepository: FuelRepository) {} + constructor( + private readonly fuelRepository: FuelRepository, + @InjectRepository(FuelPurchase) + private readonly purchaseRepository: Repository, + ) {} async recordFuelPurchase(dto: CreateFuelPurchaseDto): Promise { const totalCost = dto.liters * dto.costPerLiter; - const purchase = this.fuelRepository.create({ + const purchase = this.purchaseRepository.create({ ...dto, totalCost, }); - const saved = await this.fuelRepository.save(purchase); + const saved = await this.purchaseRepository.save(purchase); // Update monthly consumption await this.updateMonthlyConsumption(dto.vehicleId, new Date(dto.purchaseDate)); @@ -45,8 +51,8 @@ export class FuelService { const purchases = await this.getFuelPurchases(vehicleId, startDate, endDate); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); const averagePrice = totalLiters > 0 ? totalCost / totalLiters : 0; return { @@ -61,19 +67,16 @@ export class FuelService { private async updateMonthlyConsumption(vehicleId: string, date: Date): Promise { const monthStart = new Date(date.getFullYear(), date.getMonth(), 1); + const monthEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1); - const purchases = await this.fuelRepository.find({ - where: { - vehicleId, - purchaseDate: { - $gte: monthStart, - $lt: new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1), - }, - }, - }); + const purchases = await this.fuelRepository.findByVehicleAndDateRange( + vehicleId, + monthStart, + monthEnd, + ); - const totalLiters = purchases.reduce((sum, p) => sum + Number(p.liters), 0); - const totalCost = purchases.reduce((sum, p) => sum + Number(p.totalCost), 0); + const totalLiters = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.liters), 0); + const totalCost = purchases.reduce((sum: number, p: FuelPurchase) => sum + Number(p.totalCost), 0); const numberOfPurchases = purchases.length; const averageCostPerLiter = totalLiters > 0 ? totalCost / totalLiters : 0; @@ -82,6 +85,6 @@ export class FuelService { totalCost, numberOfPurchases, averageCostPerLiter, - } as Partial); + }); } } From 3f81bb77adfcdd8de95e99ad351f5d469f918708 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:11:13 +0000 Subject: [PATCH 12/79] feat: add fuel management frontend pages - FuelPurchasePage: Record fuel purchases, calculate total costs - FuelStatsPage: View fuel consumption stats, efficiency metrics - Routes: /dashboard/fuel-purchases and /dashboard/fuel-stats - Sidebar menu items in Fleet Management section Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 ++ .../src/pages/fleet/FuelPurchasePage.tsx | 320 ++++++++++++++++++ .../src/pages/fleet/FuelStatsPage.tsx | 207 +++++++++++ 3 files changed, 557 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..8d5b568dc 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -57,6 +57,8 @@ import FileUploadSettingsPage from "./pages/documents/FileUploadSettingsPage"; import DropdownSettingsPage from "./pages/dropdown_settings/DropdownSettingsPage"; import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; +import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; +import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -200,6 +202,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Fuel Purchases", + href: "/dashboard/fuel-purchases", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Fuel Analytics", + href: "/dashboard/fuel-stats", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -725,6 +739,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> { + const res = await api.get("/vehicles?pageSize=1000"); + return res.data?.data || []; + }, + }); + + // Fetch fuel purchases + const { data: purchasesData = [] } = useQuery({ + queryKey: ["fuel-purchases"], + queryFn: async () => { + const res = await api.get("/fuel/purchases"); + return res.data || []; + }, + }); + + // Record purchase mutation + const recordMutation = useMutation({ + mutationFn: async (data: typeof formData) => { + const res = await api.post("/fuel/purchases", { + ...data, + liters: parseFloat(data.liters.toString()), + costPerLiter: parseFloat(data.costPerLiter.toString()), + }); + return res.data; + }, + onSuccess: () => { + toast({ title: "Fuel purchase recorded" }); + setModalOpen(false); + setFormData({ + vehicleId: "", + purchaseDate: new Date().toISOString().split("T")[0], + liters: 0, + costPerLiter: 0, + fuelStation: "", + paymentMethod: "CASH", + odometerReading: undefined, + receiptNumber: "", + notes: "", + }); + qc.invalidateQueries({ queryKey: ["fuel-purchases"] }); + }, + onError: (error: any) => { + toast({ + title: "Error recording purchase", + message: error?.response?.data?.message || "Failed to record fuel purchase", + color: "red", + }); + }, + }); + + const vehicleOptions = + vehiclesData?.map((v: Vehicle) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const totalCost = formData.liters * formData.costPerLiter; + + return ( + + + + + Fuel Purchases + + + + {/* Stats Cards */} + + + + + Total Purchases + + + {purchasesData.length} + + + + + + + Total Liters + + + {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) + .toFixed(2)}{" "} + L + + + + + + + Total Cost + + + ETB {purchasesData + .reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) + .toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB{" "} + {( + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.totalCost, 0) / + purchasesData.reduce((sum: number, p: FuelPurchase) => sum + p.liters, 0) || 0 + ).toFixed(2)} + + + + + + {/* Purchases Table */} + + + + + Vehicle + Date + Liters + Cost/L + Total + Station + Payment + + + + {(purchasesData as FuelPurchase[])?.map((purchase) => ( + + {purchase.vehicleName || purchase.vehicleId} + {new Date(purchase.purchaseDate).toLocaleDateString()} + {purchase.liters.toFixed(2)} + ETB {purchase.costPerLiter.toFixed(2)} + ETB {purchase.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + {purchase.fuelStation || "—"} + + {purchase.paymentMethod} + + + ))} + +
+
+ + {/* Modal */} + setModalOpen(false)} title="Record Fuel Purchase" size="lg"> + + setFormData({ ...formData, paymentMethod: val || "CASH" })} + /> + + setFormData({ ...formData, odometerReading: val as number | undefined })} + decimalScale={0} + min={0} + /> + + setFormData({ ...formData, receiptNumber: e.currentTarget.value })} + /> + + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx new file mode 100644 index 000000000..b2efb85be --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -0,0 +1,207 @@ +import { useQuery } from "@tanstack/react-query"; +import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, Badge } from "@mantine/core"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { api } from "@/auth/http"; +import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { useState } from "react"; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalLiters: number; + totalCost: number; + averagePricePerLiter: number; + dateRange: { startDate: string; endDate: string }; +} + +interface Vehicle { + id: string; + plateNumber: string; + manufacturer: string; + model: string; + actualDistanceKm?: number; +} + +export default function FuelStatsPage() { + const [selectedVehicleId, setSelectedVehicleId] = useState(""); + const [monthsBack, setMonthsBack] = useState("12"); + + // Fetch vehicles + const { data: vehiclesData } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await api.get("/vehicles?pageSize=1000"); + return res.data?.data || []; + }, + }); + + // Fetch fuel stats + const { data: statsData } = useQuery({ + queryKey: ["fuel-stats", selectedVehicleId, monthsBack], + queryFn: async () => { + if (!selectedVehicleId) return null; + const res = await api.get(`/fuel/stats/${selectedVehicleId}?months=${monthsBack}`); + return res.data; + }, + enabled: !!selectedVehicleId, + }); + + const vehicleOptions = + vehiclesData?.map((v: Vehicle) => ({ + value: v.id, + label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, + })) || []; + + const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId); + + const costPerKm = + statsData && selectedVehicle?.actualDistanceKm + ? (statsData.totalCost / selectedVehicle.actualDistanceKm).toFixed(2) + : "—"; + + const efficiency = statsData + ? (statsData.totalLiters > 0 ? (selectedVehicle?.actualDistanceKm || 0) / statsData.totalLiters : 0).toFixed(2) + : "—"; + + return ( + + + + + Fuel Consumption Analysis + + + {/* Filters */} + + + + setMonthsBack(val || "12")} + /> + + + + + {selectedVehicleId && statsData ? ( + <> + {/* Stats Cards */} + + + + + Total Purchases + + + {statsData.totalPurchases} + + + + + + + Total Fuel + + + {statsData.totalLiters.toFixed(2)} L + + + + + + + Total Cost + + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + + + + + + Avg Price/L + + + ETB {statsData.averagePricePerLiter.toFixed(2)} + + + + + + {/* Efficiency Metrics */} + + + + + Fuel Efficiency + + + {efficiency} km/L + + + + + + + Cost per KM + + + ETB {costPerKm} + + + + + + {/* Summary */} + + +
+ + Summary + + + {selectedVehicle?.plateNumber} consumed{" "} + + {statsData.totalLiters.toFixed(2)} liters + {" "} + over the last {monthsBack} months, costing{" "} + + ETB {statsData.totalCost.toLocaleString("en-US", { maximumFractionDigits: 2 })} + + . Average fuel price was{" "} + + ETB {statsData.averagePricePerLiter.toFixed(2)} per liter + + . + +
+
+
+ + ) : ( + + + Select a vehicle to view fuel consumption statistics + + + )} +
+ ); +} From f27f00d4806787911047ff70a65a006ed477be53 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 10:15:43 +0000 Subject: [PATCH 13/79] fix: use vehiclesService and add VEHICLES QUERY_KEYS - Add VEHICLES to QUERY_KEYS constant - Use vehiclesService.getAll() instead of direct API call - Remove duplicate Vehicle type definitions - Fix TypeScript type references Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/constants/QUERY_KEYS.ts | 6 ++++++ .../src/pages/fleet/FuelPurchasePage.tsx | 13 ++++--------- .../src/pages/fleet/FuelStatsPage.tsx | 17 +++++------------ 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 59b493bf3..c47f8e2e5 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -98,6 +98,12 @@ export const QUERY_KEYS = { list: (resource: FleetResourceSlug | string) => ["fleet", "list", resource] as const, }, + VEHICLES: { + ROOT: ["vehicles"] as const, + list: (filter?: Record) => ["vehicles", "list", filter ?? {}] as const, + byId: (id: string) => ["vehicles", "detail", id] as const, + }, + FIRST_MILE: { ROOT: ["first-mile"] as const, list: (filter?: Record) => ["first-mile", "list", filter ?? {}] as const, diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index deaca7041..0869ceb26 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -22,6 +22,7 @@ import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; interface FuelPurchase { id: string; @@ -38,12 +39,6 @@ interface FuelPurchase { notes?: string; } -interface Vehicle { - id: string; - plateNumber: string; - manufacturer: string; - model: string; -} export default function FuelPurchasePage() { const { toast } = useToast(); @@ -65,8 +60,8 @@ export default function FuelPurchasePage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -115,7 +110,7 @@ export default function FuelPurchasePage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index b2efb85be..78ed36498 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -3,6 +3,7 @@ import { Box, Card, Container, Grid, Group, Select, Stack, Table, Text, Title, B import Breadcrumbs from "@/components/ui/Breadcrumbs"; import { api } from "@/auth/http"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; +import { vehiclesService, type Vehicle as VehicleType } from "@/services/vehicles.service"; import { useState } from "react"; interface FuelStats { @@ -14,14 +15,6 @@ interface FuelStats { dateRange: { startDate: string; endDate: string }; } -interface Vehicle { - id: string; - plateNumber: string; - manufacturer: string; - model: string; - actualDistanceKm?: number; -} - export default function FuelStatsPage() { const [selectedVehicleId, setSelectedVehicleId] = useState(""); const [monthsBack, setMonthsBack] = useState("12"); @@ -30,8 +23,8 @@ export default function FuelStatsPage() { const { data: vehiclesData } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), queryFn: async () => { - const res = await api.get("/vehicles?pageSize=1000"); - return res.data?.data || []; + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; }, }); @@ -47,12 +40,12 @@ export default function FuelStatsPage() { }); const vehicleOptions = - vehiclesData?.map((v: Vehicle) => ({ + vehiclesData?.map((v: VehicleType) => ({ value: v.id, label: `${v.plateNumber} - ${v.manufacturer} ${v.model}`, })) || []; - const selectedVehicle = vehiclesData?.find((v: Vehicle) => v.id === selectedVehicleId); + const selectedVehicle = vehiclesData?.find((v: VehicleType) => v.id === selectedVehicleId); const costPerKm = statsData && selectedVehicle?.actualDistanceKm From 5d70c3b5577a088960b60098d374c5f96723214e Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:13:48 +0000 Subject: [PATCH 14/79] fix --- .../src/modules/fuel/entities/fuel-consumption.entity.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts index 002eca861..aabafd17c 100644 --- a/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts +++ b/apps/edr-freight-api/src/modules/fuel/entities/fuel-consumption.entity.ts @@ -21,8 +21,8 @@ export class FuelConsumption extends BaseEntity { @Column({ name: 'total_cost', type: 'numeric', precision: 14, scale: 2 }) totalCost!: number; - @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2 }) - totalDistanceKm!: number; + @Column({ name: 'total_distance_km', type: 'numeric', precision: 10, scale: 2, default: 0 }) + totalDistanceKm: number = 0; @Column({ name: 'fuel_efficiency_km_per_l', type: 'numeric', precision: 10, scale: 2, nullable: true }) fuelEfficiencyKmPerL?: number; From e59a77b859bdf8b281545ff07805590ac4fe1db3 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:17:17 +0000 Subject: [PATCH 15/79] feat: add maintenance tracking module foundation Entities: - MaintenanceSchedule: track preventive/corrective maintenance - MaintenanceCost: record actual maintenance expenses DTOs: - CreateMaintenanceScheduleDto: schedule maintenance - CreateMaintenanceCostDto: log costs - UpdateMaintenanceScheduleDto: mark complete/adjust cost Repository: - getUpcomingMaintenance(): find due maintenance - getMaintenanceCosts(): historical costs by date - getTotalMaintenanceCost(): aggregate spending Also fixed fuel-consumption.entity.ts: totalDistanceKm default 0 Co-Authored-By: Claude Haiku 4.5 --- .../maintenance/dto/create-maintenance.dto.ts | 87 +++++++++++++++++++ .../entities/maintenance-cost.entity.ts | 43 +++++++++ .../entities/maintenance-schedule.entity.ts | 65 ++++++++++++++ .../maintenance/maintenance.repository.ts | 50 +++++++++++ 4 files changed, 245 insertions(+) create mode 100644 apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts diff --git a/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts new file mode 100644 index 000000000..d3e70acff --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/dto/create-maintenance.dto.ts @@ -0,0 +1,87 @@ +import { IsUUID, IsString, IsDateString, IsNumber, IsOptional, IsEnum } from 'class-validator'; +import { MaintenanceType, MaintenanceStatus } from '../entities/maintenance-schedule.entity'; + +export class CreateMaintenanceScheduleDto { + @IsUUID() + vehicleId!: string; + + @IsEnum(MaintenanceType) + maintenanceType!: MaintenanceType; + + @IsString() + description!: string; + + @IsDateString() + scheduledDate!: string; + + @IsOptional() + @IsNumber() + estimatedCost?: number; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + notes?: string; + + @IsOptional() + @IsNumber() + nextDueKm?: number; + + @IsOptional() + @IsDateString() + nextDueDate?: string; +} + +export class CreateMaintenanceCostDto { + @IsUUID() + vehicleId!: string; + + @IsOptional() + @IsUUID() + maintenanceScheduleId?: string; + + @IsDateString() + incurredDate!: string; + + @IsNumber() + costAmount!: number; + + @IsString() + costType!: string; + + @IsString() + description!: string; + + @IsOptional() + @IsString() + serviceProvider?: string; + + @IsOptional() + @IsString() + invoiceNumber?: string; + + @IsOptional() + @IsString() + notes?: string; +} + +export class UpdateMaintenanceScheduleDto { + @IsOptional() + @IsEnum(MaintenanceStatus) + status?: MaintenanceStatus; + + @IsOptional() + @IsDateString() + completedDate?: string; + + @IsOptional() + @IsNumber() + actualCost?: number; + + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts new file mode 100644 index 000000000..5afbaa80f --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-cost.entity.ts @@ -0,0 +1,43 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceSchedule } from './maintenance-schedule.entity'; + +@Entity({ name: 'maintenance_costs', schema: 'freight' }) +@Index(['vehicleId', 'incurredDate']) +export class MaintenanceCost extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_schedule_id', type: 'uuid', nullable: true }) + maintenanceScheduleId?: string; + + @ManyToOne(() => MaintenanceSchedule, { eager: false, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'maintenance_schedule_id' }) + maintenanceSchedule?: MaintenanceSchedule; + + @Column({ name: 'incurred_date', type: 'timestamptz' }) + incurredDate!: Date; + + @Column({ name: 'cost_amount', type: 'numeric', precision: 14, scale: 2 }) + costAmount!: number; + + @Column({ name: 'cost_type' }) + costType!: string; // 'PARTS', 'LABOR', 'DIAGNOSTICS', 'OTHER' + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'invoice_number', nullable: true }) + invoiceNumber?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts new file mode 100644 index 000000000..a4d4d60a0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-schedule.entity.ts @@ -0,0 +1,65 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; + +export enum MaintenanceType { + PREVENTIVE = 'PREVENTIVE', + CORRECTIVE = 'CORRECTIVE', + INSPECTION = 'INSPECTION', + REPAIR = 'REPAIR', +} + +export enum MaintenanceStatus { + SCHEDULED = 'SCHEDULED', + IN_PROGRESS = 'IN_PROGRESS', + COMPLETED = 'COMPLETED', + CANCELLED = 'CANCELLED', + OVERDUE = 'OVERDUE', +} + +@Entity({ name: 'maintenance_schedules', schema: 'freight' }) +@Index(['vehicleId', 'scheduledDate']) +export class MaintenanceSchedule extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { eager: false }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + @Column({ name: 'description' }) + description!: string; + + @Column({ name: 'scheduled_date', type: 'timestamptz' }) + scheduledDate!: Date; + + @Column({ name: 'completed_date', type: 'timestamptz', nullable: true }) + completedDate?: Date; + + @Column({ name: 'estimated_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + estimatedCost?: number; + + @Column({ name: 'actual_cost', type: 'numeric', precision: 14, scale: 2, nullable: true }) + actualCost?: number; + + @Column({ name: 'status', type: 'varchar', default: MaintenanceStatus.SCHEDULED }) + status!: MaintenanceStatus; + + @Column({ name: 'odometer_reading', type: 'numeric', nullable: true }) + odometerReading?: number; + + @Column({ name: 'service_provider', nullable: true }) + serviceProvider?: string; + + @Column({ name: 'notes', type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'next_due_km', type: 'numeric', nullable: true }) + nextDueKm?: number; + + @Column({ name: 'next_due_date', type: 'timestamptz', nullable: true }) + nextDueDate?: Date; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts new file mode 100644 index 000000000..9e8cf972e --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.repository.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository, Between } from 'typeorm'; +import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; + +@Injectable() +export class MaintenanceRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) { + super(scheduleRepository); + } + + async getUpcomingMaintenance(vehicleId: string, daysAhead: number = 30) { + const futureDate = new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000); + return this.scheduleRepository.find({ + where: { + vehicleId, + scheduledDate: Between(new Date(), futureDate), + status: MaintenanceStatus.SCHEDULED, + }, + order: { scheduledDate: 'ASC' }, + }); + } + + async getMaintenanceCosts(vehicleId: string, startDate: Date, endDate: Date) { + return this.costRepository.find({ + where: { + vehicleId, + incurredDate: Between(startDate, endDate), + }, + order: { incurredDate: 'DESC' }, + }); + } + + async getTotalMaintenanceCost(vehicleId: string, startDate: Date, endDate: Date) { + const result = await this.costRepository + .createQueryBuilder() + .select('SUM(cost_amount)', 'total') + .where('vehicle_id = :vehicleId', { vehicleId }) + .andWhere('incurred_date BETWEEN :startDate AND :endDate', { startDate, endDate }) + .getRawOne(); + return result?.total || 0; + } +} From f436916c42499e29c429b3130d2eec79c6ba9671 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:32:08 +0000 Subject: [PATCH 16/79] feat: complete maintenance tracking backend Service/Controller/Module: - scheduleMaintenanceAsync: schedule work - recordMaintenanceCost: log expenses - getUpcomingMaintenance: due items - getVehicleMaintenanceStats: cost aggregation Endpoints: - POST /maintenance/schedules - POST /maintenance/costs - PATCH /maintenance/schedules/:id - GET /maintenance/upcoming/:vehicleId - GET /maintenance/history/:vehicleId - GET /maintenance/stats/:vehicleId Migration: idempotent maintenance tables creation Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-api/src/app.module.ts | 2 + .../1850000000000-CreateMaintenanceTables.ts | 82 +++++++++++++++++++ .../maintenance/maintenance.controller.ts | 46 +++++++++++ .../modules/maintenance/maintenance.module.ts | 15 ++++ .../maintenance/maintenance.service.ts | 82 +++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts create mode 100644 apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index cbc8ce22a..f40e4f40f 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -70,6 +70,7 @@ import { OverviewModule } from './modules/overview/overview.module'; import { VehiclesModule } from './modules/vehicles/vehicles.module'; import { DriversModule } from './modules/drivers/drivers.module'; import { FuelModule } from './modules/fuel/fuel.module'; +import { MaintenanceModule } from './modules/maintenance/maintenance.module'; import { FirstMileModule } from './modules/first-mile/first-mile.module'; import { LastMileModule } from './modules/last-mile/last-mile.module'; import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module'; @@ -135,6 +136,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera VehiclesModule, DriversModule, FuelModule, + MaintenanceModule, FirstMileModule, LastMileModule, InterchangeDocumentsModule, diff --git a/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts new file mode 100644 index 000000000..26d4afe21 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1850000000000-CreateMaintenanceTables.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateMaintenanceTables1850000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create maintenance_schedules table + const scheduleTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_schedules' + ) + `); + + if (!scheduleTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_schedules" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_type" varchar NOT NULL, + "description" varchar NOT NULL, + "scheduled_date" timestamptz NOT NULL, + "completed_date" timestamptz, + "estimated_cost" numeric(14,2), + "actual_cost" numeric(14,2), + "status" varchar NOT NULL DEFAULT 'SCHEDULED', + "odometer_reading" numeric, + "service_provider" varchar, + "notes" text, + "next_due_km" numeric, + "next_due_date" timestamptz, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id") + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_schedules_vehicle_date" ON "freight"."maintenance_schedules" ("vehicle_id", "scheduled_date")` + ); + } + + // Create maintenance_costs table + const costsTableExists = await queryRunner.query(` + SELECT EXISTS( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'freight' AND table_name = 'maintenance_costs' + ) + `); + + if (!costsTableExists[0].exists) { + await queryRunner.query(` + CREATE TABLE "freight"."maintenance_costs" ( + "id" uuid NOT NULL DEFAULT gen_random_uuid(), + "vehicle_id" uuid NOT NULL, + "maintenance_schedule_id" uuid, + "incurred_date" timestamptz NOT NULL, + "cost_amount" numeric(14,2) NOT NULL, + "cost_type" varchar NOT NULL, + "description" varchar NOT NULL, + "service_provider" varchar, + "invoice_number" varchar, + "notes" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now(), + "deleted_at" timestamptz, + PRIMARY KEY ("id"), + CONSTRAINT "fk_maintenance_schedule" FOREIGN KEY ("maintenance_schedule_id") + REFERENCES "freight"."maintenance_schedules" ("id") ON DELETE SET NULL + ) + `); + + await queryRunner.query( + `CREATE INDEX "idx_maintenance_costs_vehicle_date" ON "freight"."maintenance_costs" ("vehicle_id", "incurred_date")` + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_costs"`); + await queryRunner.query(`DROP TABLE IF EXISTS "freight"."maintenance_schedules"`); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts new file mode 100644 index 000000000..6f29089d3 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.controller.ts @@ -0,0 +1,46 @@ +import { Controller, Post, Get, Patch, Body, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { MaintenanceService } from './maintenance.service'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@ApiTags('Maintenance Management') +@Controller('maintenance') +export class MaintenanceController { + constructor(private readonly maintenanceService: MaintenanceService) {} + + @Post('schedules') + @ApiOperation({ summary: 'Schedule maintenance' }) + async scheduleMaintenanceAsync(@Body() dto: CreateMaintenanceScheduleDto) { + return this.maintenanceService.scheduleMaintenanceAsync(dto); + } + + @Post('costs') + @ApiOperation({ summary: 'Record maintenance cost' }) + async recordCost(@Body() dto: CreateMaintenanceCostDto) { + return this.maintenanceService.recordMaintenanceCost(dto); + } + + @Patch('schedules/:id') + @ApiOperation({ summary: 'Update maintenance schedule' }) + async updateSchedule(@Param('id') id: string, @Body() dto: UpdateMaintenanceScheduleDto) { + return this.maintenanceService.updateMaintenanceSchedule(id, dto); + } + + @Get('upcoming/:vehicleId') + @ApiOperation({ summary: 'Get upcoming maintenance' }) + async getUpcoming(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getUpcomingMaintenance(vehicleId); + } + + @Get('history/:vehicleId') + @ApiOperation({ summary: 'Get maintenance history' }) + async getHistory(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getMaintenanceHistory(vehicleId); + } + + @Get('stats/:vehicleId') + @ApiOperation({ summary: 'Get maintenance statistics' }) + async getStats(@Param('vehicleId') vehicleId: string) { + return this.maintenanceService.getVehicleMaintenanceStats(vehicleId); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts new file mode 100644 index 000000000..a0227a733 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { MaintenanceService } from './maintenance.service'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceController } from './maintenance.controller'; + +@Module({ + imports: [TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost])], + providers: [MaintenanceService, MaintenanceRepository], + controllers: [MaintenanceController], + exports: [MaintenanceService], +}) +export class MaintenanceModule {} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts new file mode 100644 index 000000000..4cbe6886c --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -0,0 +1,82 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceSchedule } from './entities/maintenance-schedule.entity'; +import { MaintenanceCost } from './entities/maintenance-cost.entity'; +import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; + +@Injectable() +export class MaintenanceService { + constructor( + private readonly maintenanceRepository: MaintenanceRepository, + @InjectRepository(MaintenanceSchedule) + private readonly scheduleRepository: Repository, + @InjectRepository(MaintenanceCost) + private readonly costRepository: Repository, + ) {} + + async scheduleMaintenanceAsync(dto: CreateMaintenanceScheduleDto): Promise { + const schedule = this.scheduleRepository.create({ + ...dto, + scheduledDate: new Date(dto.scheduledDate), + nextDueDate: dto.nextDueDate ? new Date(dto.nextDueDate) : undefined, + }); + return this.scheduleRepository.save(schedule); + } + + async recordMaintenanceCost(dto: CreateMaintenanceCostDto): Promise { + const cost = this.costRepository.create({ + ...dto, + incurredDate: new Date(dto.incurredDate), + }); + return this.costRepository.save(cost); + } + + async updateMaintenanceSchedule( + id: string, + dto: UpdateMaintenanceScheduleDto, + ): Promise { + await this.scheduleRepository.update(id, { + ...dto, + completedDate: dto.completedDate ? new Date(dto.completedDate) : undefined, + }); + const updated = await this.scheduleRepository.findOneBy({ id }); + return updated!; + } + + async getUpcomingMaintenance(vehicleId: string) { + return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); + } + + async getMaintenanceHistory(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + return this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + } + + async getVehicleMaintenanceStats(vehicleId: string, monthsBack: number = 12) { + const endDate = new Date(); + const startDate = new Date(endDate.getFullYear(), endDate.getMonth() - monthsBack, 1); + + const costs = await this.maintenanceRepository.getMaintenanceCosts(vehicleId, startDate, endDate); + const totalCost = costs.reduce((sum: number, c: MaintenanceCost) => sum + Number(c.costAmount), 0); + + return { + vehicleId, + totalCost, + numberOfMaintenanceItems: costs.length, + averageCostPerMaintenance: costs.length > 0 ? totalCost / costs.length : 0, + costByType: this.groupCostsByType(costs), + }; + } + + private groupCostsByType(costs: MaintenanceCost[]) { + const grouped: Record = {}; + costs.forEach((c) => { + if (!grouped[c.costType]) grouped[c.costType] = 0; + grouped[c.costType] += Number(c.costAmount); + }); + return grouped; + } +} From 373c0356f2e82143d587135f8c538e6eab0fa13c Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 12:41:23 +0000 Subject: [PATCH 17/79] feat: maintenance + financial reports frontend MaintenancePage: - Schedule maintenance (PREVENTIVE/CORRECTIVE/INSPECTION/REPAIR) - View upcoming by vehicle - Modal form with date, cost, provider, notes FinancialReportsPage: - Aggregate fuel + maintenance costs - Period selector (3/6/12 months) - Cost breakdown (percentages, ring progress) - Operating insights (purchases, efficiency, items, avg cost) - Cost per month calculation Routes: - /dashboard/maintenance - /dashboard/financial-reports Sidebar: - "Maintenance" in Fleet Management - "Financial Reports" in Fleet Management QUERY_KEYS: - FUEL, MAINTENANCE, FINANCIAL_REPORTS cache patterns Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 30 +++ .../backoffice/src/constants/QUERY_KEYS.ts | 20 ++ .../src/pages/fleet/FinancialReportsPage.tsx | 245 ++++++++++++++++++ .../src/pages/fleet/MaintenancePage.tsx | 197 ++++++++++++++ 4 files changed, 492 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 8d5b568dc..513dd1300 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -59,6 +59,8 @@ import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import RoutesPage from "./pages/fleet/RoutesPage"; import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; +import { MaintenancePage } from "./pages/fleet/MaintenancePage"; +import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -214,6 +216,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Maintenance", + href: "/dashboard/maintenance", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, + { + label: "Financial Reports", + href: "/dashboard/financial-reports", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, // { // label: "Containers", // href: "/dashboard/containers", @@ -755,6 +769,22 @@ const App = () => { } /> + + + + } + /> + + + + } + /> ["overview", "customers", range ?? "30d"] as const, staffTab: (range?: string) => ["overview", "staff", range ?? "30d"] as const, }, + + FUEL: { + ROOT: ["fuel"] as const, + purchases: (vehicleId?: string) => ["fuel", "purchases", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["fuel", "stats", vehicleId ?? "all"] as const, + }, + + MAINTENANCE: { + ROOT: ["maintenance"] as const, + schedules: (vehicleId?: string) => ["maintenance", "schedules", vehicleId ?? "all"] as const, + upcoming: (vehicleId?: string) => ["maintenance", "upcoming", vehicleId ?? "all"] as const, + history: (vehicleId?: string) => ["maintenance", "history", vehicleId ?? "all"] as const, + stats: (vehicleId?: string) => ["maintenance", "stats", vehicleId ?? "all"] as const, + }, + + FINANCIAL_REPORTS: { + ROOT: ["financial-reports"] as const, + fleet: (vehicleId?: string, months?: number) => + ["financial-reports", "fleet", vehicleId ?? "all", months ?? 12] as const, + }, } as const; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx new file mode 100644 index 000000000..31d7a7a23 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -0,0 +1,245 @@ +import { useMemo, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface FuelStats { + vehicleId: string; + totalPurchases: number; + totalFuel: number; + totalCost: number; + averageCostPerLiter: number; +} + +interface MaintenanceStats { + vehicleId: string; + totalCost: number; + numberOfMaintenanceItems: number; + averageCostPerMaintenance: number; + costByType: Record; +} + +interface CombinedReport { + vehicleId: string; + fuelCost: number; + maintenanceCost: number; + totalOperatingCost: number; + fuelPercentage: number; + maintenancePercentage: number; +} + +export function FinancialReportsPage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [months, setMonths] = useState('12'); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: fuelStats } = useQuery({ + queryKey: QUERY_KEYS.FUEL.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/fuel/stats/${selectedVehicle}?months=${months}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.stats(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/stats/${selectedVehicle}`) : Promise.resolve(null), + enabled: !!selectedVehicle, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const report = useMemo(() => { + if (!fuelStats || !maintenanceStats) return null; + + const fuelCost = Number(fuelStats.totalCost) || 0; + const maintenanceCost = Number(maintenanceStats.totalCost) || 0; + const total = fuelCost + maintenanceCost; + + return { + vehicleId: selectedVehicle!, + fuelCost, + maintenanceCost, + totalOperatingCost: total, + fuelPercentage: total > 0 ? Math.round((fuelCost / total) * 100) : 0, + maintenancePercentage: total > 0 ? Math.round((maintenanceCost / total) * 100) : 0, + }; + }, [fuelStats, maintenanceStats, selectedVehicle]); + + const StatCard = ({ label, value }: { label: string; value: string }) => ( + + + + {label} + + + {value} + + + + ); + + return ( + + + + Fleet Financial Analysis + + + + setMonths(v || '12')} + style={{ flex: 1 }} + /> + + + + + {report && ( + <> + + + + + + + + + + + + + + + Monthly Avg + + + ${(report.totalOperatingCost / parseInt(months)).toFixed(2)} + + + + + + + + + + + Cost Breakdown + + + + + + + Fuel + + {report.fuelPercentage}% + + + {report.fuelPercentage}% + + } + size={100} + thickness={4} + /> + + + + + Maintenance + + {report.maintenancePercentage}% + + + {report.maintenancePercentage}% + + } + size={100} + thickness={4} + /> + + + + + + + + + + Operational Insights + + + +
+ + Fuel Purchases + + {fuelStats?.totalPurchases || 0} transactions +
+
+ + Fuel Efficiency + + + {fuelStats?.fuelEfficiency?.toFixed(2) || 'N/A'} km/L + +
+
+ + Maintenance Items + + {maintenanceStats?.numberOfMaintenanceItems || 0} records +
+
+ + Avg Maintenance Cost + + ${maintenanceStats?.averageCostPerMaintenance?.toFixed(2) || '0.00'} +
+
+
+
+
+
+ + )} + + {!selectedVehicle && ( + + + Select a vehicle to view financial reports + + + )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx new file mode 100644 index 000000000..162ee5f21 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -0,0 +1,197 @@ +import { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { DateInput } from '@mantine/dates'; +import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; +import { api } from '@/services/api'; +import { vehiclesService } from '@/services/vehicles.service'; + +interface MaintenanceSchedule { + id: string; + vehicleId: string; + maintenanceType: string; + description: string; + scheduledDate: string; + completedDate?: string; + status: string; + estimatedCost?: number; + actualCost?: number; + serviceProvider?: string; +} + +export function MaintenancePage() { + const [selectedVehicle, setSelectedVehicle] = useState(null); + const [openScheduleModal, setOpenScheduleModal] = useState(false); + const [formData, setFormData] = useState({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + + const queryClient = useQueryClient(); + + const { data: vehicles } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: () => vehiclesService.getAll({ limit: 1000 }), + }); + + const { data: upcoming, isLoading } = useQuery({ + queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''), + queryFn: () => selectedVehicle ? api.get(`/maintenance/upcoming/${selectedVehicle}`) : Promise.resolve([]), + enabled: !!selectedVehicle, + }); + + const scheduleMutation = useMutation({ + mutationFn: async () => { + if (!selectedVehicle) return; + return api.post('/maintenance/schedules', { + vehicleId: selectedVehicle, + ...formData, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || '') }); + setOpenScheduleModal(false); + setFormData({ + maintenanceType: 'PREVENTIVE', + description: '', + scheduledDate: new Date(), + estimatedCost: 0, + serviceProvider: '', + notes: '', + }); + }, + }); + + const vehicleOptions = useMemo( + () => vehicles?.map(v => ({ label: v.registrationNumber || v.id, value: v.id })) || [], + [vehicles] + ); + + const statusColor = (status: string) => { + const colors: Record = { + SCHEDULED: 'blue', + IN_PROGRESS: 'yellow', + COMPLETED: 'green', + OVERDUE: 'red', + }; + return colors[status] || 'gray'; + }; + + return ( + + + + + Schedule Maintenance + + + + + setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })} + /> + setFormData({ ...formData, description: e.currentTarget.value })} + /> + setFormData({ ...formData, scheduledDate: d || new Date() })} + /> + setFormData({ ...formData, estimatedCost: Number(v) })} + /> + setFormData({ ...formData, serviceProvider: e.currentTarget.value })} + /> + setFormData({ ...formData, notes: e.currentTarget.value })} + /> + + + + + + + + ); +} From 8e0cc7d5ee37bf720c42d4431bdda95c19e4a7d2 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:10:47 +0000 Subject: [PATCH 18/79] fix: vehicles data extraction in maintenance + financial pages Both MaintenancePage and FinancialReportsPage were calling vehiclesService.getAll() but not extracting res.data property. Result was vehicles being undefined, causing .map error. Fixed to match FuelPurchasePage pattern: const res = await vehiclesService.getAll() return res.data || [] Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 5 ++++- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index 31d7a7a23..729c50427 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -36,7 +36,10 @@ export function FinancialReportsPage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: fuelStats } = useQuery({ diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 162ee5f21..d94ed375c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -35,7 +35,10 @@ export function MaintenancePage() { const { data: vehicles } = useQuery({ queryKey: QUERY_KEYS.VEHICLES.list(), - queryFn: () => vehiclesService.getAll({ limit: 1000 }), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, }); const { data: upcoming, isLoading } = useQuery({ From 814db8a17d93ef5b9c04b44a93993a387059d717 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:38:35 +0000 Subject: [PATCH 19/79] feat: fleet dashboard page Shows fleet overview + key metrics: - Total vehicles, active count - Total fuel spending - Total maintenance spending - Average fuel efficiency - Fleet status (active/idle/maintenance) - Operating cost breakdown (fuel vs maintenance pie chart) - Fleet vehicle list (first 10) Route: /dashboard/fleet-dashboard Sidebar: Added to Fleet Management section Metrics aggregate from: - /vehicles (fleet size) - /fuel/stats (fuel spending + efficiency) - /maintenance/stats (maintenance spending) Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 15 + .../src/pages/fleet/FleetDashboard.tsx | 262 ++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/FleetDashboard.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 513dd1300..7d8d07ab5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -61,6 +61,7 @@ import FuelPurchasePage from "./pages/fleet/FuelPurchasePage"; import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; +import { FleetDashboard } from "./pages/fleet/FleetDashboard"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -168,6 +169,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ { title: "Fleet Management", items: [ + { + label: "Fleet Dashboard", + href: "/dashboard/fleet-dashboard", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Routes", href: "/dashboard/routes", @@ -785,6 +792,14 @@ const App = () => { } /> + + + + } + /> ( + + + + + {label} + + + {value} + + + + + + + +); + +export function FleetDashboard() { + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + const { data: fuelStats } = useQuery({ + queryKey: ['fleet-fuel-stats'], + queryFn: async () => { + try { + const res = await api.get('/fuel/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const { data: maintenanceStats } = useQuery({ + queryKey: ['fleet-maintenance-stats'], + queryFn: async () => { + try { + const res = await api.get('/maintenance/stats'); + return res.data || {}; + } catch { + return {}; + } + }, + }); + + const metrics = useMemo((): FleetMetrics => { + const totalVehicles = (vehicles as Vehicle[]).length; + const activeVehicles = (vehicles as Vehicle[]).filter(v => v.status === 'ACTIVE').length; + + const fuelTotal = fuelStats?.totalCost || 0; + const maintenanceTotal = maintenanceStats?.totalCost || 0; + + return { + totalVehicles, + activeVehicles, + maintenanceOverdue: 0, // TODO: fetch from API + totalFuelSpend: fuelTotal, + totalMaintenanceSpend: maintenanceTotal, + averageFuelEfficiency: fuelStats?.averageEfficiency || 0, + costPerKm: (fuelTotal + maintenanceTotal) / 100000, // Placeholder + }; + }, [vehicles, fuelStats, maintenanceStats]); + + const operatingCost = metrics.totalFuelSpend + metrics.totalMaintenanceSpend; + const fuelPercent = operatingCost > 0 ? Math.round((metrics.totalFuelSpend / operatingCost) * 100) : 0; + + return ( + + + + + Fleet Overview + + + {/* Key Metrics */} + + + + + + + + + + + + + + + + {/* Fleet Status */} + + + + + Fleet Status + + + +
+ + Active Vehicles + {metrics.activeVehicles} / {metrics.totalVehicles} + + +
+ +
+ + Maintenance Overdue + {metrics.maintenanceOverdue} + + +
+ +
+ + Idle / Under Maintenance + {metrics.totalVehicles - metrics.activeVehicles} + + +
+
+
+
+
+ + + + + Operating Cost Breakdown + + + + + + + ${operatingCost.toFixed(0)} + + + Total Cost + + + } + size={120} + thickness={4} + /> + + +
+ + + + + + Fuel + + {fuelPercent}% + +
+ +
+ + + + + + Maintenance + + {100 - fuelPercent}% + +
+
+
+
+
+
+ + {/* Fleet List */} + + + Fleet Vehicles + + + {(vehicles as Vehicle[]).length > 0 ? ( + + + + Registration + Plate + Model + Status + + + + {(vehicles as Vehicle[]).slice(0, 10).map(v => ( + + {v.registrationNumber} + {v.plateNumber} + + {v.manufacturer} {v.model} + + + {v.status || 'UNKNOWN'} + + + ))} + +
+ ) : ( + + + + No vehicles in fleet + + + )} +
+
+
+ ); +} From 6e81090f8c3e2f91688e6343c5e693042fdcad13 Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Tue, 30 Jun 2026 16:45:51 +0300 Subject: [PATCH 20/79] Added optimization for search result and fix sea map --- .../src/modules/search/search.service.ts | 284 ++++++++---------- .../src/modules/segments/segments.service.ts | 90 ++++++ .../portal/src/app/booking/seats/page.tsx | 24 +- .../portal/src/components/AppHeader.tsx | 4 - 4 files changed, 229 insertions(+), 173 deletions(-) diff --git a/apps/edr-passenger-api/src/modules/search/search.service.ts b/apps/edr-passenger-api/src/modules/search/search.service.ts index e14db6b0a..08687d291 100644 --- a/apps/edr-passenger-api/src/modules/search/search.service.ts +++ b/apps/edr-passenger-api/src/modules/search/search.service.ts @@ -9,6 +9,30 @@ import { Currency } from '@prisma/client'; const POINTS_TO_MINOR = 10; +// Shape returned by the heavy schedule include used throughout this service +type ScheduleWithIncludes = { + id: string; + routeId: string | null; + departureAt: Date; + arrivalAt: Date; + status: string; + train: any; + originStation: any; + destinationStation: any; + stopTimes: Array<{ stationId: string; sequence: number; plannedArrivalAt: Date | null; plannedDepartureAt: Date | null; station: any }>; + coachAssignments: Array<{ coach: { id: string; seats: any[]; coachType: { id: string; name: string; code: string; seatClasses: any[] } | null } }>; +}; + +const SCHEDULE_INCLUDE = { + train: true, + originStation: true, + destinationStation: true, + stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, + coachAssignments: { + include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + }, +} as const; + @Injectable() export class SearchService { constructor( @@ -124,7 +148,7 @@ export class SearchService { if (windowStart < now) windowStart.setTime(now.getTime()); const windowEnd = new Date(requestedDate); - windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); // exclusive upper bound + windowEnd.setDate(windowEnd.getDate() + daysAfter + 1); const totalPassengers = adultCount + (childCount ?? 0); @@ -139,30 +163,16 @@ export class SearchService { ], stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, orderBy: { departureAt: 'asc' }, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult( - schedule, - originStationId, - destinationStationId, - totalPassengers, - nationality, - ); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } private async searchSchedules( @@ -185,29 +195,18 @@ export class SearchService { departureAt: { gte: date < now ? now : date, lt: nextDay }, stopTimes: { some: { stationId: originStationId } }, }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + include: SCHEDULE_INCLUDE, }); - const results: any[] = []; - for (const schedule of schedules) { - const result = await this.buildScheduleResult(schedule, originStationId, destinationStationId, totalPassengers, nationality); - if (result) results.push(result); - } - return results; + const results = await Promise.all( + schedules.map(schedule => + this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality) + ) + ); + return results.filter(Boolean); } // ── Transit search ───────────────────────────────────────────────────────── - // Finds pairs of schedules (leg1: origin→transit, leg2: transit→destination) - // where the passenger has between MIN_CONNECTION and MAX_CONNECTION minutes - // to change trains at the transit station. private readonly MIN_CONNECTION_MINUTES = 30; private readonly MAX_CONNECTION_MINUTES = 360; @@ -219,82 +218,67 @@ export class SearchService { childCount?: number, nationality?: string, ) { - // Find all stations that can serve as transit points: - // they must be a stop after origin on some schedule AND - // a stop before destination on another schedule on the same day. const [y, m, d] = dateStr.split('-').map(Number); const dayStart = new Date(y, m - 1, d, 0, 0, 0, 0); const dayEnd = new Date(y, m - 1, d + 1, 0, 0, 0, 0); + const leg2WindowEnd = new Date(dayEnd.getTime() + this.MAX_CONNECTION_MINUTES * 60_000); const totalPassengers = adultCount + (childCount ?? 0); - // Load all schedules on this date that pass through origin - const leg1Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: dayStart, lt: dayEnd }, - stopTimes: { some: { stationId: originStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, + // Load leg1 and all potential leg2 candidates in one parallel round-trip + // instead of firing a separate DB query per transit stop. + const [leg1Schedules, allCandidates] = await Promise.all([ + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: dayEnd }, + stopTimes: { some: { stationId: originStationId } }, }, - }, - }); + include: SCHEDULE_INCLUDE, + }), + this.prisma.trainSchedule.findMany({ + where: { + status: { in: ['SCHEDULED', 'BOARDING'] }, + departureAt: { gte: dayStart, lt: leg2WindowEnd }, + }, + include: SCHEDULE_INCLUDE, + }), + ]); const results: any[] = []; - for (const leg1 of leg1Schedules) { - const originStop = leg1.stopTimes.find((s: any) => s.stationId === originStationId); + for (const leg1 of leg1Schedules as ScheduleWithIncludes[]) { + const originStop = leg1.stopTimes.find(s => s.stationId === originStationId); if (!originStop) continue; - // Every stop after origin on leg1 is a candidate transit station const candidateTransitStops = leg1.stopTimes.filter( - (s: any) => s.sequence > originStop.sequence, + s => s.sequence > originStop.sequence, ); for (const transitStop of candidateTransitStops) { - // leg1 must NOT already contain the final destination - const leg1HasDest = leg1.stopTimes.some((s: any) => s.stationId === destinationStationId); - if (leg1HasDest) continue; // direct route exists — already returned by searchSchedules + const leg1HasDest = leg1.stopTimes.some(s => s.stationId === destinationStationId); + if (leg1HasDest) continue; const transitStationId = transitStop.stationId; const leg1ArrivalAt = transitStop.plannedArrivalAt ?? transitStop.plannedDepartureAt ?? leg1.arrivalAt; - // Find leg2 schedules departing from the transit station within the connection window, - // and reaching the final destination. Search up to the next calendar day to handle - // overnight connections. const connWindowStart = new Date(new Date(leg1ArrivalAt).getTime() + this.MIN_CONNECTION_MINUTES * 60_000); const connWindowEnd = new Date(new Date(leg1ArrivalAt).getTime() + this.MAX_CONNECTION_MINUTES * 60_000); - const leg2Schedules = await this.prisma.trainSchedule.findMany({ - where: { - status: { in: ['SCHEDULED', 'BOARDING'] }, - departureAt: { gte: connWindowStart, lte: connWindowEnd }, - stopTimes: { some: { stationId: transitStationId } }, - }, - include: { - train: true, - originStation: true, - destinationStation: true, - stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } }, - coachAssignments: { - include: { coach: { include: { seats: true, coachType: { include: { seatClasses: true } } } } }, - }, - }, + // Filter from pre-loaded candidates in memory — no extra DB query + const leg2Schedules = (allCandidates as ScheduleWithIncludes[]).filter(s => { + const dep = new Date(s.departureAt).getTime(); + return dep >= connWindowStart.getTime() + && dep <= connWindowEnd.getTime() + && s.stopTimes.some(st => st.stationId === transitStationId); }); for (const leg2 of leg2Schedules) { - const leg2TransitStop = leg2.stopTimes.find((s: any) => s.stationId === transitStationId); - const leg2DestStop = leg2.stopTimes.find((s: any) => s.stationId === destinationStationId); + const leg2TransitStop = leg2.stopTimes.find(s => s.stationId === transitStationId); + const leg2DestStop = leg2.stopTimes.find(s => s.stationId === destinationStationId); if (!leg2TransitStop || !leg2DestStop) continue; if (leg2TransitStop.sequence >= leg2DestStop.sequence) continue; - // Build individual leg result objects (reuse existing per-schedule logic) const [leg1Result, leg2Result] = await Promise.all([ this.buildScheduleResult(leg1, originStationId, transitStationId, totalPassengers, nationality), this.buildScheduleResult(leg2, transitStationId, destinationStationId, totalPassengers, nationality), @@ -326,7 +310,6 @@ export class SearchService { displayCurrency, combinedMinFareMinor, combinedMinFareDisplay, - // Convenience top-level fields so round-trip filter can read them uniformly departureAt: leg1Result.departureAt, arrivalAt: leg2Result.arrivalAt, totalDurationMinutes: @@ -339,19 +322,37 @@ export class SearchService { return results; } - // Builds the same result shape as searchSchedules for a single schedule+leg, - // extracted so both direct and transit paths share identical output. private async buildScheduleResult( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, totalPassengers: number, nationality?: string, ) { - const originStop = schedule.stopTimes.find((s: any) => s.stationId === originStationId); - const destStop = schedule.stopTimes.find((s: any) => s.stationId === destinationStationId); + const originStop = schedule.stopTimes.find(s => s.stationId === originStationId); + const destStop = schedule.stopTimes.find(s => s.stationId === destinationStationId); if (!originStop || !destStop || originStop.sequence >= destStop.sequence) return null; + // Collect all valid seat IDs upfront for a single batch availability check + const allValidSeatIds = schedule.coachAssignments.flatMap(a => + a.coach.seats + .filter((s: any) => s.status !== 'BLOCKED' && s.seatNumber?.trim()) + .map((s: any) => s.id as string) + ); + + // Run availability batch and fare calculation in parallel + const [freeSeats, faresByClass] = await Promise.all([ + this.segmentsService.getFreeSeatIds( + schedule.id, + allValidSeatIds, + schedule.stopTimes, + originStop.sequence, + destStop.sequence, + ), + this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality), + ]); + + // Compute per-class availability using the pre-computed free seat set const availabilityByClass: Record = {}; for (const assignment of schedule.coachAssignments) { const seatClassNames = assignment.coach.coachType?.seatClasses?.map((sc: any) => sc.name) || ['Standard']; @@ -362,8 +363,7 @@ export class SearchService { let count = 0; for (const seat of assignment.coach.seats) { if (seat.bedPosition !== bedPosition || seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) count++; + if (freeSeats.has(seat.id)) count++; } if (count > 0) { const matchingClass = seatClassNames.find((n: string) => n.toLowerCase().includes(bedPosition)); @@ -374,15 +374,13 @@ export class SearchService { let available = 0; for (const seat of assignment.coach.seats) { if (seat.status === 'BLOCKED' || !seat.seatNumber?.trim()) continue; - const free = await this.segmentsService.isSeatFreeForLeg(schedule.id, seat.id, originStop.sequence, destStop.sequence); - if (free) available++; + if (freeSeats.has(seat.id)) available++; } for (const name of seatClassNames) availabilityByClass[name] = (availabilityByClass[name] ?? 0) + available; } } - const faresByClass = await this.calculateFaresForSegment(schedule, originStationId, destinationStationId, nationality); - const coachTypes = await this.buildCoachTypeDetails(schedule, faresByClass); + const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass); const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt; const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt; @@ -400,8 +398,8 @@ export class SearchService { durationMinutes: Math.round((new Date(legArrivalAt).getTime() - new Date(legDepartureAt).getTime()) / 60_000), status: schedule.status, stops: schedule.stopTimes - .filter((st: any) => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) - .map((st: any) => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), + .filter(st => st.sequence >= originStop.sequence && st.sequence <= destStop.sequence) + .map(st => ({ stationId: st.stationId, stationName: st.station.name, sequence: st.sequence, plannedArrivalAt: st.plannedArrivalAt, plannedDepartureAt: st.plannedDepartureAt })), availabilityByClass, hasAvailability: Object.values(availabilityByClass).some(n => n >= totalPassengers), displayCurrency, @@ -500,46 +498,31 @@ export class SearchService { } private async calculateFaresForSegment( - schedule: any, + schedule: ScheduleWithIncludes, originStationId: string, destinationStationId: string, nationality?: string, ): Promise> { const displayCurrency = resolveCurrencyFromNationality(nationality); - const seatClassIds: string[] = Array.from( - new Set( - schedule.coachAssignments - .flatMap((a: any) => a.coach.coachType?.seatClasses || []) - .map((sc: any) => sc.id) - .filter((id: any) => id) - ) - ); - - if (seatClassIds.length === 0) { - console.log(`No seat classes assigned to schedule ${schedule.id}`); - return []; + // Use seat class data already loaded in the schedule include — avoids an extra seatClass.findMany + const seatClassMap = new Map(); + for (const a of schedule.coachAssignments) { + for (const sc of (a.coach.coachType?.seatClasses ?? [])) { + if (sc.isActive && !seatClassMap.has(sc.id)) seatClassMap.set(sc.id, sc); + } } + const seatClasses = Array.from(seatClassMap.values()) + .sort((a: any, b: any) => a.baseFareMinor - b.baseFareMinor); - const seatClasses = await this.prisma.seatClass.findMany({ - where: { - isActive: true, - id: { in: seatClassIds } - }, - orderBy: { baseFareMinor: 'asc' }, - }); - - if (seatClasses.length === 0) { - console.log(`No active seat classes for schedule ${schedule.id}`); - return []; - } + if (seatClasses.length === 0) return []; if (schedule.routeId) { const results = await Promise.all( seatClasses.map(async (sc) => { try { const fare = await this.fareEngine.calculate({ - routeId: schedule.routeId, + routeId: schedule.routeId!, originStationId, destinationStationId, seatClassId: sc.id, @@ -552,8 +535,7 @@ export class SearchService { displayCurrency: fare.billingCurrency as Currency, displayAmountMinor: Math.round(fare.baseFarePerPassengerMinor * fare.exchangeRate), }; - } catch (error) { - console.error(`Failed to calculate fare for ${sc.name}:`, (error as Error).message); + } catch { return null; } }), @@ -562,36 +544,32 @@ export class SearchService { const validResults = results.filter( (r): r is { seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => r !== null, ); - if (validResults.length > 0) { - return validResults; - } + if (validResults.length > 0) return validResults; } - const originStation = await this.prisma.station.findUnique({ where: { id: originStationId } }); - const destStation = await this.prisma.station.findUnique({ where: { id: destinationStationId } }); + // Fallback: use station codes from already-loaded stopTimes when available + const originStop = schedule.stopTimes.find(st => st.stationId === originStationId); + const destStop = schedule.stopTimes.find(st => st.stationId === destinationStationId); + const originCode = originStop?.station?.code; + const destCode = destStop?.station?.code; - if (originStation && destStation) { - const segmentRoute = `${originStation.code}-${destStation.code}`; + if (originCode && destCode) { + const segmentRoute = `${originCode}-${destCode}`; const now = new Date(); const fareRules = await this.prisma.fareRule.findMany({ where: { route: segmentRoute, - seatClassId: { in: seatClassIds }, + seatClassId: { in: seatClasses.map((sc: any) => sc.id) }, validFrom: { lte: now }, - OR: [ - { validUntil: null }, - { validUntil: { gte: now } }, - ], + OR: [{ validUntil: null }, { validUntil: { gte: now } }], }, }); if (fareRules.length > 0) { - console.log(`Found ${fareRules.length} fare rules for segment ${segmentRoute}`); - const seatClassMap = Object.fromEntries(seatClasses.map(sc => [sc.id, sc.name])); const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, displayCurrency); return fareRules.map(rule => ({ - seatClassName: seatClassMap[rule.seatClassId] || 'Unknown', + seatClassName: seatClassMap.get(rule.seatClassId)?.name ?? 'Unknown', baseFareMinor: rule.baseFareMinor, displayCurrency, displayAmountMinor: Math.round(rule.baseFareMinor * exchangeRate), @@ -599,20 +577,20 @@ export class SearchService { } } - console.log(`No fares found via engine or rules for ${originStationId} to ${destinationStationId}`); return []; } - private async buildCoachTypeDetails( - schedule: any, + // buildCoachTypeDetails is pure in-memory — no async needed + private buildCoachTypeDetails( + schedule: ScheduleWithIncludes, faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>, - ): Promise; - }>> { + }> { const coachTypeMap = new Map< string, { coachType: any; classNames: Set; coachId: string } @@ -682,14 +660,6 @@ export class SearchService { return fare.baseFarePerPassengerMinor; } - private getDefaultFareForClass(_className: string): never { - throw new Error('getDefaultFareForClass should not be called — use resolveScheduleFare instead'); - } - - private defaultFare(_seatClassName: string): never { - throw new Error('defaultFare should not be called — use resolveScheduleFare instead'); - } - private selectBestFareRule( candidates: any[], scheduleId: string, diff --git a/apps/edr-passenger-api/src/modules/segments/segments.service.ts b/apps/edr-passenger-api/src/modules/segments/segments.service.ts index 2eef0302e..16b486bbe 100644 --- a/apps/edr-passenger-api/src/modules/segments/segments.service.ts +++ b/apps/edr-passenger-api/src/modules/segments/segments.service.ts @@ -146,6 +146,96 @@ export class SegmentsService { return true; } + /** + * Batch availability check for multiple seats on a single schedule. + * Replaces N×isSeatFreeForLeg calls with 2 queries total. + * Returns a Set of seat IDs that are free for [reqFrom, reqTo). + */ + async getFreeSeatIds( + scheduleId: string, + seatIds: string[], + stopTimesForSeqLookup: ReadonlyArray<{ stationId: string; sequence: number }>, + reqFrom: number, + reqTo: number, + ): Promise> { + if (seatIds.length === 0) return new Set(); + + const seqOf = (stationId: string) => + stopTimesForSeqLookup.find(s => s.stationId === stationId)?.sequence; + + const seatIdSet = new Set(seatIds); + const now = new Date(); + + const [allHolds, bookedLegs] = await Promise.all([ + this.prisma.seatHold.findMany({ + where: { scheduleId, expiresAt: { gt: now } }, + select: { seatIds: true, createdBy: true }, + }), + this.prisma.journeySegment.findMany({ + where: { + scheduleId, + seatId: { in: seatIds }, + journey: { status: { in: ['CONFIRMED', 'PENDING_PAYMENT'] } }, + }, + select: { seatId: true, journeyId: true, departureStationId: true, arrivalStationId: true }, + }), + ]); + + // Determine which seats are blocked by active holds + const holdBlockedSeats = new Set(); + for (const hold of allHolds) { + let holdFrom: number | undefined; + let holdTo: number | undefined; + try { + if (hold.createdBy) { + const meta = JSON.parse(hold.createdBy as string); + holdFrom = seqOf(meta.originStationId); + holdTo = seqOf(meta.destinationStationId); + } + } catch { /* ignore */ } + + for (const sid of hold.seatIds) { + if (!seatIdSet.has(sid)) continue; + // Conservative block if leg can't be resolved; otherwise check overlap + if (holdFrom === undefined || holdTo === undefined || (holdFrom < reqTo && reqFrom < holdTo)) { + holdBlockedSeats.add(sid); + } + } + } + + // Build full journey ranges per seat (group multi-leg journeys) + const journeyRangesBySeat = new Map>(); + for (const leg of bookedLegs) { + if (!leg.seatId || !leg.journeyId || !leg.departureStationId || !leg.arrivalStationId) continue; + const depSeq = seqOf(leg.departureStationId); + const arrSeq = seqOf(leg.arrivalStationId); + if (depSeq === undefined || arrSeq === undefined) continue; + + let rangeMap = journeyRangesBySeat.get(leg.seatId); + if (!rangeMap) { rangeMap = new Map(); journeyRangesBySeat.set(leg.seatId, rangeMap); } + + const existing = rangeMap.get(leg.journeyId); + rangeMap.set(leg.journeyId, existing + ? { from: Math.min(existing.from, depSeq), to: Math.max(existing.to, arrSeq) } + : { from: depSeq, to: arrSeq }); + } + + const freeSeats = new Set(); + for (const seatId of seatIds) { + if (holdBlockedSeats.has(seatId)) continue; + let blocked = false; + const rangeMap = journeyRangesBySeat.get(seatId); + if (rangeMap) { + for (const { from, to } of rangeMap.values()) { + if (from < reqTo && reqFrom < to) { blocked = true; break; } + } + } + if (!blocked) freeSeats.add(seatId); + } + + return freeSeats; + } + /** Legacy wrapper used by EnhancedSeatsService.getOverlappingReservations */ async getOverlappingReservations( scheduleId: string, diff --git a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx index 696dcb45b..a02f00eec 100644 --- a/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/booking/seats/page.tsx @@ -259,20 +259,14 @@ export default function SeatsPage() { })), }); - const coachesWithSeats = coaches.filter( - (c: any) => c.seats && c.seats.length > 0, - ); - - if (!currentSchedule?.selectedSeatClass) { - console.log( - "✅ No filter applied, returning all coaches:", - coachesWithSeats.length, - ); - return coachesWithSeats; - } + const coachesWithSeats = coaches.filter((c: any) => { + // Bed coaches store occupants in rooms.beds, not seats + if (c.rooms?.length > 0) return c.rooms.some((r: any) => r.beds?.length > 0); + return c.seats && c.seats.length > 0; + }); console.log( - "✅ No seat class filter - returning all coaches with seats:", + "✅ Returning all coaches with seats/beds:", coachesWithSeats.length, ); return coachesWithSeats; @@ -333,6 +327,8 @@ export default function SeatsPage() { return seatLabel && !seatLabel.startsWith("-"); }); const isBedCoach = + selectedCoachData?.isBedCoach === true || + seats.some((s: any) => s.bedPosition) || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1071,6 +1067,8 @@ export default function SeatsPage() { const allSelected = selectedSeats.length === passengers.length; const isBedCoach = + selectedCoachData?.isBedCoach === true || + selectedCoachData?.rooms?.length > 0 || selectedCoachData?.seatClass?.toLowerCase().includes("bed") || selectedCoachData?.mode?.toLowerCase().includes("bed"); @@ -1314,6 +1312,8 @@ export default function SeatsPage() { } const isBed = + coach.isBedCoach === true || + coach.rooms?.length > 0 || coach.seatClass?.toLowerCase().includes("bed") || coach.mode?.toLowerCase().includes("bed"); diff --git a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx index 3b6c19336..6422e12fa 100644 --- a/apps/edr-passenger-web/portal/src/components/AppHeader.tsx +++ b/apps/edr-passenger-web/portal/src/components/AppHeader.tsx @@ -4,7 +4,6 @@ import { Menu, X, Moon, Sun, HelpCircle } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; import { useEffect, useState } from "react"; -import { LanguageSwitcher } from "./LanguageSwitcher"; export default function AppHeader() { const [isOpen, setIsOpen] = useState(false); @@ -72,9 +71,6 @@ export default function AppHeader() { - {/* Language Switcher */} - - {/* Theme Toggler */} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index d94ed375c..61c1215ba 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -2,9 +2,11 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; import { DateInput } from '@mantine/dates'; +import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; +import { freightBrand } from '@/theme/freight-brand'; interface MaintenanceSchedule { id: string; @@ -76,12 +78,12 @@ export function MaintenancePage() { const statusColor = (status: string) => { const colors: Record = { - SCHEDULED: 'blue', - IN_PROGRESS: 'yellow', - COMPLETED: 'green', - OVERDUE: 'red', + SCHEDULED: 'edr-blue', + IN_PROGRESS: 'edr-amber-soft', + COMPLETED: 'edr-green', + OVERDUE: 'edr-red', }; - return colors[status] || 'gray'; + return colors[status] || 'edr-slate'; }; return ( @@ -90,7 +92,9 @@ export function MaintenancePage() { Schedule Maintenance - + From 69d1d3073f0724003b9f08045db747b0e551d795 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 13:59:20 +0000 Subject: [PATCH 23/79] style: standardize dashboard padding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All fleet pages now use consistent layout: - Container size: xl - Vertical padding: xl Pages updated: - FleetDashboard: size="xl" py="xl" (unchanged) - FuelPurchasePage: lg → xl - FuelStatsPage: lg → xl - MaintenancePage: Added Container wrapper (xl, xl) - FinancialReportsPage: Added Container wrapper (xl, xl) Uniform spacing across all fleet management dashboards. Co-Authored-By: Claude Haiku 4.5 --- .../backoffice/src/pages/fleet/FinancialReportsPage.tsx | 8 +++++--- .../backoffice/src/pages/fleet/FuelPurchasePage.tsx | 2 +- .../backoffice/src/pages/fleet/FuelStatsPage.tsx | 2 +- .../backoffice/src/pages/fleet/MaintenancePage.tsx | 8 +++++--- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx index f61583f8f..312d38676 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FinancialReportsPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress } from '@mantine/core'; +import { Card, Button, Stack, Group, Grid, Select, Text, ThemeIcon, RingProgress, Container } from '@mantine/core'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; import { api } from '@/services/api'; import { vehiclesService } from '@/services/vehicles.service'; @@ -91,7 +91,8 @@ export function FinancialReportsPage() { ); return ( - + + Fleet Financial Analysis @@ -244,6 +245,7 @@ export function FinancialReportsPage() { )} - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx index 574b9d1c6..21974fc37 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelPurchasePage.tsx @@ -119,7 +119,7 @@ export default function FuelPurchasePage() { const totalCost = formData.liters * formData.costPerLiter; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx index 78ed36498..f5efe8cdd 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/FuelStatsPage.tsx @@ -57,7 +57,7 @@ export default function FuelStatsPage() { : "—"; return ( - + diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx index 61c1215ba..455290647 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/MaintenancePage.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text } from '@mantine/core'; +import { Card, Button, Modal, Stack, Group, Grid, Select, TextInput, NumberInput, Table, Badge, Text, Container } from '@mantine/core'; import { DateInput } from '@mantine/dates'; import { Plus } from 'lucide-react'; import { QUERY_KEYS } from '@/constants/QUERY_KEYS'; @@ -87,7 +87,8 @@ export function MaintenancePage() { }; return ( - + + @@ -199,6 +200,7 @@ export function MaintenancePage() { - + + ); } From 2e1c720b86970942945a8b0c7b648386c839694d Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 14:08:21 +0000 Subject: [PATCH 24/79] feat: add vehicle tracking/GPS map New TrackingPage with: - Interactive map grid showing vehicle locations - Real-time GPS coordinates (mock data) - Vehicle speed & heading display - Vehicle selector dropdown - Live status indicators - All vehicles list with speed - Click-to-track functionality - Location details sidebar: * Latitude/Longitude * Current speed * Heading direction * Last update timestamp * View history button Features: - Grid-based map (no external dependencies) - Vehicle markers (color-coded selected/inactive) - SVG grid background (lat/lng lines) - Responsive layout (map + sidebar) - Mantine UI + brand colors - Mock GPS generation per vehicle Route: /dashboard/tracking Sidebar: "Track Vehicles" in Fleet Management Co-Authored-By: Claude Haiku 4.5 --- apps/edr-freight-web/backoffice/src/App.tsx | 16 + .../src/pages/fleet/TrackingPage.tsx | 353 ++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 7d8d07ab5..8c1562bc4 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -6,6 +6,7 @@ import { FileText, LayoutDashboard, LayoutGrid, + MapPin, Network, Package, PackageCheck, @@ -62,6 +63,7 @@ import FuelStatsPage from "./pages/fleet/FuelStatsPage"; import { MaintenancePage } from "./pages/fleet/MaintenancePage"; import { FinancialReportsPage } from "./pages/fleet/FinancialReportsPage"; import { FleetDashboard } from "./pages/fleet/FleetDashboard"; +import { TrackingPage } from "./pages/fleet/TrackingPage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect"; import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage"; @@ -211,6 +213,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , permission: FREIGHT_PERMS.fleet.view, }, + { + label: "Track Vehicles", + href: "/dashboard/tracking", + icon: , + permission: FREIGHT_PERMS.fleet.view, + }, { label: "Fuel Purchases", href: "/dashboard/fuel-purchases", @@ -800,6 +808,14 @@ const App = () => { } /> + + + + } + /> ({ + lat: 9.0 + Math.random() * 0.5, + lng: 38.7 + Math.random() * 0.5, + speed: Math.floor(Math.random() * 120), + heading: Math.floor(Math.random() * 360), + lastUpdate: new Date(Date.now() - Math.random() * 300000).toLocaleTimeString(), +}); + +export function TrackingPage() { + const [selectedVehicleId, setSelectedVehicleId] = useState(null); + const [mapCenter] = useState({ lat: 9.0, lng: 38.8 }); + const mapZoom = 10; + + const { data: vehicles = [] } = useQuery({ + queryKey: QUERY_KEYS.VEHICLES.list(), + queryFn: async () => { + const res = await vehiclesService.getAll({ limit: 1000 }); + return res.data || []; + }, + }); + + // Generate mock GPS data for each vehicle + const vehiclesWithGPS = useMemo(() => { + return (vehicles as Vehicle[]).map((v, idx) => ({ + ...v, + gps: generateMockGPS(idx), + })); + }, [vehicles]); + + const selectedVehicle = vehiclesWithGPS.find(v => v.id === selectedVehicleId); + const vehicleOptions = useMemo( + () => vehiclesWithGPS.map(v => ({ label: v.registrationNumber, value: v.id })), + [vehiclesWithGPS] + ); + + // Map dimensions + const mapWidth = 800; + const mapHeight = 500; + const pixelsPerLat = mapHeight / 0.6; + const pixelsPerLng = mapWidth / 0.6; + + const getMapCoords = (lat: number, lng: number) => ({ + x: ((lng - (mapCenter.lng - 0.3)) * pixelsPerLng), + y: ((mapCenter.lat + 0.3 - lat) * pixelsPerLat), + }); + + return ( + + + + + +
+ + Real-Time Vehicle Tracking + + + Monitor vehicle locations, speed, and status + +
+
+ + + {/* Map Section */} + + + + + Map View + + }> + {vehiclesWithGPS.filter(v => v.status === 'ACTIVE').length} Active + + + + + + + + {/* Grid background */} + + {/* Latitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + {/* Longitude lines */} + {[0, 1, 2, 3, 4, 5, 6].map(i => ( + + ))} + + + {/* Vehicle markers */} + {vehiclesWithGPS.map((vehicle) => { + const coords = getMapCoords(vehicle.gps.lat, vehicle.gps.lng); + const isSelected = vehicle.id === selectedVehicleId; + + return ( + setSelectedVehicleId(vehicle.id)} + title={vehicle.registrationNumber} + > + + + + + ); + })} + + {/* Map labels */} + + + 📍 Addis Ababa, Ethiopia + + + + + + + + {/* Sidebar */} + + + {/* Vehicle Selector */} + + + qtyField.onChange(e.target.value)} - onBlur={qtyField.onBlur} - type="number" - min={1} - className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" - /> - - - {fieldState.error?.message && ( - - {fieldState.error.message} + render={({ field: qtyField, fieldState }) => { + // Keep the per-line hazmat/reefer quantities within the new + // line quantity whenever it drops, so an old larger value + // can't outlive a shrink. + const setQty = (next: number) => { + const n = Math.max(1, next); + qtyField.onChange(n.toString()); + clampDependentQty(index, n); + }; + return ( +
+ + Quantity * - )} -
- )} +
+ + { + qtyField.onChange(e.target.value); + const n = Number(e.target.value); + if (!Number.isNaN(n) && n >= 1) + clampDependentQty(index, n); + }} + onBlur={qtyField.onBlur} + type="number" + min={1} + className="h-9 w-full rounded-lg border border-gray-200 bg-white px-3 text-center text-sm outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500" + /> + +
+ {fieldState.error?.message && ( + + {fieldState.error.message} + + )} + + ); + }} /> + + {/* Per-container hazmat / reefer. Each switch reveals a bounded + "how many of this line" input (1..line quantity). */} +
+ ( + } + iconBg="#FBEAE7" + iconColor="#C0392B" + title="Hazardous" + description="Some of these containers carry hazardous cargo." + checked={!!hazField.value} + onChange={(v) => { + hazField.onChange(v); + form.setValue( + `containers.${index}.hazardousQty`, + v ? defaultLineQty(index) : "0", + { shouldDirty: true, shouldValidate: true }, + ); + }} + > + ( + + hq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={hq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> + ( + } + iconBg="#E9F0F8" + iconColor="#2E5B96" + title="Refrigerated" + description="Some of these containers need reefer transport." + checked={!!reeField.value} + onChange={(v) => { + reeField.onChange(v); + form.setValue( + `containers.${index}.reeferQty`, + v ? defaultLineQty(index) : "0", + { shouldDirty: true, shouldValidate: true }, + ); + }} + > + ( + + rq.onChange( + clampToLine(e.currentTarget.value, index), + ) + } + onBlur={rq.onBlur} + error={fieldState.error?.message} + radius="md" + /> + )} + /> + + )} + /> +
))} diff --git a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx index f658b84cd..a789943c2 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/new-booking-form/step8-review.tsx @@ -204,6 +204,12 @@ export function Step8Review({ // Bulk PER_ITEM cargo is a whole item count, not tons — label it accordingly. const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM"; + const bulkUnit = isPerItem ? "items" : "tons"; + const bulkUnitAmount = (raw?: string) => { + const n = Number(raw ?? 0); + if (Number.isNaN(n)) return "0"; + return isPerItem ? String(Math.round(n)) : n.toFixed(1); + }; const totalQuantityRow = isPerItem ? { label: "Total quantity", @@ -306,7 +312,16 @@ export function Step8Review({ c.isHazardous) && "Hazardous", + values.containers.some((c) => c.isReefer) && "Refrigerated", + ] + : [ + values.isHazardous && "Hazardous", + values.isRefrigerated && "Refrigerated", + ] + ) .filter(Boolean) .join(", ") || "None" } @@ -377,12 +392,26 @@ export function Step8Review({ value={totalQuantityRow.value} /> )} + {values.cargoType === "bulk" && values.isHazardous && ( + + )} + {values.cargoType === "bulk" && values.isRefrigerated && ( + + )} {values.cargoType === "container" && values.containers.length > 0 && ( Type Qty + Hazardous + Refrigerated @@ -392,6 +421,12 @@ export function Step8Review({ {c.containerType || c.type} {c.qty} + + {c.isHazardous ? `${c.hazardousQty} of ${c.qty}` : "—"} + + + {c.isReefer ? `${c.reeferQty} of ${c.qty}` : "—"} + ))} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx deleted file mode 100644 index 66cd94ac8..000000000 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractChangesRequestedView.tsx +++ /dev/null @@ -1,309 +0,0 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - Alert, - Box, - Button, - Group, - Loader, - Paper, - Stack, - Text, -} from "@mantine/core"; -import { SmartFileInput } from "@edr/ui-common"; -import { - AlertCircle, - ArrowLeft, - CheckCircle2, - Download, - FileText, - Send, -} from "lucide-react"; - -import type { Freight } from "@edr/types"; -import useAuth from "@/hooks/useAuth"; -import { api } from "@/services/api"; -import { fileViewUrl } from "@/constants/apiConfig"; -import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; -import { BORDER, GREEN, INK } from "./contract-ui"; - -type DocumentsValue = Record; -type ContractFile = NonNullable[number]; - -/** Onboarding document setting code for the company's nationality. */ -function documentSettingCode(nationality: string | null | undefined): string { - return nationality === "foreign" - ? "company_onboarding_documents_foreign" - : "company_onboarding_documents_ethiopian"; -} - -function hasFile(value: File | File[] | null | undefined): boolean { - if (!value) return false; - return Array.isArray(value) ? value.length > 0 : true; -} - -/** One row per distinct doc code already on the contract (latest upload). */ -function dedupeLatestByCode(files: ContractFile[]): ContractFile[] { - const order: string[] = []; - const latest = new Map(); - for (const f of files) { - if (f.code === "contract" || f.code.startsWith("signature_")) continue; - if (!latest.has(f.code)) order.push(f.code); - latest.set(f.code, f); - } - return order.map((c) => latest.get(c)!); -} - -/** - * Edit-and-resubmit view for a contract staff returned with CHANGES_REQUESTED. - * The customer reviews the request, updates their documents (business license, - * TIN, national ID, passport, … — driven by the company onboarding setting), - * then resubmits. Documents already on the contract are shown as "on file". - */ -export function ContractChangesRequestedView({ - contract, -}: { - contract: Freight.IContract; -}) { - const navigate = useNavigate(); - const auth = useAuth(); - const queryClient = useQueryClient(); - - const nationality = auth.company?.company?.nationality as - | string - | null - | undefined; - const settingQuery = useQuery( - api.fileUploadSettings.getByCode.queryOptions({ - input: { code: documentSettingCode(nationality) }, - }), - ); - - const files = contract.files ?? []; - const onFile = useMemo(() => dedupeLatestByCode(files), [files]); - const existingCodes = useMemo(() => new Set(files.map((f) => f.code)), [files]); - - const [documents, setDocuments] = useState({}); - const [showErrors, setShowErrors] = useState(false); - const [error, setError] = useState(""); - - const fields = settingQuery.data?.fields ?? []; - const missingRequiredKeys = useMemo( - () => - fields - .filter((f) => f.isRequired) - .filter( - (f) => - !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]), - ) - .map((f) => f.fileKey), - [fields, existingCodes, documents], - ); - - const updateMutation = useMutation({ - mutationFn: (docs: DocumentsValue) => - api.contracts.update.call({ id: contract.id, dto: {}, documents: docs }), - }); - const submitMutation = useMutation({ - mutationFn: () => api.contracts.submit.call({ id: contract.id }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: api.contracts.get.queryKey({ id: contract.id }), - }); - queryClient.invalidateQueries({ queryKey: api.contracts.list.queryKey() }); - navigate(`/contracts/${contract.id}`); - }, - }); - - const isBusy = - settingQuery.isLoading || - updateMutation.isPending || - submitMutation.isPending; - - const resubmit = () => { - if (missingRequiredKeys.length > 0) { - setShowErrors(true); - setError("Please attach all required documents before resubmitting."); - return; - } - setShowErrors(false); - setError(""); - - const docs: DocumentsValue = {}; - for (const [k, v] of Object.entries(documents)) if (hasFile(v)) docs[k] = v; - - if (Object.keys(docs).length > 0) { - updateMutation.mutate(docs, { onSuccess: () => submitMutation.mutate() }); - } else { - submitMutation.mutate(); - } - }; - - const fieldErrors = showErrors - ? Object.fromEntries(missingRequiredKeys.map((k) => [k, "Required"])) - : {}; - - return ( - - - - -
- - {contract.reference} - - - Changes requested — update your documents and resubmit. - -
-
- - } - title="A reviewer asked for changes" - > - Update the documents below — replace anything that needs to change and - attach any required document that isn't on file yet — then resubmit the - contract for review. - - - - {onFile.length > 0 && ( - - - Already on file - - {onFile.map((file) => ( - - - - - - - {labelForDocCode(file.code)} - - - {file.name} - - - - - - - On file - - - - - - ))} - - )} - - - Update documents - - - Replace any document you need to change. Documents marked required - must be on file before you can resubmit. - - - {settingQuery.isLoading ? ( - - - - ) : settingQuery.data ? ( - - ) : ( - - No document requirements are configured for your account. You can - resubmit using the documents already on file. - - )} - - {error && ( - } - mt="md" - > - {error} - - )} - {(updateMutation.isError || submitMutation.isError) && ( - } - mt="md" - > - Couldn't resubmit. Please try again. - - )} - - - -
-
- ); -} - -export default ContractChangesRequestedView; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index bd7e1bafe..03be22005 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -1,5 +1,10 @@ import { useEffect, useMemo, useState } from "react"; -import { useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { + Navigate, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Badge, @@ -23,6 +28,7 @@ import { CheckCircle2, Download, Eye, + FileBadge, FileSignature, FileText, Flame, @@ -46,7 +52,6 @@ import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; -import { ContractChangesRequestedView } from "./ContractChangesRequestedView"; import { formatRateUnit } from "./new-contract-form/unit-rates"; import { BORDER, @@ -72,14 +77,19 @@ const CLEARANCE_UPLOAD_STATUSES = [ type ContractFile = NonNullable[number]; +// Business-license document codes — surfaced as their own section so they stand +// out from the rest of the onboarding/profile set. +const BUSINESS_LICENSE_DOC_CODES = new Set([ + "business_license", + "commercial_license", + "investment_license", +]); + // Onboarding / company-profile document codes seeded in file-upload-settings. // These get attached to the contract at creation and belong under "Profile // documents" rather than the clearance set. const PROFILE_DOC_CODES = new Set([ "tin_certificate", - "commercial_license", - "business_license", - "investment_license", "national_id", "national_id_passport", "passport", @@ -98,18 +108,21 @@ interface DocGroup { * groups are dropped so the tab only renders sections that have files. */ function groupContractDocuments(files: ContractFile[]): DocGroup[] { + const businessLicense: ContractFile[] = []; const profile: ContractFile[] = []; const clearance: ContractFile[] = []; for (const f of files) { // The generated contract PDF lives in the contract list / home rows, not // here. Signature images are baked into that PDF — skip both. if (f.code === "contract" || f.code.startsWith("signature_")) continue; + else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f); else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f); else clearance.push(f); } return [ - { key: "profile", title: "Profile documents", files: profile }, { key: "clearance", title: "Clearance documents", files: clearance }, + { key: "businessLicense", title: "Business license", files: businessLicense }, + { key: "profile", title: "Profile documents", files: profile }, ].filter((g) => g.files.length > 0); } @@ -194,10 +207,11 @@ export default function ContractDetailPage() { ); } - // Staff returned the contract for changes — show the edit-and-resubmit view - // (update documents → resubmit) instead of the read-only detail. + // Staff returned the contract for changes — send the customer to the full edit + // wizard (edit any term + replace documents → resubmit) rather than the + // read-only detail. if (contract.status === "CHANGES_REQUESTED") { - return ; + return ; } const isContainer = contract.freightType === "CONTAINER"; @@ -980,12 +994,14 @@ const KEY_FACT_ACCENT: Record = { // Per-section accent + icon for the Documents tab groups. const DOC_GROUP_ACCENT: Record = { - profile: "#2B6CB0", clearance: "#C77F09", + businessLicense: "#0A6F4D", + profile: "#2B6CB0", }; const DOC_GROUP_ICON: Record = { - profile: FileText, clearance: Upload, + businessLicense: FileBadge, + profile: FileText, }; /** diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 5a623e296..0456bca3e 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -28,9 +28,14 @@ import { Upload, XCircle, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm } from "react-hook-form"; -import { Navigate, useLocation, useNavigate } from "react-router-dom"; +import { + Navigate, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; import useAuth from "@/hooks/useAuth"; import { CONTRACT_STEPS, @@ -47,6 +52,12 @@ import { operationToProfileType, operationToTradeDirection, } from "./new-contract-form/helpers"; +import { contractToFormValues } from "./new-contract-form/contractToForm"; +import { + ContractDocsEditor, + documentSettingCode, + missingRequiredDocKeys, +} from "./new-contract-form/ContractDocsEditor"; import { PROFILE_TYPE_LABELS } from "@/constants/profileMode"; import type { ProfileTypeValue } from "@/services/companies.service"; import { StepIndicator } from "./new-contract-form/StepIndicator"; @@ -55,7 +66,6 @@ import { useContractDraft, } from "./new-contract-form/useContractDraft"; import { - Step0OperationType, Step1ContractType, Step2ServiceType, Step3CargoScope, @@ -67,14 +77,42 @@ import { formatRateUnit } from "./new-contract-form/unit-rates"; type PriceModalMode = "submit" | "draft"; -export default function NewContractPage() { +/** + * The contract wizard, used both to create a new contract and — in `edit` mode — + * to edit & resubmit a contract staff returned with CHANGES_REQUESTED. Edit mode + * hydrates the form from the saved contract, lets the customer change any term + * and replace documents, then runs the same update → price → submit flow. + */ +export default function NewContractPage({ + mode = "create", +}: { + mode?: "create" | "edit"; +}) { const navigate = useNavigate(); const queryClient = useQueryClient(); + const { id: editId } = useParams<{ id: string }>(); + const isEdit = mode === "edit" && Boolean(editId); const [step, setStep] = useState(0); const auth = useAuth(); const { data: referenceData, isLoading: refDataLoading } = useQuery( api.bookings.referenceData.queryOptions(), ); + const { data: editContract } = useQuery({ + ...api.contracts.get.queryOptions({ input: { id: editId ?? "" } }), + enabled: isEdit, + }); + // Onboarding document requirements — used in edit mode to block resubmit until + // every required document is on file (existing or freshly attached). + const editDocSettingQuery = useQuery({ + ...api.fileUploadSettings.getByCode.queryOptions({ + input: { + code: documentSettingCode( + auth.company?.company?.nationality as string | null | undefined, + ), + }, + }), + enabled: isEdit, + }); // Contract creation is gated on profile approval, same as bookings. if (!auth.isPending && auth.company && !auth.canBook) { @@ -105,7 +143,17 @@ export default function NewContractPage() { const [pricingData, setPricingData] = useState(null); - const [priceContractId, setPriceContractId] = useState(null); + // In edit mode the contract already exists, so seed its id — this makes + // persistAndPriceMutation take the UPDATE branch instead of creating anew. + const [priceContractId, setPriceContractId] = useState( + isEdit ? (editId ?? null) : null, + ); + // Documents freshly attached on the review step (edit mode only). Merged into + // the form's `documents` map before the contract is updated. + const [editDocuments, setEditDocuments] = useState< + Record + >({}); + const [showDocErrors, setShowDocErrors] = useState(false); const [priceModalMode, setPriceModalMode] = useState( null, ); @@ -205,7 +253,30 @@ export default function NewContractPage() { const location = useLocation(); const startFresh = (location.state as { fresh?: boolean } | null)?.fresh === true; - useContractDraft({ form, step, setStep, fresh: startFresh }); + useContractDraft({ + form, + step, + setStep, + fresh: startFresh, + enabled: !isEdit, + }); + + // Edit mode: hydrate the form from the saved contract once both the contract + // and the reference data (needed to rebuild the cargo-type path) have loaded. + const hydratedRef = useRef(false); + useEffect(() => { + if (!isEdit || hydratedRef.current) return; + if (!editContract || !referenceData) return; + hydratedRef.current = true; + const forwarderProfile = + (auth.company?.company?.companyProfiles ?? []).find( + (p) => p.id === editContract.companyProfileId, + )?.type === "freight_forwarder"; + form.reset( + contractToFormValues(editContract, referenceData, forwarderProfile), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEdit, editContract, referenceData]); const originYard = form.watch("originYard"); const destinationYard = form.watch("destinationYard"); @@ -239,19 +310,43 @@ export default function NewContractPage() { ); }, [originYard, destinationYard, operationType, referenceData]); + // type -> status ("active" = approved | "pending" = awaiting staff approval) + // for the company's operational profiles. Drives both the select-time gate and + // the per-option dropdown badges. + const profileStatusByType = useMemo(() => { + const m = new Map(); + for (const p of auth.company?.company?.companyProfiles ?? []) + m.set(p.type, p.status); + return m; + }, [auth.company]); const profileTypes = useMemo( - () => (auth.company?.company?.companyProfiles ?? []).map((p) => p.type), - [auth.company], + () => [...profileStatusByType.keys()], + [profileStatusByType], ); // All operation types are always selectable. Picking one the company has no // profile for prompts a license upload that creates the profile on the fly - // (mirrors the header "Add service" flow). + // (mirrors the header "Add service" flow); picking one backed by a not-yet- + // approved profile is blocked with an "awaiting approval" notice. const allowedOperations = useMemo( () => [...OPERATION_TYPES], [], ); + // Approval state of the profile each operation maps to — used for the dropdown + // badges. Intercity rides any customer profile, so always "approved". + const operationStatus = useMemo( + () => + (op: OperationType): "approved" | "pending" | "missing" => { + if (op === "intercity") return "approved"; + const target = operationToProfileType(op, profileTypes); + const status = profileStatusByType.get(target); + if (!status) return "missing"; + return status === "active" ? "approved" : "pending"; + }, + [profileStatusByType, profileTypes], + ); + // Create-profile modal state (license upload → createProfileAndSwitch). const [createTarget, setCreateTarget] = useState( null, @@ -260,6 +355,13 @@ export default function NewContractPage() { useState(null); const [licenseFiles, setLicenseFiles] = useState([]); const [createError, setCreateError] = useState(null); + // After a license is uploaded the new profile comes back "pending", so the + // create-profile modal switches to an "awaiting approval" success state. + const [licenseSubmitted, setLicenseSubmitted] = useState(false); + // Set when the user picks an operation whose profile exists but isn't approved + // yet — drives the "awaiting approval" block modal. + const [pendingApprovalProfile, setPendingApprovalProfile] = + useState(null); const createProfileMutation = useMutation({ mutationFn: async ({ @@ -275,11 +377,13 @@ export default function NewContractPage() { } }, onSuccess: () => { - // The operation was already set on the form when the modal opened. - setCreateTarget(null); - setPendingOperation(null); + // The new profile comes back "pending", so the user can't proceed under + // this operation yet: revert the select and switch the modal to its + // "awaiting approval" success state (kept open until the user dismisses). + form.setValue("operationType", undefined as never, { shouldDirty: true }); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(true); }, onError: (err) => { setCreateError( @@ -292,15 +396,25 @@ export default function NewContractPage() { // Intercity (domestic) runs on any existing customer profile — no switch. if (op === "intercity") return; const target = operationToProfileType(op, profileTypes) as ProfileTypeValue; - const hasProfile = profileTypes.includes(target); - if (!hasProfile) { - // No matching profile — collect a license and create one. + const status = profileStatusByType.get(target); + + if (!status) { + // Case 3 — no matching profile: collect a license and create one. setPendingOperation(op); setCreateTarget(target); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(false); return; } + if (status !== "active") { + // Case 2 — profile exists but isn't approved yet: block + revert the + // select so an unusable operation is never left chosen. + setPendingApprovalProfile(target); + form.setValue("operationType", undefined as never, { shouldDirty: true }); + return; + } + // Case 1 — approved: proceed, switching the active profile if needed. if (auth.activeProfileType !== target) { void auth.switchMode(target as never); } @@ -324,6 +438,17 @@ export default function NewContractPage() { setPendingOperation(null); setLicenseFiles([]); setCreateError(null); + setLicenseSubmitted(false); + }; + + // Dismiss the post-submit "awaiting approval" success state. The select was + // already reverted on success — just close and reset the modal. + const handleCreateProfileDone = () => { + setCreateTarget(null); + setPendingOperation(null); + setLicenseFiles([]); + setCreateError(null); + setLicenseSubmitted(false); }; const createTargetLabel = createTarget @@ -466,6 +591,25 @@ export default function NewContractPage() { const handleSubmitContract = form.handleSubmit((data) => { try { + // Edit mode: all required documents must be on file (already uploaded or + // freshly attached) before resubmitting, and freshly attached files are + // merged into the form's documents map so the update sends them. + if (isEdit && editContract) { + const missing = missingRequiredDocKeys( + editDocSettingQuery.data, + editContract, + editDocuments, + ); + if (missing.length > 0) { + setShowDocErrors(true); + return; + } + setShowDocErrors(false); + form.setValue("documents", { + ...(form.getValues("documents") ?? {}), + ...editDocuments, + }); + } const apiPayload = buildApiPayload(data); persistAndPriceMutation.mutate({ payload: apiPayload, @@ -511,20 +655,23 @@ export default function NewContractPage() { > - New Contract + {isEdit ? "Edit Contract" : "New Contract"} - Define your freight contract — scope, routes, and unit rates. Book - shipments against it after signing. + {isEdit + ? "Update your contract details and documents, then resubmit it for EDR staff review." + : "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."} @@ -535,6 +682,20 @@ export default function NewContractPage() { onSubmit={(e) => e.preventDefault()} > + {isEdit && ( + } + title="A reviewer asked for changes" + mb="lg" + > + Update any contract detail or document that needs to change, then + resubmit the contract for review. + + )} + @@ -565,12 +726,13 @@ export default function NewContractPage() { description="Define the operation, contract kind, and the service this contract is for." /> - - @@ -625,6 +787,27 @@ export default function NewContractPage() { persistAndPriceMutation.isPending && persistAndPriceMutation.variables?.mode === "submit" } + isEdit={isEdit} + documentsEditor={ + isEdit && editContract ? ( + [k, "Required"]), + ) + : {} + } + /> + ) : undefined + } /> )} @@ -891,47 +1074,116 @@ export default function NewContractPage() { {/* Create-profile modal — opens when the chosen operation type has no matching company profile yet. Collects a license, creates the profile, - and switches to it (mirrors the header "Add service" flow). */} + then shows an "awaiting approval" state (the new profile is pending). */} { - if (!createProfileMutation.isPending) handleCreateProfileCancel(); + if (createProfileMutation.isPending) return; + if (licenseSubmitted) handleCreateProfileDone(); + else handleCreateProfileCancel(); }} - title={`Set up your ${createTargetLabel} profile`} + title={ + licenseSubmitted + ? "Awaiting approval" + : `Set up your ${createTargetLabel} profile` + } + centered + radius="lg" + > + {licenseSubmitted ? ( + + + + + + + License submitted. Your {createTargetLabel.toLowerCase()} profile + is now awaiting staff approval. We'll notify you once it's + approved — then you can create this contract as{" "} + {createTargetLabel.toLowerCase()}. + + + + + + + ) : ( + + + You don't have a {createTargetLabel.toLowerCase()} profile yet. Add + your business license to create one. It goes to staff for approval + before you can use it. + + } + placeholder="Select license file(s)" + value={licenseFiles} + onChange={(files) => setLicenseFiles(files ?? [])} + error={createError ?? undefined} + /> + + + + + + )} + + + {/* Awaiting-approval modal — the chosen operation maps to a profile that + exists but isn't approved yet. The select was already reverted. */} + setPendingApprovalProfile(null)} + title="Awaiting approval" centered radius="lg" > - You don't have a {createTargetLabel.toLowerCase()} profile yet. Add - your business license to create one and continue this contract as{" "} - {createTargetLabel.toLowerCase()}. + Your{" "} + {pendingApprovalProfile + ? (PROFILE_TYPE_LABELS[pendingApprovalProfile] ?? + pendingApprovalProfile) + : ""}{" "} + profile was submitted and is under staff review. You can start a + contract under it once it's approved. - } - placeholder="Select license file(s)" - value={licenseFiles} - onChange={(files) => setLicenseFiles(files ?? [])} - error={createError ?? undefined} - /> - diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx new file mode 100644 index 000000000..e491cd88a --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/ContractDocsEditor.tsx @@ -0,0 +1,192 @@ +import { useMemo } from "react"; +import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core"; +import { SmartFileInput } from "@edr/ui-common"; +import { CheckCircle2, Download, FileText } from "lucide-react"; + +import type { Freight } from "@edr/types"; +import { useQuery } from "@tanstack/react-query"; +import useAuth from "@/hooks/useAuth"; +import { api } from "@/services/api"; +import { fileViewUrl } from "@/constants/apiConfig"; +import { labelForDocCode } from "@/pages/bookings/resubmit/resubmitDocs"; +import { BORDER, GREEN, INK } from "../contract-ui"; + +type DocumentsValue = Record; +type ContractFile = NonNullable[number]; + +/** Onboarding document setting code for the company's nationality. */ +function documentSettingCode(nationality: string | null | undefined): string { + return nationality === "foreign" + ? "company_onboarding_documents_foreign" + : "company_onboarding_documents_ethiopian"; +} + +function hasFile(value: File | File[] | null | undefined): boolean { + if (!value) return false; + return Array.isArray(value) ? value.length > 0 : true; +} + +/** One row per distinct doc code already on the contract (latest upload). */ +function dedupeLatestByCode(files: ContractFile[]): ContractFile[] { + const order: string[] = []; + const latest = new Map(); + for (const f of files) { + if (f.code === "contract" || f.code.startsWith("signature_")) continue; + if (!latest.has(f.code)) order.push(f.code); + latest.set(f.code, f); + } + return order.map((c) => latest.get(c)!); +} + +/** + * Document replace/upload block for an existing contract. Lists the documents + * already on file (latest upload per code) and renders the onboarding-driven + * `SmartFileInput` so the customer can replace any of them or attach any + * required document that isn't on file yet. Used on the contract wizard's review + * step when editing a CHANGES_REQUESTED contract. + * + * The keys returned through `onChange` are doc-setting field keys; the parent + * wizard merges them into the form's `documents` map, which is uploaded with the + * contract update. + */ +export function ContractDocsEditor({ + contract, + value, + onChange, + errors, +}: { + contract: Freight.IContract; + value: DocumentsValue; + onChange: (next: DocumentsValue) => void; + errors?: Record; +}) { + const auth = useAuth(); + + const nationality = auth.company?.company?.nationality as + | string + | null + | undefined; + const settingQuery = useQuery( + api.fileUploadSettings.getByCode.queryOptions({ + input: { code: documentSettingCode(nationality) }, + }), + ); + + const files = contract.files ?? []; + const onFile = useMemo(() => dedupeLatestByCode(files), [files]); + + return ( + + {onFile.length > 0 && ( + + + Already on file + + {onFile.map((file) => ( + + + + + + + {labelForDocCode(file.code)} + + + {file.name} + + + + + + + On file + + + + + + ))} + + )} + + + + Update documents + + + Replace any document you need to change. Documents marked required must + be on file before you can resubmit. + + + {settingQuery.isLoading ? ( + + + + ) : settingQuery.data ? ( + + ) : ( + + No document requirements are configured for your account. You can + resubmit using the documents already on file. + + )} + + + ); +} + +/** + * Keys of the documents the onboarding setting marks required that are neither + * already on the contract nor freshly attached in `documents`. Empty means the + * customer may resubmit. + */ +export function missingRequiredDocKeys( + setting: Freight.IFileUploadSetting | undefined, + contract: Freight.IContract, + documents: DocumentsValue, +): string[] { + const fields = setting?.fields ?? []; + const existingCodes = new Set((contract.files ?? []).map((f) => f.code)); + return fields + .filter((f) => f.isRequired) + .filter( + (f) => !existingCodes.has(f.fileKey) && !hasFile(documents[f.fileKey]), + ) + .map((f) => f.fileKey); +} + +export { documentSettingCode, hasFile, dedupeLatestByCode }; +export default ContractDocsEditor; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts new file mode 100644 index 000000000..f3d66de3d --- /dev/null +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -0,0 +1,147 @@ +import type { Freight } from "@edr/types"; +import type { ContractFormInputValues, OperationType } from "./schema"; +import { initialContractFormValues } from "./schema"; + +/** + * Trade direction → base operation type. The freight-forwarder variants + * (import_ff / export_ff) are selected by the caller when the contract is + * stamped to a freight_forwarder profile (see `isForwarderProfile`). + */ +function directionToOperation( + direction: Freight.ContractTradeDirection, + isForwarderProfile: boolean, +): OperationType { + if (direction === "IMPORT") return isForwarderProfile ? "import_ff" : "import"; + if (direction === "EXPORT") return isForwarderProfile ? "export_ff" : "export"; + return "intercity"; +} + +/** ISO timestamp → `YYYY-MM-DD` for the native date input. "" when absent. */ +function isoToDateInput(iso: string | null | undefined): string { + if (!iso) return ""; + return iso.slice(0, 10); +} + +/** + * Rebuild the cargo-type path `[groupId, commodityId]` from a flat cargoTypeId + * by locating which reference cargo-type group owns it. + */ +function cargoTypePathFor( + cargoTypeId: string | null | undefined, + referenceData: Freight.BookingReferenceData | undefined, +): string[] { + if (!cargoTypeId || !referenceData?.cargo_type) return []; + for (const group of referenceData.cargo_type) { + if (group.children?.some((c) => c.id === cargoTypeId)) { + return [group.id, cargoTypeId]; + } + } + return []; +} + +/** + * Inverse of `buildApiPayload` in NewContractPage: hydrate the contract wizard + * form from a saved contract so a customer can edit a CHANGES_REQUESTED (or + * DRAFT) contract in the same UI used to create it. Missing fields fall back to + * `initialContractFormValues`. Files are not mapped — the wizard's `documents` + * map only holds freshly attached replacements; existing files are listed + * separately by `ContractDocsEditor`. + */ +export function contractToFormValues( + contract: Freight.IContract, + referenceData: Freight.BookingReferenceData | undefined, + isForwarderProfile: boolean, +): Partial { + const isContainer = contract.freightType === "CONTAINER"; + const isGeneral = contract.contractKind === "GENERAL"; + + const routes = [...(contract.routes ?? [])].sort( + (a, b) => a.sortOrder - b.sortOrder, + ); + const primaryRoute = routes[0]; + const extraRoutes = isGeneral + ? routes.slice(1).map((r) => ({ + originYard: r.originYardId, + destinationYard: r.destinationYardId, + })) + : []; + + const scope = contract.cargoScope ?? []; + + // Container scope: one row per enabled size, with per-size caps for GENERAL. + const enabledContainerSizes = isContainer + ? scope + .map((s) => s.containerSize) + .filter((s): s is string => Boolean(s)) + : []; + const containerSizeCaps: Record = {}; + if (isContainer && isGeneral) { + for (const s of scope) { + if (s.containerSize && s.quantityCap != null) { + containerSizeCaps[s.containerSize] = s.quantityCap; + } + } + } + + // Bulk scope: a single commodity row. + const bulkRow = !isContainer ? scope[0] : undefined; + const cargoTypePath = bulkRow + ? cargoTypePathFor(bulkRow.cargoTypeId, referenceData) + : []; + + const hasFirstMile = Boolean(contract.firstMilePickupAddress); + const hasLastMile = Boolean(contract.lastMileDeliveryAddress); + + return { + ...initialContractFormValues, + + operationType: directionToOperation( + contract.tradeDirection, + isForwarderProfile, + ), + contractKind: isGeneral ? "general_contract" : "one_time", + contractType: contract.renewalOfId ? "renewal" : "new", + previousContractRef: contract.renewalOfId ?? "", + + serviceTypeId: contract.serviceTypeId, + paymentCurrency: + contract.paymentCurrency === "ETB" ? "ETB" : "USD", + + firstMile: { + enabled: hasFirstMile, + pickUpAddress: contract.firstMilePickupAddress ?? "", + exactLocation: "", + lat: contract.firstMilePickupLat ?? null, + lng: contract.firstMilePickupLng ?? null, + }, + lastMile: { + enabled: hasLastMile, + deliveryAddress: contract.lastMileDeliveryAddress ?? "", + exactLocation: "", + lat: contract.lastMileDeliveryLat ?? null, + lng: contract.lastMileDeliveryLng ?? null, + }, + customsClearingEnabled: contract.customsClearingEnabled, + customsClearingAgent: contract.customsClearingAgent ?? "", + + cargoType: isContainer ? "container" : "bulk", + enabledContainerSizes: + enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"], + containerSizeCaps, + cargoTypePath, + cargoFreeText: bulkRow?.cargoFreeText ?? "", + bulkQuantityCap: + isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0, + isHazardous: contract.isHazardous, + isRefrigerated: contract.isReefer, + + originYard: primaryRoute?.originYardId ?? "", + destinationYard: primaryRoute?.destinationYardId ?? "", + extraRoutes, + estimatedShipmentDate: isoToDateInput(contract.estimatedShipmentDate), + + documents: {}, + }; +} + +export default contractToFormValues; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index 7ffb38e97..c86e17fc5 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -3,11 +3,13 @@ import type { Freight } from "@edr/types"; import { useQuery } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { Controller, type UseFormReturn } from "react-hook-form"; -import { Select, Stack, Text } from "@mantine/core"; +import { Badge, Group, Select, Stack, Text } from "@mantine/core"; import { CONTRACT_KIND_OPTIONS, ContractFormInputValues, + OPERATION_TYPE_OPTIONS, type ContractFormValues, + type OperationType, } from "./schema"; import { AlertBox, AsyncComboboxField, fieldStyles } from "./shared"; @@ -31,11 +33,22 @@ interface PreviousContractOption { export function Step1ContractType({ form, referenceData, + allowedOperations, + onOperationSelect, + operationStatus, }: { form: ContractForm; referenceData?: Freight.BookingReferenceData; + allowedOperations: OperationType[]; + onOperationSelect?: (op: OperationType) => void; + /** Approval state of the profile each operation maps to (for the badges). */ + operationStatus?: (op: OperationType) => "approved" | "pending" | "missing"; }) { const contractType = form.watch("contractType"); + + const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => + allowedOperations.includes(opt.value), + ).map((opt) => ({ value: opt.value, label: opt.label })); const previousContractRef = form.watch("previousContractRef"); const [searchQuery, setSearchQuery] = useState(""); @@ -167,38 +180,81 @@ export function Step1ContractType({ return ( -
+ {allowedOperations.length === 0 && ( + + Your company has no operational profile yet. Complete onboarding to + register as an importer, exporter, or freight forwarder. + + )} + + {/* Operation Type + Contract Kind + New/Renewal on one wrapping row. Each + field keeps a sensible min width and flexes to fill / wrap below on + narrow screens. */} +
+ ( + ({ - value: o.value, - label: o.label, - }))} - value={field.value ?? "one_time"} - onChange={(v) => field.onChange(v ?? "one_time")} - allowDeselect={false} - radius={10} - checkIconPosition="right" - comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }} - styles={fieldStyles} - /> - {selected && ( - - {selected.description} - - )} -
- ); - }} + render={({ field }) => ( + ) : ( -
+
{services.map((s) => { const selected = s.id === value; return ( @@ -147,8 +147,8 @@ function ServiceTypeSelector({ position: "relative", textAlign: "left", cursor: "pointer", - padding: 16, - borderRadius: 16, + padding: 12, + borderRadius: 14, border: `1.5px solid ${selected ? GREEN : error ? "#F0B4B4" : BORDER}`, background: selected ? GREEN_SOFT : "#fff", boxShadow: selected @@ -162,10 +162,10 @@ function ServiceTypeSelector({ - {selected ? : null} + {selected ? : null} - + - + - - + + {s.serviceName} - {s.description ? ( - - {s.description} - - ) : null} - {serviceFeatures(s).map((f) => ( Trucking & customs options + {/* First mile, last mile and customs sit side by side on one wrapping + row; each keeps a min width and stacks below on narrow screens. */} +
{includesFirstMile && ( )} +
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index 7fbf45d71..a5fbbc989 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -188,6 +188,8 @@ export function Step8Review({ onSubmit, saveDraftPending = false, submitPending = false, + documentsEditor, + isEdit = false, }: { form: ContractForm; /** Retained for caller compatibility; the review page is read-only. */ @@ -201,6 +203,14 @@ export function Step8Review({ onSubmit?: () => void; saveDraftPending?: boolean; submitPending?: boolean; + /** + * Document replace/upload block, shown only when editing an existing contract + * (resubmit after CHANGES_REQUESTED). Omitted for fresh creation, where docs + * come from the company profile automatically. + */ + documentsEditor?: React.ReactNode; + /** Editing an existing contract (resubmit) rather than creating a new one. */ + isEdit?: boolean; }) { const values = form.watch(); const serviceType = referenceData?.service.find( @@ -429,6 +439,17 @@ export function Step8Review({ /> )} + {documentsEditor && ( + + {documentsEditor} + + )} + - {pricing - ? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review." - : "Ready to submit. You'll review the unit-rate quotation before final submission."} + {isEdit + ? pricing + ? "Review your updated unit-rate quotation. Approve to resubmit the contract for EDR staff review." + : "Ready to resubmit. You'll review the unit-rate quotation before final resubmission." + : pricing + ? "Review your unit-rate quotation. Approve to submit the contract for EDR staff review." + : "Ready to submit. You'll review the unit-rate quotation before final submission."} - + {!isEdit && ( + + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx index e40b33a4f..0226ccadd 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/steps.tsx @@ -1,4 +1,3 @@ -export { Step0OperationType } from "./step0-operation-type"; export { Step1ContractType } from "./step1-contract-type"; export { Step2ServiceType } from "./step2-service-type"; export { Step3CargoScope } from "./step3-cargo-scope"; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts index 7c44b7382..a94e77168 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/useContractDraft.ts @@ -58,14 +58,22 @@ export function useContractDraft({ step, setStep, fresh, + enabled = true, }: { form: ContractForm; step: number; setStep: (step: number) => void; fresh: boolean; + /** + * When false the draft is neither restored nor persisted — used in edit mode, + * where the server contract is the source of truth and the localStorage key is + * shared with the create flow. + */ + enabled?: boolean; }): { clearDraft: () => void } { const restoredRef = useRef(false); useEffect(() => { + if (!enabled) return; if (restoredRef.current) return; restoredRef.current = true; @@ -107,6 +115,7 @@ export function useContractDraft({ }; useEffect(() => { + if (!enabled) return; if (!restoredRef.current) return; const sub = form.watch(() => { if (timerRef.current) clearTimeout(timerRef.current); @@ -117,9 +126,10 @@ export function useContractDraft({ if (timerRef.current) clearTimeout(timerRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [form]); + }, [form, enabled]); useEffect(() => { + if (!enabled) return; if (!restoredRef.current) return; write(); // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/packages/types/src/freight/index.ts b/packages/types/src/freight/index.ts index dbfcf655d..100b1a20a 100644 --- a/packages/types/src/freight/index.ts +++ b/packages/types/src/freight/index.ts @@ -432,6 +432,10 @@ export interface IBooking extends BaseEntity { isHazardous: boolean; isRefrigerated: boolean; + /** Bulk-only hazardous amount in the cargo's unit (tons/items); 0 otherwise. */ + bulkHazardousQuantity?: number; + /** Bulk-only refrigerated amount in the cargo's unit (tons/items); 0 otherwise. */ + bulkReeferQuantity?: number; tradeDirection: "IMPORT" | "EXPORT"; paymentCurrency: string; @@ -717,6 +721,10 @@ export interface CreateBookingContainerDto { containerTypeId: string; quantity: number; vgmPerUnitTons: number; + /** How many of this line's containers are hazardous (0..quantity). */ + hazardousQuantity?: number; + /** How many of this line's containers are refrigerated (0..quantity). */ + reeferQuantity?: number; } /** A contracted route+quantity line for a GENERAL contract. */ @@ -768,6 +776,10 @@ export interface CreateBookingDto { isHazardous?: boolean | undefined; /** Booking-level refrigerated flag (bulk freight only; containers derive reefer from the container type). */ isReefer?: boolean | undefined; + /** Bulk-only: hazardous amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */ + bulkHazardousQuantity?: number | undefined; + /** Bulk-only: refrigerated amount in the cargo's unit (tons or items), <= cargoTotalWeightVgm. */ + bulkReeferQuantity?: number | undefined; paymentCurrency: string; pnrCode?: string | undefined; startDate?: string | undefined; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd8c81c83..203eb4d71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -326,6 +326,9 @@ importers: '@mantine/core': specifier: ^9.3.0 version: 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': + specifier: ^9.3.0 + version: 9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': specifier: ^9.3.0 version: 9.3.0(react@19.2.6) @@ -12495,10 +12498,19 @@ snapshots: transitivePeerDependencies: - '@types/react' - '@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@mantine/dates@7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) + clsx: 2.1.1 + dayjs: 1.11.21 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@mantine/dates@9.3.2(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@mantine/core': 9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.0(react@19.2.6) clsx: 2.1.1 dayjs: 1.11.21 react: 19.2.6 @@ -15399,7 +15411,7 @@ snapshots: '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -20233,7 +20245,7 @@ snapshots: mantine-react-table@2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': 7.17.8(@mantine/core@9.3.0(@mantine/hooks@9.3.0(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.0(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@mantine/hooks': 7.17.8(react@19.2.6) '@tabler/icons-react': 3.44.0(react@19.2.6) '@tanstack/match-sorter-utils': 8.19.4 From 33d3aa9294aa01847d4b0fdba5f755eef6039747 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 30 Jun 2026 15:18:30 +0000 Subject: [PATCH 33/79] Enhance rule engine form to support currency formatting and suffix display --- .../src/components/ruleEngine/RuleEngineFormDialog.tsx | 8 ++++++++ .../src/components/ruleEngine/ruleEngineFormat.tsx | 9 +++++++++ .../backoffice/src/pages/ruleEngine/config/resources.ts | 7 +++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index f60ac3045..6ae71cdc0 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -307,6 +307,14 @@ const RuleEngineFormDialog = ({ size="md" radius="md" styles={inputStyles} + rightSection={ + field.suffix ? ( + + {field.suffix} + + ) : undefined + } + rightSectionWidth={field.suffix ? 52 : undefined} /> ); }; diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx index 5251141a4..5ec7597b9 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/ruleEngineFormat.tsx @@ -94,6 +94,15 @@ export const formatCell = (value: unknown, format?: ColumnFormat): ReactNode => return {Number.isNaN(num) ? String(value) : num.toLocaleString()}; } + if (format === "currency") { + const num = Number(value); + return ( + + {Number.isNaN(num) ? String(value) : `USD ${num.toLocaleString()}`} + + ); + } + if (format === "date") { const d = new Date(String(value)); if (Number.isNaN(d.getTime())) return {String(value)}; diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 1740cfbe6..23b77f943 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -11,6 +11,7 @@ export type ColumnFormat = | "rateStatus" | "date" | "number" + | "currency" | "entityLabel" | "rateLabel"; @@ -36,6 +37,8 @@ export interface FormFieldDef { placeholder?: string; description?: string; disabled?: boolean; + /** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */ + suffix?: string; /** Hide this field when another field currently equals one of these values. */ hideWhen?: { field: string; equals: string[] }; /** @@ -402,7 +405,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ columns: [ { id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" }, { id: "trigger", header: "Trigger", accessorKey: "trigger" }, - { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" }, + { id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" }, { id: "rateUnit", header: "Unit", accessorKey: "rateUnit" }, { id: "status", header: "Status", accessorKey: "status", format: "rateStatus" }, { id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" }, @@ -454,7 +457,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Select bulk commodity (optional)", showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] }, }, - { name: "rateValue", label: "Rate value", type: "number", required: true }, + { name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" }, { name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS }, { name: "effectiveFrom", label: "Effective from", type: "date", required: true }, { name: "effectiveTo", label: "Effective to", type: "date" }, From 22eedf05f3c58823a1c3210e0a6f2c0036d8d5a7 Mon Sep 17 00:00:00 2001 From: natib21 Date: Tue, 30 Jun 2026 15:24:36 +0000 Subject: [PATCH 34/79] fix --- .../backoffice/src/pages/fleet/format.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/edr-freight-web/backoffice/src/pages/fleet/format.ts diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts new file mode 100644 index 000000000..6193ca58c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/format.ts @@ -0,0 +1,16 @@ +/** Shared formatting helpers for the fleet-management pages. */ + +/** Format a number as Ethiopian Birr, e.g. 12345.6 → "ETB 12,346". */ +export function formatETB(amount: number, fractionDigits = 0): string { + const value = Number.isFinite(amount) ? amount : 0; + return `ETB ${value.toLocaleString("en-US", { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + })}`; +} + +/** Safe percentage of `part` over `total`, rounded, 0 when total is 0. */ +export function pct(part: number, total: number): number { + if (!total || !Number.isFinite(total) || !Number.isFinite(part)) return 0; + return Math.round((part / total) * 100); +} From 1c9efed1031fd4658ccab5b3815f059350935499 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 30 Jun 2026 20:06:43 +0300 Subject: [PATCH 35/79] fix: ( payment ) add ispublic to payment event consumer --- apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts | 4 ++++ .../src/modules/payments/payment-events.consumer.ts | 2 ++ .../payment-providers/src/providers/dmoney/dmoney.provider.ts | 3 +++ 3 files changed, 9 insertions(+) diff --git a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts index 43ddecddf..7a450bfaf 100644 --- a/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts +++ b/apps/edr-passenger-api/src/common/dynamic-throttler.guard.ts @@ -15,6 +15,10 @@ export class DynamicThrottlerGuard extends ThrottlerGuard { } async canActivate(context: ExecutionContext): Promise { + if (context.getType() !== 'http') { + return true; + } + const [authLimit, authTtl, strictLimit, strictTtl, defaultLimit, defaultTtl] = await Promise.all([ this.systemConfig.getNumber(CONFIG_KEYS.THROTTLE_AUTH_LIMIT), diff --git a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts index f13191c48..a80c63468 100644 --- a/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts +++ b/apps/edr-passenger-api/src/modules/payments/payment-events.consumer.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Nack, RabbitSubscribe } from '@golevelup/nestjs-rabbitmq'; +import { IsPublic } from '@tria-plc/api-common/modules/auth/decorators/public.decorator'; import { PAYMENT_EVENTS_DLX, PAYMENT_EVENTS_EXCHANGE, @@ -19,6 +20,7 @@ export class PaymentEventsConsumer { constructor(private readonly paymentsService: PaymentsService) {} + @IsPublic() @RabbitSubscribe({ exchange: PAYMENT_EVENTS_EXCHANGE, routingKey: paymentServiceBindingPattern(PaymentService.PASSENGER), // payment.passenger.* diff --git a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts index 32b41dbb1..15a2e3a62 100644 --- a/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts +++ b/packages/payment-providers/src/providers/dmoney/dmoney.provider.ts @@ -61,6 +61,9 @@ export class DMoneyProvider implements PaymentProvider { ): Promise { const fabricToken = await this.applyFabricToken(); const requestBody = this.buildPreOrderRequest(input); + this.logger.log( + `D-Money preOrder send request merchOrderId=${input.merchantOrderId} body=${JSON.stringify(this.sanitize(requestBody))}`, + ); const response = await this.postJson( `${this.baseUrl}/apiaccess/payment/gateway/payment/v1/merchant/preOrder`, requestBody, From 28c1089ce677cbfc5fc121b01b5a1f9dcbc1498c Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 1 Jul 2026 02:42:05 +0000 Subject: [PATCH 36/79] Remove estimated shipment date from contract forms and related components --- .../contract-document-view-model.builder.ts | 4 ++- .../modules/contracts/contracts.service.ts | 6 ---- .../contracts/dto/create-contract.dto.ts | 9 ------ .../pages/contracts/ContractDetailPage.tsx | 11 ------- .../src/pages/contracts/NewContractPage.tsx | 7 ----- .../new-contract-form/contractToForm.ts | 7 ----- .../contracts/new-contract-form/schema.ts | 15 +-------- .../new-contract-form/step4-route.tsx | 31 ++----------------- .../new-contract-form/step8-review.tsx | 15 --------- 9 files changed, 6 insertions(+), 99 deletions(-) diff --git a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts index caf161614..05061ed9a 100644 --- a/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts +++ b/apps/edr-freight-api/src/contracts/contract-document-view-model.builder.ts @@ -200,7 +200,9 @@ export class ContractDocumentViewModelBuilder { serviceType: this.valueOrDash( contract.serviceType?.serviceName ?? contract.serviceType?.code, ), - scheduledDate: this.formatDate(contract.estimatedShipmentDate), + // Estimated shipment date was removed from the contract wizard; the + // binding scheduled date is set per-booking, not on the contract. + scheduledDate: this.formatDate(null), contractType: this.valueOrDash(contract.contractType), cargoDescription: this.valueOrDash(cargoName), totalWeightVgm: '—', diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts index 70d2632fa..8d864aac7 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.service.ts @@ -200,9 +200,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? null, isHazardous: dto.isHazardous ?? false, isReefer: dto.isReefer ?? false, - estimatedShipmentDate: dto.estimatedShipmentDate - ? new Date(dto.estimatedShipmentDate) - : null, contractType: dto.contractType ?? null, status: 'DRAFT', clearanceStatus: 'NOT_APPLICABLE', @@ -347,9 +344,6 @@ export class ContractsService { lastMileDeliveryLng: dto.lastMileDeliveryLng ?? existing.lastMileDeliveryLng, contractType: dto.contractType ?? existing.contractType, }; - if (dto.estimatedShipmentDate) { - updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate); - } if (dto.renewalOfId !== undefined) updates.renewalOfId = dto.renewalOfId ?? null; // Customs clearing always mirrors the (possibly changed) service type. diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts index 70dba1e90..b20b99575 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-contract.dto.ts @@ -4,7 +4,6 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsDateString, IsIn, IsNumber, IsOptional, @@ -219,14 +218,6 @@ export class CreateContractDto { @Transform(({ value }) => value === 'true' || value === true) isReefer?: boolean; - @ApiPropertyOptional({ - description: 'Non-binding estimate from the wizard (NOT validated against departures)', - example: '2026-07-15T00:00:00.000Z', - }) - @IsOptional() - @IsDateString() - estimatedShipmentDate?: string; - @ApiPropertyOptional({ description: 'Contract document type (SPOT, etc.)' }) @IsOptional() @IsString() diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 03be22005..538c58fe7 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -449,17 +449,6 @@ export default function ContractDetailPage() { routes[0]?.destinationYard?.label ?? "—" }`} /> - } - value={ - contract.estimatedShipmentDate - ? new Date( - contract.estimatedShipmentDate, - ).toLocaleDateString() - : "—" - } - /> } diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 0456bca3e..7821b0076 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -541,13 +541,6 @@ export default function NewContractPage({ isHazardous: data.isHazardous, // Reefer is a contract-level flag for both container and bulk. isReefer: data.isRefrigerated, - ...(data.estimatedShipmentDate - ? { - estimatedShipmentDate: new Date( - data.estimatedShipmentDate, - ).toISOString(), - } - : {}), ...(data.previousContractRef ? { renewalOfId: data.previousContractRef } : {}), diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts index f3d66de3d..8916ef67d 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/contractToForm.ts @@ -16,12 +16,6 @@ function directionToOperation( return "intercity"; } -/** ISO timestamp → `YYYY-MM-DD` for the native date input. "" when absent. */ -function isoToDateInput(iso: string | null | undefined): string { - if (!iso) return ""; - return iso.slice(0, 10); -} - /** * Rebuild the cargo-type path `[groupId, commodityId]` from a flat cargoTypeId * by locating which reference cargo-type group owns it. @@ -138,7 +132,6 @@ export function contractToFormValues( originYard: primaryRoute?.originYardId ?? "", destinationYard: primaryRoute?.destinationYardId ?? "", extraRoutes, - estimatedShipmentDate: isoToDateInput(contract.estimatedShipmentDate), documents: {}, }; diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 015517f07..5152163d8 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -187,8 +187,6 @@ export const contractFormSchema = z }), ) .default([]), - // Non-binding estimate. The binding scheduled date is captured at booking. - estimatedShipmentDate: z.string().default(""), documents: z.record(z.string(), z.any()).default({}), notes: z.string().default(""), @@ -222,15 +220,6 @@ export const contractFormSchema = z }, ) .superRefine((data, ctx) => { - // Estimated shipment date is a planning value and is required for all - // contracts (doc §7 step 4). - if (!data.estimatedShipmentDate.trim()) { - ctx.addIssue({ - code: "custom", - path: ["estimatedShipmentDate"], - message: "Select an estimated shipment date.", - }); - } if (data.cargoType === "container") { // Container scope: at least one enabled size. if (data.enabledContainerSizes.length === 0) { @@ -287,7 +276,6 @@ export const initialContractFormValues: DeepPartial = { originYard: "", destinationYard: "", extraRoutes: [], - estimatedShipmentDate: "", documents: {}, notes: "", @@ -311,7 +299,7 @@ export const contractStepFields: Record< "firstMile", "lastMile", ], - // Step 1 — Cargo & Route: scope, sizes, flags, origin/destination, date. + // Step 1 — Cargo & Route: scope, sizes, flags, origin/destination. 1: [ "cargoType", "enabledContainerSizes", @@ -324,7 +312,6 @@ export const contractStepFields: Record< "originYard", "destinationYard", "extraRoutes", - "estimatedShipmentDate", ], // Step 2 — Review & Submit. (The separate Documents step was removed — the // company profile documents are attached to the contract automatically.) diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx index d678dd318..1e9ead268 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step4-route.tsx @@ -1,6 +1,6 @@ import type { Freight } from "@edr/types"; -import { Box, Button, Group, Skeleton, Stack, Text, TextInput } from "@mantine/core"; -import { CalendarDays, MapPin, Plus, Trash2 } from "lucide-react"; +import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core"; +import { MapPin, Plus, Trash2 } from "lucide-react"; import { useCallback, useEffect, useMemo } from "react"; import { Controller, @@ -125,12 +125,6 @@ export function Step4Route({ const stationSelectDisabled = yardOptions.length === 0; - const todayISODate = useMemo(() => { - const now = new Date(); - const tz = now.getTimezoneOffset() * 60000; - return new Date(now.getTime() - tz).toISOString().slice(0, 10); - }, []); - return ( {isLoading ? ( @@ -177,27 +171,6 @@ export function Step4Route({
)} - {/* Estimated shipment date — a planning value for every contract. The - binding scheduled date is captured later at booking time. */} - - ( - } - error={fieldState.error?.message} - value={field.value ?? ""} - onChange={(e) => field.onChange(e.currentTarget.value)} - radius="md" - /> - )} - /> -
)} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx index a5fbbc989..549d5d8be 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step8-review.tsx @@ -9,9 +9,7 @@ import { Text, Textarea, } from "@mantine/core"; -import { format } from "date-fns"; import { - Calendar, CheckCircle2, Circle, ClipboardCheck, @@ -248,10 +246,6 @@ export function Step8Review({ referenceData?.yard.find((y) => y.id === values.destinationYard)?.name ?? values.destinationYard; - const scheduleLabel = values.estimatedShipmentDate - ? format(new Date(values.estimatedShipmentDate), "EEEE, MMM d, yyyy") - : "—"; - const directionLabel = direction ? direction.charAt(0) + direction.slice(1).toLowerCase() : "—"; @@ -353,11 +347,6 @@ export function Step8Review({ : directionLabel } /> - } - label="Estimated shipment" - value={scheduleLabel} - /> } label="Cargo scope" @@ -485,10 +474,6 @@ export function Step8Review({ done={Boolean(values.originYard && values.destinationYard)} label="Route selected" /> - Date: Wed, 1 Jul 2026 07:27:53 +0300 Subject: [PATCH 37/79] Implement contract document download functionality and enhance clearance review status checks - Added methods to assert clearance reviewable, finalizable, and uploadable statuses in the ContractClearanceService. - Introduced a new endpoint in ContractsController for downloading contract PDFs. - Implemented download functionality in the contracts service for both backoffice and portal applications. - Updated UI components to include download buttons for contract PDFs in relevant pages. - Enhanced contract request and view pages to support contract document downloads. --- .../contracts/contract-clearance.service.ts | 87 +++++++--- .../contracts/contract-transition.service.ts | 8 + .../modules/contracts/contracts.controller.ts | 22 +++ apps/edr-freight-web/backoffice/src/App.tsx | 13 ++ .../detail/ClearanceReviewSection.tsx | 23 +-- .../ContractClearanceReviewSection.tsx | 24 +-- .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../src/components/layout/route-meta.ts | 7 + .../backoffice/src/constants/URLS.ts | 1 + .../ContractValidityPeriodsPage.tsx | 145 ++++++++++++++++ .../contracts/ContractClearanceDetailPage.tsx | 12 +- .../contracts/ContractRequestDetailPage.tsx | 70 ++++++++ .../src/pages/contracts/ContractViewPage.tsx | 45 ++++- .../src/services/contracts.service.ts | 7 + .../contracts/ContractSignSuccessModal.tsx | 43 +++++ .../portal/src/constants/URLS.ts | 1 + .../pages/contracts/ContractDetailPage.tsx | 29 ++++ .../src/pages/contracts/ContractViewPage.tsx | 161 +++++++++++++++--- .../src/pages/contracts/NewContractPage.tsx | 84 +++++---- .../contracts/new-contract-form/schema.ts | 20 +++ .../new-contract-form/step2-service-type.tsx | 49 +++--- .../new-contract-form/step3-cargo-scope.tsx | 7 - .../new-contract-form/step8-review.tsx | 10 ++ .../portal/src/services/contracts.service.ts | 7 + 24 files changed, 785 insertions(+), 133 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/contracts/ContractSignSuccessModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx create mode 100644 apps/edr-freight-web/portal/src/components/contracts/ContractSignSuccessModal.tsx diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index 71cfa0580..a57ca275b 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -154,6 +154,59 @@ export class ContractClearanceService { ); } + /** Staff may approve/query documents during review, after a query cycle, or post-finalize re-query. */ + private assertClearanceReviewableStatus(contract: Contract): void { + const allowed = [ + 'CLEARANCE_UNDER_REVIEW', + 'AWAITING_CLEARANCE_DOCUMENTS', + 'CLEARANCE_READY_FOR_BOOKING', + ]; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot review clearance documents on status "${contract.status}".`, + ); + } + } + + /** Finalize when docs are under review or all approved after a partial query cycle. */ + private assertClearanceFinalizableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot finalize clearance on status "${contract.status}".`, + ); + } + } + + private assertClearanceOutputUploadableStatus(contract: Contract): void { + const allowed = ['CLEARANCE_UNDER_REVIEW', 'AWAITING_CLEARANCE_DOCUMENTS']; + if (!allowed.includes(contract.status)) { + throw new ConflictException( + `Cannot upload output documents on status "${contract.status}".`, + ); + } + } + + private async bumpToUnderReviewWhenFullyApproved(contractId: string): Promise { + const refreshed = await this.contractsService.findById(contractId); + const allApproved = await this.isClearanceFullyApproved(refreshed); + if ( + !allApproved || + (refreshed.status !== 'AWAITING_CLEARANCE_DOCUMENTS' && + refreshed.status !== 'CLEARANCE_READY_FOR_BOOKING') + ) { + return; + } + await this.contractsRepository.update(contractId, { + status: 'CLEARANCE_UNDER_REVIEW', + clearanceStatus: 'DOCUMENTS_UNDER_REVIEW', + } as never); + const cycle = await this.contractsRepository.currentCycle(contractId); + if (cycle) { + await this.contractsRepository.setCycleStatus(cycle.id, 'DOCUMENTS_UNDER_REVIEW'); + } + } + /** * Customer uploads clearance documents on the contract. When every required * input is present, auto-advance to CLEARANCE_UNDER_REVIEW for GL ET. @@ -294,19 +347,7 @@ export class ContractClearanceService { note?: string, ): Promise { const contract = await this.contractsService.findById(contractId); - // Reviewing is allowed both while the batch is UNDER_REVIEW and after it has - // dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips - // the contract to "awaiting" (the customer must re-upload), but the reviewer - // may still be working through the rest of the batch. Restricting to - // UNDER_REVIEW only would 409 every review after the first query. - if ( - contract.status !== 'CLEARANCE_UNDER_REVIEW' && - contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS' - ) { - throw new ConflictException( - `Cannot review clearance documents on status "${contract.status}".`, - ); - } + this.assertClearanceReviewableStatus(contract); if (status === 'QUERIED' && !note?.trim()) { throw new BadRequestException('A note is required when querying a document'); } @@ -348,6 +389,8 @@ export class ContractClearanceService { if (cycle) { await this.contractsRepository.setCycleStatus(cycle.id, 'AWAITING_DOCUMENTS'); } + } else if (status === 'APPROVED') { + await this.bumpToUnderReviewWhenFullyApproved(contractId); } return this.contractsService.findById(contractId); @@ -359,11 +402,7 @@ export class ContractClearanceService { files: Express.Multer.File[], ): Promise { const contract = await this.contractsService.findById(contractId); - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot upload output documents on status "${contract.status}".`, - ); - } + this.assertClearanceOutputUploadableStatus(contract); const { outputCode } = contractClearanceCodes(contract); if (!outputCode) { throw new BadRequestException('This contract has no customs output documents'); @@ -394,11 +433,7 @@ export class ContractClearanceService { 'Self-clearance (Path A) contracts are finalized by Operations, not GL.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { @@ -452,11 +487,7 @@ export class ContractClearanceService { 'Operations finalize applies only to self-clearance (non-customs) contracts.', ); } - if (contract.status !== 'CLEARANCE_UNDER_REVIEW') { - throw new ConflictException( - `Cannot finalize clearance on status "${contract.status}".`, - ); - } + this.assertClearanceFinalizableStatus(contract); const approved = await this.isClearanceFullyApproved(contract); if (!approved) { diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 4fcbc9a6c..8bcce1250 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -345,6 +345,14 @@ export class ContractTransitionService { return { view, html, signatures: view.signatures }; } + /** Lazy-generate (or refresh) the stored contract PDF and stream it for download. */ + async streamContractPdf(contractId: string) { + const contract = await this.contractsService.findById(contractId); + const { view } = await this.documentViewModelBuilder.build(contractId); + const record = await this.upsertContractPdf(contractId, contract.reference, view); + return this.filesService.streamById(record.id); + } + /** * Rebuild the stored `contract` PDF from the current aggregate (now including * the latest signatures) so the downloaded/viewed file matches the live HTML diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 0ef013347..100183e64 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -9,6 +9,7 @@ import { Patch, Post, Query, + Res, UnauthorizedException, UploadedFiles, UseInterceptors, @@ -16,6 +17,7 @@ import { import { CurrentUser } from '@edr/api-common'; import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type'; import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; import { ApiBearerAuth, ApiBody, @@ -417,6 +419,26 @@ export class ContractsController { }; } + @Get(':id/contract/document') + @ApiOperation({ summary: 'Download contract PDF' }) + async downloadContractDocument( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: TCurrentUser, + @Res() res: Response, + ): Promise { + const contract = await this.contractsService.findById(id); + if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) { + await this.contractsService.assertCustomerCanAccessContract(user?.id, contract); + } + const { stream, record } = await this.transitionService.streamContractPdf(id); + res.setHeader('Content-Type', record.mimeType ?? 'application/pdf'); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${record.name}"`, + ); + stream.pipe(res); + } + @Post(':id/contract/sign') @ApiOperation({ summary: 'Apply digital signature (customer or staff/director/ceo)' }) signContract( diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 542a848a4..6526fb6cb 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -67,6 +67,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage"; import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage"; import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; +import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -343,6 +344,10 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ icon: , children: [ ...getCategorySidebarChildren("configuration"), + { + label: "Contract validity", + href: "/dashboard/configuration/contract-validity-periods", + }, // { // label: "Train scheduling rules", // href: "/dashboard/configuration/train-scheduling-rules", @@ -811,6 +816,14 @@ const App = () => { } /> + + + + } + /> } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx index 16bd6ed27..0f9f42217 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/ClearanceReviewSection.tsx @@ -419,6 +419,7 @@ function DocReviewCard({ const status = doc.reviewStatus ?? "PENDING"; const meta = STATUS_META[status]; const hasFile = !!doc.file; + const isApproved = status === "APPROVED"; return ( Open query - + {!isApproved && ( + + )} ) : ( )} - {!readOnly && hasFile && !isApproved && ( + {!readOnly && hasFile && ( {!queryOpen ? ( @@ -598,16 +598,18 @@ function DocReviewCard({ > Open query - + {!isApproved && ( + + )} ) : ( void; + reference: string; + message?: string; + confirmLabel?: string; +} + +export function ContractSignSuccessModal({ + opened, + onClose, + reference, + message = "The contract has been signed and recorded.", + confirmLabel = "Back to contract request", +}: ContractSignSuccessModalProps) { + return ( + + + + + + {reference} + + {message} + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index b400ddba4..9b75f35f5 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -184,6 +184,13 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage dropdown options used across the platform", }, }, + { + prefix: "/dashboard/configuration/contract-validity-periods", + meta: { + title: "Contract validity periods", + subtitle: "Validity options staff choose when accepting a submitted contract", + }, + }, { prefix: "/dashboard/configuration/train-scheduling-rules", meta: { diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2b767d082..3acacd36b 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -137,6 +137,7 @@ export const URL_CONSTANTS = { `/contracts/${id}/approval-steps/${stepId}/approve`, CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`, CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`, CLEARANCE_QUEUE: "/contracts/clearance/queue", CLEARANCE: (id: string) => `/contracts/${id}/clearance`, diff --git a/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx new file mode 100644 index 000000000..ee6bb5ebd --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/configuration/ContractValidityPeriodsPage.tsx @@ -0,0 +1,145 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Badge, + Button, + Center, + Group, + Loader, + Paper, + Stack, + Table, + Text, + Title, +} from "@mantine/core"; +import { CalendarClock, Pencil } from "lucide-react"; + +import { PageContainer } from "@/components/page"; +import Breadcrumbs from "@/components/ui/Breadcrumbs"; +import { api } from "@/services/api"; +import ManageDropdownOptionsDialog from "@/pages/dropdown_settings/ManageDropdownOptionsDialog"; + +const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods"; + +/** + * Admin UI for contract validity options used when staff accepts a submitted + * contract (SUBMITTED → PENDING_APPROVAL). Backed by dropdown_settings. + */ +export default function ContractValidityPeriodsPage() { + const [editOpen, setEditOpen] = useState(false); + + const { data: setting, isLoading, isError } = useQuery( + api.dropdownSettings.getByCode.queryOptions({ + input: { code: CONTRACT_VALIDITY_PERIODS_CODE }, + }), + ); + + const options = useMemo( + () => + [...(setting?.children ?? [])].sort( + (a, b) => (a.order ?? 0) - (b.order ?? 0), + ), + [setting], + ); + + return ( + + + + + + + Contract validity periods + + Options shown when line staff accepts a submitted contract. Each + value is the number of days the contract stays valid from the + accept date. + + + {setting && ( + + )} + + + + {isLoading ? ( +
+ +
+ ) : isError || !setting ? ( + + Could not load contract validity settings. Ensure{" "} + + {CONTRACT_VALIDITY_PERIODS_CODE} + {" "} + is seeded in dropdown settings. + + ) : options.length === 0 ? ( + + + No validity periods configured yet. + + + ) : ( +
+ + + Label + Days (value) + Order + Status + + + + {options.map((opt) => ( + + {opt.label} + + + {opt.value} + + + {opt.order ?? "—"} + + + {opt.disabled ? "Disabled" : "Active"} + + + + ))} + +
+ )} + +
+ + {setting ? ( + + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx index 63f011ea6..08f0ed0eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractClearanceDetailPage.tsx @@ -62,6 +62,16 @@ export default function ContractClearanceDetailPage() { // Customs (Path B) hub. The customer always creates the booking in the portal // after GL finalizes clearance — there is no GL "Create booking" action here. const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING"; + const clearanceReadOnly = Boolean( + contract?.status && + [ + "ACTIVE_SHIPMENT_IN_PROGRESS", + "FULLY_EXECUTED", + "CONTRACT_ACTIVE", + "CONTRACT_CLOSED", + "EXPIRED", + ].includes(contract.status), + ); if (isLoading) { return ( @@ -160,7 +170,7 @@ export default function ContractClearanceDetailPage() { contractId={id!} hideSummary selfClear={false} - readOnly={ready} + readOnly={clearanceReadOnly} />
diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx index 44d46c652..64737c3eb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestDetailPage.tsx @@ -6,6 +6,8 @@ import { Building2, Calendar, CalendarClock, + Download, + FileSignature, FileText, Files, Flame, @@ -56,6 +58,8 @@ import { useContractDetail, useContractMutations, } from "@/hooks/contracts/useContracts"; +import { contractsService } from "@/services/contracts.service"; +import { fileViewUrl } from "@/constants/apiConfig"; import { downloadBookingFile } from "@/services/files.service"; import type { Freight } from "@edr/types"; @@ -136,6 +140,22 @@ export default function ContractRequestDetailPage() { } }; + const downloadContractPdf = async () => { + if (!contract?.id) return; + try { + const blob = await contractsService.downloadContractDocument(contract.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + const contractPdf = contract.files?.find((f) => f.code === "contract"); + a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }; + if (isLoading) { return ( @@ -207,6 +227,14 @@ export default function ContractRequestDetailPage() { // Path A (no customs) → Operations reviews; Path B (customs) → GL reviews. const selfClear = !contract.customsClearingEnabled; const files = contract.files ?? []; + const contractPdf = files.find((f) => f.code === "contract"); + const hasContractDocument = Boolean( + contractPdf || contract.contractGeneratedAt, + ); + const canViewSign = + (contract.status === "CONTRACT_READY" || + contract.status === "SIGNED_CUSTOMER") && + Boolean(contract.contractGeneratedAt); // Resolve the active tab from the URL, falling back to details when the // requested tab isn't available for this contract (e.g. clearance pre-phase). const currentTab = @@ -292,6 +320,48 @@ export default function ContractRequestDetailPage() { /> ) : null}
+ {hasContractDocument && ( + + {canViewSign && ( + + )} + {contractPdf && ( + + )} + + + )} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx index ecb8f1c62..7e8f06e0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractViewPage.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -13,18 +13,17 @@ import { Text, TextInput, } from "@mantine/core"; -import { ArrowLeft, FileSignature, Printer } from "lucide-react"; +import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react"; import toast from "react-hot-toast"; +import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal"; import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad"; import { contractsService } from "@/services/contracts.service"; import { QUERY_KEYS } from "@/constants/QUERY_KEYS"; /** * Staff contract preview + sign. Staff must open and read the generated - * contract here before signing — there is no sign action on the detail page or - * the list table. Signing as STAFF is only possible once the contract has been - * generated and is in CONTRACT_READY / SIGNED_CUSTOMER. + * contract here before signing. */ export default function ContractViewPage() { const { id } = useParams<{ id: string }>(); @@ -33,9 +32,9 @@ export default function ContractViewPage() { const iframeRef = useRef(null); const [signOpen, setSignOpen] = useState(false); + const [successOpen, setSuccessOpen] = useState(false); const [signerName, setSignerName] = useState(""); const [signatureData, setSignatureData] = useState(null); - // Offer the staff member's saved signature first; they can draw a fresh one. const [drawNew, setDrawNew] = useState(false); const { data, isLoading, isError, refetch } = useQuery({ @@ -58,8 +57,8 @@ export default function ContractViewPage() { consentText: "I confirm this contract on behalf of EDR.", }), onSuccess: () => { - toast.success("Contract signed"); setSignOpen(false); + setSuccessOpen(true); void refetch(); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) }); void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }); @@ -69,6 +68,21 @@ export default function ContractViewPage() { const handlePrint = () => iframeRef.current?.contentWindow?.print(); + const downloadPdf = useCallback(async () => { + if (!id) return; + try { + const blob = await contractsService.downloadContractDocument(id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `contract-${data?.reference ?? id}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }, [id, data?.reference]); + const openSign = () => { setSignerName(data?.savedSignature?.signerDisplayName ?? ""); setSignatureData(null); @@ -124,6 +138,13 @@ export default function ContractViewPage() { > Print + {data.canSignStaff && ( + + + + ); +} diff --git a/apps/edr-freight-web/portal/src/constants/URLS.ts b/apps/edr-freight-web/portal/src/constants/URLS.ts index 22f2a441b..cf974ceab 100644 --- a/apps/edr-freight-web/portal/src/constants/URLS.ts +++ b/apps/edr-freight-web/portal/src/constants/URLS.ts @@ -118,6 +118,7 @@ export const URL_CONSTANTS = { CONFIRM_SUBMIT: (id: string) => `/api/contracts/${id}/confirm-submit`, CONTRACT_GENERATE: (id: string) => `/api/contracts/${id}/contract/generate`, CONTRACT_VIEW: (id: string) => `/api/contracts/${id}/contract/view`, + CONTRACT_DOCUMENT: (id: string) => `/api/contracts/${id}/contract/document`, CONTRACT_SIGN: (id: string) => `/api/contracts/${id}/contract/sign`, RENEW: (id: string) => `/api/contracts/${id}/renew`, CLEARANCE: (id: string) => `/api/contracts/${id}/clearance`, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx index 538c58fe7..a25bfe489 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx @@ -48,8 +48,10 @@ import { useDisclosure } from "@mantine/hooks"; import { isViewable, type ViewableFile } from "@edr/ui-common"; import type { Freight } from "@edr/types"; import { api } from "@/services/api"; +import { contractsService } from "@/services/contracts.service"; import { fileViewUrl } from "@/constants/apiConfig"; import { useFileViewer } from "@/hooks/useFileViewer"; +import toast from "react-hot-toast"; import { labelForDocCode } from "@/pages/bookings/resubmit"; import { ContractClearancePanel } from "./ContractClearancePanel"; import { formatRateUnit } from "./new-contract-form/unit-rates"; @@ -223,6 +225,22 @@ export default function ContractDetailPage() { // The generated contract PDF — surfaced via a dedicated "View contract" button // in the header (it's excluded from the Documents tab groups). const contractPdf = files.find((f) => f.code === "contract"); + const hasContractDocument = Boolean(contractPdf || contract.contractGeneratedAt); + + const downloadContractPdf = async () => { + if (!contract?.id) return; + try { + const blob = await contractsService.downloadContractDocument(contract.id); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`; + a.click(); + URL.revokeObjectURL(url); + } catch { + toast.error("Could not download contract PDF."); + } + }; const canSign = contract.status === "CONTRACT_READY"; const customsPath = contract.customsClearingEnabled; @@ -303,6 +321,17 @@ export default function ContractDetailPage() { ) )} + {hasContractDocument && ( + + )} {canBookShipment && ( - {data.canSignCustomer && ( - - )} + + {data.canSignCustomer && !hasScrolledToBottom && ( + + Please scroll through the entire contract before signing. + + )} +