mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -146,6 +146,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Overview",
|
||||
href: "/dashboard/overview",
|
||||
icon: <LayoutDashboard />,
|
||||
permission: FREIGHT_PERMS.overview.view,
|
||||
},
|
||||
{
|
||||
label: "Customers",
|
||||
@@ -189,7 +190,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Support",
|
||||
href: "/dashboard/support",
|
||||
icon: <LifeBuoy />,
|
||||
permission: FREIGHT_PERMS.bookings.view,
|
||||
permission: FREIGHT_PERMS.support.view,
|
||||
},
|
||||
...demoItems,
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -2,6 +2,12 @@ import type { AuthUser } from "@/auth/types";
|
||||
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
|
||||
|
||||
export const FREIGHT_PERMS = {
|
||||
overview: {
|
||||
view: "edr_freight_app:overview:view",
|
||||
},
|
||||
support: {
|
||||
view: "edr_freight_app:support:view",
|
||||
},
|
||||
bookings: {
|
||||
view: "edr_freight_app:bookings:view",
|
||||
create: "edr_freight_app:bookings:create",
|
||||
|
||||
@@ -61,6 +61,7 @@ const clean = <T extends Record<string, unknown>>(obj: T): Partial<T> =>
|
||||
) as Partial<T>;
|
||||
|
||||
const emptyAcquisition = {
|
||||
itemName: "",
|
||||
vehicleId: "",
|
||||
vendorId: "",
|
||||
acquisitionType: "PURCHASE" as AcquisitionType,
|
||||
@@ -239,6 +240,7 @@ export default function ProcurementPage() {
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Item / Asset</Table.Th>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
@@ -249,7 +251,7 @@ export default function ProcurementPage() {
|
||||
<Table.Tbody>
|
||||
{loadingAcquisitions ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Group justify="center" py="md">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
@@ -257,7 +259,7 @@ export default function ProcurementPage() {
|
||||
</Table.Tr>
|
||||
) : acquisitions.length === 0 ? (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={5}>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="md">
|
||||
No acquisitions recorded yet.
|
||||
</Text>
|
||||
@@ -266,6 +268,7 @@ export default function ProcurementPage() {
|
||||
) : null}
|
||||
{acquisitions.map((a: AssetAcquisition) => (
|
||||
<Table.Tr key={a.id}>
|
||||
<Table.Td>{a.itemName || "—"}</Table.Td>
|
||||
<Table.Td>{vehicleLabel(a.vehicle, a.vehicleId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={typeBadgeColor(a.acquisitionType)}>
|
||||
@@ -411,31 +414,51 @@ export default function ProcurementPage() {
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Item / Asset"
|
||||
placeholder="What was acquired — e.g. brake pads, tyres, truck 3-15288"
|
||||
value={acqForm.itemName}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, itemName: e.currentTarget.value })}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Vehicle"
|
||||
placeholder="Select vehicle"
|
||||
label="Related vehicle (optional)"
|
||||
description="Only when the acquisition is a fleet vehicle itself — parts and general procurement stay unlinked."
|
||||
placeholder="Not tied to a vehicle"
|
||||
data={vehicleOptions}
|
||||
value={acqForm.vehicleId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vehicleId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Group gap="xs" align="flex-end" wrap="nowrap">
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
label="Vendor"
|
||||
placeholder="Select vendor"
|
||||
data={vendorOptions}
|
||||
value={acqForm.vendorId}
|
||||
onChange={(val) => setAcqForm({ ...acqForm, vendorId: val || "" })}
|
||||
searchable
|
||||
clearable
|
||||
/>
|
||||
<Button variant="light" size="sm" onClick={() => setVendorModalOpen(true)}>
|
||||
Register vendor
|
||||
</Button>
|
||||
</Group>
|
||||
<Select
|
||||
label="Acquisition Type"
|
||||
data={ACQUISITION_TYPES}
|
||||
value={acqForm.acquisitionType}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, acquisitionType: (val as AcquisitionType) || "PURCHASE" })
|
||||
}
|
||||
onChange={(val) => {
|
||||
const acquisitionType = (val as AcquisitionType) || "PURCHASE";
|
||||
// Lease terms are invalid on a purchase — drop them on switch.
|
||||
setAcqForm(
|
||||
acquisitionType === "PURCHASE"
|
||||
? { ...acqForm, acquisitionType, leaseStart: "", leaseEnd: "", monthlyPayment: undefined }
|
||||
: { ...acqForm, acquisitionType },
|
||||
);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
@@ -471,28 +494,32 @@ export default function ProcurementPage() {
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
{acqForm.acquisitionType !== "PURCHASE" && (
|
||||
<>
|
||||
<TextInput
|
||||
label="Lease Start"
|
||||
type="date"
|
||||
value={acqForm.leaseStart}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseStart: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Lease End"
|
||||
type="date"
|
||||
value={acqForm.leaseEnd}
|
||||
onChange={(e) => setAcqForm({ ...acqForm, leaseEnd: e.currentTarget.value })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Monthly Payment"
|
||||
placeholder="0.00"
|
||||
value={acqForm.monthlyPayment}
|
||||
onChange={(val) =>
|
||||
setAcqForm({ ...acqForm, monthlyPayment: val as number | undefined })
|
||||
}
|
||||
decimalScale={2}
|
||||
min={0}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Select
|
||||
label="Status"
|
||||
data={ACQUISITION_STATUSES}
|
||||
@@ -514,7 +541,7 @@ export default function ProcurementPage() {
|
||||
<Button
|
||||
onClick={() => createAcquisition.mutate()}
|
||||
loading={createAcquisition.isPending}
|
||||
disabled={!acqForm.acquisitionDate}
|
||||
disabled={!acqForm.acquisitionDate || acqForm.itemName.trim().length < 2}
|
||||
>
|
||||
Save Acquisition
|
||||
</Button>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface Vendor {
|
||||
|
||||
export interface AssetAcquisition {
|
||||
id: string;
|
||||
itemName?: string | null;
|
||||
vehicleId?: string | null;
|
||||
vendorId?: string | null;
|
||||
acquisitionType: AcquisitionType;
|
||||
|
||||
@@ -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