mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #905 from Tria-plc/testfixes
Fixes Download Excel template with instructions Parse uploaded file, preview trucks Validate container assignments (1x40ft OR 2x20ft per truck) Commit bulk upload in one call
This commit is contained in:
@@ -480,6 +480,20 @@ export class BookingsController {
|
||||
return this.customerTruckService.addTruck(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/bulk')
|
||||
@ApiOperation({ summary: 'Bulk add customer trucks from array payload (Excel parsed)' })
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.customerTruckService.addBulkTrucks(id, payload.trucks);
|
||||
}
|
||||
|
||||
@Patch(':id/customer-trucks/:assignmentId')
|
||||
@ApiOperation({ summary: 'Edit a not-yet-arrived customer truck (plate/driver/type + containers)' })
|
||||
async updateCustomerTruck(
|
||||
|
||||
@@ -576,4 +576,35 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: successCount,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
}
|
||||
@@ -2811,13 +2811,12 @@ export class TrainSchedulingService {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
@@ -2826,8 +2825,6 @@ export class TrainSchedulingService {
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
@@ -2909,8 +2906,6 @@ export class TrainSchedulingService {
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
@@ -2918,7 +2913,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
${rows || '<tr><td colspan="10">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
|
||||
/**
|
||||
* Customer Pickup + Proof of Delivery capture for a LOADED cargo.
|
||||
@@ -27,6 +28,10 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
toast({ title: 'Receiver name is required', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
if (isBackdated(pickupDate)) {
|
||||
toast({ title: 'Pickup date cannot be in the past', variant: 'destructive' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliver.mutateAsync({
|
||||
id: cargoId,
|
||||
@@ -75,6 +80,7 @@ export function DeliverCargoDialog({ cargoId, onSuccess }: { cargoId: string; on
|
||||
<Label>Pickup date</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
min={nowLocalDateTimeInput()}
|
||||
value={pickupDate}
|
||||
onChange={(e) => setPickupDate(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated } from '@/lib/no-backdate';
|
||||
import { lastMileService, type LastMileRecord } from '@/services/last-mile.service';
|
||||
|
||||
interface TruckDetentionModalProps {
|
||||
@@ -111,6 +112,7 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Detention clock start"
|
||||
value={arrived}
|
||||
onChange={(v) => setArrived(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
<DateTimePicker
|
||||
@@ -118,11 +120,26 @@ export function TruckDetentionModal({ opened, onClose, record }: TruckDetentionM
|
||||
description="Clock end (blank = still out)"
|
||||
value={delivered}
|
||||
onChange={(v) => setDelivered(v ? new Date(v) : null)}
|
||||
minDate={new Date()}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" loading={saveTimes.isPending} onClick={() => saveTimes.mutate()}>
|
||||
<Button
|
||||
variant="light"
|
||||
loading={saveTimes.isPending}
|
||||
onClick={() => {
|
||||
// No backdating: detention times are recorded as they happen.
|
||||
if (isBackdated(arrived) || isBackdated(delivered)) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Detention times cannot be in the past',
|
||||
});
|
||||
return;
|
||||
}
|
||||
saveTimes.mutate();
|
||||
}}
|
||||
>
|
||||
Save times
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { isBackdated, nowLocalDateTimeInput } from '@/lib/no-backdate';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
@@ -440,6 +441,12 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
// No backdating: gate times are recorded as they happen. The locked
|
||||
// entrance (exit step) keeps its original past gate-in untouched.
|
||||
if (!isEntranceLocked && isBackdated(gateInTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate in time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && (!gateOutTime || (!skipWeighing && grossWeight === ''))) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
@@ -447,6 +454,10 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (isExitStep && isBackdated(gateOutTime)) {
|
||||
toast({ variant: 'destructive', title: 'Gate out time cannot be in the past' });
|
||||
return;
|
||||
}
|
||||
if (isExitStep && hasContainerWeights && selectedContainerNumbers.length === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select the containers loaded on this truck' });
|
||||
return;
|
||||
@@ -646,7 +657,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
)}
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Gate in time" type="datetime-local" min={isEntranceLocked ? undefined : nowLocalDateTimeInput()} value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
</Group>
|
||||
{hasContainerWeights && (
|
||||
<Group gap="md" align="center">
|
||||
@@ -679,7 +690,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} t`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
<TextInput label="Gate out time" type="datetime-local" min={nowLocalDateTimeInput()} value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} disabled={!isExitStep || hasTruckLeft} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
|
||||
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
20
apps/edr-freight-web/backoffice/src/lib/no-backdate.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Backdating guard for operational time entries (gate in/out, mile truck
|
||||
* times, delivery pickups): times must be recorded as they happen, never
|
||||
* dated back. A one-hour grace covers real-world lag (weighbridge queue,
|
||||
* operator finishing the form after the event).
|
||||
*/
|
||||
export const BACKDATE_GRACE_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Local-time "YYYY-MM-DDTHH:mm" for a datetime-local input's `min`. */
|
||||
export const nowLocalDateTimeInput = (): string =>
|
||||
new Date(Date.now() - new Date().getTimezoneOffset() * 60_000)
|
||||
.toISOString()
|
||||
.slice(0, 16);
|
||||
|
||||
/** True when the value is more than the grace period in the past. */
|
||||
export const isBackdated = (value: string | Date | null | undefined): boolean => {
|
||||
if (!value) return false;
|
||||
const t = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
return Number.isFinite(t) && t < Date.now() - BACKDATE_GRACE_MS;
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core";
|
||||
import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||
|
||||
interface BulkTruckUploadModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function BulkTruckUploadModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
onSuccess,
|
||||
}: BulkTruckUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
>([]);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`/bookings/${bookingId}/customer-trucks/bulk`, {
|
||||
trucks: parsed,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess?.();
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileSelect = async (selectedFile: File | null) => {
|
||||
if (!selectedFile) {
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
setParseError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setParseError(null);
|
||||
const trucks = await parseTruckAssignmentFile(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setParsed(trucks);
|
||||
} catch (err: any) {
|
||||
setParseError(err.message || "Failed to parse Excel file");
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
generateTruckAssignmentTemplate("truck-assignments.xlsx");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Bulk Upload Truck Assignments"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue">
|
||||
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
|
||||
</Alert>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
variant="light"
|
||||
onClick={handleDownloadTemplate}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<FileInput
|
||||
label="Select Excel File"
|
||||
placeholder="Choose .xlsx file"
|
||||
accept=".xlsx,.xls"
|
||||
value={file}
|
||||
onChange={handleFileSelect}
|
||||
leftSection={<Upload size={14} />}
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
|
||||
{parseError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{parsed.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={600} mb="xs">
|
||||
Preview ({parsed.length} trucks)
|
||||
</Text>
|
||||
<Table striped highlightOnHover size="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate Number</Table.Th>
|
||||
<Table.Th>Driver Name</Table.Th>
|
||||
<Table.Th>Truck Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{parsed.map((truck, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.truckPlateNumber}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.driverName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.truckType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{truck.containerNumbers?.length ? (
|
||||
<Group gap="xs">
|
||||
{truck.containerNumbers.map((c) => (
|
||||
<Badge key={c} size="sm">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Ready to upload {parsed.length} truck(s)
|
||||
</Text>
|
||||
<Button
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
leftSection={<CheckCircle size={16} />}
|
||||
>
|
||||
Upload Trucks
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert icon={<AlertTriangle size={16} />} color="red">
|
||||
{uploadMutation.error instanceof Error
|
||||
? uploadMutation.error.message
|
||||
: "Upload failed"}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
@@ -23,6 +23,7 @@ import { api } from "@/services/api";
|
||||
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
|
||||
@@ -65,6 +66,7 @@ export function CustomerTruckAssignmentCard({
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
@@ -163,6 +165,14 @@ export function CustomerTruckAssignmentCard({
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
<Group gap={12}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
{pendingAssignmentCount > 0 && (
|
||||
<Text size="sm" fw={600} c="#b45309">
|
||||
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
|
||||
@@ -325,6 +335,16 @@ export function CustomerTruckAssignmentCard({
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<BulkTruckUploadModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
bookingId={booking.id}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: trucksKey });
|
||||
onAssigned();
|
||||
}}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void {
|
||||
const data = [
|
||||
{
|
||||
'Truck Plate Number': '3-12345/67890',
|
||||
'Driver Name': 'John Doe',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'MAEU1234567',
|
||||
'Container 2': 'HLXU7654321',
|
||||
},
|
||||
{
|
||||
'Truck Plate Number': '3-98765/43210',
|
||||
'Driver Name': 'Jane Smith',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'COSCO1111111',
|
||||
'Container 2': '',
|
||||
},
|
||||
];
|
||||
|
||||
const instructions = [
|
||||
['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'],
|
||||
[],
|
||||
['Column', 'Required', 'Notes'],
|
||||
['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'],
|
||||
['Driver Name', 'Yes', 'Full name of truck driver'],
|
||||
['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'],
|
||||
['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'],
|
||||
['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'],
|
||||
[],
|
||||
['CONTAINER RULES'],
|
||||
['- A 40ft container fills one truck (max 1 per truck)'],
|
||||
['- Two 20ft containers fit on one truck (max 2 per truck)'],
|
||||
['- No size mixing on same truck'],
|
||||
['- Containers must be from the booking'],
|
||||
[],
|
||||
['Example Data Below →'],
|
||||
];
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
// Instructions sheet
|
||||
const wsInstructions = XLSX.utils.aoa_to_sheet(instructions);
|
||||
wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions');
|
||||
|
||||
// Data template sheet
|
||||
const wsData = XLSX.utils.json_to_sheet(data, {
|
||||
header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'],
|
||||
});
|
||||
wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsData, 'Trucks');
|
||||
|
||||
XLSX.writeFile(wb, filename);
|
||||
}
|
||||
|
||||
export function parseTruckAssignmentFile(
|
||||
file: File,
|
||||
): Promise<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0];
|
||||
|
||||
if (!wsData) {
|
||||
reject(new Error('No data sheet found in Excel file'));
|
||||
return;
|
||||
}
|
||||
|
||||
const jsonData = XLSX.utils.sheet_to_json(wsData) as Array<Record<string, any>>;
|
||||
|
||||
const trucks = jsonData.map((row) => {
|
||||
const containers = [
|
||||
row['Container 1'],
|
||||
row['Container 2'],
|
||||
]
|
||||
.filter((c) => c && c.trim())
|
||||
.map((c) => c.trim().toUpperCase());
|
||||
|
||||
return {
|
||||
truckPlateNumber: row['Truck Plate Number']?.trim() || '',
|
||||
driverName: row['Driver Name']?.trim() || '',
|
||||
truckType: row['Truck Type']?.trim() || '',
|
||||
containerNumbers: containers.length > 0 ? containers : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
resolve(trucks);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user