diff --git a/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts new file mode 100644 index 000000000..3013f0320 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000000-AddMaintenanceIntervals.ts @@ -0,0 +1,103 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey, TableUnique } from 'typeorm'; + +export class AddMaintenanceIntervals2800000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'maintenance_intervals', + schema: 'freight', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { + name: 'vehicle_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'maintenance_type', + type: 'varchar', + isNullable: false, + }, + { + name: 'interval_km', + type: 'numeric', + precision: 14, + scale: 2, + isNullable: true, + }, + { + name: 'interval_days', + type: 'integer', + isNullable: true, + }, + { + name: 'description', + type: 'text', + isNullable: true, + }, + { + name: 'is_active', + type: 'boolean', + default: true, + }, + { + name: 'created_at', + type: 'timestamptz', + default: 'now()', + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'now()', + }, + { + name: 'deleted_at', + type: 'timestamptz', + isNullable: true, + }, + ], + }), + ); + + // Add indexes + await queryRunner.createIndex( + new Table({ name: 'maintenance_intervals', schema: 'freight' }), + new TableIndex({ + name: 'IDX_maintenance_intervals_vehicle_type', + columnNames: ['vehicle_id', 'maintenance_type'], + }), + ); + + // Add unique constraint + await queryRunner.createUniqueConstraint( + 'maintenance_intervals', + new TableUnique({ + name: 'UQ_maintenance_intervals_vehicle_type', + columnNames: ['vehicle_id', 'maintenance_type'], + }), + ); + + // Add foreign key + await queryRunner.createForeignKey( + 'maintenance_intervals', + new TableForeignKey({ + name: 'FK_maintenance_intervals_vehicle', + columnNames: ['vehicle_id'], + referencedSchema: 'freight', + referencedTableName: 'vehicles', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('maintenance_intervals', true, true, true); + } +} diff --git a/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts new file mode 100644 index 000000000..679846484 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2800000000001-AddSignatureToHandover.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Persist the signer's saved-signature image on the handover record, so the + * signed handover document can render the actual signature (not just the + * typed name) — parity with the booking-contract signing flow. + */ +export class AddSignatureToHandover2800000000001 implements MigrationInterface { + name = 'AddSignatureToHandover2800000000001'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts new file mode 100644 index 000000000..095123c09 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/entities/maintenance-interval.entity.ts @@ -0,0 +1,39 @@ +import { BaseEntity } from '@edr/api-common'; +import { Entity, Column, ManyToOne, JoinColumn, Index, Unique } from 'typeorm'; +import { Vehicle } from '../../vehicles/entities/vehicle.entity'; +import { MaintenanceType } from './maintenance-schedule.entity'; + +/** + * Maintenance interval configuration. Defines how often a vehicle/type needs maintenance. + * Each vehicle can have different intervals for different maintenance types (e.g., oil every 10k km, tires every 50k km). + */ +@Entity({ name: 'maintenance_intervals', schema: 'freight' }) +@Index(['vehicleId', 'maintenanceType']) +@Unique(['vehicleId', 'maintenanceType']) +export class MaintenanceInterval extends BaseEntity { + @Column({ name: 'vehicle_id', type: 'uuid' }) + vehicleId!: string; + + @ManyToOne(() => Vehicle, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'vehicle_id' }) + vehicle!: Vehicle; + + @Column({ name: 'maintenance_type', type: 'varchar' }) + maintenanceType!: MaintenanceType; + + /** Maintenance interval in kilometers. E.g., 10000 for oil changes every 10k km. */ + @Column({ name: 'interval_km', type: 'numeric', precision: 14, scale: 2, nullable: true }) + intervalKm?: number | null; + + /** Maintenance interval in days. E.g., 365 for annual inspection. */ + @Column({ name: 'interval_days', type: 'integer', nullable: true }) + intervalDays?: number | null; + + /** Human-readable description. E.g., "Oil and filter change". */ + @Column({ name: 'description', type: 'text', nullable: true }) + description?: string | null; + + /** Is this interval active? Can be disabled without deleting historical data. */ + @Column({ name: 'is_active', type: 'boolean', default: true }) + isActive!: boolean; +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts new file mode 100644 index 000000000..eded86274 --- /dev/null +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance-interval.repository.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { BaseRepository } from '@edr/api-common'; +import { Repository } from 'typeorm'; +import { MaintenanceInterval } from './entities/maintenance-interval.entity'; +import { MaintenanceType } from './entities/maintenance-schedule.entity'; + +@Injectable() +export class MaintenanceIntervalRepository extends BaseRepository { + constructor( + @InjectRepository(MaintenanceInterval) + private readonly intervalRepository: Repository, + ) { + super(intervalRepository); + } + + async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise { + return this.intervalRepository.findOne({ + where: { vehicleId, maintenanceType, isActive: true }, + }); + } + + async getActiveIntervals(vehicleId: string): Promise { + return this.intervalRepository.find({ + where: { vehicleId, isActive: true }, + order: { maintenanceType: 'ASC' }, + }); + } + + async upsertInterval( + vehicleId: string, + maintenanceType: MaintenanceType, + intervalKm?: number | null, + intervalDays?: number | null, + description?: string | null, + ): Promise { + const existing = await this.getByVehicleAndType(vehicleId, maintenanceType); + + if (existing) { + await this.intervalRepository.update(existing.id, { + intervalKm: intervalKm ?? existing.intervalKm, + intervalDays: intervalDays ?? existing.intervalDays, + description: description ?? existing.description, + }); + const updated = await this.intervalRepository.findOneBy({ id: existing.id }); + return updated!; + } + + return this.intervalRepository.save( + this.intervalRepository.create({ + vehicleId, + maintenanceType, + intervalKm, + intervalDays, + description, + isActive: true, + }), + ); + } +} diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts index 5287948a7..b64b77bae 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.module.ts @@ -2,12 +2,14 @@ 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 { MaintenanceInterval } from './entities/maintenance-interval.entity'; import { WorkOrder } from './entities/work-order.entity'; import { Part } from './entities/part.entity'; import { Warranty } from './entities/warranty.entity'; import { MaintenanceService } from './maintenance.service'; import { MaintenanceDepthService } from './maintenance-depth.service'; import { MaintenanceRepository } from './maintenance.repository'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; import { WorkOrderRepository } from './work-order.repository'; import { PartRepository } from './part.repository'; import { WarrantyRepository } from './warranty.repository'; @@ -16,13 +18,21 @@ import { NotificationInboxModule } from '../notification-inbox/notification-inbo @Module({ imports: [ - TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]), + TypeOrmModule.forFeature([ + MaintenanceSchedule, + MaintenanceCost, + MaintenanceInterval, + WorkOrder, + Part, + Warranty, + ]), NotificationInboxModule, ], providers: [ MaintenanceService, MaintenanceDepthService, MaintenanceRepository, + MaintenanceIntervalRepository, WorkOrderRepository, PartRepository, WarrantyRepository, diff --git a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts index 7bf83647a..283ca5eea 100644 --- a/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts +++ b/apps/edr-freight-api/src/modules/maintenance/maintenance.service.ts @@ -4,7 +4,8 @@ import { Cron, CronExpression } from '@nestjs/schedule'; import { NotificationAudience, NotificationType } from '@edr/types'; import { DataSource, Repository } from 'typeorm'; import { MaintenanceRepository } from './maintenance.repository'; -import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity'; +import { MaintenanceIntervalRepository } from './maintenance-interval.repository'; +import { MaintenanceSchedule, MaintenanceStatus, MaintenanceType } from './entities/maintenance-schedule.entity'; import { MaintenanceCost } from './entities/maintenance-cost.entity'; import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity'; import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto'; @@ -16,6 +17,7 @@ export class MaintenanceService { constructor( private readonly maintenanceRepository: MaintenanceRepository, + private readonly intervalRepository: MaintenanceIntervalRepository, @InjectRepository(MaintenanceSchedule) private readonly scheduleRepository: Repository, @InjectRepository(MaintenanceCost) @@ -119,6 +121,11 @@ export class MaintenanceService { ) { // Maintenance finished/aborted → vehicle back in service. await this.setVehicleMaintenanceState(updated.vehicleId, false); + + // If completed, schedule the next maintenance based on interval + if (dto.status === MaintenanceStatus.COMPLETED && updated.odometerReading != null) { + await this.scheduleNextMaintenance(updated); + } } else if (dto.status === MaintenanceStatus.IN_PROGRESS) { // Maintenance started → keep the vehicle out of service. await this.setVehicleMaintenanceState(updated.vehicleId, true); @@ -128,6 +135,60 @@ export class MaintenanceService { return updated!; } + private async scheduleNextMaintenance(completed: MaintenanceSchedule): Promise { + try { + // Get maintenance interval for this type + const interval = await this.intervalRepository.getByVehicleAndType( + completed.vehicleId, + completed.maintenanceType as MaintenanceType, + ); + + if (!interval) return; // No interval defined, skip auto-scheduling + + const now = new Date(); + const completedKm = Number(completed.odometerReading ?? 0); + + // Calculate next due based on KM interval + if (interval.intervalKm && interval.intervalKm > 0) { + const nextDueKm = completedKm + Number(interval.intervalKm); + + // Create next scheduled maintenance + const nextSchedule = this.scheduleRepository.create({ + vehicleId: completed.vehicleId, + maintenanceType: completed.maintenanceType, + description: `${interval.description || completed.description} (Next interval: ${nextDueKm} km)`, + scheduledDate: now, + nextDueKm, + status: MaintenanceStatus.SCHEDULED, + }); + await this.scheduleRepository.save(nextSchedule); + } + + // Calculate next due based on date interval + if (interval.intervalDays && interval.intervalDays > 0) { + const nextDueDate = new Date(now.getTime() + interval.intervalDays * 24 * 60 * 60 * 1000); + + // If no KM-based next maintenance was created, use date-based + if (!interval.intervalKm) { + const nextSchedule = this.scheduleRepository.create({ + vehicleId: completed.vehicleId, + maintenanceType: completed.maintenanceType, + description: completed.description, + scheduledDate: now, + nextDueDate, + status: MaintenanceStatus.SCHEDULED, + }); + await this.scheduleRepository.save(nextSchedule); + } + } + } catch (err) { + this.logger.error( + `Failed to schedule next maintenance for vehicle ${completed.vehicleId}: ${(err as Error).message}`, + (err as Error).stack, + ); + } + } + async getUpcomingMaintenance(vehicleId: string) { return this.maintenanceRepository.getUpcomingMaintenance(vehicleId); } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts index c90fb5a16..85f66b74f 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/booking-handover.entity.ts @@ -52,4 +52,8 @@ export class BookingHandover extends BaseEntity { /** EDR last-mile: when the goods were delivered to the customer. */ @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) deliveredAt?: Date | null; + + /** URL to the signer's saved signature image, if available at sign time. */ + @Column({ name: 'signature_image_url', type: 'text', nullable: true }) + signatureImageUrl?: string | null; } diff --git a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts index 81f83a57a..d7bb881cd 100644 --- a/apps/edr-freight-api/src/modules/warehouses/handover.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/handover.service.ts @@ -287,6 +287,7 @@ export class HandoverService { handoverId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { const repo = this.dataSource.getRepository(BookingHandover); const handover = await repo.findOne({ where: { id: handoverId } }); @@ -297,6 +298,7 @@ export class HandoverService { handover.signedAt = new Date(); handover.signedByUserId = userId ?? null; handover.signerName = signerName?.trim() || null; + handover.signatureImageUrl = signatureImageUrl ?? null; return repo.save(handover); } @@ -305,6 +307,7 @@ export class HandoverService { bookingId: string, userId?: string | null, signerName?: string | null, + signatureImageUrl?: string | null, ): Promise { await this.dataSource .getRepository(BookingHandover) @@ -314,6 +317,7 @@ export class HandoverService { signedAt: new Date(), signedByUserId: userId ?? null, signerName: signerName?.trim() || null, + signatureImageUrl: signatureImageUrl ?? null, }, ); } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 8e9e0bc76..e42cbdcea 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -4087,10 +4087,11 @@ export class WarehouseInventoryService { await this.invoices.assertClearanceAllowed(item.id); const approvedAt = new Date(); + const signatureImageUrl = signature?.signatureImageUrl ?? null; const approval = { approvedAt: approvedAt.toISOString(), signerDisplayName: name, - signatureImageUrl: signature?.signatureImageUrl ?? null, + signatureImageUrl, userId, }; const existingNotes = this.stripCustomerDeliveryApproval(item.notes); @@ -4114,7 +4115,7 @@ export class WarehouseInventoryService { // Sign the structured handover record(s) for this booking (self-haul: before // the truck leaves). Kept alongside the legacy approval note. - await this.handover.signForBooking(bookingId, userId, name); + await this.handover.signForBooking(bookingId, userId, name, signatureImageUrl); return { bookingId, @@ -4149,6 +4150,8 @@ export class WarehouseInventoryService { throw new BadRequestException('Please enter your full name to sign the handover'); } + const signature = await this.signatures.getForUser(userId).catch(() => null); + const [h]: Array<{ bookingId: string; reference: string; @@ -4181,7 +4184,7 @@ export class WarehouseInventoryService { ); if (inv) await this.invoices.assertClearanceAllowed(inv.id); - const signed = await this.handover.sign(handoverId, userId, name); + const signed = await this.handover.sign(handoverId, userId, name, signature?.signatureImageUrl ?? null); const allSigned = await this.handover.isFullySigned(h.bookingId); if (inv) { diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx index 8934a0a60..e43bb43b9 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/components/DocumentsTab.tsx @@ -15,6 +15,7 @@ import { isViewable } from "@edr/ui-common"; import { api } from "@/services/api"; import { fetchViewableFile, downloadStoredFile } from "@/services/files.service"; +import { warehouseService } from "@/services/warehouse.service"; import { useFileViewer } from "@/hooks/useFileViewer"; import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal"; import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction"; @@ -241,6 +242,11 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { }), ); + const { data: handovers = [] } = useQuery({ + queryKey: ["bookingHandovers", booking.id], + queryFn: () => warehouseService.bookingHandovers(booking.id), + }); + const customerDocs = useMemo( () => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"), [clearance], @@ -486,6 +492,69 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) { )} + {/* ── 4. Handover signatures ──────────────────────────────────────── */} + {handovers.length > 0 && ( + + Handover signatures + + Records of goods handover and customer signatures. + + + {handovers.map((h, i) => ( + + + + + {h.reference} + + + {h.mileType === "SELF_HAUL" + ? "Customer truck delivery" + : `EDR delivery${h.truckPlate ? ` (${h.truckPlate})` : ""}`} + + {h.signedAt && ( + + Signed by {h.signerName || "Unknown"} on{" "} + {new Date(h.signedAt).toLocaleDateString()} + + )} + + + + {h.signedAt && h.signatureImageUrl && ( + + Signature + + )} + + ))} + + + )} + {/* ── Warehouse documents (one-click bundle) ──────────────────────── */} Warehouse documents diff --git a/apps/edr-freight-web/portal/src/services/warehouse.service.ts b/apps/edr-freight-web/portal/src/services/warehouse.service.ts index b641a8809..990077147 100644 --- a/apps/edr-freight-web/portal/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/portal/src/services/warehouse.service.ts @@ -41,6 +41,22 @@ export interface BookingScheduleView { } | null; } +export interface BookingHandover { + id: string; + bookingId: string; + truckAssignmentId?: string | null; + edrAssignmentId?: string | null; + truckPlate?: string | null; + mileType: 'SELF_HAUL' | 'EDR_LAST_MILE'; + reference: string; + generatedAt: string; + signedAt?: string | null; + signerName?: string | null; + signedByUserId?: string | null; + signatureImageUrl?: string | null; + deliveredAt?: string | null; +} + export const warehouseService = { listInventory: async (filter?: InventoryFilter): Promise => { const { data } = await client.get("/warehouse-inventory", { @@ -59,4 +75,9 @@ export const warehouseService = { const { data } = await client.get(`/warehouse-inventory/booking-schedule/${bookingId}`); return data?.data ?? data ?? { schedule: null, wagon: null }; }, + + bookingHandovers: async (bookingId: string): Promise => { + const { data } = await client.get(`/warehouse-inventory/bookings/${bookingId}/handovers`); + return data?.data ?? data ?? []; + }, };