This commit is contained in:
natib21
2026-07-04 01:47:28 +00:00
parent 58c49d34b2
commit bd88424167
10 changed files with 637 additions and 87 deletions

View File

@@ -5,12 +5,14 @@ import {
Eye,
MoreHorizontal,
PackageCheck,
Plus,
Printer,
Receipt,
RefreshCw,
Ruler,
Trash,
Truck,
X,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
@@ -45,6 +47,7 @@ import {
FIRST_MILE_STATUSES,
type FirstMileApiStatus,
type FirstMileRecord,
type FirstMileVehicle,
firstMileService,
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
@@ -109,7 +112,14 @@ const vehicleLabel = (record: FirstMileRecord) => {
return parts.join(" · ");
};
const isAssigned = (record: FirstMileRecord) => Boolean(record.vehicleId);
const isAssigned = (record: FirstMileRecord) =>
Boolean(record.vehicleId) || Boolean(record.vehicleAssignments?.length);
/** Container numbers on a booking, in line order (skips lines without one). */
const bookingContainerNumbers = (record: FirstMileRecord): string[] =>
(record.booking?.bookingContainers ?? [])
.map((c) => c.containerNumber)
.filter((n): n is string => Boolean(n));
// Paid = record flag set OR its invoice reached PAID.
const isPaidRecord = (r: FirstMileRecord) =>
@@ -360,7 +370,10 @@ const FirstMilePage = () => {
const [tripSlipOpen, setTripSlipOpen] = useState(false);
const [tripSlipRecord, setTripSlipRecord] = useState<FirstMileRecord | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const [vehicleValue, setVehicleValue] = useState<string | null>(null);
// Multi-vehicle assign: one row per truck — vehicle + the container it carries.
const [vehicleRows, setVehicleRows] = useState<
Array<{ vehicleId: string | null; containerNumber: string }>
>([{ vehicleId: null, containerNumber: "" }]);
const [acceptOpen, setAcceptOpen] = useState(false);
const [acceptStep, setAcceptStep] = useState<1 | 2>(1);
@@ -369,7 +382,8 @@ const FirstMilePage = () => {
const [bookingSearch, setBookingSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
// Per-vehicle actual distance, keyed by vehicleId.
const [distanceRows, setDistanceRows] = useState<Record<string, string>>({});
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
@@ -435,14 +449,36 @@ const FirstMilePage = () => {
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
const setVehiclesMutation = useMutation({
mutationFn: ({
id,
vehicles,
}: {
id: string;
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>;
}) => firstMileService.setVehicles(id, vehicles),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
void qc.invalidateQueries({ queryKey: ["vehicles"] });
},
onError: () => {
toast({ title: "Assign failed", variant: "destructive" });
},
});
const setDistancesMutation = useMutation({
mutationFn: ({
id,
distances,
remainingPayment,
}: {
id: string;
distances: Array<{ vehicleId: string; distanceKm: number }>;
remainingPayment?: number;
}) => firstMileService.setDistances(id, distances, remainingPayment),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
toast({ title: "Distances saved", description: activeRecord ? bookingRef(activeRecord) : undefined });
closeDistance();
},
onError: () => {
@@ -501,6 +537,37 @@ const FirstMilePage = () => {
[records, activeId],
);
// Picker options = free vehicles PLUS the ones already on this record (which are
// BUSY, so absent from the free list) so a reassign shows its current trucks
// selected instead of blank.
const assignVehicleOptions = useMemo(() => {
const opts = [...vehicleOptions];
const seen = new Set(opts.map((o) => o.value));
const pushVehicle = (v?: FirstMileVehicle | null) => {
if (v && !seen.has(v.id)) {
seen.add(v.id);
const parts = [`${v.manufacturer} ${v.model}`, v.plateNumber];
if (v.code) parts.unshift(v.code);
opts.push({ value: v.id, label: parts.join(" · ") });
}
};
for (const a of activeRecord?.vehicleAssignments ?? []) pushVehicle(a.vehicle);
pushVehicle(activeRecord?.vehicle);
for (const a of activeRecord?.vehicleAssignments ?? []) {
if (!seen.has(a.vehicleId)) {
seen.add(a.vehicleId);
opts.push({
value: a.vehicleId,
label: a.containerNumber ? `Assigned · ${a.containerNumber}` : "Assigned vehicle",
});
}
}
if (activeRecord?.vehicleId && !seen.has(activeRecord.vehicleId)) {
opts.push({ value: activeRecord.vehicleId, label: "Assigned vehicle" });
}
return opts;
}, [vehicleOptions, activeRecord]);
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
@@ -554,15 +621,20 @@ const FirstMilePage = () => {
};
const openDistance = (id: string) => {
const rec = records.find((r) => r.id === id);
const rows: Record<string, string> = {};
for (const a of rec?.vehicleAssignments ?? []) {
rows[a.vehicleId] = a.distanceKm != null ? String(a.distanceKm) : "";
}
setActiveId(id);
setDistanceValue("");
setDistanceRows(rows);
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
setDistanceRows({});
};
@@ -577,24 +649,27 @@ const FirstMilePage = () => {
};
const handleSaveDistance = () => {
const distance = parseFloat(distanceValue);
if (!activeId || isNaN(distance) || distance < 0) {
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
const distances = Object.entries(distanceRows)
.map(([vehicleId, val]) => ({ vehicleId, distanceKm: parseFloat(val) }))
.filter((d) => !Number.isNaN(d.distanceKm) && d.distanceKm >= 0);
if (!activeId || !distances.length) {
toast({ title: "Invalid distance", description: "Enter a distance for at least one vehicle.", variant: "destructive" });
return;
}
const total = distances.reduce((s, d) => s + d.distanceKm, 0);
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
const rateValue = parseFloat(firstMileRate.rateValue);
remainingPayment = distance * rateValue;
remainingPayment = total * parseFloat(firstMileRate.rateValue);
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
setDistancesMutation.mutate({ id: activeId, distances, remainingPayment });
};
const matchesFilter = (r: FirstMileRecord) => {
@@ -651,16 +726,29 @@ const FirstMilePage = () => {
const openAssign = (id: string | null) => {
const resolved = id ?? filteredRecords.find((r) => !isAssigned(r))?.id ?? null;
const rec = records.find((r) => r.id === resolved);
// Prefill each row's container number from the booking's container numbers
// (by order) when the assignment doesn't already carry one.
const nums = rec ? bookingContainerNumbers(rec) : [];
const rows =
rec?.vehicleAssignments?.length
? rec.vehicleAssignments.map((a, i) => ({
vehicleId: a.vehicleId,
containerNumber: a.containerNumber ?? nums[i] ?? "",
}))
: rec?.vehicleId
? [{ vehicleId: rec.vehicleId, containerNumber: nums[0] ?? "" }]
: [{ vehicleId: null, containerNumber: nums[0] ?? "" }];
setBulkMode(false);
setActiveId(resolved);
setVehicleValue(null);
setVehicleRows(rows.length ? rows : [{ vehicleId: null, containerNumber: nums[0] ?? "" }]);
setAssignOpen(true);
};
const openBulkAssign = () => {
setBulkMode(true);
setActiveId(null);
setVehicleValue(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
setAssignOpen(true);
};
@@ -668,28 +756,31 @@ const FirstMilePage = () => {
setAssignOpen(false);
setBulkMode(false);
setActiveId(null);
setVehicleValue(null);
setVehicleRows([{ vehicleId: null, containerNumber: "" }]);
};
const handleAssign = () => {
if (!vehicleValue) {
toast({ title: "Select a vehicle", description: "Choose a vehicle to assign.", variant: "destructive" });
return;
}
const seen = new Set<string>();
const vehicles = vehicleRows
.filter((r): r is { vehicleId: string; containerNumber: string } => Boolean(r.vehicleId))
.filter((r) => (seen.has(r.vehicleId) ? false : seen.add(r.vehicleId)))
.map((r) => ({ vehicleId: r.vehicleId, containerNumber: r.containerNumber.trim() || null }));
const count = vehicles.length;
const targetIds = bulkMode
? selectedIds
: [activeId ?? filteredRecords.find((r) => !isAssigned(r))?.id].filter((id): id is string => Boolean(id));
if (!targetIds.length) return;
const selectedLabel = vehicleOptions.find((o) => o.value === vehicleValue)?.label ?? vehicleValue;
Promise.all(targetIds.map((id) => updateMutation.mutateAsync({ id, data: { vehicleId: vehicleValue } })))
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
toast({
title: "Vehicle assigned",
description: bulkMode ? `${targetIds.length} pickups → ${selectedLabel}` : selectedLabel,
title: count === 0 ? "Vehicles unassigned" : count > 1 ? "Vehicles assigned" : "Vehicle assigned",
description:
count === 0
? bulkMode ? `${targetIds.length} pickups` : undefined
: `${bulkMode ? `${targetIds.length} pickups · ` : ""}${count} vehicle${count > 1 ? "s" : ""}`,
});
if (bulkMode) setRowSelection({});
closeAssign();
@@ -772,8 +863,39 @@ const FirstMilePage = () => {
id: "vehicle",
header: "Vehicle",
meta: { headerClassName, cellClassName },
cell: ({ row }) =>
vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>,
cell: ({ row }) => {
const assigns = row.original.vehicleAssignments ?? [];
if (assigns.length > 1) {
const labelFor = (a: (typeof assigns)[number]) => {
const v = a.vehicle;
const l = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return a.containerNumber ? `${l} · ${a.containerNumber}` : l;
};
return (
<Tooltip
withArrow
multiline
label={
<div style={{ whiteSpace: "pre-line" }}>
{assigns.map(labelFor).join("\n")}
</div>
}
>
<Group gap={4} wrap="nowrap" style={{ whiteSpace: "nowrap" }}>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{assigns[0].vehicle
? [assigns[0].vehicle.code, assigns[0].vehicle.plateNumber].filter(Boolean).join(" · ")
: assigns[0].vehicleId}
</Text>
<Badge size="sm" variant="light" color="blue">
+{assigns.length - 1}
</Badge>
</Group>
</Tooltip>
);
}
return vehicleLabel(row.original) ?? <Text c="dimmed">Unassigned</Text>;
},
},
{
id: "exactKm",
@@ -1036,7 +1158,7 @@ const FirstMilePage = () => {
<Stack gap="md">
{bulkMode ? (
<Text size="sm">
Assigning a vehicle to{" "}
Assigning vehicles to{" "}
<Text span fw={600}>{selectedIds.length}</Text>{" "}
selected {selectedIds.length === 1 ? "pickup" : "pickups"}.
</Text>
@@ -1046,28 +1168,80 @@ const FirstMilePage = () => {
<Text size="sm" c="dimmed">No unassigned pickups available.</Text>
)}
<Divider />
<Select
label="Vehicle"
placeholder={vehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
description={
vehicleOptions.length === 0
? "No free vehicles available — all vehicles are busy, in maintenance, or retired. Free up a vehicle in Fleet Vehicles first."
: undefined
}
data={vehicleOptions}
value={vehicleValue}
onChange={setVehicleValue}
searchable
disabled={vehicleOptions.length === 0}
/>
<Stack gap="xs">
{vehicleRows.map((row, i) => (
<Group key={i} gap="xs" wrap="nowrap" align="flex-end">
<Select
style={{ flex: 1.4 }}
label={i === 0 ? "Vehicle" : undefined}
placeholder={assignVehicleOptions.length === 0 ? "No free vehicles" : "Select a vehicle"}
data={assignVehicleOptions.filter(
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
searchable
clearable
disabled={assignVehicleOptions.length === 0}
/>
<TextInput
style={{ flex: 1 }}
label={i === 0 ? "Container no." : undefined}
placeholder="Container number"
value={row.containerNumber}
onChange={(e) => {
const value = e.currentTarget.value;
setVehicleRows((prev) =>
prev.map((x, idx) => (idx === i ? { ...x, containerNumber: value } : x)),
);
}}
/>
{vehicleRows.length > 1 && (
<ActionIcon
variant="subtle"
color="red"
aria-label="Remove vehicle"
onClick={() => setVehicleRows((prev) => prev.filter((_, idx) => idx !== i))}
>
<X size={16} />
</ActionIcon>
)}
</Group>
))}
<Button
variant="light"
size="xs"
leftSection={<Plus size={14} />}
onClick={() =>
setVehicleRows((prev) => [
...prev,
{
vehicleId: null,
containerNumber:
(activeRecord ? bookingContainerNumbers(activeRecord)[prev.length] : "") ?? "",
},
])
}
disabled={
assignVehicleOptions.length === 0 ||
vehicleRows.some((r) => !r.vehicleId) ||
vehicleRows.filter((r) => r.vehicleId).length >= assignVehicleOptions.length
}
style={{ alignSelf: "flex-start" }}
>
Add vehicle
</Button>
</Stack>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeAssign}>Cancel</Button>
<Button
onClick={handleAssign}
loading={updateMutation.isPending}
loading={setVehiclesMutation.isPending}
disabled={bulkMode ? selectedIds.length === 0 : !activeRecord}
>
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Reassign" : "Assign"}
{!bulkMode && activeRecord && isAssigned(activeRecord) ? "Update vehicles" : "Assign"}
</Button>
</Group>
</Stack>
@@ -1274,34 +1448,51 @@ const FirstMilePage = () => {
<Stack gap="md">
{activeRecord && (
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm">{bookingRef(activeRecord)}</Text>
<Group justify="space-between">
<Text size="xs" c="dimmed">Customer</Text>
<Text size="sm">{customerName(activeRecord)}</Text>
</Group>
<Group justify="space-between">
<Text size="xs" c="dimmed">Est. Distance (KM)</Text>
<Text size="sm">{activeRecord.estimatedKm ?? "—"}</Text>
</Group>
</Stack>
<Text size="xs" c="dimmed">Est. {activeRecord.estimatedKm ?? "—"} km</Text>
</Group>
</Card>
)}
<NumberInput
label="Actual Distance (KM)"
placeholder="Enter distance"
value={distanceValue}
onChange={(v) => setDistanceValue(String(v ?? ""))}
min={0}
step={0.1}
decimalScale={2}
/>
{(activeRecord?.vehicleAssignments?.length ?? 0) === 0 ? (
<Text size="sm" c="dimmed">Assign a vehicle before entering distance.</Text>
) : (
<Stack gap="sm">
{activeRecord!.vehicleAssignments!.map((a) => {
const v = a.vehicle;
const label = v ? [v.code, v.plateNumber].filter(Boolean).join(" · ") : a.vehicleId;
return (
<NumberInput
key={a.id}
label={`${label}${a.containerNumber ? ` · ${a.containerNumber}` : ""}`}
placeholder="Distance (km)"
value={distanceRows[a.vehicleId] ?? ""}
onChange={(val) =>
setDistanceRows((prev) => ({ ...prev, [a.vehicleId]: String(val ?? "") }))
}
min={0}
step={0.1}
decimalScale={2}
/>
);
})}
<Group justify="space-between">
<Text size="xs" c="dimmed">Total</Text>
<Text size="sm" fw={600}>
{Object.values(distanceRows)
.reduce((s, val) => s + (parseFloat(val) || 0), 0)
.toFixed(2)}{" "}
km
</Text>
</Group>
</Stack>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeDistance}>Cancel</Button>
<Button
onClick={handleSaveDistance}
loading={updateDistanceMutation.isPending}
disabled={!distanceValue}
loading={setDistancesMutation.isPending}
disabled={Object.values(distanceRows).every((v) => !v)}
>
Save
</Button>

View File

@@ -23,6 +23,14 @@ export interface FirstMileBooking {
originYard?: { id: string; label?: string } | null;
destinationYard?: { id: string; label?: string } | null;
cargoType?: { id: string; label?: string } | null;
/** Container lines — total container count drives how many trucks are needed. */
bookingContainers?: Array<{
id: string;
quantity: number;
containerNumber?: string | null;
containerSize?: string | null;
containerType?: { id: string; name?: string; label?: string; code?: string } | null;
}>;
}
export interface FirstMileVehicle {
@@ -30,9 +38,12 @@ export interface FirstMileVehicle {
plateNumber: string;
manufacturer: string;
model: string;
vehicleType?: string | null;
code?: string | null;
powerPlateNo?: string | null;
trailerPlateNo?: string | null;
assignedDriverId?: string | null;
assignedDriverName?: string | null;
}
export interface FirstMileRecord {
@@ -46,6 +57,14 @@ export interface FirstMileRecord {
vehicleId?: string | null;
booking?: FirstMileBooking | null;
vehicle?: FirstMileVehicle | null;
/** Full set of vehicles serving this pickup (multi-truck). */
vehicleAssignments?: Array<{
id: string;
vehicleId: string;
containerNumber?: string | null;
distanceKm?: number | null;
vehicle?: FirstMileVehicle | null;
}>;
/** Present only when an invoice has actually been generated (not on distance). */
invoice?: { id: string; number: string; status: string } | null;
createdAt: string;
@@ -69,6 +88,15 @@ export const firstMileService = {
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),
remove: (id: string) =>
api.delete<void>(FM.BY_ID(id)),
setVehicles: (
id: string,
vehicles: Array<{ vehicleId: string; containerNumber?: string | null }>,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/vehicles`, { vehicles }),
setDistances: (
id: string,
distances: Array<{ vehicleId: string; distanceKm: number }>,
remainingPayment?: number,
) => api.post<FirstMileRecord>(`${FM.BASE}/${id}/distances`, { distances, remainingPayment }),
generateInvoice: (id: string) =>
api.post<{ id: string; invoiceNumber?: string } | null>(`${FM.BASE}/${id}/invoice`),
};