mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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')
|
||||
@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(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
@Query('copies') copies?: string,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
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 } =
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id);
|
||||
await this.bookingsService.customerTruckFreightOrderCopies(id, extraCopyIndexes);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
|
||||
@@ -142,8 +142,21 @@ export class BookingsService {
|
||||
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(
|
||||
bookingId: string,
|
||||
extraCopyIndexes: number[] = [],
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (!booking.customerTruckAssignedAt) {
|
||||
@@ -171,7 +184,12 @@ export class BookingsService {
|
||||
[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
|
||||
// generic text dump — the freight order is an outward-facing gate document).
|
||||
const buffer = await this.pdfRender.htmlToPdfBuffer(html, {
|
||||
@@ -268,6 +286,7 @@ export class BookingsService {
|
||||
arrivedAt: string | null;
|
||||
containers: string | null;
|
||||
}>,
|
||||
extraCopies: string[] = [],
|
||||
): string {
|
||||
const esc = (v: unknown) => this.escapeHtml(String(v ?? '-'));
|
||||
const assignedAt = booking.customerTruckAssignedAt
|
||||
@@ -386,6 +405,7 @@ export class BookingsService {
|
||||
<body>
|
||||
${copy('Copy 1: Port Operations Copy')}
|
||||
${copy('Copy 2: Gate Security & Carrier Copy')}
|
||||
${extraCopies.map((label) => copy(label)).join('')}
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@@ -62,4 +62,8 @@ export class MaintenanceSchedule extends BaseEntity {
|
||||
|
||||
@Column({ name: 'next_due_date', type: 'timestamptz', nullable: true })
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@BookingStaff(FREIGHT_PERMS.maintenance.view)
|
||||
@ApiOperation({ summary: 'Get upcoming maintenance' })
|
||||
|
||||
@@ -12,10 +12,12 @@ import { WorkOrderRepository } from './work-order.repository';
|
||||
import { PartRepository } from './part.repository';
|
||||
import { WarrantyRepository } from './warranty.repository';
|
||||
import { MaintenanceController } from './maintenance.controller';
|
||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MaintenanceSchedule, MaintenanceCost, WorkOrder, Part, Warranty]),
|
||||
NotificationInboxModule,
|
||||
],
|
||||
providers: [
|
||||
MaintenanceService,
|
||||
|
||||
@@ -47,4 +47,105 @@ export class MaintenanceRepository extends BaseRepository<MaintenanceSchedule> {
|
||||
.getRawOne();
|
||||
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 { 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 { MaintenanceCost } from './entities/maintenance-cost.entity';
|
||||
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
|
||||
import { CreateMaintenanceScheduleDto, CreateMaintenanceCostDto, UpdateMaintenanceScheduleDto } from './dto/create-maintenance.dto';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
private readonly logger = new Logger(MaintenanceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly maintenanceRepository: MaintenanceRepository,
|
||||
@InjectRepository(MaintenanceSchedule)
|
||||
@@ -18,8 +23,44 @@ export class MaintenanceService {
|
||||
// Vehicle isn't registered in this module's TypeOrmModule.forFeature, so we
|
||||
// reach it through the global DataSource rather than @InjectRepository.
|
||||
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
|
||||
* 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}`,
|
||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverLicense: truckPrefill.driverLicense ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||
trailerPlate: '',
|
||||
driverName: t.driverName,
|
||||
driverLicense: '',
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
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}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverLicense: t.driverLicense ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
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.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
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;
|
||||
|
||||
/** 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);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTrailerLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<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 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} />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -27,6 +28,19 @@ import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
||||
|
||||
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 url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
@@ -138,8 +152,11 @@ export function CustomerTruckAssignmentCard({
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
const [selectedCopies, setSelectedCopies] = useState<number[]>(
|
||||
FREIGHT_ORDER_COPIES.map((c) => c.index),
|
||||
);
|
||||
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`);
|
||||
};
|
||||
|
||||
@@ -322,17 +339,52 @@ export function CustomerTruckAssignmentCard({
|
||||
)}
|
||||
|
||||
{trucks.length > 0 && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
loading={downloadMutation.isPending}
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Text fz="13px" fw={600}>Copies</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
onClick={() => setSelectedCopies(FREIGHT_ORDER_COPIES.map((c) => c.index))}
|
||||
>
|
||||
8 copies
|
||||
</Button>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -308,10 +308,10 @@ export const api = {
|
||||
bookingsService.assignCustomerTruck(id, payload),
|
||||
),
|
||||
|
||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
|
||||
downloadCustomerTruckFreightOrder: endpoint<{ id: string; copies?: number[] }, Blob>(
|
||||
"bookings",
|
||||
"downloadCustomerTruckFreightOrder",
|
||||
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
|
||||
({ id, copies }) => bookingsService.downloadCustomerTruckFreightOrder(id, copies),
|
||||
),
|
||||
|
||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||
|
||||
@@ -212,10 +212,10 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
|
||||
downloadCustomerTruckFreightOrder: async (id: string, copies?: number[]): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
|
||||
{ responseType: "blob" },
|
||||
{ responseType: "blob", params: copies?.length ? { copies: copies.join(",") } : undefined },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user