Merge pull request #930 from Tria-plc/Truckmaintenance

Truckmaintenance

MaintenanceInterval entity per vehicle + type (intervalKm, intervalDays). 
Migration creates maintenance_intervals table with unique constraint. 
scheduleNextMaintenance() auto-creates SCHEDULED item when one completes: 
nextDueKm = completedKm + intervalKm. Handles both KM + date intervals.

Customer Handover Signatures with Document Visibility

Portal booking detail shows warehouse/yard/zone + cargo arrival time. 
Integrated WarehouseLocationCard into ReadonlyBookingView between 
ContainersCard and ContractInfoCard. Uses customer-safe /bookings/:id/location 
endpoint (staff perm prevented direct call).
This commit is contained in:
Hagernesh Tadesse
2026-07-23 10:56:05 +03:00
committed by GitHub
11 changed files with 401 additions and 5 deletions

View File

@@ -0,0 +1,103 @@
import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey, TableUnique } from 'typeorm';
export class AddMaintenanceIntervals2800000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
await queryRunner.dropTable('maintenance_intervals', true, true, true);
}
}

View File

@@ -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<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers ADD COLUMN IF NOT EXISTS signature_image_url text;`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.booking_handovers DROP COLUMN IF EXISTS signature_image_url;`,
);
}
}

View File

@@ -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;
}

View File

@@ -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<MaintenanceInterval> {
constructor(
@InjectRepository(MaintenanceInterval)
private readonly intervalRepository: Repository<MaintenanceInterval>,
) {
super(intervalRepository);
}
async getByVehicleAndType(vehicleId: string, maintenanceType: MaintenanceType): Promise<MaintenanceInterval | null> {
return this.intervalRepository.findOne({
where: { vehicleId, maintenanceType, isActive: true },
});
}
async getActiveIntervals(vehicleId: string): Promise<MaintenanceInterval[]> {
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<MaintenanceInterval> {
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,
}),
);
}
}

View File

@@ -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,

View File

@@ -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<MaintenanceSchedule>,
@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<void> {
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);
}

View File

@@ -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;
}

View File

@@ -287,6 +287,7 @@ export class HandoverService {
handoverId: string,
userId?: string | null,
signerName?: string | null,
signatureImageUrl?: string | null,
): Promise<BookingHandover> {
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<void> {
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,
},
);
}

View File

@@ -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) {

View File

@@ -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 }) {
</SectionCard>
)}
{/* ── 4. Handover signatures ──────────────────────────────────────── */}
{handovers.length > 0 && (
<SectionCard>
<CardTitle>Handover signatures</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="sm">
Records of goods handover and customer signatures.
</Text>
<Stack gap={0}>
{handovers.map((h, i) => (
<Box
key={h.id}
px="md"
py="sm"
style={{
borderBottom:
i !== handovers.length - 1
? `1px solid ${BORDER}`
: "none",
}}
>
<Group justify="space-between" align="flex-start">
<Box>
<Text fw={500} fz="14px">
{h.reference}
</Text>
<Text fz="12.5px" c="dimmed" mt={2}>
{h.mileType === "SELF_HAUL"
? "Customer truck delivery"
: `EDR delivery${h.truckPlate ? ` (${h.truckPlate})` : ""}`}
</Text>
{h.signedAt && (
<Text fz="12.5px" c="dimmed" mt={1}>
Signed by {h.signerName || "Unknown"} on{" "}
{new Date(h.signedAt).toLocaleDateString()}
</Text>
)}
</Box>
<Pill
tone={h.signedAt ? "green" : "blue"}
label={h.signedAt ? "Signed" : "Pending signature"}
/>
</Group>
{h.signedAt && h.signatureImageUrl && (
<Box mt="sm">
<img
src={h.signatureImageUrl}
alt="Signature"
style={{
maxHeight: "60px",
maxWidth: "200px",
border: `1px solid ${BORDER}`,
borderRadius: "4px",
padding: "4px",
}}
/>
</Box>
)}
</Box>
))}
</Stack>
</SectionCard>
)}
{/* ── Warehouse documents (one-click bundle) ──────────────────────── */}
<SectionCard>
<CardTitle>Warehouse documents</CardTitle>

View File

@@ -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<WarehouseInventoryItem[]> => {
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<BookingHandover[]> => {
const { data } = await client.get(`/warehouse-inventory/bookings/${bookingId}/handovers`);
return data?.data ?? data ?? [];
},
};