mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
merge conflict
This commit is contained in:
@@ -42,19 +42,10 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { api as appApi } from "@/services/api";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
|
||||
interface CompanyOption {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
tin?: string | null;
|
||||
email?: string | null;
|
||||
}
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
type FreightType = "CONTAINER" | "BULK";
|
||||
|
||||
@@ -219,15 +210,12 @@ export default function NewBookingPage() {
|
||||
queryFn: () => bookingsService.getReferenceData() as Promise<ReferenceData>,
|
||||
});
|
||||
|
||||
const { data: companies, isLoading: companiesLoading } = useQuery({
|
||||
const { data: companiesPage, isLoading: companiesLoading } = useQuery({
|
||||
queryKey: ["companies", "list"],
|
||||
queryFn: async () => {
|
||||
const res = await api.get(URL_CONSTANTS.COMPANIES.BASE);
|
||||
return unwrap(res.data) as CompanyOption[];
|
||||
},
|
||||
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
||||
});
|
||||
|
||||
const companyOptions = (companies ?? []).map((c) => ({
|
||||
const companyOptions = (companiesPage?.items ?? []).map((c) => ({
|
||||
value: c.id,
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
LayoutGrid,
|
||||
Package,
|
||||
} from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
@@ -85,9 +85,6 @@ export default function CustomerDetailPage() {
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const approveMutation = useMutation(
|
||||
api.customers.setCompanyStatus.mutationOptions(),
|
||||
);
|
||||
const bookingsQuery = useQuery(
|
||||
api.customers.bookings.queryOptions({
|
||||
input: { id: id ?? "" },
|
||||
@@ -395,28 +392,12 @@ export default function CustomerDetailPage() {
|
||||
]}
|
||||
backTo="/dashboard/customers"
|
||||
title={company.name}
|
||||
subtitle={`TIN ${company.tin}${
|
||||
company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
subtitle={`TIN ${company.tin}${company.country ? ` · ${company.country}` : ""
|
||||
}`}
|
||||
meta={
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<CompanyTypeBadge type={company.type} />
|
||||
<CompanyStatusBadge status={company.status} />
|
||||
{company.status === "pending" && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="green"
|
||||
loading={approveMutation.isPending}
|
||||
onClick={() =>
|
||||
approveMutation.mutate({
|
||||
companyId: company.id,
|
||||
status: "active",
|
||||
})
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -547,9 +528,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
bookingsQuery.isError
|
||||
? {
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load bookings.",
|
||||
onRetry: () => void bookingsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -568,9 +549,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
documentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load documents.",
|
||||
onRetry: () => void documentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
@@ -589,9 +570,9 @@ export default function CustomerDetailPage() {
|
||||
error={
|
||||
paymentsQuery.isError
|
||||
? {
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
message: "Failed to load payments.",
|
||||
onRetry: () => void paymentsQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from "@tria-plc/iamui";
|
||||
} from '@tria-plc/iamui';
|
||||
import { iamConfig } from './iamConfig';
|
||||
|
||||
import { getCookie } from "@/auth/cookies";
|
||||
function readCookieValue(name: string): string | null {
|
||||
const escaped = name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1');
|
||||
const match = document.cookie.match(
|
||||
new RegExp(`(?:^|; )${escaped}=([^;]*)`),
|
||||
);
|
||||
|
||||
import { iamConfig } from "./iamConfig";
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token = getCookie("auth-token");
|
||||
const token =
|
||||
localStorage.getItem('fhc-backoffice-auth-token') ??
|
||||
readCookieValue('auth-token');
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||
const refreshToken =
|
||||
localStorage.getItem('fhc-backoffice-auth-refresh-token') ??
|
||||
readCookieValue('refresh-token') ??
|
||||
undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
@@ -47,15 +58,14 @@ export default function UserManagementHostPage() {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||
const iamApiUrl = "/um-api";
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, '');
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: "/um",
|
||||
basename: '/um',
|
||||
apiBaseUrl,
|
||||
apiUrl: iamApiUrl,
|
||||
recordApiUrl: iamApiUrl,
|
||||
chronicleUrl: iamApiUrl,
|
||||
auditApiUrl: iamApiUrl,
|
||||
apiUrl: `${apiBaseUrl}/api`,
|
||||
recordApiUrl: `${apiBaseUrl}/api`,
|
||||
chronicleUrl: `${apiBaseUrl}/api`,
|
||||
auditApiUrl: `${apiBaseUrl}/api`,
|
||||
};
|
||||
|
||||
rootRef.current.render(
|
||||
@@ -78,5 +88,5 @@ export default function UserManagementHostPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||
return <div ref={mountRef} style={{ position: 'fixed', inset: 0 }} />;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ id: "fuelType", header: "Fuel Type", accessorKey: "fuelType", format: "code", size: 110 },
|
||||
{ id: "capacity", header: "Capacity (tons)", accessorKey: "capacity", format: "number", size: 130 },
|
||||
{ id: "assignedDriverName", header: "Assigned Driver", accessorKey: "assignedDriverName", format: "code", size: 140 },
|
||||
{ id: "estimatedDistanceKm", header: "Est. Distance (KM)", accessorKey: "estimatedDistanceKm", format: "number", size: 150 },
|
||||
{ id: "actualDistanceKm", header: "Actual Distance (KM)", accessorKey: "actualDistanceKm", format: "number", size: 150 },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge", size: 100 },
|
||||
],
|
||||
formFields: [
|
||||
@@ -72,6 +74,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
{ name: "year", label: "Year", type: "number", required: true },
|
||||
{ name: "fuelType", label: "Fuel Type", type: "select", required: true, options: FUEL_TYPE_OPTIONS },
|
||||
{ name: "capacity", label: "Capacity", type: "number", required: true },
|
||||
{ name: "estimatedDistanceKm", label: "Estimated Distance (KM)", type: "number" },
|
||||
{ name: "actualDistanceKm", label: "Actual Distance (KM)", type: "number" },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: VEHICLE_STATUS_OPTIONS },
|
||||
{ name: "description", label: "Description", type: "textarea" },
|
||||
],
|
||||
@@ -86,6 +90,8 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
year: new Date().getFullYear(),
|
||||
fuelType: "DIESEL",
|
||||
capacity: 0,
|
||||
estimatedDistanceKm: "",
|
||||
actualDistanceKm: "",
|
||||
status: "ACTIVE",
|
||||
description: "",
|
||||
},
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Group,
|
||||
Menu,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
@@ -67,7 +68,7 @@ type StatusFilter = "ALL" | FirstMileApiStatus | AssignmentStatus;
|
||||
|
||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
...FIRST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
...FIRST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
{ value: "ASSIGNED", label: "Assigned" },
|
||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||
];
|
||||
@@ -89,12 +90,10 @@ const bookingRef = (r: FirstMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
|
||||
const cargoDesc = (r: FirstMileRecord) => {
|
||||
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
const parts = [r.booking?.cargoType?.label ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const priceAmount = (r: FirstMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
// First-mile destination is the origin yard (pickup → origin yard)
|
||||
const destinationYardName = (r: FirstMileRecord) =>
|
||||
r.booking?.originYard?.label ?? "—";
|
||||
@@ -142,11 +141,14 @@ const BookingInfo = ({ record }: { record: FirstMileRecord }) => (
|
||||
<InfoRow label="Pickup location" value={pickupLocation(record)} />
|
||||
<InfoRow label="Destination (origin yard)" value={destinationYardName(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -158,10 +160,13 @@ const tripSlipRows = (record: FirstMileRecord): [string, string][] => [
|
||||
["Pickup location", pickupLocation(record)],
|
||||
["Destination yard", destinationYardName(record)],
|
||||
["Cargo", cargoDesc(record)],
|
||||
["Price", formatPrice(priceAmount(record))],
|
||||
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||
["Post Payment", formatPrice(record.remainingPayment)],
|
||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||
["Requested date", requestedDate(record)],
|
||||
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||
["Status", STATUS_META[record.status].label],
|
||||
];
|
||||
|
||||
@@ -323,6 +328,9 @@ const FirstMilePage = () => {
|
||||
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
|
||||
const [bookingSearch, setBookingSearch] = useState("");
|
||||
|
||||
const [distanceOpen, setDistanceOpen] = useState(false);
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.FIRST_MILE.list(),
|
||||
queryFn: async () => {
|
||||
@@ -347,6 +355,10 @@ const FirstMilePage = () => {
|
||||
const paidBookings = paidBookingsData?.items ?? [];
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
const existingFirstMileBookingIds = useMemo(
|
||||
() => new Set(records.map((record) => record.bookingId)),
|
||||
[records],
|
||||
);
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
@@ -371,10 +383,28 @@ const FirstMilePage = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const updateDistanceMutation = useMutation({
|
||||
mutationFn: ({ id, exactKm }: { id: string; exactKm: number }) =>
|
||||
firstMileService.update(id, { exactKm }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
|
||||
if (activeRecord) {
|
||||
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)} → ${distanceValue} km` });
|
||||
}
|
||||
closeDistance();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Update failed", variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
const acceptMutation = useMutation({
|
||||
mutationFn: async ({ reference, vehicleId }: { reference: string; vehicleId: string | null }) => {
|
||||
const res = await firstMileService.accept(reference);
|
||||
const created = res.data;
|
||||
if (!created?.id) {
|
||||
throw new Error("First-mile leg was not created for this booking.");
|
||||
}
|
||||
if (vehicleId) await firstMileService.update(created.id, { vehicleId });
|
||||
return created;
|
||||
},
|
||||
@@ -383,8 +413,11 @@ const FirstMilePage = () => {
|
||||
toast({ title: "Booking accepted", description: "First-mile leg created successfully." });
|
||||
closeAccept();
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Accept failed", variant: "destructive" });
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
(err instanceof Error ? err.message : undefined);
|
||||
toast({ title: "Accept failed", description, variant: "destructive" });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -398,16 +431,26 @@ const FirstMilePage = () => {
|
||||
[rowSelection],
|
||||
);
|
||||
|
||||
const firstMileEligiblePaidBookings = useMemo(
|
||||
() =>
|
||||
paidBookings.filter(
|
||||
(booking) =>
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!existingFirstMileBookingIds.has(booking.id),
|
||||
),
|
||||
[existingFirstMileBookingIds, paidBookings],
|
||||
);
|
||||
|
||||
const filteredPaidBookings = useMemo(() => {
|
||||
const term = bookingSearch.trim().toLowerCase();
|
||||
if (!term) return paidBookings;
|
||||
return paidBookings.filter((b) =>
|
||||
if (!term) return firstMileEligiblePaidBookings;
|
||||
return firstMileEligiblePaidBookings.filter((b) =>
|
||||
[b.reference, b.company?.name, b.company?.companyName]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(term),
|
||||
);
|
||||
}, [paidBookings, bookingSearch]);
|
||||
}, [firstMileEligiblePaidBookings, bookingSearch]);
|
||||
|
||||
const openAccept = () => {
|
||||
setAcceptOpen(true);
|
||||
@@ -430,6 +473,27 @@ const FirstMilePage = () => {
|
||||
acceptMutation.mutate({ reference: selectedBooking.reference, vehicleId: acceptVehicleValue });
|
||||
};
|
||||
|
||||
const openDistance = (id: string) => {
|
||||
setActiveId(id);
|
||||
setDistanceValue("");
|
||||
setDistanceOpen(true);
|
||||
};
|
||||
|
||||
const closeDistance = () => {
|
||||
setDistanceOpen(false);
|
||||
setActiveId(null);
|
||||
setDistanceValue("");
|
||||
};
|
||||
|
||||
const handleSaveDistance = () => {
|
||||
const distance = parseFloat(distanceValue);
|
||||
if (!activeId || isNaN(distance) || distance < 0) {
|
||||
toast({ title: "Invalid distance", description: "Enter a valid distance value.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
updateDistanceMutation.mutate({ id: activeId, exactKm: distance });
|
||||
};
|
||||
|
||||
const matchesFilter = (r: FirstMileRecord) => {
|
||||
switch (statusFilter) {
|
||||
case "ALL": return true;
|
||||
@@ -608,10 +672,16 @@ const FirstMilePage = () => {
|
||||
cell: ({ row }) => cargoDesc(row.original),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
id: "advancedPayment",
|
||||
header: "Advanced Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
||||
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
header: "Post Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||
},
|
||||
{
|
||||
id: "vehicle",
|
||||
@@ -619,6 +689,18 @@ const FirstMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "estimatedKm",
|
||||
header: "Est. Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
@@ -684,6 +766,11 @@ const FirstMilePage = () => {
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
>
|
||||
Add Actual distance
|
||||
</Menu.Item>
|
||||
{canPrint && (
|
||||
<Menu.Item
|
||||
leftSection={<Printer size={15} />}
|
||||
@@ -879,7 +966,7 @@ const FirstMilePage = () => {
|
||||
{bookingsLoading ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">Loading bookings…</Text>
|
||||
) : filteredPaidBookings.length === 0 ? (
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid bookings found.</Text>
|
||||
<Text c="dimmed" size="sm" ta="center" py="md">No paid export bookings need first mile.</Text>
|
||||
) : (
|
||||
filteredPaidBookings.map((b) => (
|
||||
<UnstyledButton
|
||||
@@ -988,6 +1075,53 @@ const FirstMilePage = () => {
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Add Actual Distance modal */}
|
||||
<Modal
|
||||
opened={distanceOpen}
|
||||
onClose={closeDistance}
|
||||
title={<Text fw={600}>Add Actual Distance</Text>}
|
||||
size="md"
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{activeRecord && (
|
||||
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
|
||||
<Stack gap="sm">
|
||||
<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>
|
||||
</Card>
|
||||
)}
|
||||
<NumberInput
|
||||
label="Actual Distance (KM)"
|
||||
placeholder="Enter distance"
|
||||
value={distanceValue}
|
||||
onChange={(v) => setDistanceValue(String(v ?? ""))}
|
||||
min={0}
|
||||
step={0.1}
|
||||
decimalScale={2}
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={closeDistance}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSaveDistance}
|
||||
loading={updateDistanceMutation.isPending}
|
||||
disabled={!distanceValue}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -66,7 +66,7 @@ type StatusFilter = "ALL" | LastMileApiStatus | AssignmentStatus;
|
||||
|
||||
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
...LAST_MILE_STATUSES.map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
...LAST_MILE_STATUSES.filter((s) => s !== "PAYMENT_PENDING").map((s) => ({ value: s as StatusFilter, label: STATUS_META[s].label })),
|
||||
{ value: "ASSIGNED", label: "Assigned" },
|
||||
{ value: "UNASSIGNED", label: "Unassigned" },
|
||||
];
|
||||
@@ -87,14 +87,12 @@ const bookingRef = (r: LastMileRecord) => r.booking?.reference ?? r.bookingId;
|
||||
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
|
||||
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
|
||||
const cargoDesc = (r: LastMileRecord) => {
|
||||
const parts = [r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
const parts = [r.booking?.cargoType?.cargoTypeName ?? r.booking?.cargoType?.label ?? r.booking?.cargoType?.name ?? r.booking?.cargoFreeText].filter(Boolean);
|
||||
if (r.booking?.cargoTotalWeightVgm) parts.push(`${r.booking.cargoTotalWeightVgm} t`);
|
||||
return parts.join(" · ") || "—";
|
||||
};
|
||||
const priceAmount = (r: LastMileRecord) =>
|
||||
r.booking?.totalAmount ?? r.advancedPayment;
|
||||
const originYardName = (r: LastMileRecord) =>
|
||||
r.booking?.originYard?.name ?? "—";
|
||||
r.booking?.originYard?.label ?? r.booking?.originYard?.name ?? "—";
|
||||
const contactPersonName = (r: LastMileRecord) =>
|
||||
r.booking?.company?.contactPersonName ?? "—";
|
||||
const contactPhone = (r: LastMileRecord) =>
|
||||
@@ -104,7 +102,7 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
return d ? new Date(d).toISOString().slice(0, 10) : "—";
|
||||
};
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.name ?? "—";
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
@@ -130,14 +128,17 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<InfoRow label="Customer" value={customerName(record)} />
|
||||
<InfoRow label="Service type" value={serviceTypeName(record)} />
|
||||
<InfoRow label="Origin yard" value={originYardName(record)} />
|
||||
<InfoRow label="Pickup (origin yard)" value={originYardName(record)} />
|
||||
<InfoRow label="Destination" value={deliveryLocation(record)} />
|
||||
<InfoRow label="Cargo" value={cargoDesc(record)} />
|
||||
<InfoRow label="Price" value={formatPrice(priceAmount(record))} />
|
||||
<InfoRow label="Advanced Payment" value={formatPrice(record.advancedPayment)} />
|
||||
<InfoRow label="Post Payment" value={formatPrice(record.remainingPayment)} />
|
||||
<InfoRow label="Contact" value={contactPersonName(record)} />
|
||||
<InfoRow label="Phone" value={contactPhone(record)} />
|
||||
<InfoRow label="Requested date" value={requestedDate(record)} />
|
||||
<InfoRow label="Assigned vehicle" value={vehicleLabel(record) ?? "—"} />
|
||||
<InfoRow label="Est. Distance (KM)" value={record.estimatedKm != null ? String(record.estimatedKm) : "—"} />
|
||||
<InfoRow label="Actual Distance (KM)" value={record.exactKm != null ? String(record.exactKm) : "—"} />
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
</Card>
|
||||
@@ -146,13 +147,16 @@ const BookingInfo = ({ record }: { record: LastMileRecord }) => (
|
||||
const tripSlipRows = (record: LastMileRecord): [string, string][] => [
|
||||
["Customer", customerName(record)],
|
||||
["Service", serviceTypeName(record)],
|
||||
["Origin yard", originYardName(record)],
|
||||
["Pickup (origin yard)", originYardName(record)],
|
||||
["Destination", deliveryLocation(record)],
|
||||
["Cargo", cargoDesc(record)],
|
||||
["Price", formatPrice(priceAmount(record))],
|
||||
["Advanced Payment", formatPrice(record.advancedPayment)],
|
||||
["Post Payment", formatPrice(record.remainingPayment)],
|
||||
["Vehicle", vehicleLabel(record) ?? "Unassigned"],
|
||||
["Contact", `${contactPersonName(record)} · ${contactPhone(record)}`],
|
||||
["Requested date", requestedDate(record)],
|
||||
["Est. Distance (KM)", record.estimatedKm != null ? String(record.estimatedKm) : "—"],
|
||||
["Actual Distance (KM)", record.exactKm != null ? String(record.exactKm) : "—"],
|
||||
["Status", STATUS_META[record.status].label],
|
||||
];
|
||||
|
||||
@@ -587,6 +591,12 @@ const LastMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => customerName(row.original),
|
||||
},
|
||||
{
|
||||
id: "pickup",
|
||||
header: "Pickup",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => originYardName(row.original),
|
||||
},
|
||||
{
|
||||
id: "destination",
|
||||
header: "Destination",
|
||||
@@ -600,10 +610,16 @@ const LastMilePage = () => {
|
||||
cell: ({ row }) => cargoDesc(row.original),
|
||||
},
|
||||
{
|
||||
id: "price",
|
||||
header: "Price",
|
||||
id: "advancedPayment",
|
||||
header: "Advanced Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(priceAmount(row.original)),
|
||||
cell: ({ row }) => formatPrice(row.original.advancedPayment),
|
||||
},
|
||||
{
|
||||
id: "postPayment",
|
||||
header: "Post Payment",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => formatPrice(row.original.remainingPayment),
|
||||
},
|
||||
{
|
||||
id: "vehicle",
|
||||
@@ -611,6 +627,18 @@ const LastMilePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => vehicleLabel(row.original) ?? <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "estimatedKm",
|
||||
header: "Est. Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.estimatedKm != null ? row.original.estimatedKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "exactKm",
|
||||
header: "Actual Distance (KM)",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : <Text c="dimmed">—</Text>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
@@ -55,8 +56,10 @@ import {
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
@@ -123,6 +126,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||
direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
@@ -287,6 +296,33 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||
|
||||
const openMarshallingDocument = async () => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
});
|
||||
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Marshalling document ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open marshalling document",
|
||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!allSelectedIds.length) return;
|
||||
@@ -749,6 +785,19 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{canPrintMarshalling ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() => void openMarshallingDocument()}
|
||||
>
|
||||
Marshalling PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -37,6 +37,12 @@ const getErrorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
train.pendingUnloadBookings ?? train.totalBookings;
|
||||
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
|
||||
@@ -67,6 +73,7 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -88,6 +95,11 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
@@ -105,10 +117,20 @@ export default function ArrivalQueuePage() {
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
description: `${train.trainNumber ?? 'This train'} has no remaining bookings to auto unload.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
const details = [
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
@@ -117,8 +139,10 @@ export default function ArrivalQueuePage() {
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
title: alreadyUnloaded ? 'Already unloaded' : `${result.unloadedCount} booking(s) unloaded`,
|
||||
description: alreadyUnloaded
|
||||
? firstReason ?? `${train.trainNumber ?? 'Train'} is already in warehouse inventory.`
|
||||
: details || `${train.trainNumber ?? 'Train'} moved into warehouse inventory.`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast({
|
||||
@@ -181,6 +205,8 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Tbody>
|
||||
{trains.map((train: ImportTrain) => {
|
||||
const isOpen = openScheduleId === train.scheduleId;
|
||||
const fullyUnloaded = isFullyUnloaded(train);
|
||||
const unloadedBookings = train.unloadedBookings ?? train.totalBookings - getPendingUnloadBookings(train);
|
||||
return (
|
||||
<Fragment key={train.scheduleId}>
|
||||
<Table.Tr>
|
||||
@@ -204,9 +230,14 @@ export default function ArrivalQueuePage() {
|
||||
<Table.Td ta="center">{train.totalContainers}</Table.Td>
|
||||
<Table.Td ta="center">{train.totalCargoes}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{train.status}
|
||||
</Badge>
|
||||
<Stack gap={2}>
|
||||
<Badge variant="light" color={fullyUnloaded ? 'green' : 'teal'} size="sm">
|
||||
{fullyUnloaded ? 'UNLOADED' : train.status}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max(unloadedBookings, 0)}/{train.totalBookings} unloaded
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -220,12 +251,13 @@ export default function ArrivalQueuePage() {
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="orange"
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
Auto Unload
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Alert,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
|
||||
export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
|
||||
const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
|
||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||
const generateInterchange = useGenerateInterchangeDocument();
|
||||
@@ -193,14 +194,14 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
result.skippedCount ? `${result.skippedCount} skipped` : '',
|
||||
result.failedCount ? `${result.failedCount} failed` : '',
|
||||
result.interchangeDocument
|
||||
? `Interchange document ${result.interchangeDocument.documentNo} generated`
|
||||
? `Signed interchange document ${result.interchangeDocument.documentNo} generated`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
toast({
|
||||
title: `${result.unloadedCount} export item(s) unloaded`,
|
||||
title: `${result.unloadedCount} export item(s) auto unloaded`,
|
||||
description: details || `${train.trainNumber ?? 'Train'} unloaded at Djibouti Port.`,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -224,7 +225,8 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
handoverFrom: 'EDR',
|
||||
handoverTo: 'Djibouti Port Operator',
|
||||
portOperatorName: 'Doraleh Multipurpose Port',
|
||||
remarks: 'Generated after export unloading at Djibouti Port',
|
||||
generatedBy: 'EDR Operations',
|
||||
remarks: 'Generated after export unloading at Djibouti Port; signed by EDR and Djibouti Port Operator.',
|
||||
});
|
||||
toast({
|
||||
title: 'Interchange document generated',
|
||||
@@ -249,21 +251,21 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Stack gap="lg" mt="sm">
|
||||
<PageHeader
|
||||
title="Djibouti Arrival / Unloading Queue"
|
||||
subtitle="Arrived export trains at Djibouti-side destinations ready for unloading."
|
||||
subtitle="Arrived export trains at Djibouti-side destinations, auto unloading, and signed interchange handover."
|
||||
/>
|
||||
|
||||
<WarehouseHero
|
||||
variant="train"
|
||||
secondaryVariant="container"
|
||||
title="Export Unloading at Djibouti Port"
|
||||
subtitle="Review arrived export trains and unload eligible assigned export items."
|
||||
subtitle="Review arrived trains, auto unload eligible export items, then generate the EDR and Djibouti Port signed interchange document."
|
||||
/>
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived export train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned export items, then auto unload it.
|
||||
Open a train, auto unload it, then view the signed interchange document.
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -271,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Alert color="red" variant="light" title="Could not load arrived export trains">
|
||||
{getErrorMessage(error) ?? 'Check your API connection and sign in again.'}
|
||||
</Alert>
|
||||
) : trains.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
@@ -368,7 +374,7 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
loading={busyScheduleId === train.scheduleId && generateInterchange.isPending}
|
||||
onClick={() => generateInterchangeDocument(train)}
|
||||
>
|
||||
Generate Interchange Document
|
||||
Generate Signed Document
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
|
||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const filenameFor = (document: InterchangeDocument) =>
|
||||
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
|
||||
|
||||
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
const items = document.items ?? [];
|
||||
const rows = items
|
||||
.map(
|
||||
(item, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(item.itemType)}</td>
|
||||
<td>${escapeHtml(item.containerNumber)}</td>
|
||||
<td>${escapeHtml(item.sealNumber)}</td>
|
||||
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
|
||||
<td>${escapeHtml(formatNumber(item.weight))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.quantity))}</td>
|
||||
<td>${escapeHtml(item.wagonNumber)}</td>
|
||||
<td>${escapeHtml(item.conditionStatus)}</td>
|
||||
<td>${escapeHtml(item.damageDescription)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
|
||||
h1 { margin: 0; font-size: 24px; }
|
||||
.muted { color: #4b5563; font-size: 12px; }
|
||||
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
|
||||
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
|
||||
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
|
||||
th { background: #f3f4f6; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
|
||||
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
|
||||
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<h1>EDR / Djibouti Port Interchange Document</h1>
|
||||
<div class="muted">Official export handover document</div>
|
||||
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
|
||||
</div>
|
||||
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
||||
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
|
||||
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
|
||||
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
|
||||
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
|
||||
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
|
||||
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Booking</th>
|
||||
<th>Type</th>
|
||||
<th>Container</th>
|
||||
<th>Seal</th>
|
||||
<th>Cargo</th>
|
||||
<th>Weight</th>
|
||||
<th>Qty</th>
|
||||
<th>Wagon</th>
|
||||
<th>Condition</th>
|
||||
<th>Damage / Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
|
||||
</table>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
|
||||
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
|
||||
</div>
|
||||
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -93,6 +204,8 @@ function InterchangeDocumentDetail({ id }: { id: string }) {
|
||||
/>
|
||||
<DetailField label="Generated At" value={formatDate(document.generatedAt)} />
|
||||
<DetailField label="Acknowledged At" value={formatDate(document.acknowledgedAt)} />
|
||||
<DetailField label="Signed by EDR" value={document.generatedBy} />
|
||||
<DetailField label="Signed by Djibouti Port" value={document.acknowledgedBy} />
|
||||
<DetailField label="Customs Ref" value={document.customsReference} />
|
||||
<DetailField label="Manifest Ref" value={document.manifestReference} />
|
||||
</SimpleGrid>
|
||||
@@ -155,6 +268,37 @@ export default function InterchangeDocumentsPage() {
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
const cancel = useCancelInterchangeDocument();
|
||||
|
||||
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
|
||||
};
|
||||
|
||||
const printDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) {
|
||||
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
|
||||
return;
|
||||
}
|
||||
win.document.write(buildPrintableInterchangeHtml(fullDocument));
|
||||
win.document.close();
|
||||
win.focus();
|
||||
setTimeout(() => win.print(), 250);
|
||||
};
|
||||
|
||||
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filenameFor(fullDocument);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, title: string) => {
|
||||
try {
|
||||
await fn();
|
||||
@@ -167,10 +311,10 @@ export default function InterchangeDocumentsPage() {
|
||||
const acknowledgeDocument = (document: InterchangeDocument) => {
|
||||
const acknowledgedBy = window.prompt('Acknowledged by');
|
||||
if (!acknowledgedBy) return;
|
||||
run(
|
||||
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
|
||||
'Interchange document acknowledged',
|
||||
);
|
||||
run(async () => {
|
||||
const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
|
||||
await printDocument(response.data);
|
||||
}, 'Interchange document acknowledged');
|
||||
};
|
||||
|
||||
const disputeDocument = (document: InterchangeDocument) => {
|
||||
@@ -221,6 +365,7 @@ export default function InterchangeDocumentsPage() {
|
||||
<Table.Th>Handover From</Table.Th>
|
||||
<Table.Th>Handover To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Signed By</Table.Th>
|
||||
<Table.Th>Generated At</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
@@ -251,6 +396,14 @@ export default function InterchangeDocumentsPage() {
|
||||
{document.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm">{document.generatedBy ?? '-'}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{document.acknowledgedBy ?? 'Awaiting Djibouti Port'}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(document.generatedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
@@ -273,6 +426,28 @@ export default function InterchangeDocumentsPage() {
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(document), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -15,19 +15,24 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { Ban, CreditCard, Eye, Search } from 'lucide-react';
|
||||
import { Ban, CreditCard, DoorOpen, Download, Eye, Receipt, Search } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { api } from '@/services/api';
|
||||
import { bookingsService } from '@/services/bookings.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
import { buildWarehouseExitPaperPdf, buildWarehouseInvoicePdf } from '@/components/warehouses/warehousePdf';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const STATUS_COLOR: Record<WarehouseInvoiceStatus, string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -158,18 +163,120 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
);
|
||||
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
|
||||
};
|
||||
|
||||
const downloadReceiptPdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const blob = buildWarehouseInvoicePdf(invoice, 'RECEIPT');
|
||||
openPdfBlob(blob, `warehouse-receipt-${invoice.invoiceNumber}.pdf`);
|
||||
};
|
||||
|
||||
const getExitPaperContext = async (invoice: WarehouseFeeInvoice) => {
|
||||
const [booking, inventoryRows] = await Promise.all([
|
||||
invoice.bookingId
|
||||
? bookingsService.getById(invoice.bookingId).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
invoice.bookingId
|
||||
? warehouseService.listInventory({ bookingId: invoice.bookingId }).then((response) => response.data).catch(() => [])
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const inventory = inventoryRows.find((item) => item.id === invoice.inventoryId) ?? inventoryRows[0] ?? undefined;
|
||||
return { booking, inventory };
|
||||
};
|
||||
|
||||
const handleGateClearance = async (invoice: WarehouseFeeInvoice) => {
|
||||
if (!invoice.inventoryId) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Gate clearance failed',
|
||||
description: 'This invoice is not linked to an inventory item.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const releasedAt = new Date();
|
||||
const releasedItem = await gateClear.mutateAsync(invoice.inventoryId);
|
||||
let documentResponse: Awaited<ReturnType<typeof warehouseService.downloadReleaseDocument>>;
|
||||
try {
|
||||
documentResponse = await warehouseService.downloadReleaseDocument(invoice.inventoryId);
|
||||
} catch (documentError) {
|
||||
const context = await getExitPaperContext(invoice);
|
||||
const fallbackBlob = buildWarehouseExitPaperPdf({
|
||||
invoice,
|
||||
releasedItem,
|
||||
inventory: context.inventory,
|
||||
booking: context.booking,
|
||||
releasedAt,
|
||||
});
|
||||
const opened = openPdfBlob(
|
||||
fallbackBlob,
|
||||
`release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`,
|
||||
pdfWindow,
|
||||
);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The API exit paper failed, so a sealed fallback PDF opened instead.'
|
||||
: `The API exit paper failed (${extractErrorMessage(documentError)}), so a sealed fallback PDF was downloaded.`,
|
||||
});
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? invoice.inventoryId}.pdf`;
|
||||
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
|
||||
toast({
|
||||
title: 'Gate clearance recorded',
|
||||
description: opened
|
||||
? 'The exit paper opened in a browser tab.'
|
||||
: 'The browser blocked the preview tab, so the exit paper was downloaded.',
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Gate clearance failed',
|
||||
description: extractErrorMessage(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
try {
|
||||
await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
toast({ title: 'Payment recorded' });
|
||||
const paidInvoice = await pay.mutateAsync({
|
||||
id: inv.id,
|
||||
payload: {
|
||||
amount: Number(payAmount),
|
||||
method: 'MANUAL',
|
||||
driverName: driverName.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setPayAmount('');
|
||||
setDriverName('');
|
||||
setDriverPhone('');
|
||||
if (paidInvoice.status === 'PAID') {
|
||||
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
||||
await downloadReceiptPdf(paidInvoice);
|
||||
await handleGateClearance(paidInvoice);
|
||||
} else {
|
||||
toast({ title: 'Payment recorded' });
|
||||
}
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: (e as Error)?.message });
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -180,7 +287,7 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
toast({ title: 'Invoice cancelled' });
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: (e as Error)?.message });
|
||||
toast({ variant: 'destructive', title: 'Cancel failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -239,6 +346,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Pickup driver"
|
||||
value={driverName}
|
||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
value={driverPhone}
|
||||
onChange={(e) => setDriverPhone(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
||||
Pay
|
||||
</Button>
|
||||
@@ -247,6 +366,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button
|
||||
variant="light"
|
||||
color="gray"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => downloadInvoicePdf(inv)}
|
||||
>
|
||||
Invoice PDF
|
||||
</Button>
|
||||
{Number(inv.paidAmount) > 0 && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Receipt size={16} />}
|
||||
onClick={() => downloadReceiptPdf(inv)}
|
||||
>
|
||||
Receipt PDF
|
||||
</Button>
|
||||
)}
|
||||
{canGateClear && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<DoorOpen size={16} />}
|
||||
loading={gateClear.isPending}
|
||||
onClick={() => handleGateClearance(inv)}
|
||||
>
|
||||
Gate clearance & exit paper
|
||||
</Button>
|
||||
)}
|
||||
{inv.status !== 'PAID' && inv.status !== 'CANCELLED' && (
|
||||
<Button variant="light" color="red" leftSection={<Ban size={16} />} loading={cancel.isPending} onClick={handleCancel}>
|
||||
Cancel invoice
|
||||
|
||||
Reference in New Issue
Block a user