mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Fix:
Added assertCapacity() checks before saving (matches single receive) Added applyCapacityDelta() after save to increment counters Now validates warehouse → yard → zone capacity hierarchy Single receive already had both checks; bulk receive was gap.
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedup stamp for the km/date-due maintenance alert — without it the daily
|
||||||
|
* cron would re-notify every day a schedule stays due.
|
||||||
|
*/
|
||||||
|
export class AddMaintenanceDueNotifiedAt2480000000000 implements MigrationInterface {
|
||||||
|
name = 'AddMaintenanceDueNotifiedAt2480000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.maintenance_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS due_notified_at timestamptz NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.maintenance_schedules DROP COLUMN IF EXISTS due_notified_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -436,18 +436,26 @@ export class BookingsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/customer-truck-assignment/freight-order')
|
@Get(':id/customer-truck-assignment/freight-order')
|
||||||
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Download freight order copies. The 2 gate copies always print; ?copies=1,2,8 adds waybill-style copies (catalog indexes 1-8).',
|
||||||
|
})
|
||||||
async customerTruckFreightOrder(
|
async customerTruckFreightOrder(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
@CurrentUser() user: TCurrentUser,
|
@CurrentUser() user: TCurrentUser,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
|
@Query('copies') copies?: string,
|
||||||
) {
|
) {
|
||||||
const booking = await this.bookingsService.findById(id);
|
const booking = await this.bookingsService.findById(id);
|
||||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
}
|
}
|
||||||
|
const extraCopyIndexes = (copies ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map((n) => Number(n.trim()))
|
||||||
|
.filter((n) => Number.isInteger(n) && n >= 1 && n <= 8);
|
||||||
const { filename, buffer } =
|
const { filename, buffer } =
|
||||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
|
|||||||
@@ -142,8 +142,21 @@ export class BookingsService {
|
|||||||
return this.findById(bookingId);
|
return this.findById(bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Selectable freight-order copies (rail-waybill style). Indexes 1-8. */
|
||||||
|
static readonly FREIGHT_ORDER_EXTRA_COPIES = [
|
||||||
|
'Original 1 (for Issuing Carrier)',
|
||||||
|
'Original 2 (for Consignee)',
|
||||||
|
'Original 3 (for Shipper)',
|
||||||
|
'Copy 4 (Delivery Receipt)',
|
||||||
|
'Copy 5 (Extra Copy)',
|
||||||
|
'Copy 6 (Extra Copy)',
|
||||||
|
'Copy 7 (Extra Copy)',
|
||||||
|
'Copy 8 (for Agent)',
|
||||||
|
] as const;
|
||||||
|
|
||||||
async customerTruckFreightOrderCopies(
|
async customerTruckFreightOrderCopies(
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
|
extraCopyIndexes: number[] = [],
|
||||||
): Promise<{ filename: string; buffer: Buffer }> {
|
): Promise<{ filename: string; buffer: Buffer }> {
|
||||||
const booking = await this.findById(bookingId);
|
const booking = await this.findById(bookingId);
|
||||||
if (!booking.customerTruckAssignedAt) {
|
if (!booking.customerTruckAssignedAt) {
|
||||||
@@ -171,7 +184,12 @@ export class BookingsService {
|
|||||||
[bookingId],
|
[bookingId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
// The 2 gate copies are ALWAYS printed; the waybill-style copies are
|
||||||
|
// whatever the customer ticked (indexes into the fixed catalog).
|
||||||
|
const extraCopies = [...new Set(extraCopyIndexes)]
|
||||||
|
.map((i) => BookingsService.FREIGHT_ORDER_EXTRA_COPIES[i - 1])
|
||||||
|
.filter(Boolean);
|
||||||
|
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks, extraCopies);
|
||||||
// Chromium when available; otherwise the styled tabular fallback (never the
|
// Chromium when available; otherwise the styled tabular fallback (never the
|
||||||
// generic text dump — the freight order is an outward-facing gate document).
|
// generic text dump — the freight order is an outward-facing gate document).
|
||||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||||
@@ -268,6 +286,7 @@ export class BookingsService {
|
|||||||
arrivedAt: string | null;
|
arrivedAt: string | null;
|
||||||
containers: string | null;
|
containers: string | null;
|
||||||
}>,
|
}>,
|
||||||
|
extraCopies: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||||
const assignedAt = booking.customerTruckAssignedAt
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
@@ -386,6 +405,7 @@ export class BookingsService {
|
|||||||
<body>
|
<body>
|
||||||
${copy('Copy 1: Port Operations Copy')}
|
${copy('Copy 1: Port Operations Copy')}
|
||||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||||
|
${extraCopies.map((label) => copy(label)).join('')}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,4 +62,8 @@ export class MaintenanceSchedule extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
||||||
nextDueDate?: Date;
|
nextDueDate?: Date;
|
||||||
|
|
||||||
|
/** Stamped once the km/date-due alert has fired, so the daily check doesn't repeat it. */
|
||||||
|
@Column({ name: 'due_notified_at', type: 'timestamptz', nullable: true })
|
||||||
|
dueNotifiedAt?: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,13 @@ export class MaintenanceController {
|
|||||||
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
return this.maintenanceService.updateMaintenanceSchedule(id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('due-board')
|
||||||
|
@BookingStaff([FREIGHT_PERMS.maintenance.view, FREIGHT_PERMS.fleetDashboard.view])
|
||||||
|
@ApiOperation({ summary: 'Fleet-wide next-due maintenance board (by date and km)' })
|
||||||
|
async getDueBoard() {
|
||||||
|
return this.maintenanceService.getDueBoard();
|
||||||
|
}
|
||||||
|
|
||||||
@Get('upcoming/:vehicleId')
|
@Get('upcoming/:vehicleId')
|
||||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ import { WorkOrderRepository } from './work-order.repository';
|
|||||||
import { PartRepository } from './part.repository';
|
import { PartRepository } from './part.repository';
|
||||||
import { WarrantyRepository } from './warranty.repository';
|
import { WarrantyRepository } from './warranty.repository';
|
||||||
import { MaintenanceController } from './maintenance.controller';
|
import { MaintenanceController } from './maintenance.controller';
|
||||||
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
||||||
|
NotificationInboxModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MaintenanceService,
|
MaintenanceService,
|
||||||
|
|||||||
@@ -47,4 +47,105 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
|||||||
.getRawOne();
|
.getRawOne();
|
||||||
return result?.total || 0;
|
return result?.total || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fleet-wide "next due" board: one row per vehicle with a SCHEDULED
|
||||||
|
* maintenance item, driven by time AND km — whichever is soonest. Current km
|
||||||
|
* is the vehicle's latest fuel-up odometer reading (how mileage is actually
|
||||||
|
* captured today), falling back to vehicle.actual_distance_km when the
|
||||||
|
* vehicle has no fuel purchase on file yet.
|
||||||
|
*/
|
||||||
|
async getDueBoard(): Promise<
|
||||||
|
Array<{
|
||||||
|
scheduleId: string;
|
||||||
|
vehicleId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
maintenanceType: string;
|
||||||
|
description: string;
|
||||||
|
scheduledDate: Date;
|
||||||
|
nextDueDate: Date | null;
|
||||||
|
nextDueKm: number | null;
|
||||||
|
currentKm: number | null;
|
||||||
|
kmRemaining: number | null;
|
||||||
|
daysRemaining: number | null;
|
||||||
|
overdue: boolean;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.scheduleRepository.manager.query(`
|
||||||
|
SELECT DISTINCT ON (s.vehicle_id)
|
||||||
|
s.id AS "scheduleId",
|
||||||
|
s.vehicle_id AS "vehicleId",
|
||||||
|
v.plate_number AS "plateNumber",
|
||||||
|
s.maintenance_type AS "maintenanceType",
|
||||||
|
s.description,
|
||||||
|
s.scheduled_date AS "scheduledDate",
|
||||||
|
s.next_due_date AS "nextDueDate",
|
||||||
|
s.next_due_km AS "nextDueKm",
|
||||||
|
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm",
|
||||||
|
CASE WHEN s.next_due_km IS NOT NULL
|
||||||
|
THEN s.next_due_km - COALESCE(fp.max_odometer, v.actual_distance_km, 0)
|
||||||
|
ELSE NULL END AS "kmRemaining",
|
||||||
|
CASE WHEN s.next_due_date IS NOT NULL
|
||||||
|
THEN EXTRACT(DAY FROM s.next_due_date - now())
|
||||||
|
ELSE NULL END AS "daysRemaining",
|
||||||
|
(
|
||||||
|
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||||
|
OR (s.next_due_km IS NOT NULL
|
||||||
|
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||||
|
) AS overdue
|
||||||
|
FROM freight.maintenance_schedules s
|
||||||
|
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MAX(odometer_reading) AS max_odometer
|
||||||
|
FROM freight.fuel_purchases fp2
|
||||||
|
WHERE fp2.vehicle_id = s.vehicle_id
|
||||||
|
) fp ON true
|
||||||
|
WHERE s.status = 'SCHEDULED' AND s.deleted_at IS NULL
|
||||||
|
ORDER BY s.vehicle_id, s.scheduled_date ASC
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SCHEDULED items that have crossed their km or date due-point and have not
|
||||||
|
* yet been notified. Backs the daily km/date maintenance alert.
|
||||||
|
*/
|
||||||
|
async getUnnotifiedDue(): Promise<
|
||||||
|
Array<{
|
||||||
|
id: string;
|
||||||
|
vehicleId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
maintenanceType: string;
|
||||||
|
description: string;
|
||||||
|
nextDueKm: number | null;
|
||||||
|
nextDueDate: Date | null;
|
||||||
|
currentKm: number | null;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
return this.scheduleRepository.manager.query(`
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.vehicle_id AS "vehicleId",
|
||||||
|
v.plate_number AS "plateNumber",
|
||||||
|
s.maintenance_type AS "maintenanceType",
|
||||||
|
s.description,
|
||||||
|
s.next_due_km AS "nextDueKm",
|
||||||
|
s.next_due_date AS "nextDueDate",
|
||||||
|
COALESCE(fp.max_odometer, v.actual_distance_km) AS "currentKm"
|
||||||
|
FROM freight.maintenance_schedules s
|
||||||
|
JOIN freight.vehicles v ON v.id = s.vehicle_id AND v.deleted_at IS NULL
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MAX(odometer_reading) AS max_odometer
|
||||||
|
FROM freight.fuel_purchases fp2
|
||||||
|
WHERE fp2.vehicle_id = s.vehicle_id
|
||||||
|
) fp ON true
|
||||||
|
WHERE s.status = 'SCHEDULED'
|
||||||
|
AND s.deleted_at IS NULL
|
||||||
|
AND s.due_notified_at IS NULL
|
||||||
|
AND (
|
||||||
|
(s.next_due_date IS NOT NULL AND s.next_due_date <= now())
|
||||||
|
OR (s.next_due_km IS NOT NULL
|
||||||
|
AND COALESCE(fp.max_odometer, v.actual_distance_km, 0) >= s.next_due_km)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { MaintenanceRepository } from './maintenance.repository';
|
import { MaintenanceRepository } from './maintenance.repository';
|
||||||
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
import { MaintenanceSchedule, MaintenanceStatus } from './entities/maintenance-schedule.entity';
|
||||||
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
import { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||||
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MaintenanceService {
|
export class MaintenanceService {
|
||||||
|
private readonly logger = new Logger(MaintenanceService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly maintenanceRepository: MaintenanceRepository,
|
private readonly maintenanceRepository: MaintenanceRepository,
|
||||||
@InjectRepository(MaintenanceSchedule)
|
@InjectRepository(MaintenanceSchedule)
|
||||||
@@ -18,8 +23,44 @@ export class MaintenanceService {
|
|||||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||||
// reach it through the global DataSource rather than @InjectRepository.
|
// reach it through the global DataSource rather than @InjectRepository.
|
||||||
private readonly dataSource: DataSource,
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly inbox: NotificationInboxService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** Fleet-wide next-due board — see MaintenanceRepository.getDueBoard. */
|
||||||
|
async getDueBoard() {
|
||||||
|
return this.maintenanceRepository.getDueBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Daily check: a vehicle's driven km (latest fuel-up odometer reading, since
|
||||||
|
* that's the only place mileage is actually recorded) or its due date has
|
||||||
|
* reached a SCHEDULED item's threshold → alert backoffice once.
|
||||||
|
*/
|
||||||
|
@Cron(CronExpression.EVERY_DAY_AT_7AM, { name: 'maintenance-due-alert' })
|
||||||
|
async sendDueAlerts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const due = await this.maintenanceRepository.getUnnotifiedDue();
|
||||||
|
for (const item of due) {
|
||||||
|
const reason =
|
||||||
|
item.nextDueKm != null && (item.currentKm ?? 0) >= item.nextDueKm
|
||||||
|
? `driven ${item.currentKm} km (due at ${item.nextDueKm} km)`
|
||||||
|
: `due ${new Date(item.nextDueDate as Date).toLocaleDateString()}`;
|
||||||
|
await this.inbox.notify({
|
||||||
|
recipients: { allBackoffice: true },
|
||||||
|
audience: NotificationAudience.BACKOFFICE,
|
||||||
|
type: NotificationType.GENERIC,
|
||||||
|
title: `Maintenance due — ${item.plateNumber}`,
|
||||||
|
body: `${item.plateNumber} (${item.maintenanceType}) is due for maintenance — ${reason}. ${item.description}`,
|
||||||
|
link: `/dashboard/maintenance?vehicleId=${item.vehicleId}`,
|
||||||
|
data: { vehicleId: item.vehicleId, scheduleId: item.id, action: 'MAINTENANCE_DUE' },
|
||||||
|
});
|
||||||
|
await this.scheduleRepository.update(item.id, { dueNotifiedAt: new Date() });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`sendDueAlerts failed: ${(err as Error).message}`, (err as Error).stack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
* Reflect a maintenance schedule's lifecycle on the target vehicle. A vehicle
|
||||||
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
* under maintenance is taken out of service (MAINTENANCE + BUSY); once the
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||||
driverName: truckPrefill.driverName ?? '',
|
driverName: truckPrefill.driverName ?? '',
|
||||||
|
driverLicense: truckPrefill.driverLicense ?? '',
|
||||||
driverPhone: truckPrefill.driverPhone ?? '',
|
driverPhone: truckPrefill.driverPhone ?? '',
|
||||||
truckType: truckPrefill.truckType ?? '',
|
truckType: truckPrefill.truckType ?? '',
|
||||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||||
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||||
trailerPlate: '',
|
trailerPlate: '',
|
||||||
driverName: t.driverName,
|
driverName: t.driverName,
|
||||||
|
driverLicense: '',
|
||||||
driverPhone: '',
|
driverPhone: '',
|
||||||
truckType: t.truckType,
|
truckType: t.truckType,
|
||||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||||
@@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||||
trailerPlate: t.trailerPlateNumber ?? '',
|
trailerPlate: t.trailerPlateNumber ?? '',
|
||||||
driverName: t.driverName ?? '',
|
driverName: t.driverName ?? '',
|
||||||
|
driverLicense: t.driverLicense ?? '',
|
||||||
driverPhone: t.driverPhone ?? '',
|
driverPhone: t.driverPhone ?? '',
|
||||||
truckType: t.truckType ?? '',
|
truckType: t.truckType ?? '',
|
||||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||||
@@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||||
|
// The freight order's truck details are the customer's / fleet's record — the
|
||||||
|
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
|
||||||
|
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
|
||||||
|
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
|
||||||
|
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
|
||||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||||
|
|
||||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||||
@@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
setTruckPlateNumber(plate);
|
setTruckPlateNumber(plate);
|
||||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||||
setDriverName(block?.driverName || option?.driverName || '');
|
setDriverName(block?.driverName || option?.driverName || '');
|
||||||
setDriverLicense(block?.driverLicense || '');
|
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||||
setTruckType(block?.truckType || option?.truckType || '');
|
setTruckType(block?.truckType || option?.truckType || '');
|
||||||
const loaded = block
|
const loaded = block
|
||||||
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
|||||||
label="Trailer plate number"
|
label="Trailer plate number"
|
||||||
value={trailerPlateNumber}
|
value={trailerPlateNumber}
|
||||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||||
readOnly={isEntranceLocked}
|
readOnly={isTrailerLocked}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
|
||||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Checkbox,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -27,6 +28,19 @@ import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
|||||||
|
|
||||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||||
|
|
||||||
|
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
|
||||||
|
// copies (Port Operations, Gate Security & Carrier) are always printed.
|
||||||
|
const FREIGHT_ORDER_COPIES = [
|
||||||
|
{ index: 1, label: "Original 1 (for Issuing Carrier)" },
|
||||||
|
{ index: 2, label: "Original 2 (for Consignee)" },
|
||||||
|
{ index: 3, label: "Original 3 (for Shipper)" },
|
||||||
|
{ index: 4, label: "Copy 4 (Delivery Receipt)" },
|
||||||
|
{ index: 5, label: "Copy 5 (Extra Copy)" },
|
||||||
|
{ index: 6, label: "Copy 6 (Extra Copy)" },
|
||||||
|
{ index: 7, label: "Copy 7 (Extra Copy)" },
|
||||||
|
{ index: 8, label: "Copy 8 (for Agent)" },
|
||||||
|
];
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob, filename: string) => {
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -138,8 +152,11 @@ export function CustomerTruckAssignmentCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||||
|
const [selectedCopies, setSelectedCopies] = useState<number[]>(
|
||||||
|
FREIGHT_ORDER_COPIES.map((c) => c.index),
|
||||||
|
);
|
||||||
const downloadFreightOrder = async () => {
|
const downloadFreightOrder = async () => {
|
||||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
const blob = await downloadMutation.mutateAsync({ id: booking.id, copies: selectedCopies });
|
||||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,17 +339,52 @@ export function CustomerTruckAssignmentCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{trucks.length > 0 && (
|
{trucks.length > 0 && (
|
||||||
<Group justify="flex-end">
|
<Stack gap="xs">
|
||||||
<Button
|
<Text fz="13px" fw={600}>Copies</Text>
|
||||||
variant="light"
|
<Group gap="xs">
|
||||||
leftSection={<Download size={16} />}
|
<Button
|
||||||
color="edr-green"
|
size="compact-xs"
|
||||||
onClick={downloadFreightOrder}
|
variant="default"
|
||||||
loading={downloadMutation.isPending}
|
onClick={() => setSelectedCopies(FREIGHT_ORDER_COPIES.map((c) => c.index))}
|
||||||
>
|
>
|
||||||
Generate Freight Order Copies
|
8 copies
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
<Button size="compact-xs" variant="default" onClick={() => setSelectedCopies([])}>
|
||||||
|
clear selection
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<SimpleGrid cols={2} spacing={6} verticalSpacing={6}>
|
||||||
|
{FREIGHT_ORDER_COPIES.map((c) => (
|
||||||
|
<Checkbox
|
||||||
|
key={c.index}
|
||||||
|
size="xs"
|
||||||
|
label={c.label}
|
||||||
|
checked={selectedCopies.includes(c.index)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedCopies((prev) =>
|
||||||
|
e.currentTarget.checked
|
||||||
|
? [...prev, c.index].sort((a, b) => a - b)
|
||||||
|
: prev.filter((i) => i !== c.index),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Group justify="space-between" align="center">
|
||||||
|
<Text fz="11.5px" c="#9AA8B5">
|
||||||
|
Port Operations and Gate Security copies are always included.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
leftSection={<Download size={16} />}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={downloadFreightOrder}
|
||||||
|
loading={downloadMutation.isPending}
|
||||||
|
>
|
||||||
|
Generate Freight Order Copies
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -308,10 +308,10 @@ export const api = {
|
|||||||
bookingsService.assignCustomerTruck(id, payload),
|
bookingsService.assignCustomerTruck(id, payload),
|
||||||
),
|
),
|
||||||
|
|
||||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
|
downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>(
|
||||||
"bookings",
|
"bookings",
|
||||||
"downloadCustomerTruckFreightOrder",
|
"downloadCustomerTruckFreightOrder",
|
||||||
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
|
({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies),
|
||||||
),
|
),
|
||||||
|
|
||||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||||
|
|||||||
@@ -212,10 +212,10 @@ export const bookingsService = {
|
|||||||
);
|
);
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
|
downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise<Blob> => {
|
||||||
const { data } = await client.get(
|
const { data } = await client.get(
|
||||||
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
|
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
|
||||||
{ responseType: "blob" },
|
{ responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user