merge conflict

This commit is contained in:
marshal
2026-06-29 12:45:40 +03:00
202 changed files with 16895 additions and 3799 deletions

View File

@@ -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,
}));

View File

@@ -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
}
/>

View File

@@ -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 }} />;
}

View File

@@ -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: "",
},

View File

@@ -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>
);
};

View File

@@ -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",

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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"

View File

@@ -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