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:
Hagernesh
2026-07-22 13:13:14 +00:00
parent 93cf4d1e2a
commit ec066f3d29
12 changed files with 289 additions and 24 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' })

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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