Merge branch 'dev' into freight/feat/invoice

This commit is contained in:
Nathnael
2026-06-29 10:58:12 +00:00
425 changed files with 46006 additions and 7705 deletions

View File

@@ -2,6 +2,7 @@ import {
Boxes,
Building2,
Container,
FileSignature,
FileText,
LayoutDashboard,
LayoutGrid,
@@ -28,9 +29,14 @@ import LoginPage from "./pages/auth/LoginPage";
import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingRequestDetailPage from "./pages/bookings/BookingRequestDetailPage";
import BookingRequestsPage from "./pages/bookings/BookingRequestsPage";
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
import DocumentClearanceListPage from "./pages/bookings/DocumentClearanceListPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ContractRequestsPage from "./pages/contracts/ContractRequestsPage";
import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import CustomersPage from "./pages/customers/CustomersPage";
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
@@ -77,6 +83,7 @@ import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
import WarehouseInvoicesPage from "./pages/warehouses/WarehouseInvoicesPage";
import WarehouseListPage from "./pages/warehouses/WarehouseListPage";
import WarehouseRulesPage from "./pages/warehouses/WarehouseRulesPage";
import { HealthCheck } from "./features/health/HealthCheck";
const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
{
@@ -88,7 +95,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
icon: <LayoutDashboard />,
},
{
label: "UM",
label: "User Management",
href: "/um",
icon: <Users />,
},
@@ -97,6 +104,12 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/booking-requests",
icon: <FileText />,
},
{
label: "Contract requests",
href: "/dashboard/contract-requests",
icon: <FileSignature />,
permission: FREIGHT_PERMS.contracts.view,
},
{
label: "Customers",
href: "/dashboard/customers",
@@ -116,9 +129,9 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
items: [
{
label: "Document Clearance",
href: "/dashboard/clearance",
href: "/dashboard/contracts/clearance",
icon: <ShieldCheck />,
permission: FREIGHT_PERMS.bookings.reviewDocuments,
permission: FREIGHT_PERMS.contracts.clearanceReview,
},
{
label: "Train Schedules",
@@ -374,6 +387,7 @@ const App = () => {
return (
<Routes>
<Route path="/um/*" element={<UserManagementHostPage />} />
<Route path="/health" element={<HealthCheck />} />
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
<Route path="/dashboard" element={<DashboardShell />}>
@@ -397,19 +411,76 @@ const App = () => {
path="booking-requests/:id/contract"
element={<BookingContractPage />}
/>
{/* Legacy booking-based clearance URLs → the contract clearance hub. */}
<Route
path="clearance"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
/>
<Route
path="clearance/:id"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
/>
{/* Contracts (Path A/B) */}
<Route
path="contract-requests"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceListPage />
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
<ContractRequestsPage />
</RequirePermission>
}
/>
<Route
path="clearance/:id"
path="contract-requests/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.bookings.reviewDocuments}>
<DocumentClearanceDetailPage />
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
<ContractRequestDetailPage />
</RequirePermission>
}
/>
<Route
path="contract-requests/:id/view"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.view}>
<ContractViewPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
<ContractClearanceListPage />
</RequirePermission>
}
/>
<Route
path="contracts/clearance/:id"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
<ContractClearanceDetailPage />
</RequirePermission>
}
/>
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
<Route
path="contracts/ops-clearance"
element={<Navigate to="/dashboard/contracts/clearance" replace />}
/>
<Route
path="contracts/:id/create-booking"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
<GlCreateBookingForm />
</RequirePermission>
}
/>
<Route
path="bookings/:id/milestones"
element={
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
<BookingMilestonesPage />
</RequirePermission>
}
/>

View File

@@ -1,15 +1,10 @@
import { useState } from "react";
import { Download, Zap, FileText, Clock } from "lucide-react";
import { Stack, Text, Button } from "@mantine/core";
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
import type { BookingDetail } from "@/types/booking";
import { BookingActionsMenu } from "./BookingActionsMenu";
import { SectionCard } from "./detail/SectionCard";
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { canManageScheduling } from "@/lib/permissions";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
type Mutations = ReturnType<typeof useBookingMutations>;
@@ -21,11 +16,8 @@ interface BookingActionsToolbarProps {
/** Detail-page actions: primary toolbar + downloads. */
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
const { user } = useAuth();
const row = toBookingListRow(booking);
const { status } = booking;
const [allocateOpen, setAllocateOpen] = useState(false);
const canAllocate = canManageScheduling(user);
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
const blob = await fn();
@@ -106,11 +98,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
<Text size="xs" c="dimmed">
Confirm each step before it is applied.
</Text>
<BookingActionsMenu
row={row}
variant="toolbar"
onAllocateBooking={() => setAllocateOpen(true)}
/>
<BookingActionsMenu row={row} variant="toolbar" />
</Stack>
</SectionCard>
@@ -130,14 +118,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
</Button>
</SectionCard>
)}
{canAllocate && canAllocateBooking(booking) ? (
<AllocateBookingWizard
booking={booking}
opened={allocateOpen}
onClose={() => setAllocateOpen(false)}
/>
) : null}
</Stack>
);
}

View File

@@ -1,27 +1,16 @@
import { useState } from "react";
import { Banknote, Pencil, Receipt } from "lucide-react";
import {
Button,
Divider,
Group,
NumberInput,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { Banknote, Receipt } from "lucide-react";
import { Divider, Group, Paper, Stack, Text } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { bookingsService } from "@/services/bookings.service";
import { SectionCard } from "./detail/SectionCard";
import { detailStyles } from "./detail/booking-detail.styles";
export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const qc = useQueryClient();
const computed = Number(booking.totalAmount);
// The booking price is computed from the contract and is NOT staff-editable.
// A historical `adjustedTotalAmount` (from before adjustments were removed)
// is still shown read-only so old records render correctly.
const isAdjusted =
booking.adjustedTotalAmount !== null &&
booking.adjustedTotalAmount !== undefined;
@@ -29,21 +18,6 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
const [editing, setEditing] = useState(false);
const [amount, setAmount] = useState<number | "">(effective);
const [reason, setReason] = useState("");
const adjustMutation = useMutation({
mutationFn: (payload: { amount: number | null; reason?: string }) =>
bookingsService.adjustPrice(booking.id, payload.amount, payload.reason),
onSuccess: () => {
toast.success("Price updated");
setEditing(false);
qc.invalidateQueries({ queryKey: ["bookings"] });
},
onError: () => toast.error("Could not update price"),
});
const fmt = (n: number) =>
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
@@ -51,102 +25,23 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
<SectionCard icon={Banknote} title="Pricing & payment">
<Stack gap="md">
<Paper radius="md" withBorder p="md" style={detailStyles.highlightCard}>
<Group justify="space-between" align="flex-start">
<div>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</div>
{!editing && (
<Button
size="compact-xs"
variant="light"
leftSection={<Pencil size={13} />}
onClick={() => {
setAmount(effective);
setEditing(true);
}}
>
Adjust
</Button>
)}
</Group>
{editing && (
<Stack gap="xs" mt="md">
<NumberInput
label="New total"
value={amount}
onChange={(v) => setAmount(v === "" ? "" : Number(v))}
min={0}
radius="md"
prefix={`${booking.paymentCurrency} `}
thousandSeparator=","
/>
<Textarea
label="Reason (optional)"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
/>
<Group justify="space-between" mt={4}>
{isAdjusted ? (
<Button
size="compact-sm"
variant="subtle"
color="red"
loading={adjustMutation.isPending}
onClick={() =>
adjustMutation.mutate({ amount: null })
}
>
Clear adjustment
</Button>
) : (
<span />
)}
<Group gap="xs">
<Button
size="compact-sm"
variant="default"
onClick={() => setEditing(false)}
>
Cancel
</Button>
<Button
size="compact-sm"
color="edr-green"
loading={adjustMutation.isPending}
disabled={amount === ""}
onClick={() =>
adjustMutation.mutate({
amount: Number(amount),
reason: reason.trim() || undefined,
})
}
>
Save
</Button>
</Group>
</Group>
</Stack>
<Text size="xs" c="dimmed" fw={500} tt="uppercase" lts="0.04em">
{isAdjusted ? "Adjusted total" : "Total amount"}
</Text>
<Text
size="xl"
fw={700}
c="edr-green.9"
mt={4}
style={{ fontVariantNumeric: "tabular-nums", letterSpacing: "-0.5px" }}
>
{fmt(effective)}
</Text>
{isAdjusted && (
<Text size="xs" c="dimmed" mt={2}>
Computed: {fmt(computed)}
{booking.adjustmentReason ? ` · ${booking.adjustmentReason}` : ""}
</Text>
)}
</Paper>

View File

@@ -20,7 +20,7 @@ import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
@@ -28,9 +28,12 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ClearanceReviewSectionProps {
bookingId: string;
@@ -65,7 +68,8 @@ export function ClearanceReviewSection({
const qc = useQueryClient();
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const { view, viewer } = useFileViewer();
const { data: clearance, isLoading } = useQuery({
queryKey: ["clearance", bookingId],
@@ -96,13 +100,17 @@ export function ClearanceReviewSection({
});
const outputMutation = useMutation({
mutationFn: () => bookingsService.uploadClearanceOutput(bookingId, outputFiles),
mutationFn: (files: Record<string, File>) =>
bookingsService.uploadClearanceOutput(bookingId, files),
onSuccess: () => {
toast.success("Output documents uploaded");
setOutputFiles({});
toast.success("Document uploaded");
setUploadingKey(null);
refresh();
},
onError: () => toast.error("Upload failed"),
onError: () => {
toast.error("Upload failed");
setUploadingKey(null);
},
});
const finalizeMutation = useMutation({
@@ -207,6 +215,7 @@ export function ClearanceReviewSection({
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
@@ -218,73 +227,100 @@ export function ClearanceReviewSection({
<SectionCard
icon={Upload}
title="Customs output documents"
subtitle="Upload the cleared/customs paperwork to hand back to the customer."
subtitle="Upload each document individually — changes save immediately."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
{glDocs.map((doc) => {
const isUploading =
uploadingKey === doc.fileKey && outputMutation.isPending;
return (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
)}
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<>
{isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="a"
href={fileViewUrl(doc.file.id, true)}
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
</FileButton>
<FileButton
onChange={(f) => {
if (!f) return;
setUploadingKey(doc.fileKey);
outputMutation.mutate({ [doc.fileKey]: f });
}}
accept="application/pdf,image/*"
disabled={isUploading}
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={
isUploading ? (
<Loader size={12} color="edr-green" />
) : (
<Upload size={13} />
)
}
loading={isUploading}
>
{doc.file ? "Replace" : "Upload"}
</Button>
)}
</FileButton>
</Group>
</Group>
</Group>
))}
);
})}
</Stack>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={outputMutation.isPending}
onClick={() => outputMutation.mutate()}
>
Upload output documents
</Button>
</Group>
</SectionCard>
)}
@@ -325,6 +361,7 @@ export function ClearanceReviewSection({
</Button>
</Group>
</Paper>
{viewer}
</Stack>
);
}
@@ -366,6 +403,7 @@ function DocReviewCard({
onNote,
onApprove,
onQuery,
onView,
busy,
}: {
doc: Freight.ClearanceDocument;
@@ -375,6 +413,7 @@ function DocReviewCard({
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
@@ -420,22 +459,28 @@ function DocReviewCard({
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile && (
<Tooltip label="Open document">
<Button
component="a"
href={doc.file!.url}
target="_blank"
rel="noreferrer"
size="compact-xs"
variant="default"
radius="md"
leftSection={<ExternalLink size={13} />}
>
View
</Button>
</Tooltip>
)}
{hasFile &&
isViewable({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -99,7 +99,6 @@ export interface BookingContainerView {
containerType?: {
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}

View File

@@ -15,13 +15,6 @@ function isValidValidityDays(value: string): boolean {
return Number.isInteger(days) && days >= 1 && days <= 365;
}
/** An adjusted price must be a non-negative number. */
function isValidAmount(value: string): boolean {
if (!value.trim()) return false;
const amount = Number(value.trim());
return Number.isFinite(amount) && amount >= 0;
}
export function useBookingActionDialog(
bookingId: string,
context: BookingActionContext,
@@ -93,15 +86,6 @@ export function useBookingActionDialog(
{ onSuccess },
);
break;
case "operationAdjustPrice": {
const amount = Number(inputValue.trim());
if (!Number.isFinite(amount) || amount < 0) return;
mutations.reviewOperation.mutate(
{ decision: "ADJUST_PRICE", amount },
{ onSuccess },
);
break;
}
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
@@ -151,8 +135,7 @@ export function useBookingActionDialog(
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||
(pendingAction?.input === "days" && !isValidValidityDays(inputValue)) ||
(pendingAction?.input === "amount" && !isValidAmount(inputValue));
(pendingAction?.input === "days" && !isValidValidityDays(inputValue));
return {
actions,

View File

@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2 } from 'lucide-react';
import { API_BASE_URL } from "@/constants/apiConfig";
import { API_BASE_URL } from '@/constants/apiConfig';
interface Cargo {
id: string;

View File

@@ -0,0 +1,194 @@
import { useState } from "react";
import {
Badge,
Box,
Button,
Group,
Stack,
Text,
Textarea,
ThemeIcon,
} from "@mantine/core";
import { Check, Circle, Clock, MinusCircle } from "lucide-react";
import type { Freight } from "@edr/types";
export interface ClearanceMilestoneTimelineProps {
milestones: Freight.IClearanceMilestone[];
/** Complete a milestone by code (omit to render read-only). */
onComplete?: (code: string, note?: string) => void;
/** True while a complete mutation is in flight. */
busy?: boolean;
}
const STATUS_META: Record<
Freight.MilestoneStatus,
{ color: string; label: string }
> = {
COMPLETED: { color: "edr-green", label: "Completed" },
PENDING: { color: "gray", label: "Pending" },
SKIPPED: { color: "gray", label: "Skipped" },
};
/** Vertical timeline of GL clearance milestones with inline complete actions. */
export function ClearanceMilestoneTimeline({
milestones,
onComplete,
busy,
}: ClearanceMilestoneTimelineProps) {
const [openNote, setOpenNote] = useState<Record<string, boolean>>({});
const [notes, setNotes] = useState<Record<string, string>>({});
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
const nextPending = sorted.find((m) => m.status === "PENDING");
if (sorted.length === 0) {
return (
<Text size="sm" c="dimmed">
No milestones for this shipment yet.
</Text>
);
}
return (
<Stack gap={0}>
{sorted.map((m, index) => {
const isLast = index === sorted.length - 1;
const meta = STATUS_META[m.status];
const isNext = nextPending?.id === m.id;
const Icon =
m.status === "COMPLETED"
? Check
: m.status === "SKIPPED"
? MinusCircle
: isNext
? Clock
: Circle;
return (
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
<ThemeIcon
variant={m.status === "COMPLETED" ? "filled" : "light"}
color={isNext ? "edr-green" : meta.color}
radius="xl"
size={30}
>
<Icon size={15} strokeWidth={2.2} />
</ThemeIcon>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
minHeight: 28,
background:
m.status === "COMPLETED"
? "var(--mantine-color-edr-green-4)"
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Stack>
<Box pb={isLast ? 0 : "md"} style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{m.milestoneLabel}
</Text>
<Group gap={6} mt={2} wrap="nowrap">
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
{m.ownerRegion ? (
<Badge size="xs" variant="default">
{m.ownerRegion}
</Badge>
) : null}
</Group>
{m.note ? (
<Text size="xs" c="dimmed" mt={4}>
{m.note}
</Text>
) : null}
</Box>
{onComplete && m.status === "PENDING" && isNext ? (
!openNote[m.milestoneCode] ? (
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={13} />}
disabled={busy}
onClick={() =>
setOpenNote((o) => ({
...o,
[m.milestoneCode]: true,
}))
}
>
Complete
</Button>
) : null
) : null}
</Group>
{onComplete && openNote[m.milestoneCode] && (
<Box mt="xs">
<Textarea
placeholder="Optional note for this milestone…"
value={notes[m.milestoneCode] ?? ""}
onChange={(e) =>
setNotes((n) => ({
...n,
[m.milestoneCode]: e.currentTarget.value,
}))
}
autosize
minRows={2}
size="sm"
radius="md"
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="compact-xs"
variant="subtle"
color="gray"
disabled={busy}
onClick={() =>
setOpenNote((o) => ({
...o,
[m.milestoneCode]: false,
}))
}
>
Cancel
</Button>
<Button
size="compact-xs"
color="edr-green"
leftSection={<Check size={13} />}
loading={busy}
onClick={() => {
onComplete(
m.milestoneCode,
notes[m.milestoneCode]?.trim() || undefined,
);
setOpenNote((o) => ({
...o,
[m.milestoneCode]: false,
}));
}}
>
Mark complete
</Button>
</Group>
</Box>
)}
</Box>
</Group>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Anchor,
Button,
Modal,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
Check,
FileSignature,
MessageSquareWarning,
ShieldCheck,
Sparkles,
XCircle,
Zap,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
contract: Freight.IContract;
mutations: Mutations;
/** Switch the detail page to its Clearance Review tab. */
onReviewClearance?: () => void;
}
// Contract is in the pre-booking clearance phase — staff can review the
// customer's uploaded documents.
const CLEARANCE_REVIEW_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
mutations,
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { status } = contract;
const [acceptOpen, setAcceptOpen] = useState(false);
const [validityDays, setValidityDays] = useState<string | null>(null);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Admin-configured validity durations (days) for the accept dialog. Staff can
// only pick one of these — no free-typing. Read-only setting, fetched once.
const { data: validitySetting, isLoading: validityLoading } = useQuery({
...api.dropdownSettings.getByCode.queryOptions({
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
}),
retry: false,
});
const validityOptions = useMemo(
() =>
[...(validitySetting?.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o) => ({ value: String(o.value), label: o.label })),
[validitySetting],
);
// Default the selection to the first configured option when the dialog opens.
useEffect(() => {
if (acceptOpen && !validityDays && validityOptions.length > 0) {
setValidityDays(validityOptions[0].value);
}
}, [acceptOpen, validityDays, validityOptions]);
if (["REJECTED", "CANCELLED", "EXPIRED", "CONTRACT_CLOSED"].includes(status)) {
return null;
}
if (status === "CHANGES_REQUESTED") {
return (
<SectionCard icon={Zap} title="Awaiting customer">
<Text size="sm" c="dimmed">
No staff actions until the customer resubmits the contract.
</Text>
</SectionCard>
);
}
const canAccept = status === "SUBMITTED";
// Generation only becomes available once EVERY approval step is complete and
// the contract reaches APPROVED. While any step is still pending the contract
// stays in PENDING_APPROVAL, so this button does not appear after only the
// first (line-staff) approval — the director step must land first.
const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the
// generated contract before signing) — no sign button in this toolbar.
const canViewContract =
["CONTRACT_READY", "SIGNED_CUSTOMER"].includes(status) &&
Boolean(contract.contractGeneratedAt);
// Show "Review clearance" while the contract is in the document-review phase.
// Reviewer = GL (Path B / customs) or Operations (Path A / no customs).
const canReviewClearance =
Boolean(onReviewClearance) &&
CLEARANCE_REVIEW_STATUSES.includes(status);
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
return (
<SectionCard icon={Zap} title="Staff actions">
<Stack gap="sm">
<Text size="xs" c="dimmed">
Confirm each step before it is applied.
</Text>
{canAccept && (
<>
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => setAcceptOpen(true)}
>
Accept for approval
</Button>
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
</>
)}
{needsManualGenerate && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<Sparkles size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Generate contract
</Button>
)}
{canViewContract && (
<Button
fullWidth
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}/view`)
}
>
View &amp; sign contract
</Button>
)}
{canReviewClearance && (
<Button
fullWidth
color="edr-green"
variant="light"
leftSection={<ShieldCheck size={16} />}
onClick={onReviewClearance}
>
{clearanceReviewer}
</Button>
)}
{/* GL "Create booking" removed for now — clearance ends at finalize and
the customer creates the booking in the portal. */}
{!canAccept &&
!needsManualGenerate &&
!canViewContract &&
!canReviewClearance && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.
</Text>
)}
</Stack>
{/* Accept — sets the contract validity window */}
<Modal
opened={acceptOpen}
onClose={() => setAcceptOpen(false)}
title="Accept contract for approval"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Pick the contract validity window, then start the approval chain.
</Text>
{validityOptions.length > 0 ? (
<Select
label="Validity"
placeholder="Select a validity period"
data={validityOptions}
value={validityDays}
onChange={setValidityDays}
allowDeselect={false}
comboboxProps={{ withinPortal: true }}
/>
) : (
<Text size="sm" c="orange.7">
{validityLoading
? "Loading validity periods…"
: "No validity periods are configured yet. Add them under "}
{!validityLoading && (
<Anchor
href="/dashboard/dropdown-settings"
onClick={(e) => {
e.preventDefault();
navigate("/dashboard/dropdown-settings");
}}
>
Dropdown Settings
</Anchor>
)}
{!validityLoading && "."}
</Text>
)}
<Button
color="edr-green"
loading={mutations.staffAccept.isPending}
disabled={!validityDays}
onClick={() => {
const days = Number(validityDays);
if (!days) return;
mutations.staffAccept.mutate(days, {
onSuccess: () => setAcceptOpen(false),
});
}}
>
Accept
</Button>
</Stack>
</Modal>
{/* Request changes */}
<Modal
opened={changesOpen}
onClose={() => setChangesOpen(false)}
title="Request changes"
centered
>
<Stack gap="md">
<Textarea
label="What needs to change?"
placeholder="Describe the changes the customer must make…"
autosize
minRows={3}
value={changesNote}
onChange={(e) => setChangesNote(e.currentTarget.value)}
/>
<Button
color="orange"
disabled={!changesNote.trim()}
loading={mutations.requestChanges.isPending}
onClick={() =>
mutations.requestChanges.mutate(changesNote, {
onSuccess: () => {
setChangesOpen(false);
setChangesNote("");
},
})
}
>
Send to customer
</Button>
</Stack>
</Modal>
{/* Reject */}
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
title="Reject contract"
centered
>
<Stack gap="md">
<Textarea
label="Reason for rejection"
placeholder="Explain why this contract is rejected…"
autosize
minRows={3}
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Button
color="red"
disabled={!rejectReason.trim()}
loading={mutations.reject.isPending}
onClick={() =>
mutations.reject.mutate(rejectReason, {
onSuccess: () => {
setRejectOpen(false);
setRejectReason("");
},
})
}
>
Reject
</Button>
</Stack>
</Modal>
</SectionCard>
);
}

View File

@@ -0,0 +1,33 @@
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import type { ContractListRow } from "@/features/contracts/mapContractListRow";
import { cn } from "@/lib/utils";
interface ContractApprovalProgressCellProps {
row: ContractListRow;
}
export function ContractApprovalProgressCell({
row,
}: ContractApprovalProgressCellProps) {
const summary = formatContractApprovalProgress(row.status, row.approvalSteps);
return (
<div className="min-w-[8.5rem] py-1">
<p
className={cn(
"text-sm font-semibold",
summary.complete
? "text-[color:var(--freight-brand)]"
: "text-foreground",
)}
>
{summary.label}
</p>
{summary.detail ? (
<p className="mt-0.5 line-clamp-2 text-[11px] leading-snug text-muted-foreground">
{summary.detail}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,184 @@
import { useMemo } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import type { Freight } from "@edr/types";
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractApprovalStepsCardProps {
contract: Freight.IContract;
mutations: Mutations;
}
/** Approval chain with inline approve on the next pending step. */
export function ContractApprovalStepsCard({
contract,
mutations,
}: ContractApprovalStepsCardProps) {
const steps = useMemo(
() =>
[...(contract.approvalSteps ?? [])].sort(
(a, b) => a.stepOrder - b.stepOrder,
),
[contract.approvalSteps],
);
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
const subtitle =
summary.detail ||
(nextPending
? `Next: ${nextPending.requiredRole} · step ${nextPending.stepOrder}`
: steps.length
? "All steps complete"
: "Accept submission to begin");
return (
<SectionCard
icon={ShieldCheck}
title="Approval chain"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
</Badge>
}
>
<Text size="xs" c="dimmed" mb="sm">
{subtitle}
</Text>
{steps.length === 0 ? (
<Text
size="sm"
c="dimmed"
ta="center"
py="lg"
px="md"
style={{
borderRadius: 8,
border: "1px dashed var(--mantine-color-gray-3)",
background: "var(--mantine-color-gray-0)",
}}
>
Use <strong>Accept for approval</strong> in staff actions to
instantiate steps.
</Text>
) : (
<Stack gap="xs">
{steps.map((step) => (
<StepRow
key={step.id}
step={step}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={() =>
mutations.approveStep.mutate({
stepId: step.id,
requiredRole: step.requiredRole,
})
}
/>
))}
</Stack>
)}
</SectionCard>
);
}
function StepRow({
step,
isNext,
isPending,
onApprove,
}: {
step: Freight.IContractApprovalStep;
isNext: boolean;
isPending: boolean;
onApprove: () => void;
}) {
const statusColor =
step.status === "APPROVED"
? "edr-green"
: step.status === "REJECTED"
? "red"
: isNext
? "edr-green"
: "gray";
return (
<Group
justify="space-between"
wrap="nowrap"
gap="sm"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
borderLeft: isNext
? "3px solid var(--freight-brand)"
: "1px solid var(--mantine-color-gray-2)",
background: isNext ? "var(--mantine-color-gray-0)" : "white",
}}
>
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 28,
height: 28,
borderRadius: 8,
flexShrink: 0,
fontSize: 12,
fontWeight: 700,
background: "var(--mantine-color-gray-1)",
color: isNext
? "var(--mantine-color-gray-7)"
: "var(--mantine-color-gray-6)",
}}
>
{step.stepOrder}
</Box>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600}>
{step.requiredRole}
</Text>
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}
</Text>
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{isNext && step.status === "PENDING" && (
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={onApprove}
>
Approve
</Button>
)}
<Badge
variant="light"
color={statusColor}
size="sm"
radius="sm"
tt="uppercase"
>
{step.status}
</Badge>
</Group>
</Group>
);
}

View File

@@ -0,0 +1,669 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
FileButton,
Group,
Loader,
Paper,
Progress,
Stack,
Text,
Textarea,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps {
contractId: string;
/** Called after any review/finalize mutation so the parent can refetch. */
onChanged?: () => void;
/** Hide the inline progress summary (e.g. when the parent renders its own). */
hideSummary?: boolean;
/**
* Path A (non-customs): the reviewer is Operations, not GL, and there is no GL
* output upload step. Routes review/finalize to the Operations endpoints.
*/
selfClear?: boolean;
/**
* Clearance is finalized — render the document outcomes (approved / queried,
* by whom, when) but hide all approve / query / finalize actions.
*/
readOnly?: boolean;
}
const STATUS_META: Record<
Freight.ContractDocReviewStatus,
{ label: string; color: string }
> = {
APPROVED: { label: "Approved", color: "edr-green" },
QUERIED: { label: "Queried", color: "red" },
PENDING: { label: "Pending", color: "gray" },
};
function formatReviewedAt(value?: string | null): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* Pre-booking clearance review for a CONTRACT. Approve / query each customer
* document, upload GL output documents, and finalize once every required
* document is approved. When `readOnly` it becomes an audit view: approved /
* queried outcomes with reviewer + timestamp, no actions.
*/
export function ContractClearanceReviewSection({
contractId,
onChanged,
hideSummary,
selfClear = false,
readOnly = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
queryFn: () => contractsService.getClearance(contractId),
});
const { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance } =
useContractClearanceMutations(contractId, selfClear);
const customerDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
// GL output documents — anything not uploaded by the customer. The backend
// tags these `uploadedBy: 'gl'`; matching on "not customer" keeps it robust if
// that ever splits into gl_et / gl_dj.
const glDocs = useMemo(
() =>
(clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
[clearance],
);
const stats = useMemo(() => {
const total = customerDocs.length;
const approved = customerDocs.filter(
(d) => d.reviewStatus === "APPROVED",
).length;
const queried = customerDocs.filter(
(d) => d.reviewStatus === "QUERIED",
).length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [customerDocs]);
// Documents with a file uploaded but not yet approved — "Approve all" targets.
const approvableKeys = customerDocs
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
.map((d) => d.fileKey);
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
);
}
const handleReview = (
fileKey: string,
status: "APPROVED" | "QUERIED",
note?: string,
) =>
reviewDocument.mutate(
{ fileKey, status, note },
{
onSuccess: () => {
if (status === "QUERIED")
setOpenQuery((o) => ({ ...o, [fileKey]: false }));
onChanged?.();
},
},
);
return (
<Stack gap="lg">
<SectionCard
icon={FileText}
title="Customer documents"
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: "Approve each document, or open a query to tell the customer what to fix."
}
extra={
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{!readOnly && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<FileCheck2 size={14} />}
loading={approveAll.isPending}
disabled={reviewDocument.isPending}
onClick={() => approveAll.mutate(approvableKeys)}
>
Approve all ({approvableKeys.length})
</Button>
)}
</Group>
}
>
<Stack gap={12}>
{!hideSummary && stats.total > 0 && (
<Box>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="sm"
mb={6}
/>
<Group gap="lg">
<StatPill color="edr-green" label="Approved" value={stats.approved} />
<StatPill color="red" label="Queried" value={stats.queried} />
<StatPill color="gray" label="Pending" value={stats.pending} />
</Group>
</Box>
)}
{customerDocs.length === 0 ? (
<Text size="sm" c="dimmed">
No customer documents are required for this contract.
</Text>
) : (
customerDocs.map((doc) => (
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
setOpenQuery((o) => ({ ...o, [doc.fileKey]: open }))
}
onNote={(v) =>
setQueryNotes((n) => ({ ...n, [doc.fileKey]: v }))
}
onApprove={() => handleReview(doc.fileKey, "APPROVED")}
onQuery={() =>
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
}
onView={view}
busy={reviewDocument.isPending}
/>
))
)}
</Stack>
</SectionCard>
{glDocs.length > 0 && (
<SectionCard
icon={Upload}
title="GL output documents"
subtitle="Upload each document individually — changes save immediately."
accent="edr-green"
>
<Stack gap={10}>
{glDocs.map((doc) => {
const isUploading =
uploadingKey === doc.fileKey && uploadOutputDocuments.isPending;
return (
<Group key={doc.fileKey} justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<FileText size={16} color="var(--mantine-color-edr-green-6)" />
<Text fz="13px" c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<>
{isViewable({
name: doc.file.name,
url: fileViewUrl(doc.file.id),
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="a"
href={fileViewUrl(doc.file.id, true)}
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
</Text>
)}
{!readOnly && (
<FileButton
onChange={(f) => {
if (!f) return;
setUploadingKey(doc.fileKey);
uploadOutputDocuments.mutate(
{ [doc.fileKey]: f },
{ onSuccess: () => { setUploadingKey(null); onChanged?.(); },
onError: () => setUploadingKey(null) },
);
}}
accept="application/pdf,image/*"
disabled={isUploading}
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={
isUploading ? (
<Loader size={12} color="edr-green" />
) : (
<Upload size={13} />
)
}
loading={isUploading}
>
{doc.file ? "Replace" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
);
})}
</Stack>
</SectionCard>
)}
{!readOnly && finalizeClearance.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeClearance.error instanceof Error
? finalizeClearance.error.message
: "Could not finalize clearance."}
</Alert>
)}
{readOnly ? (
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 70%)",
}}
>
<Group gap={10} wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
Clearance was finalized by the {reviewerTeam} team. This is a
read-only record of the approved documents.
</Text>
</Group>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {
onSuccess: () => onChanged?.(),
})
}
>
Finalize clearance
</Button>
</Group>
</Paper>
)}
{viewer}
</Stack>
);
}
function StatPill({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Group gap={6} wrap="nowrap">
<Box
style={{
width: 8,
height: 8,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="12.5px" c="edr-text" fw={600}>
{value}
</Text>
<Text fz="12.5px" c="dimmed">
{label}
</Text>
</Group>
);
}
function DocReviewCard({
doc,
reviewerTeam,
readOnly,
note,
queryOpen,
onToggleQuery,
onNote,
onApprove,
onQuery,
onView,
busy,
}: {
doc: Freight.ContractClearanceDocument;
reviewerTeam: string;
readOnly: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const isQueried = status === "QUERIED";
const reviewedAt = formatReviewedAt(doc.reviewedAt);
// Approved cards get a light green gradient + green border so the outcome is
// instantly scannable; queried cards get a soft red; pending stay neutral.
const cardStyle = isApproved
? {
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 72%)",
}
: isQueried
? {
borderColor: "var(--mantine-color-red-2)",
background:
"linear-gradient(135deg, var(--mantine-color-red-0) 0%, #FFFFFF 78%)",
}
: { borderColor: "var(--mantine-color-edr-border-6)" };
return (
<Paper withBorder radius="md" p="md" style={cardStyle}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={isApproved ? "edr-green" : isQueried ? "red" : "gray"}
radius="md"
size={40}
>
{isApproved ? <CheckCircle2 size={19} /> : <FileText size={19} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
{isApproved && (reviewedAt || reviewerTeam) && (
<Group gap={5} wrap="nowrap" mt={3}>
<UserCheck size={12} color="var(--mantine-color-edr-green-7)" />
<Text fz="11.5px" c="edr-green.8" fw={600} truncate>
Approved by {reviewerTeam}
{reviewedAt ? ` · ${reviewedAt}` : ""}
</Text>
</Group>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Badge
variant="light"
color={meta.color}
radius="sm"
leftSection={
isApproved ? (
<CheckCircle2 size={11} />
) : isQueried ? (
<MessageSquareWarning size={11} />
) : (
<Clock size={11} />
)
}
>
{meta.label}
</Badge>
{hasFile &&
isViewable({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
}) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({
name: doc.file!.name,
url: fileViewUrl(doc.file!.id),
})
}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>
{isQueried && doc.note && (
<Alert
mt="sm"
color="red"
variant="light"
radius="md"
icon={<MessageSquareWarning size={15} />}
p="xs"
>
<Text fz="12.5px" c="red.9">
{doc.note}
</Text>
</Alert>
)}
{!readOnly && hasFile && !isApproved && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
Approve
</Button>
</Group>
) : (
<Box
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-red-0)",
border: "1px solid var(--mantine-color-red-2)",
}}
>
<Group gap={6} mb={6}>
<MessageSquareWarning
size={14}
color="var(--mantine-color-red-7)"
/>
<Text fz="12.5px" fw={700} c="red.8">
Describe the problem for the customer
</Text>
</Group>
<Textarea
placeholder="e.g. The commercial invoice is missing the HS code."
value={note}
onChange={(e) => onNote(e.currentTarget.value)}
autosize
minRows={2}
radius="md"
size="sm"
autoFocus
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="sm"
variant="subtle"
color="gray"
radius="md"
disabled={busy}
onClick={() => onToggleQuery(false)}
>
Cancel
</Button>
<Button
size="sm"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={15} />}
loading={busy}
disabled={!note.trim()}
onClick={onQuery}
>
Send query to customer
</Button>
</Group>
</Box>
)}
</Box>
)}
</Paper>
);
}

View File

@@ -0,0 +1,71 @@
import { Badge, Group } from "@mantine/core";
import { Repeat } from "lucide-react";
import {
CONTRACT_STATUS_COLOR,
CONTRACT_STATUS_STYLES,
} from "@/features/contracts/contract-status.config";
interface ContractStatusBadgeProps {
status: string;
/** When the contract is a renewal of a prior one, show a sibling badge. */
isRenewal?: boolean;
}
export function ContractStatusBadge({
status,
isRenewal,
}: ContractStatusBadgeProps) {
const style = CONTRACT_STATUS_STYLES[status] ?? {
label: status,
color: "gray",
};
const color = CONTRACT_STATUS_COLOR[status] ?? "gray";
const statusBadge = (
<Badge
color={color}
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
title={style.label}
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
maxWidth: "100%",
whiteSpace: "nowrap",
}}
>
{style.label}
</Badge>
);
if (!isRenewal) return statusBadge;
return (
<Group gap={4} wrap="nowrap">
{statusBadge}
<Badge
color="indigo"
variant="light"
size="sm"
radius="md"
tt="uppercase"
fw={600}
leftSection={<Repeat size={12} />}
title="Renewal of a prior contract"
style={{
fontSize: "0.7rem",
letterSpacing: "0.05em",
display: "inline-flex",
whiteSpace: "nowrap",
}}
>
Renewal
</Badge>
</Group>
);
}

View File

@@ -0,0 +1,92 @@
import { Badge, ScrollArea, Tabs } from "@mantine/core";
import {
ClipboardCheck,
FileSignature,
Inbox,
LayoutGrid,
ShieldCheck,
Truck,
XCircle,
} from "lucide-react";
import "@/components/overview/overview.css";
import {
CONTRACT_LIST_TABS,
type ContractStatusTabKey,
} from "@/features/contracts/contract-status.config";
const TAB_ICONS: Record<ContractStatusTabKey, React.ReactNode> = {
all: <LayoutGrid size={17} strokeWidth={1.85} />,
intake: <Inbox size={17} strokeWidth={1.85} />,
in_approval: <ClipboardCheck size={17} strokeWidth={1.85} />,
approved_contract: <FileSignature size={17} strokeWidth={1.85} />,
clearance: <ShieldCheck size={17} strokeWidth={1.85} />,
active: <Truck size={17} strokeWidth={1.85} />,
closed: <XCircle size={17} strokeWidth={1.85} />,
};
interface ContractStatusTabsProps {
active: ContractStatusTabKey;
onChange: (tab: ContractStatusTabKey) => void;
counts?: Partial<Record<ContractStatusTabKey, number>>;
}
export function ContractStatusTabs({
active,
onChange,
counts,
}: ContractStatusTabsProps) {
return (
<Tabs
value={active}
onChange={(value) => onChange((value as ContractStatusTabKey) ?? "all")}
variant="pills"
color="edr-green"
keepMounted={false}
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<ScrollArea type="auto" scrollbarSize={6} offsetScrollbars="x">
<Tabs.List style={{ flexWrap: "nowrap", width: "max-content" }}>
{CONTRACT_LIST_TABS.map((tab) => {
const isActive = active === tab.key;
const count = counts?.[tab.key];
return (
<Tabs.Tab
key={tab.key}
value={tab.key}
leftSection={TAB_ICONS[tab.key]}
size={"sm"}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={isActive ? "white" : "light"}
color={isActive ? "edr-green" : "gray"}
styles={
isActive
? {
root: {
background: "rgba(255,255,255,0.9)",
color: "#15805f",
},
}
: undefined
}
>
{count}
</Badge>
) : undefined
}
>
{tab.label}
</Tabs.Tab>
);
})}
</Tabs.List>
</ScrollArea>
</Tabs>
);
}
export type { ContractStatusTabKey };

View File

@@ -0,0 +1,142 @@
import {
Check,
FileSignature,
FileText,
ShieldCheck,
Truck,
Workflow,
type LucideIcon,
} from "lucide-react";
import { Paper, Group, Stack, Text, Box } from "@mantine/core";
import {
CONTRACT_WORKFLOW_STAGES,
getContractWorkflowStageIndex,
} from "@/features/contracts/contract-status.config";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import {
BRAND_GREEN,
detailStyles,
} from "@/components/bookings/detail/booking-detail.styles";
const STAGE_ICONS: LucideIcon[] = [
FileText,
FileSignature,
FileSignature,
ShieldCheck,
Truck,
Check,
];
interface ContractWorkflowStepperProps {
status: string;
title: string;
description: string;
}
export function ContractWorkflowStepper({
status,
title,
description,
}: ContractWorkflowStepperProps) {
const currentStage = getContractWorkflowStageIndex(status);
const isTerminal = currentStage < 0;
return (
<SectionCard icon={Workflow} title="Workflow progress">
<Group gap={0} wrap="nowrap" align="flex-start" mb="lg">
{CONTRACT_WORKFLOW_STAGES.map((stage, index) => {
const Icon = STAGE_ICONS[index] ?? FileText;
const isComplete = !isTerminal && index < currentStage;
const isActive = !isTerminal && index === currentStage;
const isLast = index === CONTRACT_WORKFLOW_STAGES.length - 1;
return (
<Box
key={stage.label}
style={{ flex: isLast ? "0 0 auto" : 1, minWidth: 0 }}
>
<Group gap={0} wrap="nowrap" align="center">
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 34,
height: 34,
borderRadius: "50%",
background: isComplete
? BRAND_GREEN
: isActive
? "white"
: "var(--mantine-color-gray-1)",
border: isActive
? `2px solid ${BRAND_GREEN}`
: isComplete
? "2px solid transparent"
: "2px solid var(--mantine-color-gray-2)",
color: isComplete
? "white"
: isActive
? "var(--freight-brand-dark)"
: "var(--mantine-color-gray-5)",
transition: "all 0.2s ease",
}}
>
{isComplete ? (
<Check size={16} strokeWidth={3} />
) : (
<Icon size={15} />
)}
</Box>
<Text
size="xs"
fw={isActive ? 600 : 500}
c={isActive ? "edr-green.7" : isComplete ? "dark" : "dimmed"}
ta="center"
style={{ whiteSpace: "nowrap" }}
>
{stage.label}
</Text>
</Stack>
{!isLast && (
<Box
style={{
flex: 1,
height: 2,
marginInline: 8,
marginBottom: 20,
borderRadius: 2,
background: isComplete
? BRAND_GREEN
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Group>
</Box>
);
})}
</Group>
<Paper
radius="md"
withBorder
p="md"
style={
isTerminal
? detailStyles.statusBannerTerminal
: detailStyles.statusBanner
}
>
<Text size="sm" fw={600} c={isTerminal ? "red.7" : "dark"}>
{title}
</Text>
<Text size="sm" c="dimmed" mt={4}>
{description}
</Text>
</Paper>
</SectionCard>
);
}

View File

@@ -0,0 +1,918 @@
import { useEffect, useMemo, useState } from "react";
import {
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Center,
Divider,
Grid,
Group,
Loader,
Modal,
NumberInput,
Paper,
Select,
Stack,
Text,
Textarea,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Container as ContainerIcon,
FileText,
Package,
Plus,
Receipt,
Trash2,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
import {
useContractCapacity,
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
import { Boxes } from "lucide-react";
import {
computeGlShipmentTotal,
formatRateUnit,
type GlShipmentQuantities,
} from "./gl-booking-form/total";
interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: number | string;
}
interface ContainerLineDraft {
containerSize: string;
hazardousQuantity: number | string;
reeferQuantity: number | string;
units: UnitDraft[];
}
interface BulkLineDraft {
cargoTypeId: string;
cargoWeightTons: number | string;
itemCount: number | string;
hazardousQuantity: number | string;
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
}
export default function GlCreateBookingForm() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
// When GL accepts a shipment request, the form opens with ?requestId=… so it
// can prefill the requested quantities/date and mark the request accepted on
// success.
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const { data: contract, isLoading } = useContractDetail(id);
const { data: capacity = [] } = useContractCapacity(id);
const mutations = useContractMutations(id ?? "");
const { data: bookingRequest } = useQuery({
queryKey: ["shipment-request", requestId],
queryFn: () => contractsService.getBookingRequest(requestId!),
enabled: Boolean(requestId),
});
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
const [bulkLines, setBulkLines] = useState<BulkLineDraft[]>([]);
const [prefilled, setPrefilled] = useState(false);
// Prefill once from an accepted shipment request: size/qty container lines
// (one blank unit per requested container) + bulk + route + notes. GL still
// enters per-unit container numbers + sets the binding shipment date.
useEffect(() => {
if (!bookingRequest || prefilled) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
setContainerLines(
lines.containers.map((c) => ({
containerSize: c.containerSize,
hazardousQuantity: c.hazardousQuantity ?? "",
reeferQuantity: c.reeferQuantity ?? "",
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
emptyUnit(),
),
})),
);
} else if (lines.bulk) {
setBulkLines([
{
cargoTypeId: lines.bulk.cargoTypeId ?? "",
cargoWeightTons: lines.bulk.cargoWeightTons ?? "",
itemCount: lines.bulk.itemCount ?? "",
hazardousQuantity: lines.bulk.hazardousQuantity ?? "",
},
]);
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
// Price-confirm modal — GL reviews the estimate before booking on behalf of
// the customer, mirroring the portal customer flow.
const [priceOpen, setPriceOpen] = useState(false);
const isContainer = contract?.freightType === "CONTAINER";
const routes = useMemo(
() =>
[...(contract?.routes ?? [])].sort((a, b) => a.sortOrder - b.sortOrder),
[contract?.routes],
);
const needsRouteSelect = contract?.contractKind === "GENERAL" && routes.length > 1;
const containerSizes = useMemo(() => {
const sizes = new Set<string>();
(contract?.cargoScope ?? []).forEach((s) => {
if (s.containerSize) sizes.add(s.containerSize);
});
return [...sizes];
}, [contract?.cargoScope]);
// Bulk cargo types declared on the contract scope (prefill, no free-text).
const bulkCargoOptions = useMemo(() => {
const seen = new Map<string, string>();
(contract?.cargoScope ?? []).forEach((s) => {
if (s.containerSize || !s.cargoTypeId) return;
if (!seen.has(s.cargoTypeId)) {
seen.set(s.cargoTypeId, s.cargoFreeText?.trim() || s.cargoTypeId);
}
});
return [...seen.entries()].map(([value, label]) => ({ value, label }));
}, [contract?.cargoScope]);
const defaultBulkCargoTypeId = bulkCargoOptions[0]?.value ?? "";
// Normalized quantities for the client-side price estimate (same source the
// portal customer sees: the contract's frozen unit rates × entered qty).
const quantities: GlShipmentQuantities = useMemo(
() => ({
isContainer,
containers: containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
hazardousQuantity: Number(l.hazardousQuantity || 0),
reeferQuantity: Number(l.reeferQuantity || 0),
})),
bulkQuantity: bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
0,
),
bulkHazardousQuantity: bulkLines.reduce(
(s, l) => s + Number(l.hazardousQuantity || 0),
0,
),
}),
[isContainer, containerLines, bulkLines],
);
const priceTotal = useMemo(
() => (contract ? computeGlShipmentTotal(contract, quantities) : null),
[contract, quantities],
);
// The route this shipment ships on (for the cargo-aware day list). For a
// single-route contract there's exactly one; for GENERAL multi-route, the
// selected route (defaults to the first).
const selectedRoute = useMemo(
() => routes.find((r) => r.id === contractRouteId) ?? routes[0],
[routes, contractRouteId],
);
// Cargo-aware availability query: only days where a train has remaining
// capacity AND enough matching-type wagons for the entered cargo. Null until
// the cargo is entered (so the Schedule section stays empty first).
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!selectedRoute?.originYardId || !selectedRoute?.destinationYardId)
return null;
if (isContainer) {
const containers = containerLines
.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = bulkLines.reduce(
(s, l) => s + Number(l.cargoWeightTons || 0),
0,
);
if (tons <= 0) return null;
return {
originYardId: selectedRoute.originYardId,
destinationYardId: selectedRoute.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract?.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
}, [selectedRoute, isContainer, containerLines, bulkLines, contract?.pricingBreakdown]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...api.trainScheduling.availableDaysForCargo.queryOptions({
input: cargoQuery ?? {
freightType: "BULK" as const,
},
}),
enabled: cargoQuery !== null,
});
if (isLoading) {
return (
<PageContainer>
<Center mih="50vh">
<Loader color="gray" />
</Center>
</PageContainer>
);
}
if (!contract) {
return (
<PageContainer>
<PageHeader
title="Contract not found"
backTo="/dashboard/contracts/clearance"
/>
</PageContainer>
);
}
// ── Container line helpers ──
const addContainerLine = () =>
setContainerLines((prev) => [
...prev,
{
containerSize: containerSizes[0] ?? "20ft",
hazardousQuantity: "",
reeferQuantity: "",
units: [emptyUnit()],
},
]);
const removeContainerLine = (idx: number) =>
setContainerLines((prev) => prev.filter((_, i) => i !== idx));
const patchLine = (idx: number, patch: Partial<ContainerLineDraft>) =>
setContainerLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
const addUnit = (lineIdx: number) =>
patchLine(lineIdx, {
units: [...containerLines[lineIdx].units, emptyUnit()],
});
const removeUnit = (lineIdx: number, unitIdx: number) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.filter((_, i) => i !== unitIdx),
});
const patchUnit = (
lineIdx: number,
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
),
});
// ── Bulk line helpers ──
const addBulkLine = () =>
setBulkLines((prev) => [
...prev,
{
cargoTypeId: defaultBulkCargoTypeId,
cargoWeightTons: "",
itemCount: "",
hazardousQuantity: "",
},
]);
const removeBulkLine = (idx: number) =>
setBulkLines((prev) => prev.filter((_, i) => i !== idx));
const patchBulk = (idx: number, patch: Partial<BulkLineDraft>) =>
setBulkLines((prev) =>
prev.map((l, i) => (i === idx ? { ...l, ...patch } : l)),
);
const canSubmit =
Boolean(scheduledDate) &&
(!needsRouteSelect || Boolean(contractRouteId)) &&
(isContainer ? containerLines.length > 0 : bulkLines.length > 0);
const handleSubmit = () => {
if (!scheduledDate) return;
const payload: Freight.CreateBookingUnderContractDto = {
scheduledDate,
...(contractRouteId ? { contractRouteId } : {}),
...(notes.trim() ? { notes: notes.trim() } : {}),
};
if (isContainer) {
payload.containers = containerLines.map((l) => ({
containerSize: l.containerSize,
quantity: l.units.length,
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
...(l.reeferQuantity !== ""
? { reeferQuantity: Number(l.reeferQuantity) }
: {}),
units: l.units.map((u) => ({
containerNumber: u.containerNumber,
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
})),
}));
} else {
payload.bulkLines = bulkLines.map((l) => ({
...(l.cargoTypeId ? { cargoTypeId: l.cargoTypeId } : {}),
...(l.cargoWeightTons !== ""
? { cargoWeightTons: Number(l.cargoWeightTons) }
: {}),
...(l.itemCount !== "" ? { itemCount: Number(l.itemCount) } : {}),
...(l.hazardousQuantity !== ""
? { hazardousQuantity: Number(l.hazardousQuantity) }
: {}),
}));
}
mutations.createBooking.mutate(payload, {
onSuccess: async (booking) => {
if (requestId) {
// GENERAL+customs accept flow: mark the request accepted + link the
// booking, then hand off to the per-booking clearance review.
try {
await contractsService.acceptBookingRequest(requestId, booking.id);
} catch {
// Non-fatal — the booking exists; the request link can be retried.
}
navigate(`/dashboard/clearance/${booking.id}`);
} else {
navigate(`/dashboard/bookings/${booking.id}/milestones`);
}
},
});
};
return (
<PageContainer>
<PageHeader
title="Create booking (GL)"
subtitle={`Enter the shipment details on behalf of the customer for contract ${contract.reference}.`}
backTo={`/dashboard/contracts/clearance/${contract.id}`}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{
label: contract.reference,
href: `/dashboard/contracts/clearance/${contract.id}`,
},
{ label: "Create booking" },
]}
/>
<Stack gap="lg">
{capacity.length > 0 && (
<Alert
color={capacity.every((c) => c.remaining === 0) ? "red" : "blue"}
variant="light"
radius="md"
icon={<Boxes size={16} />}
title="Contract draw-down capacity"
>
<Group gap={8} wrap="wrap">
{capacity.map((c, i) => (
<Badge
key={i}
color={c.remaining === 0 ? "red" : "blue"}
variant="light"
radius="sm"
>
{c.containerSize ?? "Bulk"}: {c.remaining} of {c.cap} left
</Badge>
))}
</Group>
</Alert>
)}
{bookingRequest ? (
<Alert
color="edr-green"
variant="light"
radius="md"
icon={<FileText size={16} />}
title="From shipment request"
>
Booking on behalf of the customer for request{" "}
<b>{bookingRequest.reference}</b>.
{bookingRequest.scheduledDate ? (
<>
{" "}
Customer requested{" "}
<b>
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}
</b>{" "}
set the binding shipment date below.
</>
) : null}
</Alert>
) : null}
<SectionCard icon={FileText} title="Route">
{needsRouteSelect ? (
<Select
label="Contract route"
placeholder="Select contract route"
value={contractRouteId}
onChange={setContractRouteId}
data={routes.map((r) => ({
value: r.id,
label: `${r.originYard?.label ?? r.originYard?.code ?? "Origin"}${
r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"
}`,
}))}
required
/>
) : (
<Text size="sm" c="dimmed">
{selectedRoute
? `${selectedRoute.originYard?.label ?? selectedRoute.originYard?.code ?? "Origin"}${
selectedRoute.destinationYard?.label ??
selectedRoute.destinationYard?.code ??
"Destination"
}`
: "This contract's only route."}
</Text>
)}
</SectionCard>
{isContainer ? (
<SectionCard
icon={ContainerIcon}
title="Containers"
extra={
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
onClick={addContainerLine}
>
Add line
</Button>
}
>
{containerLines.length === 0 ? (
<Text size="sm" c="dimmed">
Add at least one container line.
</Text>
) : (
<Stack gap="lg">
{containerLines.map((line, lineIdx) => (
<Box
key={lineIdx}
p="md"
style={{
borderRadius: 10,
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group justify="space-between" mb="sm">
<Text fw={600} size="sm">
Line {lineIdx + 1}
</Text>
<ActionIcon
variant="subtle"
color="red"
onClick={() => removeContainerLine(lineIdx)}
aria-label="Remove line"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Grid gap="sm">
<Grid.Col span={{ base: 12, sm: 4 }}>
<Select
label="Container size"
value={line.containerSize}
onChange={(v) =>
patchLine(lineIdx, {
containerSize: v ?? line.containerSize,
})
}
data={
containerSizes.length > 0
? containerSizes
: ["20ft", "40ft"]
}
/>
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 4 }}>
<NumberInput
label="Hazard qty"
min={0}
value={line.hazardousQuantity}
onChange={(v) =>
patchLine(lineIdx, { hazardousQuantity: v })
}
/>
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 4 }}>
<NumberInput
label="Reefer qty"
min={0}
value={line.reeferQuantity}
onChange={(v) =>
patchLine(lineIdx, { reeferQuantity: v })
}
/>
</Grid.Col>
</Grid>
<Divider
my="sm"
label={`${line.units.length} container unit${
line.units.length === 1 ? "" : "s"
}`}
labelPosition="left"
/>
<Stack gap="xs">
{line.units.map((unit, unitIdx) => (
<Grid key={unitIdx} gap="xs" align="flex-end">
<Grid.Col span={{ base: 12, sm: 4 }}>
<TextInput
label={unitIdx === 0 ? "Container no." : undefined}
placeholder="MSKU1234567"
value={unit.containerNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
containerNumber: e.currentTarget.value,
})
}
/>
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 3 }}>
<TextInput
label={unitIdx === 0 ? "Seal no." : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
sealNumber: e.currentTarget.value,
})
}
/>
</Grid.Col>
<Grid.Col span={{ base: 5, sm: 3 }}>
<NumberInput
label={unitIdx === 0 ? "VGM (t)" : undefined}
min={0}
decimalScale={2}
value={unit.vgmTons}
onChange={(v) =>
patchUnit(lineIdx, unitIdx, { vgmTons: v })
}
/>
</Grid.Col>
<Grid.Col span={{ base: 1, sm: 2 }}>
<ActionIcon
variant="subtle"
color="red"
disabled={line.units.length === 1}
onClick={() => removeUnit(lineIdx, unitIdx)}
aria-label="Remove unit"
>
<Trash2 size={15} />
</ActionIcon>
</Grid.Col>
</Grid>
))}
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => addUnit(lineIdx)}
style={{ alignSelf: "flex-start" }}
>
Add container unit
</Button>
</Stack>
</Box>
))}
</Stack>
)}
</SectionCard>
) : (
<SectionCard
icon={Package}
title="Bulk cargo"
extra={
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<Plus size={14} />}
onClick={addBulkLine}
>
Add line
</Button>
}
>
{bulkLines.length === 0 ? (
<Text size="sm" c="dimmed">
Add at least one bulk line.
</Text>
) : (
<Stack gap="md">
{bulkLines.map((line, idx) => (
<Box
key={idx}
p="md"
style={{
borderRadius: 10,
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group justify="space-between" mb="sm">
<Text fw={600} size="sm">
Line {idx + 1}
</Text>
<ActionIcon
variant="subtle"
color="red"
onClick={() => removeBulkLine(idx)}
aria-label="Remove line"
>
<Trash2 size={16} />
</ActionIcon>
</Group>
<Grid gap="sm">
<Grid.Col span={{ base: 12, sm: 6 }}>
{bulkCargoOptions.length > 0 ? (
<Select
label="Cargo type"
placeholder="Select cargo type"
value={line.cargoTypeId || null}
onChange={(v) =>
patchBulk(idx, { cargoTypeId: v ?? "" })
}
data={bulkCargoOptions}
/>
) : (
<TextInput
label="Cargo type id"
placeholder="Optional"
value={line.cargoTypeId}
onChange={(e) =>
patchBulk(idx, {
cargoTypeId: e.currentTarget.value,
})
}
/>
)}
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 6 }}>
<NumberInput
label="Weight (tons)"
min={0}
decimalScale={2}
value={line.cargoWeightTons}
onChange={(v) =>
patchBulk(idx, { cargoWeightTons: v })
}
/>
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 6 }}>
<NumberInput
label="Item count"
min={0}
value={line.itemCount}
onChange={(v) => patchBulk(idx, { itemCount: v })}
/>
</Grid.Col>
<Grid.Col span={{ base: 6, sm: 6 }}>
<NumberInput
label="Hazard qty"
min={0}
value={line.hazardousQuantity}
onChange={(v) =>
patchBulk(idx, { hazardousQuantity: v })
}
/>
</Grid.Col>
</Grid>
</Box>
))}
</Stack>
)}
</SectionCard>
)}
<SectionCard icon={FileText} title="Schedule">
{cargoQuery === null ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
>
Enter the cargo details first available shipment days depend on
the wagons the cargo needs.
</Alert>
) : (
<>
{bookingRequest?.scheduledDate ? (
<Text size="xs" c="dimmed" mb="xs">
Customer requested{" "}
{new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(bookingRequest.scheduledDate))}{" "}
pick the binding shipment day below.
</Text>
) : null}
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={daysLoading}
value={scheduledDate}
onChange={setScheduledDate}
/>
</>
)}
</SectionCard>
<SectionCard icon={FileText} title="Notes">
<Textarea
placeholder="Internal GL notes (optional)"
autosize
minRows={2}
value={notes}
onChange={(e) => setNotes(e.currentTarget.value)}
/>
</SectionCard>
<Group justify="flex-end">
<Button
variant="default"
onClick={() =>
navigate(`/dashboard/contracts/clearance/${contract.id}`)
}
>
Cancel
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<Receipt size={16} />}
disabled={!canSubmit}
onClick={() => setPriceOpen(true)}
>
Review price &amp; book
</Button>
</Group>
</Stack>
{/* Price-confirm — GL reviews the estimate, then books on behalf of the
customer. The server recomputes the authoritative total on submit. */}
<Modal
opened={priceOpen}
onClose={() => {
if (!mutations.createBooking.isPending) setPriceOpen(false);
}}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16}>
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Booking on behalf of the customer for {contract.reference}.
</Text>
</Box>
</Group>
}
>
{priceTotal ? (
<Stack gap="md">
<Paper withBorder radius={16} p="lg">
<Stack gap={10}>
{priceTotal.lines.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" fw={500}>
{line.label}
</Text>
<Text fz="xs" c="dimmed">
{line.quantity.toLocaleString()} ×{" "}
{line.unitPrice.toLocaleString()} {priceTotal.currency} ·{" "}
{formatRateUnit(line.unit)}
</Text>
</Box>
<Text fz="sm" fw={600} style={{ whiteSpace: "nowrap" }}>
{line.amount.toLocaleString()} {priceTotal.currency}
</Text>
</Group>
))}
{priceTotal.lines.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28}>
{priceTotal.total.toLocaleString()}{" "}
<Text span fz={16} fw={700} c="dimmed">
{priceTotal.currency}
</Text>
</Text>
</Group>
</Paper>
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={() => setPriceOpen(false)}
disabled={mutations.createBooking.isPending}
>
Back to edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending}
onClick={handleSubmit}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
) : null}
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { Badge, Box, Group, Stack, Text, ThemeIcon } from "@mantine/core";
import { Check, type LucideIcon } from "lucide-react";
export interface ActionShellProps {
icon: LucideIcon;
title: string;
subtitle?: string;
/** When true the action is already done — children are hidden, a done badge shows. */
done?: boolean;
doneLabel?: ReactNode;
children: ReactNode;
}
/**
* Consistent container for one GL action card: icon, title, and either the
* input controls (pending) or a completed badge (done). Keeps every GL action
* visually uniform inside {@link GlActionsPanel}.
*/
export function ActionShell({
icon: Icon,
title,
subtitle,
done,
doneLabel,
children,
}: ActionShellProps) {
return (
<Box
p="md"
style={{
border: "1px solid var(--mantine-color-gray-2)",
borderRadius: 12,
}}
>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="sm">
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={32}>
<Icon size={16} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw={600}>
{title}
</Text>
{subtitle ? (
<Text size="xs" c="dimmed">
{subtitle}
</Text>
) : null}
</Stack>
</Group>
{done ? (
typeof doneLabel === "string" || !doneLabel ? (
<Badge
color="edr-green"
variant="light"
radius="sm"
leftSection={<Check size={12} />}
>
{doneLabel ?? "Done"}
</Badge>
) : (
doneLabel
)
) : null}
</Group>
{!done ? children : null}
</Box>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from "react";
import {
Button,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { Receipt } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAdviseDuty } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
export function AdviseDutyCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const advise = useAdviseDuty(bookingId);
const [amount, setAmount] = useState<number | string>("");
const [currency, setCurrency] = useState("ETB");
const [serial, setSerial] = useState("");
const done = milestone.status === "COMPLETED";
const meta = milestone.metadata;
return (
<ActionShell
icon={Receipt}
title="Duty & tax"
subtitle="Advise the duty/tax amount and declaration serial."
done={done}
doneLabel={
meta?.dutyAmount != null
? `${meta.dutyAmount.toLocaleString()} ${meta.dutyCurrency ?? ""}`
: "Advised"
}
>
<Stack gap="sm">
<Group grow>
<NumberInput
label="Amount"
placeholder="0.00"
value={amount}
onChange={setAmount}
min={0}
thousandSeparator=","
size="sm"
/>
<Select
label="Currency"
data={["ETB", "USD"]}
value={currency}
onChange={(v) => setCurrency(v ?? "ETB")}
size="sm"
allowDeselect={false}
/>
</Group>
<TextInput
label="Declaration serial"
placeholder="e.g. IM4-2026-00123"
value={serial}
onChange={(e) => setSerial(e.currentTarget.value)}
size="sm"
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
Customer uploads the payment slip after being advised.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={advise.isPending}
disabled={!amount || Number(amount) <= 0}
onClick={() =>
advise.mutate({
amount: Number(amount),
currency,
declarationSerial: serial.trim() || undefined,
})
}
>
Advise customer
</Button>
</Group>
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,71 @@
import { useState } from "react";
import { Badge, Box, Button, Group, SegmentedControl, Text } from "@mantine/core";
import { ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAssignRisk } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
export function AssignRiskCard({
bookingId,
milestone,
}: {
bookingId: string;
milestone: Freight.IClearanceMilestone;
}) {
const assign = useAssignRisk(bookingId);
const [level, setLevel] = useState<Freight.CustomsRiskLevel>("GREEN");
const assigned = milestone.status === "COMPLETED";
const current = milestone.metadata?.riskLevel;
return (
<ActionShell
icon={ShieldAlert}
title="Customs risk"
subtitle="Assign the customs examination risk level."
done={assigned}
doneLabel={
current ? (
<Badge color={RISK_COLOR[current]} variant="filled" radius="sm">
{current}
</Badge>
) : (
"Assigned"
)
}
>
<Box>
<SegmentedControl
fullWidth
value={level}
onChange={(v) => setLevel(v as Freight.CustomsRiskLevel)}
data={[
{ label: "Green", value: "GREEN" },
{ label: "Yellow", value: "YELLOW" },
{ label: "Red", value: "RED" },
]}
/>
<Group justify="space-between" mt="sm">
<Text size="xs" c="dimmed">
Customer is notified of the assigned risk.
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
onClick={() => assign.mutate({ riskLevel: level })}
>
Assign risk
</Button>
</Group>
</Box>
</ActionShell>
);
}

View File

@@ -0,0 +1,53 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Group, Select, Text } from "@mantine/core";
import { MapPin } from "lucide-react";
import { api } from "@/services/api";
import { useAssignStation } from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
/**
* Routes the shipment to an origin station (GL US-02). Binding a staff user is
* optional here — the station manager can assign one later.
*/
export function AssignStationCard({ bookingId }: { bookingId: string }) {
const { data: yards = [] } = useQuery(api.routes.yards.queryOptions());
const assign = useAssignStation(bookingId);
const [stationYardId, setStationYardId] = useState<string | null>(null);
return (
<ActionShell
icon={MapPin}
title="Station routing"
subtitle="Route this shipment to the handling station."
>
<Group align="flex-end" wrap="nowrap" gap="sm">
<Select
flex={1}
label="Station"
placeholder="Select station"
searchable
data={yards.map((y) => ({ value: y.id, label: y.label }))}
value={stationYardId}
onChange={setStationYardId}
size="sm"
/>
<Button
size="compact-sm"
color="edr-green"
loading={assign.isPending}
disabled={!stationYardId}
onClick={() =>
stationYardId && assign.mutate({ stationYardId })
}
>
Route
</Button>
</Group>
<Text size="xs" c="dimmed" mt={6}>
The shipment moves to the selected station's queue.
</Text>
</ActionShell>
);
}

View File

@@ -0,0 +1,67 @@
import { useMemo } from "react";
import { Stack, Text } from "@mantine/core";
import type { Freight } from "@edr/types";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { Flag } from "lucide-react";
import { AssignStationCard } from "./AssignStationCard";
import { AssignRiskCard } from "./AssignRiskCard";
import { AdviseDutyCard } from "./AdviseDutyCard";
import { GlDocumentUploadCard } from "./GlDocumentUploadCard";
import { IncidentReportCard } from "./IncidentReportCard";
export interface GlActionsPanelProps {
bookingId: string;
milestones: Freight.IClearanceMilestone[];
}
/** Find a milestone by code (post-booking milestones live on the booking). */
function findMilestone(
milestones: Freight.IClearanceMilestone[],
code: string,
): Freight.IClearanceMilestone | undefined {
return milestones.find((m) => m.milestoneCode === code);
}
/**
* Global Logistics action surface for a shipment. Each card is gated by whether
* its milestone exists on this shipment (import vs export differ) and renders the
* structured action (risk level, duty advice, document upload, incident report,
* station routing) that the plain "Complete" button can't capture.
*/
export function GlActionsPanel({ bookingId, milestones }: GlActionsPanelProps) {
const riskMs = useMemo(
() => findMilestone(milestones, "RISK_ASSIGNED"),
[milestones],
);
const dutyMs = useMemo(
() => findMilestone(milestones, "DUTY_TAXES_ADVISED"),
[milestones],
);
return (
<SectionCard icon={Flag} title="Global Logistics actions" accent="edr-green">
<Stack gap="md">
<Text size="xs" c="dimmed">
Structured GL operations for this shipment. Uploading a document
advances its milestone automatically.
</Text>
<AssignStationCard bookingId={bookingId} />
{dutyMs ? (
<AdviseDutyCard bookingId={bookingId} milestone={dutyMs} />
) : null}
<GlDocumentUploadCard bookingId={bookingId} />
{riskMs ? (
<AssignRiskCard bookingId={bookingId} milestone={riskMs} />
) : null}
<IncidentReportCard bookingId={bookingId} />
</Stack>
</SectionCard>
);
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo, useState } from "react";
import { Box, Button, FileButton, Group, Select, Stack, Text } from "@mantine/core";
import { Eye, FileText, FileUp, Upload } from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { useUploadGlDocuments } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
import { ActionShell } from "./ActionShell";
/**
* GL post-booking document slots. The fieldname (value) maps server-side to a
* doc-triggered milestone in gl-operations.service.ts — uploading auto-advances
* the matching milestone.
*/
const GL_DOC_SLOTS = [
{ value: "delivery_order", label: "Delivery Order (DO)" },
{ value: "release_order", label: "Release Order (RO)" },
{ value: "t1_transport_document", label: "T1 Transport Document" },
{ value: "import_release", label: "Import Release" },
{ value: "full_in_interchange", label: "Full-in Interchange" },
{ value: "final_declaration", label: "Final Declaration" },
];
export function GlDocumentUploadCard({ bookingId }: { bookingId: string }) {
const upload = useUploadGlDocuments(bookingId);
const [slot, setSlot] = useState<string | null>(GL_DOC_SLOTS[0].value);
const [file, setFile] = useState<File | null>(null);
const { view, viewer } = useFileViewer();
const submit = () => {
if (!slot || !file) return;
upload.mutate({ [slot]: file });
setFile(null);
};
return (
<ActionShell
icon={FileUp}
title="GL documents"
subtitle="Upload DO, RO, T1, release, interchange — advances milestones."
>
<Stack gap="sm">
<Select
label="Document type"
data={GL_DOC_SLOTS}
value={slot}
onChange={setSlot}
size="sm"
allowDeselect={false}
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setFile} accept="application/pdf,image/*">
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<Upload size={14} />}
>
{file ? file.name : "Choose file"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="edr-green"
loading={upload.isPending}
disabled={!file || !slot}
onClick={submit}
>
Upload
</Button>
</Group>
{file ? (
<StagedFilePreview file={file} onPreview={view} />
) : (
<Text size="xs" c="dimmed">
PDF or image. The matching milestone completes on upload.
</Text>
)}
</Stack>
{viewer}
</ActionShell>
);
}
/**
* A compact preview chip for the GL file staged for upload: an image thumbnail
* (or a glyph) and a Preview button that opens the file in the shared viewer via
* a local object URL (minted once, revoked on unmount).
*/
function StagedFilePreview({
file,
onPreview,
}: {
file: File;
onPreview: (f: { name: string; url: string; mimeType?: string | null }) => void;
}) {
const url = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => () => URL.revokeObjectURL(url), [url]);
const isImage =
file.type.startsWith("image/") ||
["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].includes(
file.name.split(".").pop()?.toLowerCase() ?? "",
);
const canPreview = isViewable({ name: file.name, url, mimeType: file.type });
return (
<Group
gap={10}
wrap="nowrap"
p={8}
style={{
borderRadius: 10,
border: "1px dashed var(--mantine-color-edr-green-5)",
background: "var(--mantine-color-edr-green-0)",
minWidth: 0,
}}
>
{isImage ? (
<Box
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 8,
overflow: "hidden",
cursor: "pointer",
border: "1px solid var(--mantine-color-gray-3)",
}}
>
<img
src={url}
alt=""
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
</Box>
) : (
<Box
c="edr-green"
style={{
width: 38,
height: 38,
flexShrink: 0,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "var(--mantine-color-edr-green-1)",
}}
>
<FileText size={17} />
</Box>
)}
<Box style={{ minWidth: 0, flex: 1 }}>
<Text size="xs" fw={700} c="edr-green">
Ready to upload
</Text>
<Text size="xs" truncate>
{file.name}
</Text>
</Box>
{canPreview && (
<Button
size="compact-xs"
variant="subtle"
color="edr-green"
leftSection={<Eye size={13} />}
onClick={() => onPreview({ name: file.name, url, mimeType: file.type })}
>
Preview
</Button>
)}
</Group>
);
}

View File

@@ -0,0 +1,121 @@
import { useState } from "react";
import {
Badge,
Button,
FileButton,
Group,
Select,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { AlertTriangle, ImagePlus } from "lucide-react";
import type { Freight } from "@edr/types";
import {
useBookingIncidents,
useReportIncident,
} from "@/hooks/contracts/useContracts";
import { ActionShell } from "./ActionShell";
const INCIDENT_OPTIONS: { value: Freight.IncidentType; label: string }[] = [
{ value: "SEAL_BROKEN", label: "Seal is broken" },
{ value: "CONTAINER_OPENED", label: "Container opened" },
{ value: "CONTAINER_DAMAGED", label: "Container damaged" },
{ value: "FLUID_LEAKING", label: "Fluid leaking" },
];
const LABEL: Record<Freight.IncidentType, string> = {
SEAL_BROKEN: "Seal broken",
CONTAINER_OPENED: "Container opened",
CONTAINER_DAMAGED: "Container damaged",
FLUID_LEAKING: "Fluid leaking",
};
export function IncidentReportCard({ bookingId }: { bookingId: string }) {
const report = useReportIncident(bookingId);
const { data: incidents } = useBookingIncidents(bookingId);
const [type, setType] = useState<Freight.IncidentType>("SEAL_BROKEN");
const [description, setDescription] = useState("");
const [photos, setPhotos] = useState<File[]>([]);
const submit = () => {
if (!description.trim()) return;
report.mutate(
{ incidentType: type, description: description.trim(), photos },
{
onSuccess: () => {
setDescription("");
setPhotos([]);
},
},
);
};
return (
<ActionShell
icon={AlertTriangle}
title="Cargo exception"
subtitle="Log a damage/anomaly with photo evidence (GL Djibouti)."
>
<Stack gap="sm">
{incidents && incidents.length > 0 ? (
<Stack gap={4}>
{incidents.map((inc) => (
<Group key={inc.id} gap={8} wrap="nowrap">
<Badge color="red" variant="light" radius="sm">
{LABEL[inc.incidentType]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{inc.description}
</Text>
</Group>
))}
</Stack>
) : null}
<Select
label="Incident type"
data={INCIDENT_OPTIONS}
value={type}
onChange={(v) => setType((v as Freight.IncidentType) ?? "SEAL_BROKEN")}
size="sm"
allowDeselect={false}
/>
<Textarea
label="Description"
placeholder="Describe the anomaly…"
value={description}
onChange={(e) => setDescription(e.currentTarget.value)}
autosize
minRows={2}
size="sm"
/>
<Group justify="space-between" wrap="nowrap">
<FileButton onChange={setPhotos} accept="image/jpeg,image/png" multiple>
{(props) => (
<Button
{...props}
variant="light"
color="gray"
size="compact-sm"
leftSection={<ImagePlus size={14} />}
>
{photos.length > 0 ? `${photos.length} photo(s)` : "Add photos"}
</Button>
)}
</FileButton>
<Button
size="compact-sm"
color="red"
loading={report.isPending}
disabled={!description.trim()}
onClick={submit}
>
Report incident
</Button>
</Group>
</Stack>
</ActionShell>
);
}

View File

@@ -0,0 +1,132 @@
import type { Freight } from "@edr/types";
export interface GlShipmentTotalLine {
label: string;
unitPrice: number;
unit: Freight.ContractRateUnit | string;
quantity: number;
amount: number;
}
export interface GlShipmentTotal {
currency: string;
lines: GlShipmentTotalLine[];
total: number;
}
/** A normalized view of the form quantities, freight-shape agnostic. */
export interface GlShipmentQuantities {
isContainer: boolean;
/** Container lines: size + total qty + hazardous/reefer qty. */
containers: Array<{
containerSize: string;
quantity: number;
hazardousQuantity: number;
reeferQuantity: number;
}>;
/** Bulk: tons (or item count) + hazardous qty. */
bulkQuantity: number;
bulkHazardousQuantity: number;
}
/**
* Compute the booking total client-side from the contract's frozen unit rates ×
* the quantities GL enters. Mirrors the portal customer estimate
* (new-shipment-form/total.ts) — the server recomputes the authoritative total
* on submit. Shown in the price-confirm modal before GL books on behalf of the
* customer.
*/
export function computeGlShipmentTotal(
contract: Freight.IContract,
q: GlShipmentQuantities,
): GlShipmentTotal {
const breakdown = contract.pricingBreakdown;
const currency = breakdown?.currency ?? contract.paymentCurrency ?? "ETB";
const items = breakdown?.lineItems ?? [];
const lines: GlShipmentTotalLine[] = [];
const rateFor = (
predicate: (i: Freight.ContractUnitRateLineItem) => boolean,
) => items.find(predicate);
if (q.isContainer) {
let hazardTotalQty = 0;
let reeferTotalQty = 0;
for (const line of q.containers) {
const qty = line.quantity;
if (qty <= 0) continue;
const rate =
rateFor(
(i) =>
i.containerSize === line.containerSize &&
i.unit === "per_container" &&
!i.conditionalOn,
) ?? rateFor((i) => i.containerSize === line.containerSize);
if (rate) {
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
});
}
hazardTotalQty += line.hazardousQuantity;
reeferTotalQty += line.reeferQuantity;
}
if (contract.isHazardous && hazardTotalQty > 0) {
const hz = rateFor((i) => i.conditionalOn === "is_hazardous");
if (hz) {
lines.push({
label: hz.label,
unitPrice: hz.unitPrice,
unit: hz.unit,
quantity: hazardTotalQty,
amount: hz.unitPrice * hazardTotalQty,
});
}
}
if (contract.isReefer && reeferTotalQty > 0) {
const rf = rateFor((i) => i.conditionalOn === "is_reefer");
if (rf) {
lines.push({
label: rf.label,
unitPrice: rf.unitPrice,
unit: rf.unit,
quantity: reeferTotalQty,
amount: rf.unitPrice * reeferTotalQty,
});
}
}
} else {
const qty = q.bulkQuantity;
const rate =
rateFor((i) => i.unit === "per_ton" || i.unit === "per_item") ?? items[0];
if (rate && qty > 0) {
lines.push({
label: rate.label,
unitPrice: rate.unitPrice,
unit: rate.unit,
quantity: qty,
amount: rate.unitPrice * qty,
});
}
}
const total = lines.reduce((s, l) => s + l.amount, 0);
return { currency, lines, total };
}
/** Human-readable label for a contract unit-rate's charge unit. */
export function formatRateUnit(unit: Freight.ContractRateUnit | string): string {
const map: Record<string, string> = {
per_container: "container",
per_ton: "ton",
per_item: "item",
per_km: "km",
flat: "flat",
};
return map[unit] ?? unit.replace(/_/g, " ").replace(/^per /, "");
}

View File

@@ -47,6 +47,16 @@ const buildInitialValues = (
values[field.name] = field.noneOption ? FLEET_SELECT_NONE : "";
return;
}
// For selects, snap the record value onto a real option even if its casing
// drifted (e.g. an API/seed value of "Available" vs the "AVAILABLE" option).
// Otherwise the Select renders blank and a required field fails on submit.
if (field.type === "select" && field.options?.length) {
const match = field.options.find(
(o) => String(o.value).toLowerCase() === String(raw).toLowerCase(),
);
values[field.name] = match ? match.value : raw;
return;
}
values[field.name] = raw;
});
return values;
@@ -66,12 +76,20 @@ const FleetFormDialog = ({
const [values, setValues] = useState<Record<string, unknown>>({});
const [errors, setErrors] = useState<Record<string, string>>({});
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
// dynamic select options finish loading). Re-seeding on those would wipe the
// user's in-progress edits (e.g. a changed Current Yard / status) the moment
// the yard or wagon-type options resolve.
const recordId =
initialRecord && "id" in initialRecord ? String(initialRecord.id) : null;
useEffect(() => {
if (open) {
setValues(buildInitialValues(fields, emptyValues, initialRecord));
setErrors({});
}
}, [open, fields, emptyValues, initialRecord]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, recordId]);
const shortFields = useMemo(
() => fields.filter((f) => f.type !== "textarea"),
@@ -119,6 +137,15 @@ const FleetFormDialog = ({
return Object.keys(next).length === 0;
};
// Field types keyed by name, so the submit payload can coerce each value to the
// type the API expects (number columns come back from the API as strings like
// "24.00", which the DTO's @IsNumber rejects on an otherwise-unchanged save).
const fieldTypeByName = useMemo(() => {
const map: Record<string, FleetFormFieldDef["type"]> = {};
fields.forEach((f) => (map[f.name] = f.type));
return map;
}, [fields]);
const handleSubmit = () => {
if (!validate()) return;
const payload = Object.fromEntries(
@@ -126,6 +153,10 @@ const FleetFormDialog = ({
.map(([key, value]) => {
if (value === FLEET_SELECT_NONE || value === "")
return [key, undefined];
if (fieldTypeByName[key] === "number") {
const num = Number(value);
return [key, Number.isNaN(num) ? undefined : num];
}
return [key, value];
})
.filter(([, value]) => value !== undefined),

View File

@@ -20,14 +20,11 @@ export const formatFleetCell = (
format?: FleetColumnFormat,
accessorKey?: string,
): ReactNode => {
console.log('formatFleetCell:', { value, format, accessorKey, type: typeof value });
if (format === "statusBadge") {
const status = value == null || value === "" ? "—" : String(value);
const getStatusColor = (st: string): string => {
const s = st.toUpperCase();
console.log('Status for color mapping:', s);
if (s === "ACTIVE") return "green";
if (s === "ACTIVE" || s === "AVAILABLE") return "green";
if (s === "INACTIVE") return "gray";
if (s === "SUSPENDED" || s === "OUT_OF_SERVICE") return "red";
if (s === "MAINTENANCE" || s === "ON_LEAVE") return "orange";
@@ -35,7 +32,6 @@ export const formatFleetCell = (
return "gray";
};
const color = getStatusColor(status);
console.log('Assigned color:', color, 'for status:', status);
return (
<Badge variant="light" color={color} size="sm" radius="md">
{status}
@@ -50,7 +46,5 @@ export const formatFleetCell = (
}
}
const result = formatRuleEngineCell(value, format as ColumnFormat | undefined);
console.log('formatRuleEngineCell result for', accessorKey, ':', result);
return result;
return formatRuleEngineCell(value, format as ColumnFormat | undefined);
};

View File

@@ -0,0 +1,81 @@
import { useNavigate } from "react-router-dom";
import { Badge, Paper, Stack, Table, Text } from "@mantine/core";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import type { IOverviewRecentContract } from "@/types/overview";
function kindLabel(kind: string) {
return kind === "GENERAL" ? "General" : "One-time";
}
export function OverviewRecentContractsTable({
contracts,
}: {
contracts: IOverviewRecentContract[];
}) {
const navigate = useNavigate();
return (
<Paper p="lg" radius="lg" withBorder>
<Stack gap="md">
<Text fw={600}>Recent contracts</Text>
{contracts.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="lg">
No recent contracts
</Text>
) : (
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Reference</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Kind</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Valid until</Table.Th>
<Table.Th>Created</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{contracts.map((contract) => (
<Table.Tr
key={contract.id}
style={{ cursor: "pointer" }}
onClick={() =>
navigate(`/dashboard/contract-requests/${contract.id}`)
}
>
<Table.Td>
<Text fw={600} size="sm">
{contract.reference}
</Text>
</Table.Td>
<Table.Td>{contract.customerLabel}</Table.Td>
<Table.Td>
<Badge variant="light" color="gray" radius="sm">
{kindLabel(contract.contractKind)}
</Badge>
</Table.Td>
<Table.Td>
{contract.freightType === "CONTAINER" ? "Container" : "Bulk"}
</Table.Td>
<Table.Td>
<ContractStatusBadge status={contract.status} />
</Table.Td>
<Table.Td>
{contract.validUntil
? new Date(contract.validUntil).toLocaleDateString()
: "—"}
</Table.Td>
<Table.Td>
{new Date(contract.createdAt).toLocaleDateString()}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Stack>
</Paper>
);
}

View File

@@ -4,6 +4,7 @@ import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/
import {
useOverviewBillingTab,
useOverviewBookingsTab,
useOverviewContractsTab,
useOverviewCustomersTab,
useOverviewOperationsTab,
useOverviewStaffTab,
@@ -11,6 +12,7 @@ import {
import type { OverviewRange, OverviewTabKey } from "@/types/overview";
import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel";
import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel";
import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel";
import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel";
import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel";
import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel";
@@ -32,6 +34,7 @@ interface OverviewTabContentProps {
export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const bookings = useOverviewBookingsTab(range, tab === "bookings");
const contracts = useOverviewContractsTab(range, tab === "contracts");
const billing = useOverviewBillingTab(range, tab === "billing");
const operations = useOverviewOperationsTab(tab === "operations");
const customers = useOverviewCustomersTab(range, tab === "customers");
@@ -40,13 +43,15 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
const query =
tab === "bookings"
? bookings
: tab === "billing"
? billing
: tab === "operations"
? operations
: tab === "customers"
? customers
: staff;
: tab === "contracts"
? contracts
: tab === "billing"
? billing
: tab === "operations"
? operations
: tab === "customers"
? customers
: staff;
const { isLoading, isError, refetch, isFetching } = query;
@@ -85,6 +90,9 @@ export function OverviewTabContent({ tab, range }: OverviewTabContentProps) {
{tab === "bookings" && bookings.data && (
<OverviewBookingsTabPanel data={bookings.data} />
)}
{tab === "contracts" && contracts.data && (
<OverviewContractsTabPanel data={contracts.data} />
)}
{tab === "billing" && billing.data && (
<OverviewBillingTabPanel data={billing.data} />
)}

View File

@@ -0,0 +1,129 @@
import {
AlertCircle,
FileSignature,
ShieldCheck,
UserCheck,
} from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import { CONTRACT_STATUS_META } from "@/features/contracts/contract-status.config";
import type { IOverviewContractsTab } from "@/types/overview";
import { OverviewBookingTrendChart } from "../OverviewBookingTrendChart";
import { OverviewDonutChart } from "../OverviewDonutChart";
import { OverviewHorizontalBarChart } from "../OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "../OverviewKpiStrip";
import { OverviewRecentContractsTable } from "../OverviewRecentContractsTable";
/** Human labels for the contract pipeline stages (see OVERVIEW_CONTRACT_PIPELINE on the API). */
const PIPELINE_STAGE_LABELS: Record<string, string> = {
draft: "Draft",
intake: "Intake",
in_approval: "In approval",
signing: "Signing",
clearance: "Clearance",
active: "Active",
closed: "Closed",
cancelled: "Cancelled",
};
function kindLabel(kind: string) {
return kind === "GENERAL" ? "General" : kind === "ONE_TIME" ? "One-time" : kind;
}
interface OverviewContractsTabPanelProps {
data: IOverviewContractsTab;
}
export function OverviewContractsTabPanel({
data,
}: OverviewContractsTabPanelProps) {
return (
<Stack gap="lg">
<OverviewKpiStrip
items={[
{
label: "Active contracts",
value: data.kpis.totalActive,
icon: FileSignature,
accent: "emerald",
hint: "Currently in workflow",
},
{
label: "Needs action",
value: data.kpis.needsAction,
icon: AlertCircle,
accent: "amber",
hint: "Awaiting your review",
},
{
label: "In approval",
value: data.kpis.inApproval,
icon: UserCheck,
accent: "sky",
hint: "Pending sign-off",
},
{
label: "In clearance",
value: data.kpis.inClearance,
icon: ShieldCheck,
accent: "rose",
hint: "Customs / documents",
},
{
label: "Created today",
value: data.kpis.createdToday,
icon: FileSignature,
hint: "New since midnight",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewBookingTrendChart data={data.contractTrend} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewHorizontalBarChart
title="Pipeline by stage"
data={data.contractsByPipeline.map((item) => ({
label: PIPELINE_STAGE_LABELS[item.stage] ?? item.stage,
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By status"
data={data.contractsByStatus.map((item) => ({
name: CONTRACT_STATUS_META[item.status]?.title ?? item.status,
value: item.count,
}))}
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="By kind"
data={data.contractsByKind.map((item) => ({
name: kindLabel(item.label),
value: item.count,
}))}
/>
</Grid.Col>
</Grid>
<OverviewHorizontalBarChart
title="By freight type"
data={data.contractsByFreightType.map((item) => ({
label: item.label === "CONTAINER" ? "Container" : "Bulk",
value: item.count,
}))}
/>
<OverviewRecentContractsTable contracts={data.recentContracts} />
</Stack>
);
}

View File

@@ -134,15 +134,23 @@ export function FeePreviewModal({ opened, onClose, inventoryId }: FeePreviewModa
const pdfWindow = window.open('', '_blank');
try {
const releasedItem = await gateClear.mutateAsync(inventoryId) as WarehouseInventoryItem;
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
try {
const documentResponse = await warehouseService.downloadReleaseDocument(inventoryId);
const filename = `release-${releasedItem?.booking?.reference ?? releasedItem?.bookingId ?? inventoryId}.pdf`;
const opened = openPdfBlob(documentResponse.data, filename, pdfWindow);
toast({
title: 'Gate clearance recorded',
description: opened
? 'The release PDF opened in a browser tab.'
: 'The browser blocked the preview tab, so the PDF was downloaded.',
});
} catch (documentError) {
pdfWindow?.close();
toast({
title: 'Gate clearance recorded',
description: `Release paper could not be opened: ${extractErrorMessage(documentError)}`,
});
}
onClose();
} catch (error) {
pdfWindow?.close();

View File

@@ -69,6 +69,8 @@ export function InterchangeDocumentDetailPanel({ 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>

View File

@@ -24,6 +24,15 @@ function DetailRow({ label, value }: { label: string; value: React.ReactNode })
}
export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailModalProps) {
const bookingReference = item?.booking?.reference ?? '-';
const inventorySummary = [
item?.status?.replace(/_/g, ' '),
item?.releaseOrderReference ? `Release ${item.releaseOrderReference}` : null,
item?.warehouse ? `${item.warehouse.name} (${item.warehouse.code})` : null,
]
.filter(Boolean)
.join(' / ');
return (
<Modal opened={opened} onClose={onClose} title="Inventory detail" centered size="xl">
{!item ? (
@@ -33,10 +42,10 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Text size="lg" fw={800}>
{item.booking?.reference ?? item.bookingId ?? item.id}
{bookingReference}
</Text>
<Text size="sm" c="dimmed">
Inventory ID: {item.id}
{inventorySummary || 'Inventory information'}
</Text>
</Stack>
<InventoryStatusBadge status={item.status} />
@@ -51,12 +60,12 @@ export function InventoryDetailModal({ opened, onClose, item }: InventoryDetailM
<Divider label="Booking & item" labelPosition="left" />
<SimpleGrid cols={{ base: 1, sm: 3 }}>
<DetailRow label="Booking reference" value={bookingReference} />
<DetailRow label="Booking status" value={item.booking?.status ?? '-'} />
<DetailRow label="Payment status" value={item.booking?.paymentStatus ?? '-'} />
<DetailRow label="Trade direction" value={item.booking?.tradeDirection ?? '-'} />
<DetailRow label="Container ID" value={item.containerId ?? '-'} />
<DetailRow label="Cargo ID" value={item.cargoId ?? '-'} />
<DetailRow label="Goods ID" value={item.goodsId ?? '-'} />
<DetailRow label="Inventory status" value={item.status.replace(/_/g, ' ')} />
<DetailRow label="Release reference" value={item.releaseOrderReference ?? '-'} />
<DetailRow label="Quantity" value={formatNumber(item.quantity)} />
<DetailRow label="Weight" value={`${formatNumber(item.weight)} kg`} />
<DetailRow label="Volume" value={item.volume == null ? '-' : formatNumber(item.volume)} />

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
import { Info } from 'lucide-react';
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query';
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
item: WarehouseInventoryItem | null;
}
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
].map(([powerPlate, trailerPlate], index) => ({
value: powerPlate,
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
trailerPlate,
}));
const toIsoDateTime = (value: string) => {
if (!value) return undefined;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
};
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
const [driverName, setDriverName] = useState('');
const [driverLicense, setDriverLicense] = useState('');
const [driverPhone, setDriverPhone] = useState('');
const [truckType, setTruckType] = useState('');
const [containerNumber, setContainerNumber] = useState('');
const [gateInTime, setGateInTime] = useState('');
const [tareWeight, setTareWeight] = useState<number | ''>('');
const [grossWeight, setGrossWeight] = useState<number | ''>('');
const [netWeight, setNetWeight] = useState<number | ''>('');
const [gateOutTime, setGateOutTime] = useState('');
const [downloading, setDownloading] = useState(false);
useEffect(() => {
if (opened) setReference(item?.releaseOrderReference ?? '');
if (opened) {
setReference(item?.releaseOrderReference ?? '');
setTruckPlateNumber('');
setTrailerPlateNumber('');
setDriverName('');
setDriverLicense('');
setDriverPhone('');
setTruckType('');
setContainerNumber('');
setGateInTime('');
setTareWeight('');
setGrossWeight('');
setNetWeight(item?.weight != null ? Number(item.weight) : '');
setGateOutTime('');
}
}, [opened, item]);
const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
const weightMismatch =
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
const handleSubmit = async () => {
if (!item) return;
if (!truckPlateNumber.trim() || !driverName.trim()) {
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
return;
}
if (tareWeight === '' || grossWeight === '') {
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
return;
}
if (weightMismatch) {
toast({
variant: 'destructive',
title: 'Weight mismatch',
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
});
return;
}
const pdfWindow = window.open('', '_blank');
try {
const released = await releaseMutation.mutateAsync({
id: item.id,
payload: { reference: reference.trim() || undefined },
payload: {
reference: reference.trim() || undefined,
bookingId: item.bookingId ?? undefined,
customerId: undefined,
truckPlateNumber: truckPlateNumber.trim(),
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
driverName: driverName.trim(),
driverLicense: driverLicense.trim() || undefined,
driverPhone: driverPhone.trim() || undefined,
truckType: truckType.trim() || undefined,
containerNumber: containerNumber.trim() || undefined,
gateInTime: toIsoDateTime(gateInTime),
tareWeight: Number(tareWeight),
grossWeight: Number(grossWeight),
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
gateOutTime: toIsoDateTime(gateOutTime),
},
});
setDownloading(true);
const response = await warehouseService.downloadReleaseDocument(item.id);
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
};
return (
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
<Stack gap="md">
<Alert icon={<Info size={16} />} color="orange" variant="light">
<Text size="sm">
Creates the warehouse release document with booking, customer, cargo and location details. The
printed paper authorizes the goods to leave the warehouse gate.
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
recorded net weight does not equal gross weight minus tare weight.
</Text>
</Alert>
<TextInput
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
value={reference}
onChange={(e) => setReference(e.currentTarget.value)}
/>
<Select
label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below"
searchable
clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? '');
}}
/>
<Group grow>
<TextInput
label="Truck plate number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
/>
<TextInput
label="Trailer plate number"
value={trailerPlateNumber}
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
/>
</Group>
<Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
</Group>
<Group grow>
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
</Group>
<Group grow>
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
</Group>
<Group justify="space-between">
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
</Text>
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
</Group>
{weightMismatch && (
<Alert icon={<Scale size={16} />} color="red" variant="light">
<Text size="sm">
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
reassign the item back to warehouse handling.
</Text>
</Alert>
)}
<Group justify="flex-end" mt="sm">
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
Cancel
</Button>
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
Issue & view exit paper
Exit Inspection & View Exit Paper
</Button>
</Group>
</Stack>

View File

@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
loading={busy}
onClick={() => onAdvance(item, nextAction)}
>
{humanizeEnum(nextAction.replace(/-/g, '_'))}
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
</Button>
)}
{item.status === 'READY_FOR_PICKUP' && (

View File

@@ -0,0 +1,273 @@
import type { WarehouseFeeInvoice, WarehouseInventoryItem } from '@/types/warehouse';
import type { BookingDetail } from '@/types/booking';
type PdfLine = {
text: string;
size?: number;
bold?: boolean;
x?: number;
yGap?: number;
color?: 'black' | 'green';
align?: 'left' | 'center' | 'right';
};
export interface WarehouseExitPaperContext {
invoice: WarehouseFeeInvoice;
releasedItem?: WarehouseInventoryItem;
inventory?: WarehouseInventoryItem;
booking?: BookingDetail | null;
releasedAt?: Date;
}
const escapePdfText = (value: string) =>
value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
const money = (amount: unknown, currency = 'USD') =>
`${Number(amount ?? 0).toLocaleString()} ${currency === 'ETB' ? 'Birr (ETB)' : currency}`;
const fmtDate = (value: unknown) => {
if (!value) return '-';
const date = new Date(value as string | Date);
return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
};
const GREEN = '0 0.55 0.32';
const circlePath = (cx: number, cy: number, r: number) => {
const k = 0.5522847498;
const c = r * k;
return [
`${cx + r} ${cy} m`,
`${cx + r} ${cy + c} ${cx + c} ${cy + r} ${cx} ${cy + r} c`,
`${cx - c} ${cy + r} ${cx - r} ${cy + c} ${cx - r} ${cy} c`,
`${cx - r} ${cy - c} ${cx - c} ${cy - r} ${cx} ${cy - r} c`,
`${cx + c} ${cy - r} ${cx + r} ${cy - c} ${cx + r} ${cy} c`,
'h',
].join('\n');
};
const stampText = (text: string, x: number, y: number, size: number, bold = false) =>
`BT\n${GREEN} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildCircularSeal = (cx: number, cy: number, label: 'PAID' | 'CLEARED') =>
[
'q',
`${GREEN} RG`,
`${GREEN} rg`,
'2.2 w',
circlePath(cx, cy, 52),
'S',
'0.9 w',
circlePath(cx, cy, 42),
'S',
stampText('EDR FREIGHT', cx - 33, cy + 24, 9, true),
stampText(label, cx - (label === 'CLEARED' ? 36 : 21), cy - 4, label === 'CLEARED' ? 17 : 20, true),
stampText(label === 'CLEARED' ? 'GATE RELEASE' : 'WAREHOUSE', cx - (label === 'CLEARED' ? 34 : 32), cy - 25, 8),
'Q',
].join('\n');
const estimateTextWidth = (text: string, size: number) => text.length * size * 0.52;
const textX = (text: string, size: number, align: PdfLine['align'] = 'left', x?: number) => {
if (typeof x === 'number') return x;
if (align === 'center') return Math.max(36, (595 - estimateTextWidth(text, size)) / 2);
if (align === 'right') return Math.max(36, 535 - estimateTextWidth(text, size));
return 60;
};
const lineOp = (x1: number, y1: number, x2: number, y2: number, color = '0.65 0.7 0.76') =>
`q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
const textOp = (
text: string,
x: number,
y: number,
size = 10,
bold = false,
color = '0 0 0',
) => `BT\n${color} rg\n/${bold ? 'F2' : 'F1'} ${size} Tf\n${x} ${y} Td\n(${escapePdfText(text)}) Tj\nET`;
const buildAuthorizationBand = (label: 'PAID' | 'CLEARED') => [
lineOp(60, 242, 535, 242),
textOp('AUTHORIZED SEAL', 382, 218, 9, true, GREEN),
buildCircularSeal(452, 155, label),
];
const buildWarehouseOfficerSealBand = () => [
lineOp(60, 218, 535, 218),
textOp('WAREHOUSE OFFICER SEAL', 92, 194, 9, true, GREEN),
buildCircularSeal(170, 128, 'CLEARED'),
textOp('Officer in charge name / signature / date:', 292, 154, 10),
lineOp(292, 132, 535, 132, '0 0 0'),
textOp('Customer or driver name / signature / date:', 292, 94, 10),
lineOp(292, 72, 535, 72, '0 0 0'),
];
function buildSimplePdf(lines: PdfLine[], rawOps: string[] = []): Blob {
let y = 800;
const streamLines = lines.map((line) => {
y -= line.yGap ?? 16;
const size = line.size ?? 10;
const font = line.bold ? '/F2' : '/F1';
const color = line.color === 'green' ? `${GREEN} rg` : '0 0 0 rg';
return `BT\n${color}\n${font} ${size} Tf\n${textX(line.text, size, line.align, line.x)} ${y} Td\n(${escapePdfText(line.text)}) Tj\nET`;
});
const stream = [...rawOps, ...streamLines].join('\n');
const objects = [
'<< /Type /Catalog /Pages 2 0 R >>',
'<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>',
`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`,
];
let pdf = '%PDF-1.4\n';
const offsets = [0];
objects.forEach((object, index) => {
offsets.push(pdf.length);
pdf += `${index + 1} 0 obj\n${object}\nendobj\n`;
});
const xref = pdf.length;
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
offsets.slice(1).forEach((offset) => {
pdf += `${String(offset).padStart(10, '0')} 00000 n \n`;
});
pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return new Blob([pdf], { type: 'application/pdf' });
}
export function buildWarehouseInvoicePdf(invoice: WarehouseFeeInvoice, kind: 'INVOICE' | 'RECEIPT') {
const paid = kind === 'RECEIPT' || invoice.status === 'PAID';
const title = `Warehouse Fee ${kind === 'RECEIPT' ? 'Receipt' : 'Invoice'}`;
const bookingReference = firstText(invoice.bookingReference);
const customerName = firstText(invoice.customerName);
const inventoryReference = firstText(invoice.inventoryReference);
const inventoryInfo = firstText(invoice.inventoryInfo, invoice.containerNumber, invoice.cargoDescription);
const clearanceStatus = firstText(
invoice.clearanceStatus,
paid ? 'FEE PAID - READY FOR RELEASE' : 'PENDING PAYMENT',
);
const lines: PdfLine[] = [
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: title, size: 23, bold: true, yGap: 28, align: 'center' },
{ text: `Document No: ${invoice.invoiceNumber}`, size: 12, bold: true, yGap: 32, align: 'center' },
{ text: `Status: ${invoice.status.replace(/_/g, ' ')} Type: ${invoice.invoiceType.replace(/_/g, ' ')}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference} Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Reference: ${inventoryReference} Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Clearance: ${clearanceStatus}`, align: 'center' },
{ text: `Issued: ${fmtDate(invoice.issuedAt)} Paid At: ${fmtDate(invoice.paidAt)}`, align: 'center' },
{ text: 'ITEMS', size: 13, bold: true, yGap: 30, align: 'center' },
...(invoice.items ?? []).flatMap((item) => [
{ text: item.description, bold: true, align: 'center' as const },
{
text: `${item.feeType.replace(/_/g, ' ')} | Qty ${Number(item.quantity ?? 0).toLocaleString()} | Rate ${money(item.unitRate, item.currency)} | Amount ${money(item.amount, item.currency)}`,
yGap: 13,
align: 'center' as const,
},
]),
{ text: 'TOTALS', size: 13, bold: true, yGap: 30, align: 'center' },
{ text: `Subtotal: ${money(invoice.subtotalAmount, invoice.currency)}`, align: 'center' },
{ text: `Tax: ${money(invoice.taxAmount, invoice.currency)}`, align: 'center' },
{ text: `Total: ${money(invoice.totalAmount, invoice.currency)}`, bold: true, align: 'center' },
{ text: `Paid: ${money(invoice.paidAmount, invoice.currency)}`, align: 'center' },
{ text: `Balance: ${money(invoice.balanceAmount, invoice.currency)}`, bold: true, align: 'center' },
];
const authorizationOps = [
...buildAuthorizationBand('PAID'),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
const invoiceOps = [
lineOp(60, 242, 535, 242),
textOp('Prepared by EDR warehouse finance', 72, 196, 10),
textOp('Finance officer name / signature / date:', 72, 164, 10),
lineOp(245, 162, 360, 162, '0 0 0'),
];
return buildSimplePdf(lines, paid ? authorizationOps : invoiceOps);
}
const firstText = (...values: Array<unknown>) => {
for (const value of values) {
if (value !== null && value !== undefined && String(value).trim()) return String(value);
}
return '-';
};
const tons = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num) || num <= 0) return null;
return `${num.toLocaleString(undefined, { maximumFractionDigits: 3 })} ton`;
};
const containerSummary = (booking?: BookingDetail | null, inventory?: WarehouseInventoryItem) => {
const explicitNumber = (inventory as unknown as { containerNumber?: string | null })?.containerNumber;
if (explicitNumber) return explicitNumber;
const containers = booking?.bookingContainers ?? [];
if (!containers.length) return '-';
return containers
.map((item) => {
const type = item.containerType?.code ?? item.containerType?.label ?? item.containerTypeId;
return `${item.quantity} x ${type}`;
})
.join(', ');
};
export function buildWarehouseExitPaperPdf(input: WarehouseFeeInvoice | WarehouseExitPaperContext, releasedItemArg?: WarehouseInventoryItem) {
const context: WarehouseExitPaperContext =
'invoice' in input ? input : { invoice: input, releasedItem: releasedItemArg };
const { invoice, booking } = context;
const releasedItem = context.releasedItem;
const inventory = context.inventory ?? releasedItem;
const releasedAt = context.releasedAt ?? new Date();
const releaseReference = firstText(
inventory?.releaseOrderReference,
releasedItem?.releaseOrderReference,
invoice.inventoryReference,
booking?.reference ? `REL-${booking.reference.replace(/^BK-?/i, '')}` : null,
);
const customerName = firstText(
booking?.company?.name,
booking?.company?.companyName,
booking?.company?.label,
booking?.company?.contactPersonName,
invoice.customerName,
);
const weightTons = firstText(tons(booking?.cargoTotalWeightVgm), tons(inventory?.weight));
const inventoryInfo = firstText(
invoice.inventoryInfo,
invoice.containerNumber,
invoice.cargoDescription,
inventory?.status,
releasedItem?.status,
);
const bookingReference = firstText(
booking?.reference,
(inventory as unknown as { bookingReference?: string })?.bookingReference,
invoice.bookingReference,
releasedItem?.booking?.reference,
);
return buildSimplePdf([
{ text: 'Ethio-Djibouti Railway S.C.', size: 12, bold: true, yGap: 0, align: 'center' },
{ text: 'Warehouse Release / Exit Paper', size: 23, bold: true, yGap: 28, align: 'center' },
{ text: '[ GATE CLEARANCE ]', size: 14, bold: true, color: 'green', yGap: 24, align: 'center' },
{ text: `Release Reference: ${releaseReference}`, bold: true, yGap: 30, align: 'center' },
{ text: `Invoice No: ${invoice.invoiceNumber}`, align: 'center' },
{ text: `Booking Reference: ${bookingReference}`, align: 'center' },
{ text: `Customer: ${customerName}`, align: 'center' },
{ text: `Inventory Info: ${inventoryInfo}`, align: 'center' },
{ text: `Warehouse: ${firstText(inventory?.warehouse?.name, inventory?.warehouse?.code)}`, align: 'center' },
{ text: `Yard: ${firstText(inventory?.yard?.name, inventory?.yard?.code)}`, align: 'center' },
{ text: `Zone: ${firstText(inventory?.zone?.name, inventory?.zone?.code)}`, align: 'center' },
{ text: `Booking Container: ${containerSummary(booking, inventory)}`, align: 'center' },
{ text: `Weight: ${weightTons}`, align: 'center' },
{ text: `Inventory Status: ${releasedItem?.status ?? inventory?.status ?? 'RELEASED'}`, align: 'center' },
{ text: `Clearance: ${invoice.clearanceStatus ?? 'CLEARED FOR WAREHOUSE EXIT'}`, bold: true, color: 'green', align: 'center' },
{ text: `Release Date & Time: ${fmtDate(releasedAt)}`, align: 'center' },
{ text: 'This sealed document authorizes the listed booking/goods to leave the warehouse gate.', yGap: 30, align: 'center' },
], [
...buildWarehouseOfficerSealBand(),
]);
}

View File

@@ -1,5 +1,6 @@
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
import type { BookingListFilter } from "@/services/bookings.service";
import type { ContractListFilter } from "@/services/contracts.service";
import type { RuleEngineListParams } from "@/services/ruleEngine/ruleEngine.service";
import type { CompanyListFilter } from "@/types/customer";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
@@ -45,6 +46,26 @@ export const QUERY_KEYS = {
byId: (id: string) => ["bookings", "detail", id] as const,
},
CONTRACTS: {
ROOT: ["contracts"] as const,
list: (filter?: ContractListFilter) =>
["contracts", "list", filter ?? {}] as const,
listSummary: (filter?: ContractListFilter) =>
["contracts", "list-summary", filter ?? {}] as const,
byId: (id: string) => ["contracts", "detail", id] as const,
clearance: (id: string) => ["contracts", "clearance", id] as const,
clearanceQueue: (region?: string) =>
["contracts", "clearance-queue", region ?? "ET"] as const,
clearanceHistory: (region?: string) =>
["contracts", "clearance-history", region ?? "ET"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingMilestones: (bookingId: string) =>
["contracts", "booking-milestones", bookingId] as const,
bookingIncidents: (bookingId: string) =>
["contracts", "booking-incidents", bookingId] as const,
},
BOOKING_ORDERS: {
ROOT: ["booking-orders"] as const,
byContract: (contractBookingId: string) =>
@@ -108,6 +129,7 @@ export const QUERY_KEYS = {
ROOT: ["overview"] as const,
dashboard: (range?: string) => ["overview", "dashboard", range ?? "30d"] as const,
bookingsTab: (range?: string) => ["overview", "bookings", range ?? "30d"] as const,
contractsTab: (range?: string) => ["overview", "contracts", range ?? "30d"] as const,
billingTab: (range?: string) => ["overview", "billing", range ?? "30d"] as const,
operationsTab: () => ["overview", "operations"] as const,
customersTab: (range?: string) => ["overview", "customers", range ?? "30d"] as const,

View File

@@ -88,6 +88,7 @@ export const URL_CONSTANTS = {
OVERVIEW: {
BASE: "/overview",
BOOKINGS: "/overview/bookings",
CONTRACTS: "/overview/contracts",
BILLING: "/overview/billing",
OPERATIONS: "/overview/operations",
CUSTOMERS: "/overview/customers",
@@ -124,6 +125,62 @@ export const URL_CONSTANTS = {
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
},
CONTRACTS: {
BASE: "/contracts",
LIST_SUMMARY: "/contracts/list-summary",
BY_ID: (id: string) => `/contracts/${id}`,
STAFF_ACCEPT: (id: string) => `/contracts/${id}/staff/accept`,
STAFF_REQUEST_CHANGES: (id: string) =>
`/contracts/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/contracts/${id}/staff/reject`,
APPROVE_STEP: (id: string, stepId: string) =>
`/contracts/${id}/approval-steps/${stepId}/approve`,
CONTRACT_GENERATE: (id: string) => `/contracts/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/contracts/${id}/contract/view`,
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
CLEARANCE_QUEUE: "/contracts/clearance/queue",
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,
// Path A self-clearance — Operations reviews the customer's own clearance docs.
OPS_CLEARANCE_QUEUE: "/contracts/clearance/ops-queue",
OPS_CLEARANCE_REVIEW: (id: string) =>
`/contracts/${id}/clearance/ops-review`,
OPS_CLEARANCE_FINALIZE: (id: string) =>
`/contracts/${id}/clearance/ops-finalize`,
CLEARANCE_HISTORY: "/contracts/clearance/history",
OPS_CLEARANCE_HISTORY: "/contracts/clearance/ops-history",
BOOKINGS: (id: string) => `/contracts/${id}/bookings`,
CAPACITY: (id: string) => `/contracts/${id}/capacity`,
// Shipment requests (GENERAL + customs, Path B): customer → GL queue → booking.
BOOKING_REQUEST_QUEUE: "/contracts/booking-requests/queue",
BOOKING_REQUEST_BY_ID: (reqId: string) =>
`/contracts/booking-requests/${reqId}`,
BOOKING_REQUESTS: (id: string) => `/contracts/${id}/booking-requests`,
BOOKING_REQUEST_ACCEPT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/accept`,
BOOKING_REQUEST_REJECT: (reqId: string) =>
`/contracts/booking-requests/${reqId}/reject`,
MILESTONES: (id: string) => `/contracts/${id}/milestones`,
BOOKING_MILESTONES: (bookingId: string) =>
`/contracts/bookings/${bookingId}/milestones`,
COMPLETE_BOOKING_MILESTONE: (bookingId: string, code: string) =>
`/contracts/bookings/${bookingId}/milestones/${code}/complete`,
// ── GL post-booking operational actions ──
BOOKING_RISK: (bookingId: string) =>
`/contracts/bookings/${bookingId}/risk`,
BOOKING_DUTY: (bookingId: string) =>
`/contracts/bookings/${bookingId}/duty`,
BOOKING_STATION_ASSIGN: (bookingId: string) =>
`/contracts/bookings/${bookingId}/station-assign`,
BOOKING_GL_DOCUMENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/documents`,
BOOKING_INCIDENTS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/incidents`,
},
OTP: {
SEND: "/api/otp/send",
VERIFY: "/api/otp/verify",
@@ -149,6 +206,7 @@ export const URL_CONSTANTS = {
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/train-scheduling/available-days-for-cargo",
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
BATCH_BOARD: "/train-scheduling/batch-board",
BATCH_BOARD_DETAIL: (scheduleId: string) =>
@@ -192,6 +250,24 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
IMPORT_DJIBOUTI: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti`,
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/documents`,
IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`,
IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`,
IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`,
IMPORT_DJIBOUTI_DEPART: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/depart`,
IMPORT_DJIBOUTI_LOAD_LIST: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/load-list`,
IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
`/train-scheduling/schedules/${id}/export/load-list/document`,
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) =>
@@ -322,6 +398,7 @@ export const URL_CONSTANTS = {
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
@@ -358,6 +435,8 @@ export const URL_CONSTANTS = {
WAREHOUSE_INVOICES: {
BASE: '/warehouse-fee-invoices',
BY_ID: (id: string) => `/warehouse-fee-invoices/${id}`,
DOCUMENT: (id: string) => `/warehouse-fee-invoices/${id}/document`,
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
@@ -375,6 +454,23 @@ export const URL_CONSTANTS = {
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
},
IMPORT_OPERATIONS: {
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
`/import-operations/customs/${bookingId}/release-permitted`,
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
`/import-operations/empty-container-returns/${id}/status`,
},
VEHICLES: {
BASE: '/vehicles',
BY_ID: (id: string) => `/vehicles/${id}`,

View File

@@ -1,3 +1,15 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001';
// export const API_BASE_URL = 'http://localhost:3001';
/**
* URL that streams an uploaded file through the API by its UUID. Routes the
* bytes through `GET /api/files/:id` (served from MinIO with backend
* credentials) instead of a presigned MinIO URL — the latter is not reachable
* from the browser and breaks on the minio-js port-443 signature quirk. Serves
* inline for preview by default; pass `download` to force a save dialog.
*/
export function fileViewUrl(fileId: string, download = false): string {
const base = `${API_BASE_URL}/api/files/${fileId}`;
return download ? `${base}?download=1` : base;
}

View File

@@ -2,13 +2,10 @@ import type { LucideIcon } from "lucide-react";
import {
Ban,
Check,
Coins,
FileSignature,
MessageSquareWarning,
Play,
ShieldCheck,
TrainTrack,
Truck,
XCircle,
} from "lucide-react";
@@ -38,7 +35,6 @@ export type BookingActionId =
| "complete"
| "operationAccept"
| "operationRequestChanges"
| "operationAdjustPrice"
| "cancel";
export type BookingActionInputKind =
@@ -198,20 +194,6 @@ const OPERATION_REVIEW_ACTIONS: BookingActionDef[] = [
inputLabel: "Message to customer",
inputPlaceholder: "Describe what needs to change…",
},
{
id: "operationAdjustPrice",
label: "Adjust price",
shortLabel: "Price",
description: "Set an adjusted total the customer must confirm",
confirmTitle: "Adjust the order price?",
confirmDescription:
"Enter the new total. The customer must confirm it before the order proceeds.",
variant: "outline",
icon: Coins,
input: "amount",
inputLabel: "Adjusted total",
inputPlaceholder: "0.00",
},
];
const CANCEL_ACTION: BookingActionDef = {
@@ -282,7 +264,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
complete: FREIGHT_PERMS.bookings.operations,
operationAccept: FREIGHT_PERMS.bookings.operations,
operationRequestChanges: FREIGHT_PERMS.bookings.operations,
operationAdjustPrice: FREIGHT_PERMS.bookings.operations,
allocateBooking: FREIGHT_PERMS.trainScheduling.manage,
cancel: FREIGHT_PERMS.bookings.cancel,
};
@@ -388,47 +369,10 @@ export function getBookingActions(
actions = withCancel(OPERATION_REVIEW_ACTIONS);
break;
case "PAID":
if (
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
) {
actions = [
{
id: "allocateBooking",
label: "Allocate booking",
shortLabel: "Allocate",
description: "Assign to train, wagons, and finalize schedule",
confirmTitle: "Allocate booking?",
confirmDescription: "Opens the train allocation wizard.",
variant: "default",
icon: TrainTrack,
primary: true,
},
{
id: "startTransit",
label: "Start transit",
shortLabel: "Transit",
description: "Begin rail movement",
confirmTitle: "Start transit?",
confirmDescription: "The booking will move to in transit status.",
variant: "default",
icon: Truck,
},
];
} else {
actions = [
{
id: "startTransit",
label: "Start transit",
shortLabel: "Transit",
description: "Begin rail movement",
confirmTitle: "Start transit?",
confirmDescription: "The booking will move to in transit status.",
variant: "default",
icon: Truck,
primary: true,
},
];
}
// Allocate is handled by the Operations "Ready to allocate" queue, not the
// per-booking action menu. Start transit was removed entirely. No per-row
// action remains in the PAID state.
actions = [];
break;
case "IN_TRANSIT":
actions = [

View File

@@ -0,0 +1,86 @@
import type { Freight } from "@edr/types";
export interface ApprovalProgressSummary {
label: string;
detail: string;
complete: boolean;
}
function nextPending(
steps: Freight.IContractApprovalStep[],
): Freight.IContractApprovalStep | undefined {
return steps.find((s) => s.status === "PENDING");
}
/** Compact approval-chain summary for contract list rows (mirrors bookings). */
export function formatContractApprovalProgress(
status: string,
steps?: Freight.IContractApprovalStep[] | null,
): ApprovalProgressSummary {
const sorted = [...(steps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder);
if (sorted.length === 0) {
if (status === "SUBMITTED") {
return {
label: "Awaiting accept",
detail: "Staff must accept intake",
complete: false,
};
}
if (
status === "PENDING_APPROVAL" ||
status === "APPROVED_PENDING_SIGNATURE"
) {
return {
label: "No steps",
detail: "Approval chain not started",
complete: false,
};
}
if (
[
"APPROVED",
"CONTRACT_READY",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
"CONTRACT_CLOSED",
].includes(status)
) {
return {
label: "Approved",
detail: "Internal approval complete",
complete: true,
};
}
return { label: "—", detail: "", complete: false };
}
const approved = sorted.filter((s) => s.status === "APPROVED").length;
const total = sorted.length;
const next = nextPending(sorted);
if (!next && approved === total) {
return {
label: `${approved}/${total} done`,
detail: sorted.map((s) => `${s.requiredRole}`).join(" · "),
complete: true,
};
}
if (next) {
return {
label: `${approved}/${total}`,
detail: `Next: ${next.requiredRole} (step ${next.stepOrder})`,
complete: false,
};
}
return {
label: `${approved}/${total}`,
detail: sorted.map((s) => `${s.requiredRole}: ${s.status}`).join(" · "),
complete: approved === total,
};
}

View File

@@ -0,0 +1,355 @@
import type { ContractStatus } from "@edr/types";
export interface StatusStyle {
label: string;
color: string;
}
/** Tailwind chip styling per contract status (mirrors booking-status.config). */
export const CONTRACT_STATUS_STYLES: Record<string, StatusStyle> = {
DRAFT: {
label: "Draft",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
SUBMITTED: {
label: "Submitted",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
PRICE_CHANGED_PENDING_CONFIRM: {
label: "Price Confirm",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
CHANGES_REQUESTED: {
label: "Changes Requested",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
PENDING_APPROVAL: {
label: "Pending Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
APPROVED: {
label: "Approved",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
APPROVED_PENDING_SIGNATURE: {
label: "Pending Signature",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
CONTRACT_READY: {
label: "Contract Ready",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
SIGNED_CUSTOMER: {
label: "Customer Signed",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
FULLY_EXECUTED: {
label: "Fully Executed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
CONTRACT_ACTIVE: {
label: "Active",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
AWAITING_CLEARANCE_DOCUMENTS: {
label: "Awaiting Documents",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
CLEARANCE_UNDER_REVIEW: {
label: "Clearance Review",
color: "bg-amber-50 text-amber-800 border-amber-200",
},
CLEARANCE_READY_FOR_BOOKING: {
label: "Ready for Booking",
color: "bg-[color:var(--freight-brand-muted)] text-[color:var(--freight-brand)] border-[color:var(--freight-brand-border)]",
},
ACTIVE_SHIPMENT_IN_PROGRESS: {
label: "Shipment in Progress",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
CONTRACT_CLOSED: {
label: "Closed",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
EXPIRED: {
label: "Expired",
color: "bg-red-50 text-red-700 border-red-200",
},
REJECTED: {
label: "Rejected",
color: "bg-red-50 text-red-700 border-red-200",
},
CANCELLED: {
label: "Cancelled",
color: "bg-red-50 text-red-700 border-red-200",
},
RENEWAL_DRAFT: {
label: "Renewal Draft",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
RENEWAL_SUBMITTED: {
label: "Renewal Submitted",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
RENEWAL_PENDING_APPROVAL: {
label: "Renewal Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
AMENDMENTS_PROPOSED: {
label: "Amendments Proposed",
color: "bg-orange-50 text-orange-700 border-orange-200",
},
ARCHIVED: {
label: "Archived",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
};
/** Mantine palette colour per contract status (mirrors BookingStatusBadge map). */
export const CONTRACT_STATUS_COLOR: Record<string, string> = {
DRAFT: "gray",
SUBMITTED: "yellow",
PRICE_CHANGED_PENDING_CONFIRM: "yellow",
CHANGES_REQUESTED: "orange",
PENDING_APPROVAL: "yellow",
APPROVED: "edr-green",
APPROVED_PENDING_SIGNATURE: "cyan",
CONTRACT_READY: "indigo",
SIGNED_CUSTOMER: "cyan",
FULLY_EXECUTED: "indigo",
CONTRACT_ACTIVE: "edr-green",
AWAITING_CLEARANCE_DOCUMENTS: "yellow",
CLEARANCE_UNDER_REVIEW: "yellow",
CLEARANCE_READY_FOR_BOOKING: "edr-green",
ACTIVE_SHIPMENT_IN_PROGRESS: "cyan",
CONTRACT_CLOSED: "gray",
EXPIRED: "red",
REJECTED: "red",
CANCELLED: "red",
RENEWAL_DRAFT: "gray",
RENEWAL_SUBMITTED: "yellow",
RENEWAL_PENDING_APPROVAL: "yellow",
AMENDMENTS_PROPOSED: "orange",
ARCHIVED: "gray",
};
export interface StatusMeta {
title: string;
description: string;
color: string;
stage: number;
}
export const CONTRACT_STATUS_META: Record<string, StatusMeta> = {
DRAFT: {
title: "Draft",
description: "Contract is being prepared by the customer.",
color: "text-slate-500",
stage: 0,
},
SUBMITTED: {
title: "Submitted",
description: "Awaiting staff review.",
color: "text-amber-600",
stage: 0,
},
PRICE_CHANGED_PENDING_CONFIRM: {
title: "Price Confirm",
description: "Awaiting customer confirmation of revised unit rates.",
color: "text-amber-600",
stage: 0,
},
CHANGES_REQUESTED: {
title: "Changes Requested",
description: "Returned to customer for updates.",
color: "text-orange-600",
stage: 0,
},
PENDING_APPROVAL: {
title: "Pending Approval",
description: "Moving through the internal approval chain.",
color: "text-amber-600",
stage: 1,
},
APPROVED: {
title: "Approved",
description: "Approved; contract document can be generated.",
color: "text-[color:var(--freight-brand)]",
stage: 1,
},
APPROVED_PENDING_SIGNATURE: {
title: "Pending Signature",
description: "Awaiting director or CEO signature steps.",
color: "text-sky-600",
stage: 1,
},
CONTRACT_READY: {
title: "Contract Ready",
description: "Contract generated; awaiting customer signature.",
color: "text-indigo-600",
stage: 2,
},
SIGNED_CUSTOMER: {
title: "Customer Signed",
description: "Awaiting contract execution.",
color: "text-sky-600",
stage: 2,
},
FULLY_EXECUTED: {
title: "Fully Executed",
description: "One-time contract executed; transport-only path.",
color: "text-indigo-600",
stage: 3,
},
CONTRACT_ACTIVE: {
title: "Active",
description: "General contract active over its validity window.",
color: "text-[color:var(--freight-brand)]",
stage: 3,
},
AWAITING_CLEARANCE_DOCUMENTS: {
title: "Awaiting Documents",
description: "Customer is uploading pre-booking clearance documents.",
color: "text-amber-600",
stage: 3,
},
CLEARANCE_UNDER_REVIEW: {
title: "Clearance Review",
description: "Global Logistics ET is reviewing clearance documents.",
color: "text-amber-700",
stage: 3,
},
CLEARANCE_READY_FOR_BOOKING: {
title: "Ready for Booking",
description: "Clearance complete — GL can create the shipment booking.",
color: "text-[color:var(--freight-brand)]",
stage: 4,
},
ACTIVE_SHIPMENT_IN_PROGRESS: {
title: "Shipment in Progress",
description: "A shipment booking is active under this contract.",
color: "text-sky-600",
stage: 4,
},
CONTRACT_CLOSED: {
title: "Closed",
description: "Contract fulfilled and closed.",
color: "text-slate-500",
stage: 5,
},
EXPIRED: {
title: "Expired",
description: "Validity window elapsed.",
color: "text-red-600",
stage: -1,
},
REJECTED: {
title: "Rejected",
description: "Contract was rejected.",
color: "text-red-600",
stage: -1,
},
CANCELLED: {
title: "Cancelled",
description: "Contract was cancelled.",
color: "text-red-600",
stage: -1,
},
};
export const CONTRACT_LIST_TABS = [
{ key: "all", label: "All contracts", statuses: null as string[] | null },
{
key: "intake",
label: "Submitted",
statuses: ["SUBMITTED", "PRICE_CHANGED_PENDING_CONFIRM", "CHANGES_REQUESTED"],
},
{
key: "in_approval",
label: "In approval",
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
},
{
key: "approved_contract",
label: "Contract & signature",
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
},
{
key: "clearance",
label: "Clearance",
statuses: [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
],
},
{
key: "active",
label: "Active",
statuses: [
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"ACTIVE_SHIPMENT_IN_PROGRESS",
],
},
{
key: "closed",
label: "Closed",
statuses: ["CONTRACT_CLOSED", "EXPIRED", "REJECTED", "CANCELLED"],
},
] as const;
export type ContractStatusTabKey = (typeof CONTRACT_LIST_TABS)[number]["key"];
export const CONTRACT_WORKFLOW_STAGES = [
{
label: "Submission",
statuses: [
"DRAFT",
"SUBMITTED",
"PRICE_CHANGED_PENDING_CONFIRM",
"CHANGES_REQUESTED",
],
},
{
label: "Approval",
statuses: ["PENDING_APPROVAL", "APPROVED", "APPROVED_PENDING_SIGNATURE"],
},
{
label: "Signature",
statuses: ["CONTRACT_READY", "SIGNED_CUSTOMER"],
},
{
label: "Clearance",
statuses: [
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
],
},
{
label: "Shipment",
statuses: ["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"],
},
{ label: "Done", statuses: ["CONTRACT_CLOSED"] },
] as const;
export function getContractStatusMeta(status: ContractStatus | string): StatusMeta {
return (
CONTRACT_STATUS_META[status] ?? {
title: status,
description: "",
color: "text-muted-foreground",
stage: 0,
}
);
}
export function getContractWorkflowStageIndex(
status: ContractStatus | string,
): number {
const meta = getContractStatusMeta(status);
if (meta.stage < 0) return -1;
return meta.stage;
}

View File

@@ -0,0 +1,98 @@
import type { Freight } from "@edr/types";
export interface ContractListRow {
id: string;
reference: string;
approvalSteps?: Freight.IContractApprovalStep[];
customerLabel: string;
status: string;
contractKind: Freight.ContractKind;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
validFrom?: string | null;
validUntil?: string | null;
validityDays?: number | null;
isRenewal: boolean;
createdAt: string;
customsClearingEnabled: boolean;
contractGeneratedAt?: string | null;
}
/** The single most relevant next action for a contract row, for the table CTA. */
export type ContractRowAction = {
label: string;
to: (id: string) => string;
variant: "filled" | "light" | "default";
};
/**
* Map a contract status to its one primary staff action. Returns null when the
* only action is "open the detail page" (the row click already does that).
*/
export function getStaffRowAction(
row: Pick<ContractListRow, "status" | "customsClearingEnabled" | "contractGeneratedAt">,
): ContractRowAction | null {
const detail = (id: string) => `/dashboard/contract-requests/${id}`;
const view = (id: string) => `/dashboard/contract-requests/${id}/view`;
switch (row.status) {
case "SUBMITTED":
return { label: "Review", to: detail, variant: "filled" };
case "PENDING_APPROVAL":
return { label: "Approve", to: detail, variant: "filled" };
case "CONTRACT_READY":
case "SIGNED_CUSTOMER":
return row.contractGeneratedAt
? { label: "View & sign", to: view, variant: "filled" }
: { label: "Open", to: detail, variant: "light" };
case "CLEARANCE_UNDER_REVIEW":
case "AWAITING_CLEARANCE_DOCUMENTS":
return { label: "Review clearance", to: detail, variant: "filled" };
case "CLEARANCE_READY_FOR_BOOKING":
return row.customsClearingEnabled
? { label: "Create booking", to: detail, variant: "filled" }
: { label: "Open", to: detail, variant: "light" };
default:
return { label: "Open", to: detail, variant: "default" };
}
}
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
fallback = "—",
): string {
if (!yard) return fallback;
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
export function toContractListRow(contract: Freight.IContract): ContractListRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const first = routes[0];
const last = routes[routes.length - 1] ?? first;
return {
id: contract.id,
reference: contract.reference,
approvalSteps: contract.approvalSteps,
customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"),
status: contract.status,
contractKind: contract.contractKind,
tradeDirection: contract.tradeDirection,
freightType: contract.freightType,
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
validFrom: contract.contractValidFrom,
validUntil: contract.contractValidUntil,
validityDays: contract.contractValidityDays,
isRenewal: Boolean(contract.renewalOfId),
createdAt: contract.createdAt,
customsClearingEnabled: Boolean(contract.customsClearingEnabled),
contractGeneratedAt: contract.contractGeneratedAt,
};
}

View File

@@ -74,9 +74,8 @@ export function useBookingMutations(bookingId: string) {
const reviewOperation = useMutation({
mutationFn: (payload: {
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
}) => api.bookings.reviewOperation.call({ id: bookingId, ...payload }),
onSuccess: (data) => onSuccess(data, "Operation request reviewed"),
onError: () => toast.error("Failed to review operation request"),

View File

@@ -0,0 +1,426 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import type { QueryClient } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import {
contractsService,
type ContractListFilter,
type SignContractPayload,
} from "@/services/contracts.service";
function invalidateContractDetail(qc: QueryClient, id: string): Promise<void> {
return Promise.all([
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id) }),
qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT }),
]).then(() => undefined);
}
export function useContractList(filter?: ContractListFilter, enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.list(filter),
queryFn: () => contractsService.list(filter),
enabled,
});
}
export function useContractListSummary(
filter?: ContractListFilter,
enabled = true,
) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.listSummary(filter),
queryFn: () => contractsService.getListSummary(filter),
enabled,
});
}
export function useContractDetail(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.byId(id ?? ""),
queryFn: () => contractsService.getById(id!),
enabled: Boolean(id),
});
}
export function useContractClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("GL"),
queryFn: () => contractsService.getClearanceQueue(),
enabled,
});
}
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("OPS"),
queryFn: () => contractsService.getOpsClearanceQueue(),
enabled,
});
}
export function useContractClearanceHistory(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("GL"),
queryFn: () => contractsService.getClearanceHistory(),
enabled,
});
}
export function useOpsClearanceHistory(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceHistory("OPS"),
queryFn: () => contractsService.getOpsClearanceHistory(),
enabled,
});
}
export function useContractMilestones(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.milestones(id ?? ""),
queryFn: () => contractsService.listMilestonesForContract(id!),
enabled: Boolean(id),
});
}
export function useContractCapacity(id: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.capacity(id ?? ""),
queryFn: () => contractsService.getCapacity(id!),
enabled: Boolean(id),
});
}
export function useBookingMilestones(bookingId: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId ?? ""),
queryFn: () => contractsService.listMilestonesForBooking(bookingId!),
enabled: Boolean(bookingId),
});
}
export function useContractMutations(contractId: string) {
const qc = useQueryClient();
const onSuccess = (data: { id: string }, message: string) => {
toast.success(message);
void invalidateContractDetail(qc, data.id);
};
const staffAccept = useMutation({
mutationFn: (validityDays: number) =>
contractsService.staffAccept(contractId, validityDays),
onSuccess: (data) => onSuccess(data, "Contract accepted for approval"),
onError: () => toast.error("Failed to accept contract"),
});
const requestChanges = useMutation({
mutationFn: (note: string) =>
contractsService.requestChanges(contractId, note),
onSuccess: (data) => onSuccess(data, "Changes requested from customer"),
onError: () => toast.error("Failed to request changes"),
});
const reject = useMutation({
mutationFn: (reason: string) => contractsService.reject(contractId, reason),
onSuccess: (data) => onSuccess(data, "Contract rejected"),
onError: () => toast.error("Failed to reject contract"),
});
// Statuses that mean every approval step is done and the contract is ready to
// be generated. Once the final approval lands we generate the PDF
// automatically — staff no longer click a separate "Generate" button.
const READY_TO_GENERATE = ["APPROVED", "APPROVED_PENDING_SIGNATURE"];
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
onSuccess: async (data) => {
// If this was the LAST approval, auto-generate the contract so it goes
// straight to CONTRACT_READY without a manual step.
const alreadyGenerated = Boolean(
(data as Freight.IContract).contractGeneratedAt,
);
if (READY_TO_GENERATE.includes(data.status) && !alreadyGenerated) {
toast.success("Final approval complete — generating contract…");
try {
const generated = await contractsService.generateContract(data.id);
onSuccess(generated, "Contract generated and ready to sign");
return;
} catch {
toast.error("Approved, but contract generation failed. Retry below.");
void invalidateContractDetail(qc, data.id);
return;
}
}
onSuccess(data, "Approval step completed");
},
onError: () => toast.error("Failed to approve step"),
});
// Manual fallback generate — used only if auto-generation failed.
const generateContract = useMutation({
mutationFn: () => contractsService.generateContract(contractId),
onSuccess: (data) => onSuccess(data, "Contract generated"),
onError: () => toast.error("Failed to generate contract"),
});
const signContract = useMutation({
mutationFn: (payload: SignContractPayload) =>
contractsService.signContract(contractId, payload),
onSuccess: (data) => onSuccess(data, "Contract signed"),
onError: () => toast.error("Failed to sign contract"),
});
const createBooking = useMutation({
mutationFn: (payload: Freight.CreateBookingUnderContractDto) =>
contractsService.createBookingUnderContract(contractId, payload),
onSuccess: () => {
toast.success("Booking created under contract");
void invalidateContractDetail(qc, contractId);
},
onError: () => toast.error("Failed to create booking"),
});
const isPending =
staffAccept.isPending ||
requestChanges.isPending ||
reject.isPending ||
approveStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
createBooking.isPending;
return {
staffAccept,
requestChanges,
reject,
approveStep,
generateContract,
signContract,
createBooking,
isPending,
};
}
/**
* Pre-booking clearance mutations keyed on a contract. Pass `selfClear = true`
* for Path A (non-customs) contracts so review/finalize hit the Operations
* endpoints instead of the GL ET ones. Path A has no GL output upload step.
*/
export function useContractClearanceMutations(
contractId: string,
selfClear = false,
) {
const qc = useQueryClient();
const refresh = () => {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
});
void qc.invalidateQueries({
queryKey: ["contracts", "clearance-queue"],
});
void invalidateContractDetail(qc, contractId);
};
const reviewDocument = useMutation({
mutationFn: (p: {
fileKey: string;
status: "APPROVED" | "QUERIED";
note?: string;
}) =>
selfClear
? contractsService.opsReviewClearanceDocument(contractId, p)
: contractsService.reviewClearanceDocument(contractId, p),
onSuccess: (_d, p) => {
toast.success(
p.status === "APPROVED"
? "Document approved"
: "Query sent to customer",
);
refresh();
},
onError: () => toast.error("Could not update document"),
});
// Approve every still-pending customer document in one click. There is no
// server-side bulk endpoint, so fan out the single-document review calls and
// refresh once after they all settle.
const approveAll = useMutation({
mutationFn: async (fileKeys: string[]) => {
const review = selfClear
? contractsService.opsReviewClearanceDocument
: contractsService.reviewClearanceDocument;
await Promise.all(
fileKeys.map((fileKey) =>
review(contractId, { fileKey, status: "APPROVED" }),
),
);
},
onSuccess: (_d, fileKeys) => {
toast.success(
`${fileKeys.length} document${fileKeys.length === 1 ? "" : "s"} approved`,
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
});
const uploadOutputDocuments = useMutation({
mutationFn: (files: Record<string, File | null>) =>
contractsService.uploadClearanceOutput(contractId, files),
onSuccess: () => {
toast.success("Output documents uploaded");
refresh();
},
onError: () => toast.error("Upload failed"),
});
const finalizeClearance = useMutation({
mutationFn: () =>
selfClear
? contractsService.opsFinalizeClearance(contractId)
: contractsService.finalizeClearance(contractId),
onSuccess: () => {
toast.success(
selfClear
? "Clearance approved — customer can now book"
: "Clearance finalized — ready for booking",
);
refresh();
},
onError: (e) =>
toast.error(
e instanceof Error ? e.message : "Could not finalize clearance",
),
});
return { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance };
}
/** Complete a post-booking GL milestone. */
export function useCompleteMilestone(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ code, note }: { code: string; note?: string }) =>
contractsService.completeMilestone(bookingId, code, note),
onSuccess: () => {
toast.success("Milestone completed");
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
},
onError: () => toast.error("Failed to complete milestone"),
});
}
function invalidateMilestones(qc: QueryClient, bookingId: string) {
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.bookingMilestones(bookingId),
});
}
/** Assign a customs risk level (completes RISK_ASSIGNED). */
export function useAssignRisk(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
riskLevel: Freight.CustomsRiskLevel;
note?: string;
}) => contractsService.assignRisk(bookingId, payload),
onSuccess: () => {
toast.success("Customs risk assigned");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to assign risk"),
});
}
/** Advise duty & tax (completes DUTY_TAXES_ADVISED). */
export function useAdviseDuty(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
amount: number;
currency: string;
declarationSerial?: string;
note?: string;
}) => contractsService.adviseDuty(bookingId, payload),
onSuccess: () => {
toast.success("Duty & tax advised to customer");
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to advise duty & tax"),
});
}
/** Route the shipment to a station + bind GL staff. */
export function useAssignStation(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: { stationYardId: string; staffId?: string }) =>
contractsService.assignStation(bookingId, payload),
onSuccess: () => {
toast.success("Shipment routed to station");
void qc.invalidateQueries({
queryKey: QUERY_KEYS.BOOKINGS.byId(bookingId),
});
},
onError: () => toast.error("Failed to assign station"),
});
}
/** Upload GL post-booking documents (DO/RO/T1/…); auto-completes milestones. */
export function useUploadGlDocuments(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (files: Record<string, File | null>) =>
contractsService.uploadGlDocuments(bookingId, files),
onSuccess: (res) => {
const n = res.completedMilestones.length;
toast.success(
n > 0
? `Uploaded — ${n} milestone${n === 1 ? "" : "s"} advanced`
: "Documents uploaded",
);
invalidateMilestones(qc, bookingId);
},
onError: () => toast.error("Failed to upload documents"),
});
}
/** Incidents for a shipment (damage / exceptions). */
export function useBookingIncidents(bookingId: string | undefined) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId ?? ""),
queryFn: () => contractsService.listIncidents(bookingId!),
enabled: !!bookingId,
});
}
/** Report a cargo exception with photos. */
export function useReportIncident(bookingId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: {
incidentType: Freight.IncidentType;
description: string;
photos: File[];
}) => contractsService.reportIncident(bookingId, payload),
onSuccess: () => {
toast.success("Incident reported");
void qc.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.bookingIncidents(bookingId),
});
},
onError: () => toast.error("Failed to report incident"),
});
}

View File

@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -19,6 +19,14 @@ export function useOverviewBookingsTab(range: OverviewRange, enabled: boolean) {
});
}
export function useOverviewContractsTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.contractsTab(range),
queryFn: () => overviewService.getContractsTab(range),
enabled,
});
}
export function useOverviewBillingTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.billingTab(range),

View File

@@ -20,6 +20,21 @@ export const FREIGHT_PERMS = {
uploadClearanceOutput: "edr_freight_app:bookings:upload_clearance_output",
finalizeClearance: "edr_freight_app:bookings:finalize_clearance",
},
contracts: {
view: "edr_freight_app:contracts:view",
staffAccept: "edr_freight_app:contracts:staff_accept",
requestChanges: "edr_freight_app:contracts:request_changes",
reject: "edr_freight_app:contracts:reject",
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",
generateContract: "edr_freight_app:contracts:generate_contract",
signStaff: "edr_freight_app:contracts:sign_staff",
clearanceReview: "edr_freight_app:contracts:clearance_review",
finalizeClearance: "edr_freight_app:contracts:finalize_clearance",
createBooking: "edr_freight_app:contracts:create_booking",
opsClearanceReview: "edr_freight_app:contracts:ops_clearance_review",
},
trainScheduling: {
view: "edr_freight_app:train_scheduling:view",
manage: "edr_freight_app:train_scheduling:manage",
@@ -83,6 +98,31 @@ export function canAccessBookings(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.view);
}
export function canAccessContracts(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.view);
}
/** Can review the GL Ethiopia pre-booking contract clearance queue (Path B). */
export function canReviewContractClearance(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
}
/** GL Ethiopia: can create a booking under a cleared contract (Path B). */
export function canCreateContractBooking(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
}
/** Operations: can review the Path A self-clearance queue (non-customs). */
export function canReviewSelfClearance(
user: AuthUser | null | undefined,
): boolean {
return hasPermission(user, FREIGHT_PERMS.contracts.opsClearanceReview);
}
/** Can see/manage the customs document-clearance queue (Global Logistics). */
export function canViewClearance(user: AuthUser | null | undefined): boolean {
return hasPermission(user, FREIGHT_PERMS.bookings.reviewDocuments);

View File

@@ -53,7 +53,7 @@ const BookingDetailPage = () => {
id: "1",
quantity: 2,
vgmPerUnitTons: 11.25,
containerType: { label: "20FT Standard", sizeFt: 20, isReefer: false },
containerType: { label: "20FT Standard", sizeFt: 20 },
},
],
approvalSteps: [

View File

@@ -4,6 +4,7 @@ import {
FileSignature,
Layers,
LayoutGrid,
Milestone,
Package,
ShieldCheck,
} from "lucide-react";
@@ -288,6 +289,16 @@ export default function BookingRequestDetailPage() {
booking={booking}
mutations={mutations}
/>
<Button
fullWidth
variant="default"
leftSection={<Milestone size={16} />}
onClick={() =>
navigate(`/dashboard/bookings/${booking.id}/milestones`)
}
>
View clearance milestones
</Button>
{showContractButton && (
<Button
fullWidth

View File

@@ -20,7 +20,9 @@ import {
import {
ArrowRight,
Calendar,
CheckCircle,
ChevronRight,
History,
Inbox,
LayoutGrid,
RefreshCw,
@@ -46,12 +48,15 @@ import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { bookingsService } from "@/services/bookings.service";
import type { BookingDetail } from "@/types/booking";
import {
CLEARANCE_REVIEW_STATUS,
CLEARANCE_TABS,
type ClearanceTabKey,
} from "@/features/clearance/clearance-tabs.config";
type ViewMode = "table" | "cards";
type PageTab = "queue" | "history";
const CLEARANCE_REVIEW_STATUS = "DOCUMENTS_UNDER_REVIEW";
const CLEARANCE_HISTORY_STATUS = "CLEARANCE_READY";
interface ClearanceRow {
id: string;
@@ -62,6 +67,7 @@ interface ClearanceRow {
originLabel: string;
destinationLabel: string;
scheduledDate: string;
updatedAt: string;
hasCustoms: boolean;
}
@@ -85,6 +91,7 @@ function toClearanceRow(booking: BookingDetail): ClearanceRow {
originLabel: labelFromRef(booking.originYard),
destinationLabel: labelFromRef(booking.destinationYard),
scheduledDate: booking.scheduledDate,
updatedAt: booking.updatedAt ?? "",
hasCustoms: Boolean(
booking.customsClearingEnabled ?? booking.serviceType?.includesCustoms,
),
@@ -102,12 +109,6 @@ function formatDate(iso?: string): string {
});
}
/**
* Icon-only chip for a booking's trade direction — Truck for import, ShipWheel
* for export — on a light background, matching the "awaiting review" badge
* styling. Keeps the cards within the white / light-gray / green palette and
* drops the text label in favour of a tooltip.
*/
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
@@ -129,33 +130,45 @@ function DirectionIcon({ direction }: { direction: string }) {
export default function DocumentClearanceListPage() {
const navigate = useNavigate();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const isHistory = pageTab === "history";
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["clearance", "list"],
queryKey: ["clearance", "list", isHistory],
queryFn: () =>
bookingsService.list({ status: CLEARANCE_REVIEW_STATUS, pageSize: 200 }),
bookingsService.list({
status: isHistory ? CLEARANCE_HISTORY_STATUS : CLEARANCE_REVIEW_STATUS,
pageSize: 200,
}),
});
// GL clears customs bookings only; non-customs clearance is reviewed by
// Marketing on the booking detail. Scope the queue defensively so a staff or
// marketing user opening this page still sees the customs queue.
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms),
[data?.items],
);
const allRows = useMemo(() => {
// GL clearance queue: customs bookings only
const rows = (data?.items ?? []).map(toClearanceRow).filter((r) => r.hasCustoms);
// Per-tab counts drive the badge on each tab.
const tabCounts = useMemo(() => {
return {
if (isHistory) {
return [...rows].sort((a, b) => {
const ta = new Date(a.updatedAt || 0).getTime();
const tb = new Date(b.updatedAt || 0).getTime();
return tb - ta;
});
}
return rows;
}, [data?.items, isHistory]);
const tabCounts = useMemo(
() => ({
all: allRows.length,
import: allRows.filter((r) => r.tradeDirection === "IMPORT").length,
export: allRows.filter((r) => r.tradeDirection === "EXPORT").length,
} satisfies Record<ClearanceTabKey, number>;
}, [allRows]);
}),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -185,6 +198,26 @@ export default function DocumentClearanceListPage() {
[navigate],
);
const statusBadge = isHistory ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle size={13} />}
>
{tabCounts.all} cleared
</Badge>
) : (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
);
const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [
{
@@ -249,11 +282,16 @@ export default function DocumentClearanceListPage() {
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: () => (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
),
cell: () =>
isHistory ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Cleared
</Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
),
},
{
id: "go",
@@ -265,7 +303,7 @@ export default function DocumentClearanceListPage() {
),
},
],
[],
[isHistory],
);
return (
@@ -274,16 +312,7 @@ export default function DocumentClearanceListPage() {
<PageHeader
title="Document Clearance"
subtitle="Review customer documents, raise queries, and finalize clearance for each booking."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{tabCounts.all} awaiting review
</Badge>
}
meta={statusBadge}
action={
<ActionIcon
variant="default"
@@ -298,13 +327,45 @@ export default function DocumentClearanceListPage() {
}
/>
<Group>
<SegmentedControl
value={pageTab}
onChange={(v) => {
setPageTab(v as PageTab);
setActiveTab("all");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
data={[
{
value: "queue",
label: (
<Group gap={6} wrap="nowrap">
<Inbox size={14} />
<span>Queue</span>
</Group>
),
},
{
value: "history",
label: (
<Group gap={6} wrap="nowrap">
<History size={14} />
<span>History</span>
</Group>
),
},
]}
radius="md"
/>
</Group>
<KpiStrip
loading={isLoading}
items={[
{
label: "Awaiting review",
label: isHistory ? "Cleared" : "Awaiting review",
value: tabCounts.all,
icon: Inbox,
icon: isHistory ? CheckCircle : Inbox,
color: "edr-green",
},
{
@@ -370,10 +431,7 @@ export default function DocumentClearanceListPage() {
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
rightSection={
query ? (
@@ -430,9 +488,7 @@ export default function DocumentClearanceListPage() {
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
@@ -454,6 +510,7 @@ export default function DocumentClearanceListPage() {
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
isHistory={isHistory}
onOpen={openDetail}
/>
)}
@@ -467,10 +524,12 @@ export default function DocumentClearanceListPage() {
function ClearanceCardGrid({
rows,
loading,
isHistory,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
isHistory: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
@@ -495,7 +554,12 @@ function ClearanceCardGrid({
return (
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md" p="md">
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
<ClearanceCard
key={r.id}
row={r}
isHistory={isHistory}
onOpen={() => onOpen(r.id)}
/>
))}
</SimpleGrid>
);
@@ -503,9 +567,11 @@ function ClearanceCardGrid({
function ClearanceCard({
row,
isHistory,
onOpen,
}: {
row: ClearanceRow;
isHistory: boolean;
onOpen: () => void;
}) {
return (
@@ -543,9 +609,15 @@ function ClearanceCard({
</Group>
</Box>
</Group>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Under review
</Badge>
{isHistory ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Cleared
</Badge>
) : (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
)}
</Group>
<Box

View File

@@ -0,0 +1,140 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import {
Badge,
Box,
Center,
Grid,
Group,
Loader,
Progress,
RingProgress,
Stack,
Text,
} from "@mantine/core";
import { Flag, ListChecks } from "lucide-react";
import { PageContainer } from "@/components/page";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
import { GlActionsPanel } from "@/components/contracts/gl-actions/GlActionsPanel";
import {
useBookingMilestones,
useCompleteMilestone,
} from "@/hooks/contracts/useContracts";
import { useBookingDetail } from "@/hooks/bookings/useBookings";
export default function BookingMilestonesPage() {
const { id } = useParams<{ id: string }>();
const { data: booking } = useBookingDetail(id);
const { data: milestones, isLoading } = useBookingMilestones(id);
const complete = useCompleteMilestone(id ?? "");
const stats = useMemo(() => {
const list = milestones ?? [];
const total = list.length;
const completed = list.filter((m) => m.status === "COMPLETED").length;
const pct = total === 0 ? 0 : Math.round((completed / total) * 100);
return { total, completed, pct };
}, [milestones]);
const reference = booking?.reference ?? "Shipment";
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`${reference} milestones`}
subtitle="Track and advance the Global Logistics clearance milestones for this shipment."
backTo={id ? `/dashboard/booking-requests/${id}` : undefined}
breadcrumbs={[
{ label: "Booking requests", href: "/dashboard/booking-requests" },
{ label: reference },
{ label: "Milestones" },
]}
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ListChecks size={13} />}
>
{stats.completed}/{stats.total} done
</Badge>
}
/>
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<Stack gap="lg">
<SectionCard icon={Flag} title="Clearance milestones">
{isLoading ? (
<Center py="xl">
<Loader color="edr-green" size="sm" />
</Center>
) : (
<ClearanceMilestoneTimeline
milestones={milestones ?? []}
busy={complete.isPending}
onComplete={(code, note) =>
complete.mutate({ code, note })
}
/>
)}
</SectionCard>
{id ? (
<GlActionsPanel
bookingId={id}
milestones={milestones ?? []}
/>
) : null}
</Stack>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard icon={ListChecks} title="Progress" accent="edr-green">
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
complete
</Text>
</Stack>
}
/>
<Box w="100%">
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Milestones
</Text>
<Text size="xs" c="dimmed">
{stats.completed}/{stats.total}
</Text>
</Group>
<Progress
value={stats.pct}
color="edr-green"
radius="xl"
size="md"
/>
</Box>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,340 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
Box,
Grid,
Group,
Loader,
Paper,
Progress,
RingProgress,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
AlertCircle,
ArrowRight,
CheckCircle2,
Clock,
PackageCheck,
ShieldCheck,
} from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { useContractDetail } from "@/hooks/contracts/useContracts";
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: contract } = useContractDetail(id);
const {
data: clearance,
isLoading,
isError,
} = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
queryFn: () => contractsService.getClearance(id!),
enabled: Boolean(id),
});
const stats = useMemo(() => {
const docs = (clearance?.documents ?? []).filter(
(d) => d.uploadedBy === "customer",
);
const total = docs.length;
const approved = docs.filter((d) => d.reviewStatus === "APPROVED").length;
const queried = docs.filter((d) => d.reviewStatus === "QUERIED").length;
const pending = total - approved - queried;
const pct = total === 0 ? 0 : Math.round((approved / total) * 100);
return { total, approved, queried, pending, pct };
}, [clearance]);
const reference = contract?.reference ?? "Clearance";
// Customs (Path B) hub. The customer always creates the booking in the portal
// after GL finalizes clearance — there is no GL "Create booking" action here.
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80} gap={10}>
<Loader color="edr-green" />
<Text c="dimmed">Loading clearance</Text>
</Group>
</PageContainer>
);
}
if (isError || !clearance) {
return (
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: "Not found" },
]}
/>
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldnt load this contracts clearance.
</Alert>
</PageContainer>
);
}
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: reference },
]}
meta={
ready ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={13} />}
>
Ready customer books
</Badge>
) : clearance.allApproved ? (
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<CheckCircle2 size={13} />}
>
All approved
</Badge>
) : (
<Badge
variant="light"
color="gray"
radius="sm"
leftSection={<Clock size={13} />}
>
Review pending
</Badge>
)
}
/>
<ClearanceHero contract={contract} stats={stats} />
{ready ? (
<Alert
color="edr-green"
radius="md"
icon={<PackageCheck size={16} />}
title="Clearance finalized"
>
Customs clearance is complete. The customer can now create the
shipment booking from the portal no further action is needed here.
</Alert>
) : null}
<Grid gap="lg">
<Grid.Col span={{ base: 12, lg: 8 }}>
<ContractClearanceReviewSection
contractId={id!}
hideSummary
selfClear={false}
readOnly={ready}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<SectionCard
icon={PackageCheck}
title="Review progress"
accent="edr-green"
>
<Stack align="center" gap="sm">
<RingProgress
size={140}
thickness={12}
roundCaps
sections={[{ value: stats.pct, color: "edr-green" }]}
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1}>
{stats.pct}%
</Text>
<Text size="xs" c="dimmed">
approved
</Text>
</Stack>
}
/>
<Group gap="lg" justify="center">
<ProgressStat
color="edr-green"
label="Approved"
value={stats.approved}
/>
<ProgressStat
color="red"
label="Queried"
value={stats.queried}
/>
<ProgressStat
color="gray"
label="Pending"
value={stats.pending}
/>
</Group>
</Stack>
</SectionCard>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function ClearanceHero({
contract,
stats,
}: {
contract: ReturnType<typeof useContractDetail>["data"];
stats: { pct: number; approved: number; total: number };
}) {
const direction = contract?.tradeDirection ?? "—";
const serviceName = contract?.serviceType?.serviceName ?? null;
const customs =
contract?.serviceType?.includesCustoms ??
contract?.customsClearingEnabled ??
false;
const routes = [...(contract?.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const origin =
routes[0]?.originYard?.label ?? routes[0]?.originYard?.code ?? "Origin";
const last = routes[routes.length - 1] ?? routes[0];
const destination =
last?.destinationYard?.label ??
last?.destinationYard?.code ??
"Destination";
return (
<Paper withBorder radius="md" p="lg">
<Group justify="space-between" align="flex-start" wrap="wrap" gap="lg">
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={52}>
<ShieldCheck size={26} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text fw={800} fz={20} c="edr-text" truncate>
{contract?.reference ?? "Clearance"}
</Text>
<Badge
size="sm"
variant="light"
color={direction === "IMPORT" ? "edr-green" : "gray"}
radius="sm"
>
{direction}
</Badge>
{customs ? (
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={12} />}
>
Customs
</Badge>
) : (
<Badge size="sm" variant="light" color="gray" radius="sm">
No customs
</Badge>
)}
</Group>
{serviceName && (
<Text size="sm" fw={600} c="edr-text" mt={6} truncate maw={280}>
{serviceName}
</Text>
)}
<Group gap={8} mt={6} wrap="nowrap">
<Text size="sm" fw={600} truncate maw={160}>
{origin}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={160}>
{destination}
</Text>
</Group>
</Box>
</Group>
<Box style={{ minWidth: 200, flex: 1, maxWidth: 320 }}>
<Group justify="space-between" mb={6}>
<Text size="xs" c="dimmed" fw={600}>
Document review
</Text>
<Text size="xs" c="dimmed">
{stats.approved}/{stats.total}
</Text>
</Group>
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
</Box>
</Group>
</Paper>
);
}
function ProgressStat({
color,
label,
value,
}: {
color: string;
label: string;
value: number;
}) {
return (
<Stack gap={2} align="center">
<Text fw={700} fz={18} c="edr-text">
{value}
</Text>
<Group gap={4} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: `var(--mantine-color-${color}-6)`,
}}
/>
<Text fz="11px" c="dimmed">
{label}
</Text>
</Group>
</Stack>
);
}

View File

@@ -0,0 +1,615 @@
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Group,
SegmentedControl,
Stack,
Text,
TextInput,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import {
ArrowRight,
ChevronRight,
FileText,
Inbox,
LayoutGrid,
PackageCheck,
PackagePlus,
RefreshCw,
Search,
ShieldCheck,
ShipWheel,
Table as TableIcon,
Truck,
User,
X,
} from "lucide-react";
import {
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
type ViewMode = "table" | "cards";
interface ClearanceRow {
id: string;
reference: string;
customerLabel: string;
tradeDirection: string;
freightType: string;
originLabel: string;
destinationLabel: string;
contractKind: string;
serviceTypeName: string;
customs: boolean;
status: string;
/** true once GL has finalized clearance — customer now books in the portal. */
ready: boolean;
}
function yardLabel(
yard?: { label?: string; code?: string; name?: string } | null,
fallback = "—",
): string {
if (!yard) return fallback;
return yard.label ?? yard.name ?? yard.code ?? fallback;
}
function toClearanceRow(contract: Freight.IContract): ClearanceRow {
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const first = routes[0];
const last = routes[routes.length - 1] ?? first;
return {
id: contract.id,
reference: contract.reference,
customerLabel: contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"),
tradeDirection: contract.tradeDirection ?? "—",
freightType: contract.freightType ?? "—",
originLabel: yardLabel(first?.originYard),
destinationLabel: yardLabel(last?.destinationYard),
contractKind: contract.contractKind,
serviceTypeName: contract.serviceType?.serviceName ?? "—",
customs:
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
status: contract.status,
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
};
}
function CustomsBadge({ customs }: { customs: boolean }) {
return customs ? (
<Badge
size="xs"
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={11} />}
>
Customs
</Badge>
) : (
<Badge size="xs" variant="light" color="gray" radius="sm">
No customs
</Badge>
);
}
function DirectionIcon({ direction }: { direction: string }) {
const isImport = direction === "IMPORT";
const Icon = isImport ? Truck : ShipWheel;
const label = isImport ? "Import" : direction === "EXPORT" ? "Export" : "—";
return (
<Tooltip label={label} withArrow>
<ThemeIcon
variant="light"
color={isImport ? "edr-green" : "gray"}
radius="md"
size={28}
aria-label={label}
>
<Icon size={15} strokeWidth={1.9} />
</ThemeIcon>
</Tooltip>
);
}
function StatusBadge({ row }: { row: ClearanceRow }) {
if (row.ready) {
return (
<Tooltip
label="Clearance finalized — the customer creates the booking in the portal"
withArrow
>
<Badge
size="sm"
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageCheck size={12} />}
>
Clearance finalized
</Badge>
</Tooltip>
);
}
return (
<Badge size="sm" variant="light" color="yellow" radius="sm">
Under review
</Badge>
);
}
/**
* Document Clearance hub. Lists every customs (Path B) contract that still needs
* customs clearance — awaiting documents, under GL review, or finalized and
* waiting for the customer to create the booking in the portal. A single list,
* no queue/history/direction tabs.
*/
export default function ContractClearanceListPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const { data, isLoading, isError, isFetching, refetch } =
useContractClearanceQueue(true);
const allRows = useMemo(
() => (data?.items ?? []).map(toClearanceRow),
[data?.items],
);
const counts = useMemo(
() => ({
all: allRows.length,
ready: allRows.filter((r) => r.ready).length,
review: allRows.filter((r) => !r.ready).length,
}),
[allRows],
);
const rows = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allRows;
return allRows.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.customerLabel.toLowerCase().includes(q) ||
r.originLabel.toLowerCase().includes(q) ||
r.destinationLabel.toLowerCase().includes(q),
);
}, [allRows, query]);
const total = rows.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const pagedRows = useMemo(() => {
const start = pagination.pageIndex * pagination.pageSize;
return rows.slice(start, start + pagination.pageSize);
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/contracts/clearance/${id}`),
[navigate],
);
const columns: ColumnDef<ClearanceRow>[] = useMemo(
() => [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const r = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<ShieldCheck className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{r.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{r.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} truncate maw={120}>
{r.originLabel}
</Text>
<ArrowRight size={14} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={500} truncate maw={120}>
{r.destinationLabel}
</Text>
</Group>
<Group gap={8} align="center">
<DirectionIcon direction={r.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{r.freightType}
</Badge>
</Group>
</Stack>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => (
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{row.original.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
),
},
{
id: "service",
header: () => <span className={bookingTable.headerCell}>Service</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={4} py={2} style={{ minWidth: 0 }}>
<Text size="sm" fw={500} truncate maw={160}>
{r.serviceTypeName}
</Text>
<CustomsBadge customs={r.customs} />
</Stack>
);
},
},
{
id: "status",
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => <StatusBadge row={row.original} />,
},
{
id: "go",
size: 150,
cell: ({ row }) =>
row.original.ready ? (
<Group justify="flex-end" pr="xs">
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<PackagePlus size={14} />}
onClick={(e) => {
e.stopPropagation();
navigate(
`/dashboard/contracts/${row.original.id}/create-booking`,
);
}}
>
Create booking
</Button>
</Group>
) : (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[navigate],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Document Clearance"
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<ShieldCheck size={13} />}
>
{counts.all} need clearance
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={isLoading}
items={[
{
label: "Need clearance",
value: counts.all,
icon: Inbox,
color: "edr-green",
},
{
label: "Awaiting review",
value: counts.review,
icon: ShieldCheck,
color: "yellow",
},
{
label: "Ready — customer books",
value: counts.ready,
icon: PackageCheck,
color: "edr-green",
},
]}
/>
<Card p={0} withBorder shadow="sm" radius="lg">
<Stack gap={0}>
<Box px="md" pt="md" pb="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference, customer, or route…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.currentTarget.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
) : null
}
radius="lg"
style={{ flex: 1, minWidth: 220 }}
/>
<Group gap="sm" wrap="nowrap">
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
<SegmentedControl
size="sm"
radius="md"
value={view}
onChange={(v) => setView(v as ViewMode)}
data={[
{
value: "table",
label: (
<Group gap={6} wrap="nowrap">
<TableIcon size={15} />
<Box visibleFrom="sm">Table</Box>
</Group>
),
},
{
value: "cards",
label: (
<Group gap={6} wrap="nowrap">
<LayoutGrid size={15} />
<Box visibleFrom="sm">Cards</Box>
</Group>
),
},
]}
/>
</Group>
</Group>
</Box>
{view === "table" ? (
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
<DataTable<ClearanceRow, unknown>
columns={columns}
data={pagedRows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={(row) => openDetail(row.id)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
) : (
<ClearanceCardGrid
rows={pagedRows}
loading={isLoading}
onOpen={openDetail}
/>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}
function ClearanceCardGrid({
rows,
loading,
onOpen,
}: {
rows: ClearanceRow[];
loading: boolean;
onOpen: (id: string) => void;
}) {
if (loading) {
return (
<Box px="md" py="xl">
<Text c="dimmed" ta="center">
Loading
</Text>
</Box>
);
}
if (rows.length === 0) {
return (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No contracts need customs clearance.</Text>
</Stack>
);
}
return (
<Box
px="md"
pb="md"
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: "var(--mantine-spacing-md)",
}}
>
{rows.map((r) => (
<ClearanceCard key={r.id} row={r} onOpen={() => onOpen(r.id)} />
))}
</Box>
);
}
function ClearanceCard({
row,
onOpen,
}: {
row: ClearanceRow;
onOpen: () => void;
}) {
return (
<Card
withBorder
shadow="sm"
radius="lg"
p="md"
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onOpen();
}
}}
style={{ cursor: "pointer", transition: "all 120ms ease" }}
className="hover:border-edr-green-4 hover:shadow-md"
>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={40}>
<FileText size={19} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fw={700} size="sm" c="edr-text" truncate>
{row.reference}
</Text>
<Group gap={4} wrap="nowrap">
<User size={11} className="shrink-0 opacity-70" />
<Text size="xs" c="dimmed" truncate>
{row.customerLabel}
</Text>
</Group>
</Box>
</Group>
<StatusBadge row={row} />
</Group>
<Box
mt="md"
p="sm"
style={{
borderRadius: 12,
background: "var(--mantine-color-edr-card-6)",
border: "1px solid var(--mantine-color-edr-border-6)",
}}
>
<Group gap={8} wrap="nowrap" justify="center">
<Text size="sm" fw={600} truncate maw={130}>
{row.originLabel}
</Text>
<ArrowRight size={15} className="shrink-0 text-muted-foreground" />
<Text size="sm" fw={600} truncate maw={130}>
{row.destinationLabel}
</Text>
</Group>
</Box>
<Group justify="space-between" mt="md" wrap="nowrap">
<Group gap={8} wrap="nowrap">
<DirectionIcon direction={row.tradeDirection} />
<Badge size="xs" variant="default" radius="sm">
{row.freightType}
</Badge>
</Group>
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
{row.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
</Group>
<Group justify="space-between" mt={8} wrap="nowrap" gap={8}>
<Text size="xs" c="dimmed" truncate maw={150}>
{row.serviceTypeName}
</Text>
<CustomsBadge customs={row.customs} />
</Group>
</Card>
);
}

View File

@@ -0,0 +1,475 @@
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
Box as BoxIcon,
Building2,
Calendar,
CalendarClock,
FileText,
Flame,
Package,
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
} from "lucide-react";
import {
Badge,
Box,
Button,
Center,
Container,
Grid,
Group,
Loader,
Paper,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import "@/components/overview/overview.css";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
// Clearance phase — staff can still ACT (approve / query / finalize).
const CLEARANCE_ACTIVE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
// see which documents were approved, by whom, and when.
const CLEARANCE_DONE_STATUSES = [
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
"EXPIRED",
];
// Show the Clearance Review tab in either phase (active or done).
const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_ACTIVE_STATUSES,
...CLEARANCE_DONE_STATUSES,
];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function ContractRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const {
data: contract,
isLoading,
isError,
refetch,
isFetching,
} = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
const setTab = (tab: string) =>
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (tab === "details") next.delete("tab");
else next.set("tab", tab);
return next;
},
{ replace: true },
);
if (isLoading) {
return (
<PageContainer>
<Center mih="60vh">
<Stack align="center" gap="md">
<Loader color="gray" />
<Text size="sm" c="dimmed" fw={500}>
Loading contract
</Text>
</Stack>
</Center>
</PageContainer>
);
}
if (isError || !contract) {
return (
<PageContainer>
<Container size="sm" py="xl">
<Paper radius="md" withBorder p="xl" ta="center" style={detailStyles.card}>
<Center>
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 16,
background: "var(--mantine-color-gray-1)",
color: "var(--mantine-color-gray-6)",
}}
>
<FileText size={32} />
</Box>
</Center>
<Text fw={700} size="lg" mt="lg">
Contract not found
</Text>
<Text size="sm" c="dimmed" mt={4}>
This request may have been removed or the link is invalid.
</Text>
<Button
variant="default"
mt="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/contract-requests")}
>
Back to contract requests
</Button>
</Paper>
</Container>
</PageContainer>
);
}
const statusMeta = getContractStatusMeta(contract.status);
const routes = [...(contract.routes ?? [])].sort(
(a, b) => a.sortOrder - b.sortOrder,
);
const showApprovalCard =
contract.status === "PENDING_APPROVAL" ||
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
// Once clearance is finalized the tab is informational only — no approve/query.
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// If the tab param points at clearance but the contract isn't in a clearance
// phase, fall back to details so we never show an empty tab.
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—");
return (
<PageContainer>
<Breadcrumbs
items={[
{ label: "Contract requests", href: "/dashboard/contract-requests" },
{ label: contract.reference },
]}
/>
<Stack gap="lg">
{/* Hero */}
<Paper radius="xl" p="xl" style={{ position: "relative", overflow: "hidden" }}>
<Stack gap="lg">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Button
variant="default"
size="compact-sm"
radius="lg"
leftSection={<ArrowLeft size={16} />}
onClick={() => navigate("/dashboard/contract-requests")}
>
Back to list
</Button>
<Button
variant="light"
color="edr-green"
size="compact-sm"
radius="lg"
leftSection={<RefreshCw size={15} />}
loading={isFetching}
onClick={() => refetch()}
>
Refresh
</Button>
</Group>
<Stack gap="sm">
<Text
size="xs"
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "#B26C09" }}
>
Contract reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{contract.reference}
</Title>
<ContractStatusBadge
status={contract.status}
isRenewal={Boolean(contract.renewalOfId)}
/>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.contractKind === "GENERAL" ? "General" : "One-time"}
</Badge>
</Group>
<Group gap="lg" mt={4}>
<MetaItem icon={Building2} text={customerLabel} />
<MetaItem
icon={Calendar}
text={`Created ${formatDate(contract.createdAt)}`}
/>
{contract.contractValidUntil ? (
<MetaItem
icon={CalendarClock}
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
/>
) : null}
</Group>
</Stack>
</Stack>
</Paper>
<ContractWorkflowStepper
status={contract.status}
title={statusMeta.title}
description={statusMeta.description}
/>
{showClearanceTab && (
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
Details
</Tabs.Tab>
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
</Tabs.List>
</Tabs>
)}
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? (
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
readOnly={clearanceReadOnly}
onChanged={() => refetch()}
/>
) : (
<Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
<Text size="sm" c="dimmed">
No routes on this contract.
</Text>
) : (
<Stack gap="sm">
{routes.map((r) => (
<Group
key={r.id}
justify="space-between"
wrap="nowrap"
px="sm"
py="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-gray-2)",
}}
>
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate maw={160}>
{r.originYard?.label ??
r.originYard?.code ??
"Origin"}
</Text>
<ArrowRight
size={15}
className="shrink-0 text-muted-foreground"
/>
<Text size="sm" fw={600} truncate maw={160}>
{r.destinationYard?.label ??
r.destinationYard?.code ??
"Destination"}
</Text>
</Group>
{r.km != null ? (
<Badge variant="light" color="gray" radius="sm">
{r.km} km
</Badge>
) : null}
</Group>
))}
</Stack>
)}
</SectionCard>
<SectionCard icon={Package} title="Cargo scope">
<Group gap="sm" mb="md">
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.tradeDirection}
</Badge>
<Badge variant="light" color="gray" radius="sm" tt="uppercase">
{contract.freightType}
</Badge>
{contract.isHazardous ? (
<Badge
variant="light"
color="orange"
radius="sm"
leftSection={<Flame size={12} />}
>
Hazardous
</Badge>
) : null}
{contract.isReefer ? (
<Badge
variant="light"
color="cyan"
radius="sm"
leftSection={<Snowflake size={12} />}
>
Reefer
</Badge>
) : null}
</Group>
{(contract.cargoScope ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
No cargo scope lines.
</Text>
) : (
<Stack gap="xs">
{(contract.cargoScope ?? []).map((s) => (
<Group key={s.id} gap={8} wrap="nowrap">
<BoxIcon
size={15}
color="var(--mantine-color-edr-green-6)"
/>
<Text size="sm">
{s.containerSize ??
s.cargoFreeText ??
s.cargoTypeId ??
"Cargo"}
</Text>
</Group>
))}
</Stack>
)}
</SectionCard>
{contract.pricingBreakdown?.lineItems?.length ? (
<SectionCard icon={Receipt} title="Unit rates">
<Stack gap="xs">
{contract.pricingBreakdown.lineItems.map((li) => (
<Group
key={li.code}
justify="space-between"
wrap="nowrap"
>
<Text size="sm" truncate>
{li.label}
{li.containerSize ? ` · ${li.containerSize}` : ""}
</Text>
<Text size="sm" fw={600}>
{contract.pricingBreakdown?.currency} {li.unitPrice} /{" "}
{li.unit}
</Text>
</Group>
))}
</Stack>
</SectionCard>
) : null}
{contract.contractSummary ? (
<SectionCard icon={FileText} title="Contract summary">
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{contract.contractSummary}
</Text>
</SectionCard>
) : null}
</Stack>
)}
</Grid.Col>
{/* RIGHT — sticky action rail */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<Box style={{ position: "sticky", top: 24 }}>
<Stack gap="lg">
<ContractActionsToolbar
contract={contract}
mutations={mutations}
onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined
}
/>
{showApprovalCard && (
<ContractApprovalStepsCard
contract={contract}
mutations={mutations}
/>
)}
</Stack>
</Box>
</Grid.Col>
</Grid>
</Stack>
</PageContainer>
);
}
function MetaItem({
icon: Icon,
text,
}: {
icon: typeof Building2;
text: string;
}) {
return (
<Group gap={6} wrap="nowrap">
<Icon size={14} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={600} c="dark">
{text}
</Text>
</Group>
);
}

View File

@@ -0,0 +1,410 @@
import {
ActionIcon,
Box,
Button,
Card,
Group,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
AlertTriangle,
ArrowRight,
CalendarClock,
CheckCircle2,
Clock,
FileText,
Inbox,
LayoutList,
RefreshCw,
Repeat,
Search,
User,
X,
} from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell";
import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge";
import {
ContractStatusTabs,
type ContractStatusTabKey,
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
import {
getStaffRowAction,
toContractListRow,
type ContractListRow,
} from "@/features/contracts/mapContractListRow";
import {
useContractList,
useContractListSummary,
} from "@/hooks/contracts/useContracts";
import type { ContractListFilter } from "@/services/contracts.service";
import {
Badge,
DataTable,
DataTableFooter,
usePagination,
type ColumnDef,
} from "@edr/ui-common";
function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
if (!match?.statuses?.length) return undefined;
return match.statuses.join(",");
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
return Number.isNaN(d.getTime())
? "—"
: d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export default function ContractRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
const tabStatuses = getStatusesForTab(activeTab);
const filter: ContractListFilter = useMemo(
() => ({
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
tab: activeTab,
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[pagination.pageIndex, pagination.pageSize, activeTab, tabStatuses],
);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
const {
data: summary,
isLoading: summaryLoading,
refetch: refetchSummary,
} = useContractListSummary(filter);
const rows = useMemo(() => {
const items = (data?.items ?? []).map(toContractListRow);
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(
(c) =>
c.reference.toLowerCase().includes(q) ||
c.customerLabel.toLowerCase().includes(q),
);
}, [data?.items, query]);
const total = data?.total ?? 0;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const showEmpty = !isLoading && !isError && rows.length === 0;
const metrics = summary?.metrics;
const tabCounts = summary?.tabs;
const handleRefresh = useCallback(() => {
void refetch();
void refetchSummary();
}, [refetch, refetchSummary]);
const handleRowClick = useCallback(
(row: ContractListRow) => {
navigate(`/dashboard/contract-requests/${row.id}`);
},
[navigate],
);
const columns: ColumnDef<ContractListRow>[] = [
{
id: "contract",
header: () => <span className={bookingTable.headerCell}>Contract</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3 py-1.5">
<div className={bookingTable.rowIcon}>
<FileText className="size-4" strokeWidth={1.75} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-foreground">
{c.reference}
</p>
<p className="mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground">
<User className="size-3 shrink-0 opacity-70" />
{c.customerLabel}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
cell: ({ row }) => {
const c = row.original;
return (
<div className="space-y-1 py-1">
<div className="flex items-center gap-1.5 text-sm font-medium text-foreground">
<span className="max-w-[8rem] truncate">{c.originLabel}</span>
<ArrowRight className="size-3.5 shrink-0 text-muted-foreground" />
<span className="max-w-[8rem] truncate">
{c.destinationLabel}
</span>
</div>
<div className="flex gap-1.5">
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase backdrop-blur-sm"
>
{c.tradeDirection}
</Badge>
<Badge
variant="secondary"
className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium"
>
{c.freightType}
</Badge>
</div>
</div>
);
},
},
{
id: "status",
size: 200,
minSize: 180,
header: () => <span className={bookingTable.headerCell}>Status</span>,
cell: ({ row }) => (
<div className="py-1">
<ContractStatusBadge
status={row.original.status}
isRenewal={row.original.isRenewal}
/>
</div>
),
meta: {
headerClassName: "min-w-[11rem]",
cellClassName: "min-w-[11rem]",
},
},
{
id: "approval",
header: () => <span className={bookingTable.headerCell}>Approval</span>,
cell: ({ row }) => <ContractApprovalProgressCell row={row.original} />,
},
{
id: "validity",
header: () => <span className={bookingTable.headerCell}>Validity</span>,
cell: ({ row }) => {
const c = row.original;
return (
<Stack gap={2}>
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarClock className="size-3.5" />
{c.validUntil
? `Until ${formatDate(c.validUntil)}`
: c.validityDays
? `${c.validityDays} days`
: "—"}
</span>
{c.validFrom ? (
<Text size="xs" c="dimmed">
From {formatDate(c.validFrom)}
</Text>
) : null}
</Stack>
);
},
},
{
id: "kind",
header: () => <span className={bookingTable.headerCell}>Kind</span>,
cell: ({ row }) => {
const isGeneral = row.original.contractKind === "GENERAL";
return (
<Badge
variant="outline"
className="h-5 border-border/50 bg-background/50 px-1.5 text-[10px] font-medium uppercase"
>
{isGeneral ? (
<span className="inline-flex items-center gap-1">
<Repeat className="size-3" /> General
</span>
) : (
"One-time"
)}
</Badge>
);
},
},
{
id: "actions",
header: () => <span className={bookingTable.headerCell}>Action</span>,
cell: ({ row }) => {
const action = getStaffRowAction(row.original);
if (!action) return null;
return (
<Button
size="compact-sm"
radius="md"
variant={action.variant === "filled" ? "filled" : action.variant}
color="edr-green"
onClick={(e) => {
// Don't let the row-click navigation fire as well.
e.stopPropagation();
navigate(action.to(row.original.id));
}}
>
{action.label}
</Button>
);
},
},
];
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Contract requests"
subtitle="Review, approve, and execute freight contract requests."
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
loading={isFetching}
onClick={handleRefresh}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<KpiStrip
loading={summaryLoading}
items={[
{
label: "In queue",
value: metrics?.inQueue ?? 0,
icon: LayoutList,
color: "edr-green",
},
{
label: "Needs action",
value: metrics?.needsAction ?? 0,
icon: Clock,
color: "yellow",
},
{
label: "Urgent",
value: metrics?.urgent ?? 0,
icon: AlertTriangle,
color: "red",
},
{
label: "Closed",
value: tabCounts?.closed ?? 0,
icon: CheckCircle2,
color: "edr-green",
},
]}
/>
<ContractStatusTabs
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.target.value)}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => setQuery("")}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
</Box>
{showEmpty ? (
<Stack align="center" gap={8} py={48}>
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
<Inbox size={22} />
</ThemeIcon>
<Text c="dimmed">No contracts match this view.</Text>
</Stack>
) : (
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={rows}
status={
isLoading ? "loading" : isError ? "error" : "success"
}
onRowClick={handleRowClick}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={DataTableFooter}
/>
</Box>
)}
</Stack>
</Card>
</Stack>
</PageContainer>
);
}

View File

@@ -0,0 +1,222 @@
import { useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Box,
Button,
Group,
Image,
Loader,
Modal,
Paper,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
import toast from "react-hot-toast";
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
import { contractsService } from "@/services/contracts.service";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
/**
* Staff contract preview + sign. Staff must open and read the generated
* contract here before signing — there is no sign action on the detail page or
* the list table. Signing as STAFF is only possible once the contract has been
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
*/
export default function ContractViewPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const iframeRef = useRef<HTMLIFrameElement>(null);
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Offer the staff member's saved signature first; they can draw a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
queryFn: () => contractsService.getContractView(id!),
enabled: Boolean(id),
});
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
onSuccess: () => {
toast.success("Contract signed");
setSignOpen(false);
void refetch();
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
},
onError: () => toast.error("Failed to sign contract"),
});
const handlePrint = () => iframeRef.current?.contentWindow?.print();
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
};
if (isLoading) {
return (
<Group justify="center" mih="40vh" align="center">
<Loader color="edr-green" />
</Group>
);
}
if (isError || !data) {
return (
<Box p="xl">
<Text c="dimmed">Could not load contract.</Text>
<Button variant="default" mt="md" onClick={() => navigate(-1)}>
Go back
</Button>
</Box>
);
}
return (
<Box p={{ base: "md", md: "xl" }}>
<Box maw={920} mx="auto">
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
<Button
variant="subtle"
color="gray"
leftSection={<ArrowLeft size={16} />}
onClick={() =>
navigate(`/dashboard/contract-requests/${data.contractId}`)
}
>
Back to contract
</Button>
<Group gap="sm">
<Button
variant="default"
leftSection={<Printer size={16} />}
onClick={handlePrint}
>
Print
</Button>
{data.canSignStaff && (
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={openSign}
>
{usingSaved ? "Approve & sign" : "Sign as staff"}
</Button>
)}
</Group>
</Group>
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
<iframe
ref={iframeRef}
srcDoc={data.html}
title="Contract document"
style={{
width: "100%",
minHeight: "80vh",
border: "none",
background: "white",
}}
/>
</Paper>
</Box>
<Modal
opened={signOpen}
onClose={() => setSignOpen(false)}
title="Sign contract as staff"
centered
radius="lg"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
{data.reference} your signature is stored securely on the
contract.
</Text>
<TextInput
label="Full name"
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
/>
{usingSaved ? (
<Stack gap="xs">
<Paper
withBorder
radius="md"
p="xs"
style={{ borderStyle: "dashed" }}
>
<Image
src={savedSignatureImage ?? undefined}
alt="Saved signature"
fit="contain"
h={140}
/>
</Paper>
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</Stack>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={signMutation.isPending}
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</Group>
</Stack>
</Modal>
</Box>
);
}

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Modal,
Stack,
Text,
Textarea,
} from "@mantine/core";
import {
AlertCircle,
CalendarDays,
PackagePlus,
XCircle,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { contractsService } from "@/services/contracts.service";
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
function lineRows(lines: Freight.RequestedShipmentLines) {
if (lines.containers?.length) {
return lines.containers.map(
(c) =>
`${c.quantity} × ${c.containerSize}` +
(c.hazardousQuantity ? ` · ${c.hazardousQuantity} hazardous` : "") +
(c.reeferQuantity ? ` · ${c.reeferQuantity} reefer` : ""),
);
}
if (lines.bulk) {
const b = lines.bulk;
const parts: string[] = [];
if (b.cargoWeightTons) parts.push(`${b.cargoWeightTons} tons`);
if (b.itemCount) parts.push(`${b.itemCount} items`);
if (b.hazardousQuantity) parts.push(`${b.hazardousQuantity} hazardous`);
return [parts.join(" · ") || "Bulk cargo"];
}
return ["—"];
}
export default function ShipmentRequestDetailPage() {
const { id: reqId } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectNote, setRejectNote] = useState("");
const { data: request, isLoading } = useQuery({
queryKey: ["shipment-request", reqId],
queryFn: () => contractsService.getBookingRequest(reqId!),
enabled: Boolean(reqId),
});
const reject = useMutation({
mutationFn: () => contractsService.rejectBookingRequest(reqId!, rejectNote),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
navigate("/dashboard/shipment-requests");
},
});
if (isLoading) {
return (
<PageContainer>
<Group justify="center" py={80}>
<Loader color="edr-green" />
</Group>
</PageContainer>
);
}
if (!request) {
return (
<PageContainer>
<PageHeader title="Request not found" backTo="/dashboard/shipment-requests" />
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
We couldn't load this shipment request.
</Alert>
</PageContainer>
);
}
const isPending = request.status === "PENDING";
const contractRef = request.contract?.reference ?? request.contractId;
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title={`Shipment request ${request.reference}`}
subtitle={`On contract ${contractRef}`}
backTo="/dashboard/shipment-requests"
breadcrumbs={[
{ label: "Shipment Requests", href: "/dashboard/shipment-requests" },
{ label: request.reference },
]}
meta={
<Badge
variant="light"
radius="sm"
color={
request.status === "PENDING"
? "edr-green"
: request.status === "ACCEPTED"
? "blue"
: "gray"
}
>
{request.status}
</Badge>
}
action={
isPending ? (
<Group gap="sm">
<Button
variant="light"
color="red"
radius="md"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${request.contractId}/create-booking?requestId=${request.id}`,
)
}
>
Accept &amp; create booking
</Button>
</Group>
) : request.status === "ACCEPTED" && request.createdBookingId ? (
<Button
variant="light"
color="edr-green"
radius="md"
onClick={() =>
navigate(`/dashboard/clearance/${request.createdBookingId}`)
}
>
View booking clearance
</Button>
) : undefined
}
/>
<SectionCard icon={CalendarDays} title="Requested shipment">
<Stack gap="sm">
<Group justify="space-between">
<Text size="sm" c="dimmed">
Preferred date (informational)
</Text>
<Text size="sm" fw={600}>
{fmtDate(request.scheduledDate)}
</Text>
</Group>
<Box>
<Text size="sm" c="dimmed" mb={6}>
Quantities
</Text>
<Stack gap={4}>
{lineRows(request.requestedLines ?? {}).map((l, i) => (
<Badge
key={i}
variant="light"
color="edr-green"
radius="sm"
size="lg"
>
{l}
</Badge>
))}
</Stack>
</Box>
{request.notes ? (
<Box>
<Text size="sm" c="dimmed" mb={4}>
Customer note
</Text>
<Text size="sm">{request.notes}</Text>
</Box>
) : null}
{request.reviewNote ? (
<Alert color="red" variant="light" radius="md" mt="sm">
Rejected: {request.reviewNote}
</Alert>
) : null}
</Stack>
</SectionCard>
</Stack>
<Modal
opened={rejectOpen}
onClose={() => setRejectOpen(false)}
centered
radius="md"
title="Reject shipment request"
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Tell the customer why this request can't proceed"
autosize
minRows={3}
value={rejectNote}
onChange={(e) => setRejectNote(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectOpen(false)}>
Cancel
</Button>
<Button
color="red"
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Reject request
</Button>
</Group>
</Stack>
</Modal>
</PageContainer>
);
}

View File

@@ -0,0 +1,208 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
ActionIcon,
Badge,
Box,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { contractsService } from "@/services/contracts.service";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtDate = (iso?: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso))
: "—";
/** Summarize requested quantities for the list row. */
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
if (lines.containers?.length) {
return lines.containers
.map((c) => `${c.quantity}× ${c.containerSize}`)
.join(", ");
}
if (lines.bulk) {
const b = lines.bulk;
if (b.cargoWeightTons) return `${b.cargoWeightTons} t bulk`;
if (b.itemCount) return `${b.itemCount} items`;
return "Bulk";
}
return "—";
}
interface RequestRow {
id: string;
reference: string;
contractReference: string;
scheduledDate?: string | null;
summary: string;
}
export default function ShipmentRequestsPage() {
const navigate = useNavigate();
const [query, setQuery] = useState("");
const { data, isLoading, isError, isFetching, refetch } = useQuery({
queryKey: ["shipment-request-queue"],
queryFn: () => contractsService.getBookingRequestQueue(),
refetchInterval: 30_000,
});
const rows = useMemo<RequestRow[]>(() => {
const all = (data ?? []).map((r) => ({
id: r.id,
reference: r.reference || r.id.slice(0, 8),
contractReference: r.contract?.reference ?? r.contractId,
scheduledDate: r.scheduledDate,
summary: summarizeLines(r.requestedLines ?? {}),
}));
const q = query.trim().toLowerCase();
if (!q) return all;
return all.filter(
(r) =>
r.reference.toLowerCase().includes(q) ||
r.contractReference.toLowerCase().includes(q) ||
r.summary.toLowerCase().includes(q),
);
}, [data, query]);
const columns = useMemo<ColumnDef<RequestRow>[]>(
() => [
{
id: "reference",
header: "Request",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" fw={700} c="dark.5">
{row.original.reference}
</Text>
),
},
{
id: "contract",
header: "Contract",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm" c="gray.7">
{row.original.contractReference}
</Text>
),
},
{
id: "summary",
header: "Requested",
meta: cellMeta,
cell: ({ row }) => (
<Badge variant="light" color="edr-green" radius="sm">
{row.original.summary}
</Badge>
),
},
{
id: "date",
header: "Preferred date",
meta: cellMeta,
cell: ({ row }) => (
<Text size="sm">{fmtDate(row.original.scheduledDate)}</Text>
),
},
{
id: "go",
size: 56,
cell: () => (
<Group justify="flex-end" pr="xs">
<ChevronRight size={16} className="text-muted-foreground" />
</Group>
),
},
],
[],
);
return (
<PageContainer>
<Stack gap="lg">
<PageHeader
title="Shipment Requests"
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
meta={
<Badge
variant="light"
color="edr-green"
radius="sm"
leftSection={<PackageSearch size={13} />}
>
{rows.length} pending
</Badge>
}
action={
<ActionIcon
variant="default"
size="lg"
radius="md"
onClick={() => refetch()}
loading={isFetching}
aria-label="Refresh"
>
<RefreshCw size={16} />
</ActionIcon>
}
/>
<TextInput
radius="md"
maw={360}
placeholder="Search request, contract, cargo…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
{rows.length === 0 && !isLoading ? (
<Box
py={56}
style={{
borderRadius: 14,
border: "1px dashed var(--mantine-color-gray-3)",
textAlign: "center",
}}
>
<Inbox size={26} className="text-muted-foreground" />
<Text c="dimmed" mt="sm">
No pending shipment requests.
</Text>
</Box>
) : (
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : isError ? "error" : "success"}
onRowClick={(row) =>
navigate(`/dashboard/shipment-requests/${row.id}`)
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
)}
</Stack>
</PageContainer>
);
}

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";
@@ -42,6 +42,7 @@ import {
humanize,
} from "@/components/customers";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { fileViewUrl } from "@/constants/apiConfig";
import { api } from "@/services/api";
import type {
CompanyProfile,
@@ -84,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 ?? "" },
@@ -289,7 +287,7 @@ export default function CustomerDetailPage() {
cell: ({ row }) => (
<ActionIcon
component="a"
href={row.original.url ?? "#"}
href={fileViewUrl(row.original.id, true)}
variant="subtle"
color="gray"
aria-label="Download"
@@ -394,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>
}
/>
@@ -546,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
}
/>
@@ -567,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
}
/>
@@ -588,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

@@ -2,6 +2,7 @@ import { useState } from "react";
import {
AlertCircle,
Banknote,
FileSignature,
FileText,
Train,
UserCheck,
@@ -31,7 +32,13 @@ const TAB_ITEMS: Array<{
value: OverviewTabKey;
label: string;
icon: typeof FileText;
kpiKey: "bookings" | "billing" | "operations" | "customers" | "staff";
kpiKey:
| "bookings"
| "contracts"
| "billing"
| "operations"
| "customers"
| "staff";
metricKey: string;
}> = [
{
@@ -41,6 +48,13 @@ const TAB_ITEMS: Array<{
kpiKey: "bookings",
metricKey: "totalActive",
},
{
value: "contracts",
label: "Contracts",
icon: FileSignature,
kpiKey: "contracts",
metricKey: "totalActive",
},
{
value: "billing",
label: "Billing",

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

@@ -246,7 +246,6 @@ const FleetResourcePage = () => {
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
return formatFleetCell(value, col.format, col.accessorKey);
},
}));

View File

@@ -147,8 +147,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
],
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "name", label: "Name", type: "text" },
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
@@ -160,12 +160,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
],
emptyValues: {
code: "",
name: "",
locomotiveType: "DIESEL",
status: "AVAILABLE",
currentYardId: "",
maxPullWeightTons: 0,
maxPullWeightTons: 2500,
maxTrainLengthMeters: 760,
powerKw: "",
tractionForceKn: "",

View File

@@ -1,5 +1,4 @@
import type { FleetResourceConfig } from "./resources";
import { API_BASE_URL } from "@/constants/apiConfig";
const VEHICLE_TYPE_OPTIONS = [
{ label: "Truck", value: "TRUCK" },
@@ -60,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: [
@@ -73,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" },
],
@@ -87,10 +90,11 @@ export const vehiclesConfig: FleetResourceConfig = {
year: new Date().getFullYear(),
fuelType: "DIESEL",
capacity: 0,
estimatedDistanceKm: "",
actualDistanceKm: "",
status: "ACTIVE",
description: "",
},
};
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
export { API_BASE_URL };

View File

@@ -6,6 +6,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -22,6 +23,7 @@ import {
Group,
Menu,
Modal,
NumberInput,
Select,
SimpleGrid,
Stack,
@@ -41,6 +43,7 @@ import {
} from "@/services/first-mile.service";
import { bookingsService } from "@/services/bookings.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
import type { BookingDetail } from "@/types/booking";
const formatPrice = (amount: number) =>
@@ -67,7 +70,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 +92,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 +143,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 +162,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 +330,11 @@ const FirstMilePage = () => {
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [bookingSearch, setBookingSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.FIRST_MILE.list(),
queryFn: async () => {
@@ -339,6 +351,14 @@ const FirstMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "FIRST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("FIRST_MILE");
return res.data;
},
});
const { data: paidBookingsData, isLoading: bookingsLoading } = useQuery({
queryKey: QUERY_KEYS.BOOKINGS.list({ status: "PAID" }),
queryFn: () => bookingsService.list({ status: "PAID", pageSize: 100 }),
@@ -347,6 +367,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(
() =>
@@ -364,7 +388,22 @@ const FirstMilePage = () => {
mutationFn: ({ id, data }: { id: string; data: { status?: FirstMileApiStatus; vehicleId?: string | null } }) =>
firstMileService.update(id, data),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.ROOT });
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
firstMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
@@ -375,6 +414,9 @@ const FirstMilePage = () => {
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 +425,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 +443,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 +485,49 @@ 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 openInvoice = (record: FirstMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
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;
}
let remainingPayment: number | undefined;
if (ratesData?.data) {
const firstMileRate = ratesData.data.find(
(r) => r.rateType === "FIRST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (firstMileRate) {
const rateValue = parseFloat(firstMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const matchesFilter = (r: FirstMileRecord) => {
switch (statusFilter) {
case "ALL": return true;
@@ -608,10 +706,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 +723,39 @@ 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: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -684,6 +821,12 @@ const FirstMilePage = () => {
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -879,7 +1022,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 +1131,134 @@ 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>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={pickupLocation(invoiceRecord)} />
<InfoRow label="Destination" value={destinationYardName(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -5,6 +5,7 @@ import {
MoreHorizontal,
Printer,
RefreshCw,
Ruler,
Truck,
} from "lucide-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -21,12 +22,14 @@ import {
Group,
Menu,
Modal,
NumberInput,
ScrollArea,
Select,
SimpleGrid,
Stack,
Text,
TextInput,
UnstyledButton,
} from "@mantine/core";
import type { ArrivalQueueItem } from "@/types/warehouse";
import { warehouseService } from "@/services/warehouse.service";
@@ -41,6 +44,7 @@ import {
lastMileService,
} from "@/services/last-mile.service";
import { vehiclesService } from "@/services/vehicles.service";
import { ratesService } from "@/services/rates.service";
const formatPrice = (amount: number) =>
`ETB ${amount.toLocaleString("en-US", {
@@ -66,7 +70,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 +91,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 +106,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 +132,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 +151,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],
];
@@ -307,6 +315,11 @@ const LastMilePage = () => {
const [acceptVehicleValue, setAcceptVehicleValue] = useState<string | null>(null);
const [arrivalSearch, setArrivalSearch] = useState("");
const [distanceOpen, setDistanceOpen] = useState(false);
const [distanceValue, setDistanceValue] = useState("");
const [invoiceOpen, setInvoiceOpen] = useState(false);
const [invoiceRecord, setInvoiceRecord] = useState<LastMileRecord | null>(null);
const { data: listData, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE.list(),
queryFn: async () => {
@@ -323,6 +336,14 @@ const LastMilePage = () => {
},
});
const { data: ratesData } = useQuery({
queryKey: ["rates", "LAST_MILE"],
queryFn: async () => {
const res = await ratesService.getByType("LAST_MILE");
return res.data;
},
});
const records = listData?.data ?? [];
const vehicleOptions = useMemo(
@@ -348,6 +369,21 @@ const LastMilePage = () => {
},
});
const updateDistanceMutation = useMutation({
mutationFn: ({ id, exactKm, remainingPayment }: { id: string; exactKm: number; remainingPayment?: number }) =>
lastMileService.update(id, { exactKm, ...(remainingPayment != null && { remainingPayment }) }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.list() });
if (activeRecord) {
toast({ title: "Distance updated", description: `${bookingRef(activeRecord)}${distanceValue} km` });
}
closeDistance();
},
onError: () => {
toast({ title: "Update failed", variant: "destructive" });
},
});
const { data: arrivalQueueData, isLoading: arrivalLoading } = useQuery({
queryKey: ["warehouse-inventory", "arrival-queue"],
queryFn: () => warehouseService.arrivalQueue().then((r) => r.data),
@@ -418,6 +454,49 @@ const LastMilePage = () => {
acceptMutation.mutate({ items: selectedArrivalItems, vehicleId: acceptVehicleValue });
};
const openDistance = (id: string) => {
setActiveId(id);
setDistanceValue("");
setDistanceOpen(true);
};
const closeDistance = () => {
setDistanceOpen(false);
setActiveId(null);
setDistanceValue("");
};
const openInvoice = (record: LastMileRecord) => {
setInvoiceRecord(record);
setInvoiceOpen(true);
};
const closeInvoice = () => {
setInvoiceOpen(false);
setInvoiceRecord(null);
};
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;
}
let remainingPayment: number | undefined;
if (ratesData?.data) {
const lastMileRate = ratesData.data.find(
(r) => r.rateType === "LAST_MILE" && (r.status === "LIVE" || r.status === "DRAFT")
);
if (lastMileRate) {
const rateValue = parseFloat(lastMileRate.rateValue);
remainingPayment = distance * rateValue;
}
}
updateDistanceMutation.mutate({ id: activeId, exactKm: distance, remainingPayment });
};
const activeRecord = useMemo(
() => records.find((r) => r.id === activeId) ?? null,
[records, activeId],
@@ -587,6 +666,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 +685,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 +702,39 @@ 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: "invoice",
header: "Invoice",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const hasDistance = row.original.exactKm != null && row.original.exactKm > 0;
if (!hasDistance) {
return <Text c="dimmed"></Text>;
}
return (
<UnstyledButton
onClick={() => openInvoice(row.original)}
c="blue"
fw={500}
style={{ textDecoration: "underline", cursor: "pointer" }}
>
#345
</UnstyledButton>
);
},
},
{
id: "status",
header: "Status",
@@ -676,6 +800,12 @@ const LastMilePage = () => {
>
View detail
</Menu.Item>
<Menu.Item
leftSection={<Ruler size={15} />}
onClick={() => openDistance(row.original.id)}
>
Add distance
</Menu.Item>
{canPrint && (
<Menu.Item
leftSection={<Printer size={15} />}
@@ -972,6 +1102,134 @@ const LastMilePage = () => {
</Group>
</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>
{/* Invoice modal */}
<Modal
opened={invoiceOpen}
onClose={closeInvoice}
title={<Text fw={600}>Invoice #345</Text>}
size="lg"
radius="lg"
centered
>
<Stack gap="md">
{invoiceRecord && (
<>
<Card withBorder padding="md" radius="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Group justify="space-between">
<Text fw={700}>EDR Freight</Text>
<Text fw={600} size="sm">Invoice #345</Text>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="sm">
<InfoRow label="Booking" value={bookingRef(invoiceRecord)} />
<InfoRow label="Customer" value={customerName(invoiceRecord)} />
<InfoRow label="Pickup" value={originYardName(invoiceRecord)} />
<InfoRow label="Destination" value={deliveryLocation(invoiceRecord)} />
<InfoRow label="Est. Distance" value={invoiceRecord.estimatedKm ? `${invoiceRecord.estimatedKm} km` : "—"} />
<InfoRow label="Actual Distance" value={invoiceRecord.exactKm ? `${invoiceRecord.exactKm} km` : "—"} />
<InfoRow label="Advanced Payment" value={formatPrice(invoiceRecord.advancedPayment)} />
<InfoRow label="Post Payment" value={formatPrice(invoiceRecord.remainingPayment)} />
</SimpleGrid>
<Divider />
<Group justify="flex-end">
<Stack gap={0} align="flex-end" style={{ minWidth: 200 }}>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Post Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.remainingPayment)}</Text>
</Group>
<Group justify="space-between" w="100%">
<Text size="sm" c="dimmed">Advanced Payment</Text>
<Text size="sm">{formatPrice(invoiceRecord.advancedPayment)}</Text>
</Group>
<Divider my="xs" />
{(() => {
const postPayment = parseFloat(String(invoiceRecord.remainingPayment));
const advancedPayment = parseFloat(String(invoiceRecord.advancedPayment));
const difference = postPayment - advancedPayment;
if (difference > 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Remaining to Pay</Text>
<Text fw={700} c="orange">{formatPrice(difference)}</Text>
</Group>
);
} else if (difference < 0) {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Refund</Text>
<Text fw={700} c="green">{formatPrice(Math.abs(difference))}</Text>
</Group>
);
} else {
return (
<Group justify="space-between" w="100%">
<Text size="sm" fw={600}>Status</Text>
<Text fw={700} c="blue">Settled</Text>
</Group>
);
}
})()}
</Stack>
</Group>
</Stack>
</Card>
</>
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={closeInvoice}>Close</Button>
</Group>
</Stack>
</Modal>
</Stack>
);
};

View File

@@ -183,7 +183,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
slug: "container-types",
label: "Container Types",
category: "configuration",
subtitle: "Configure container sizes and wagon capacity",
subtitle: "Configure container sizes",
searchPlaceholder: "Search container types...",
orderConfig: { field: "displayOrder", label: "Display order" },
columns: [
@@ -191,14 +191,11 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "displayOrder", header: "#", accessorKey: "displayOrder", format: "number" },
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
{ name: "isReefer", label: "Reefer", type: "boolean" },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],

View File

@@ -55,6 +55,7 @@ import {
WindowStatusPill,
} from "@/components/trainScheduling/batchVisuals";
import { RouteCorridor } from "@/components/trainScheduling/scheduleVisuals";
import { BookingsManager } from "./BookingsManager";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -482,18 +483,32 @@ export default function BatchScheduleDetailPage() {
}),
);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
if (!data) return { awaitingPayment: [], expired: [] };
const all = [
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
}, [data]);
// Batch bookings by state for the composition side panel (payment / expired lists).
const batchBookings = useMemo(() => {
const all = allBookings;
return {
awaitingPayment: all.filter((b) => b.state === "SELECTED_FOR_BATCH"),
expired: all.filter((b) => b.state === "EXPIRED"),
};
}, [data]);
}, [allBookings]);
const bookingsReadOnly = useMemo(
() => ["DISPATCHED", "ARRIVED"].includes(data?.status ?? ""),
[data?.status],
);
// Group the flat window list into per-day sections (one per EAT calendar date).
const dayGroups = useMemo(() => {
@@ -822,6 +837,40 @@ export default function BatchScheduleDetailPage() {
</Alert>
) : null}
{/* Manage bookings — search, filter, remove / re-assign (bulk too) */}
<Paper
radius="lg"
withBorder
p="lg"
mt="lg"
style={{ borderColor: "var(--mantine-color-gray-2)" }}
>
<Group gap="sm" mb="md" wrap="nowrap" align="flex-start">
<ThemeIcon
size={38}
radius="md"
variant="light"
color="#F2A516"
>
<Package size={19} />
</ThemeIcon>
<Box>
<Title order={4}>Manage bookings</Title>
<Text size="sm" c="dimmed">
Search and filter every booking on this train. Remove an
allocated booking to free its wagons, or re-assign one that
is not yet allocated individually or in bulk.
</Text>
</Box>
</Group>
<BookingsManager
scheduleId={scheduleId ?? ""}
bookings={allBookings}
onChanged={() => void refetch()}
readOnly={bookingsReadOnly}
/>
</Paper>
{/* Batch windows */}
<Paper
radius="lg"

View File

@@ -0,0 +1,625 @@
import { useMemo, useState } from "react";
import {
ActionIcon,
Badge,
Box,
Button,
Checkbox,
Group,
Menu,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import {
AlertTriangle,
MoreVertical,
PackagePlus,
Search,
Trash2,
X,
} from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BookingAllocationStatus,
} from "@/types/trainScheduling";
const cellMeta = {
headerClassName: ruleEngineTable.headerCell,
cellClassName: ruleEngineTable.bodyCell,
};
const fmtTons = (n: number) =>
`${n.toLocaleString(undefined, { maximumFractionDigits: 1 })} t`;
const fmtDateTime = (iso: string | null) =>
iso
? new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "Africa/Addis_Ababa",
}).format(new Date(iso))
: "—";
const initials = (name: string) =>
name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase() || "?";
const STATE_META: Record<
BatchBoardBookingState,
{ label: string; color: string }
> = {
ALLOCATED: { label: "Allocated", color: "edr-green" },
SELECTED_FOR_BATCH: { label: "Selected for batch", color: "orange" },
READY: { label: "Ready for batch", color: "teal" },
WAITING: { label: "Paid · waiting", color: "blue" },
PENDING_CONTRACT: { label: "Pending contract", color: "gray" },
EXPIRED: { label: "Expired", color: "red" },
};
const ALLOC_META: Record<
BookingAllocationStatus,
{ label: string; color: string }
> = {
ASSIGNED: { label: "Wagons assigned", color: "edr-green" },
NOT_ATTEMPTED: { label: "Not allocated", color: "gray" },
DEFERRED: { label: "Deferred", color: "orange" },
FAILED: { label: "Allocation failed", color: "red" },
};
const STATE_FILTERS = [
{ value: "ALL", label: "All states" },
...Object.entries(STATE_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
const ALLOC_FILTERS = [
{ value: "ALL", label: "All allocations" },
...Object.entries(ALLOC_META).map(([value, m]) => ({
value,
label: m.label,
})),
];
export interface BookingsManagerProps {
scheduleId: string;
bookings: BatchBoardBookingDetail[];
/** Re-pull the batch-board detail after a remove / re-assign mutation. */
onChanged: () => void;
/** Read-only when the schedule can no longer be edited (dispatched / arrived). */
readOnly?: boolean;
}
/**
* Searchable, filterable, bulk-manageable booking table for the batch board.
* Staff can search by reference / customer, filter by batch state and wagon
* allocation status, and remove or re-assign bookings individually or in bulk.
* Wraps the shared DataTable; selection + actions are handled locally so the
* surrounding accordion / tab layout stays untouched.
*/
export function BookingsManager({
scheduleId,
bookings,
onChanged,
readOnly = false,
}: BookingsManagerProps) {
const { toast } = useToast();
const [query, setQuery] = useState("");
const [stateFilter, setStateFilter] = useState<string>("ALL");
const [allocFilter, setAllocFilter] = useState<string>("ALL");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [confirm, setConfirm] = useState<
| { kind: "remove"; ids: string[]; label: string }
| { kind: "reassign"; ids: string[]; label: string }
| null
>(null);
const unassign = useMutation(
api.trainScheduling.unassignBooking.mutationOptions(),
);
const reassign = useMutation(
api.trainScheduling.assignUnassignedBooking.mutationOptions(),
);
const busy = unassign.isPending || reassign.isPending;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return bookings.filter((b) => {
if (stateFilter !== "ALL" && b.state !== stateFilter) return false;
if (allocFilter !== "ALL" && b.allocationStatus !== allocFilter)
return false;
if (!q) return true;
return (
b.reference.toLowerCase().includes(q) ||
b.company.toLowerCase().includes(q)
);
});
}, [bookings, query, stateFilter, allocFilter]);
// Selection is bounded to whatever is currently visible (filtered) to avoid
// acting on rows the user can't see.
const visibleIds = useMemo(() => filtered.map((b) => b.id), [filtered]);
const selectedVisible = useMemo(
() => visibleIds.filter((id) => selected.has(id)),
[visibleIds, selected],
);
const allVisibleSelected =
visibleIds.length > 0 && selectedVisible.length === visibleIds.length;
const someVisibleSelected =
selectedVisible.length > 0 && !allVisibleSelected;
const toggleAll = () =>
setSelected((prev) => {
const next = new Set(prev);
if (allVisibleSelected) {
visibleIds.forEach((id) => next.delete(id));
} else {
visibleIds.forEach((id) => next.add(id));
}
return next;
});
const toggleOne = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const clearSelection = () => setSelected(new Set());
const runRemove = async (ids: string[]) => {
let ok = 0;
let failed = 0;
// Sequential — each unassign mutates the schedule graph; parallel would race.
for (const id of ids) {
try {
await unassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Bookings removed",
description: `${ok} removed${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const runReassign = async (ids: string[]) => {
let ok = 0;
let failed = 0;
for (const id of ids) {
try {
await reassign.mutateAsync({ id: scheduleId, bookingId: id });
ok += 1;
} catch {
failed += 1;
}
}
toast({
title: "Re-assignment run",
description: `${ok} re-assigned${failed ? ` · ${failed} failed` : ""}`,
variant: failed ? "destructive" : "default",
});
clearSelection();
onChanged();
};
const confirmAction = async () => {
if (!confirm) return;
const ids = confirm.ids;
setConfirm(null);
if (confirm.kind === "remove") await runRemove(ids);
else await runReassign(ids);
};
const columns = useMemo<ColumnDef<BatchBoardBookingDetail>[]>(() => {
const cols: ColumnDef<BatchBoardBookingDetail>[] = [];
if (!readOnly) {
cols.push({
id: "select",
meta: cellMeta,
header: () => (
<Checkbox
size="xs"
aria-label="Select all"
checked={allVisibleSelected}
indeterminate={someVisibleSelected}
onChange={toggleAll}
/>
),
cell: ({ row }) => (
<Checkbox
size="xs"
aria-label={`Select ${row.original.reference}`}
checked={selected.has(row.original.id)}
onChange={() => toggleOne(row.original.id)}
/>
),
});
}
cols.push(
{
id: "reference",
header: "Reference",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={700} c="dark.5">
{b.reference}
</Text>
{b.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
Gov
</Badge>
) : null}
</Group>
);
},
},
{
id: "customer",
header: "Customer",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={8} wrap="nowrap">
<Box
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 26,
height: 26,
borderRadius: 8,
flexShrink: 0,
background: "#FEF1D5",
border: "1px solid #FBD171",
}}
>
<Text size="10px" fw={800} style={{ color: "#B26C09" }}>
{initials(b.company)}
</Text>
</Box>
<Text size="sm" c="gray.7" truncate>
{b.company}
</Text>
</Group>
);
},
},
{
id: "selectedForBatch",
header: "Selected for batch",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
if (!b.selectedForBatchAt)
return (
<Text size="sm" c="dimmed">
</Text>
);
return (
<>
<Text size="sm" style={{ whiteSpace: "nowrap" }}>
{fmtDateTime(b.selectedForBatchAt)} EAT
</Text>
{b.paymentDeadline ? (
<Text size="xs" c="orange.7" fw={600}>
Pay by {fmtDateTime(b.paymentDeadline)} EAT
</Text>
) : null}
</>
);
},
},
{
id: "capacity",
header: "Capacity",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
return (
<Group gap={4} wrap="nowrap">
<Badge variant="default" radius="sm" size="sm">
{b.wagons}w
</Badge>
<Badge variant="default" radius="sm" size="sm">
{fmtTons(b.weightTons)}
</Badge>
</Group>
);
},
},
{
id: "state",
header: "Batch state",
meta: cellMeta,
cell: ({ row }) => {
const m = STATE_META[row.original.state];
return (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
},
},
{
id: "allocation",
header: "Wagon allocation",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const m = ALLOC_META[b.allocationStatus];
const badge = (
<Badge variant="light" color={m.color} radius="sm">
{m.label}
</Badge>
);
if (!b.allocationIssue) return badge;
return (
<Tooltip label={b.allocationIssue} multiline maw={320} withArrow>
<Group gap={4} wrap="nowrap">
{badge}
<AlertTriangle size={14} color="var(--mantine-color-red-6)" />
</Group>
</Tooltip>
);
},
},
);
if (!readOnly) {
cols.push({
id: "actions",
header: "",
meta: cellMeta,
cell: ({ row }) => {
const b = row.original;
const isAssigned = b.allocationStatus === "ASSIGNED";
return (
<Group justify="flex-end" gap={4} wrap="nowrap">
<Menu position="bottom-end" withinPortal shadow="md">
<Menu.Target>
<ActionIcon variant="subtle" color="gray" aria-label="Actions">
<MoreVertical size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<PackagePlus size={14} />}
disabled={isAssigned || busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: [b.id],
label: b.reference,
})
}
>
Re-assign to wagons
</Menu.Item>
<Menu.Item
color="red"
leftSection={<Trash2 size={14} />}
disabled={!isAssigned || busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: [b.id],
label: b.reference,
})
}
>
Remove from train
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
);
},
});
}
return cols;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
readOnly,
selected,
allVisibleSelected,
someVisibleSelected,
visibleIds,
busy,
]);
return (
<Stack gap="sm">
{/* Toolbar: search + filters */}
<Group gap="sm" wrap="wrap">
<TextInput
flex={1}
miw={220}
radius="md"
placeholder="Search reference or customer…"
leftSection={<Search size={15} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={14} />
</ActionIcon>
) : null
}
/>
<Select
radius="md"
w={170}
data={STATE_FILTERS}
value={stateFilter}
onChange={(v) => setStateFilter(v ?? "ALL")}
aria-label="Filter by batch state"
/>
<Select
radius="md"
w={180}
data={ALLOC_FILTERS}
value={allocFilter}
onChange={(v) => setAllocFilter(v ?? "ALL")}
aria-label="Filter by allocation"
/>
<Text size="xs" c="dimmed">
{filtered.length} of {bookings.length}
</Text>
</Group>
{/* Bulk action bar */}
{!readOnly && selectedVisible.length > 0 ? (
<Paper
withBorder
radius="md"
px="md"
py="xs"
style={{
background: "var(--mantine-color-edr-green-0)",
borderColor: "var(--mantine-color-edr-green-2)",
}}
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Group gap="xs" wrap="nowrap">
<Badge color="edr-green" radius="sm">
{selectedVisible.length} selected
</Badge>
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={clearSelection}
>
Clear
</Button>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PackagePlus size={14} />}
loading={reassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "reassign",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Re-assign
</Button>
<Button
size="compact-sm"
variant="light"
color="red"
leftSection={<Trash2 size={14} />}
loading={unassign.isPending}
disabled={busy}
onClick={() =>
setConfirm({
kind: "remove",
ids: selectedVisible,
label: `${selectedVisible.length} booking(s)`,
})
}
>
Remove
</Button>
</Group>
</Group>
</Paper>
) : null}
<DataTable
columns={columns}
data={filtered}
status="success"
emptyMessage={
bookings.length
? "No bookings match the current search / filters."
: "No bookings in this batch window."
}
containerClassName="overflow-x-auto rounded-lg border border-edr-border"
/>
<Modal
opened={Boolean(confirm)}
onClose={() => setConfirm(null)}
centered
radius="md"
title={
confirm?.kind === "remove"
? "Remove from train"
: "Re-assign to wagons"
}
>
<Text size="sm" mb="lg">
{confirm?.kind === "remove"
? `Remove ${confirm?.label} from this train? Their wagon allocation will be released.`
: `Re-assign ${confirm?.label} to available wagons on this train?`}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setConfirm(null)}>
Cancel
</Button>
<Button
color={confirm?.kind === "remove" ? "red" : "edr-green"}
loading={busy}
onClick={confirmAction}
>
{confirm?.kind === "remove" ? "Remove" : "Re-assign"}
</Button>
</Group>
</Modal>
</Stack>
);
}
export default BookingsManager;

View File

@@ -19,6 +19,7 @@ import {
CheckCircle2,
Container as ContainerIcon,
Eye,
FileText,
LayoutGrid,
Navigation,
Package,
@@ -52,12 +53,13 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/schedulingContainerStep.util";
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,
@@ -124,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),
@@ -137,26 +145,10 @@ export default function TrainScheduleV2DetailPage() {
const containerUnits = previewResult?.containerUnits ?? [];
const containerSlots = previewResult?.containerSlotSequenceNos ?? [];
const hasContainerStep = useMemo(
() =>
shouldShowContainerPlacementStep({
containerUnitCount: containerUnits.length,
scheduleFreightType: freightType,
bookingFreightTypes: [
...(schedule?.bookings ?? []).map((b) => b.freightType),
...(eligibleQuery.data?.items ?? [])
.filter((item) => allSelectedIds.includes(item.id))
.map((item) => item.freightType),
],
}),
[
allSelectedIds,
containerUnits.length,
eligibleQuery.data?.items,
freightType,
schedule?.bookings,
],
);
// Container-number placement step removed — the customer enters container
// numbers when booking, so scheduling skips straight from the wagon plan to
// finalize. Steps: select bookings → review wagons → finalize.
const hasContainerStep = false;
const displayWagonPlan = useMemo(() => {
const savedWagons = schedule?.trainSet?.wagons ?? [];
@@ -304,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;
@@ -766,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

View File

@@ -284,6 +284,32 @@ export const api = {
],
),
availableDaysForCargo: endpoint<
{
originYardId?: string;
destinationYardId?: string;
freightType: "CONTAINER" | "BULK";
cargoTypeCode?: string;
totalWeightTons?: number;
containers?: { containerSize: string; quantity: number }[];
},
string[]
>(
"train-scheduling",
"available-days-for-cargo",
(input) => trainSchedulingService.getAvailableDaysForCargo(input),
(input) => [
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
"available-days-for-cargo",
input.originYardId ?? "",
input.destinationYardId ?? "",
input.freightType,
input.cargoTypeCode ?? "",
input.totalWeightTons ?? 0,
JSON.stringify(input.containers ?? []),
],
),
trainTrack: endpoint<{ id: string }, TrainTrackResponse>(
"train-scheduling",
"track",
@@ -644,6 +670,13 @@ export const api = {
() => ["warehouse-inventory", "ready-to-load-export"],
),
receivedExport: endpoint<void, ReadyToLoadRow[]>(
"warehouse-inventory",
"received-export",
() => warehouseService.receivedExport().then((r) => r.data),
() => ["warehouse-inventory", "received-export"],
),
loadedExport: endpoint<void, ReadyToLoadRow[]>(
"warehouse-inventory",
"loaded-export",
@@ -1838,13 +1871,12 @@ export const api = {
reviewOperation: endpoint<
{
id: string;
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE";
decision: "ACCEPT" | "REQUEST_CHANGES";
note?: string;
amount?: number;
},
BookingDetail
>("bookings", "reviewOperation", ({ id, decision, note, amount }) =>
bookingsService.reviewOperation(id, decision, { note, amount }),
>("bookings", "reviewOperation", ({ id, decision, note }) =>
bookingsService.reviewOperation(id, decision, { note }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(

View File

@@ -212,21 +212,14 @@ export const bookingsService = {
/** Marketing/operations review of a drawdown order's operation request. */
reviewOperation: (
id: string,
decision: "ACCEPT" | "REQUEST_CHANGES" | "ADJUST_PRICE",
options: { note?: string; amount?: number } = {},
decision: "ACCEPT" | "REQUEST_CHANGES",
options: { note?: string } = {},
) =>
postBooking<BookingDetail>(`/bookings/${id}/operation/review`, {
decision,
...options,
}),
/** Adjust a booking's total price (pass null amount to clear the adjustment). */
adjustPrice: (id: string, amount: number | null, reason?: string) =>
postBooking<BookingDetail>(`/bookings/${id}/adjust-price`, {
amount,
reason,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),

View File

@@ -0,0 +1,369 @@
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
import type { Freight } from "@edr/types";
const C = URL_CONSTANTS.CONTRACTS;
export interface ContractListFilter {
status?: string;
/** Comma-separated statuses for grouped tabs. */
statuses?: string;
/** Tab key for React Query cache (not sent to API). */
tab?: string;
companyId?: string;
freightType?: string;
tradeDirection?: string;
contractKind?: string;
paymentCurrency?: string;
page?: number;
pageSize?: number;
sortBy?: string;
sortOrder?: "ASC" | "DESC";
}
export interface PaginatedContracts {
items: Freight.IContract[];
total: number;
}
export interface ContractListSummaryMetrics {
inQueue: number;
needsAction: number;
urgent: number;
completed: number;
}
export interface ContractListSummaryTabs {
all: number;
intake: number;
in_approval: number;
approved_contract: number;
clearance: number;
active: number;
closed: number;
}
export interface ContractListSummary {
metrics: ContractListSummaryMetrics;
tabs: ContractListSummaryTabs;
}
export interface ContractView {
contractId: string;
reference: string;
status: string;
templateKey: string;
title: string;
html: string;
canSignCustomer: boolean;
canSignStaff: boolean;
hasContractDocument: boolean;
signatures: Array<{
role: string;
signerDisplayName: string;
signedAt: string;
signatureImageUrl?: string | null;
}>;
savedSignature?: {
signerDisplayName: string;
signatureImageUrl?: string | null;
} | null;
}
export interface SignContractPayload {
role: "CUSTOMER" | "STAFF";
signatureImageBase64: string;
signerDisplayName: string;
consentText?: string;
}
async function postContract<T>(url: string, body?: unknown): Promise<T> {
const response = await client.post<T>(url, body ?? {});
return unwrap(response.data);
}
function buildListParams(filter?: ContractListFilter) {
const params: Record<string, string | number | boolean | undefined> = {};
if (filter) {
if (filter.statuses) params.statuses = filter.statuses;
else if (filter.status) params.status = filter.status;
if (filter.page != null) params.page = filter.page;
if (filter.pageSize != null) params.pageSize = filter.pageSize;
if (filter.sortBy) params.sortBy = filter.sortBy;
if (filter.sortOrder) params.sortOrder = filter.sortOrder;
if (filter.companyId) params.companyId = filter.companyId;
if (filter.freightType) params.freightType = filter.freightType;
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.contractKind) params.contractKind = filter.contractKind;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
}
return params;
}
export const contractsService = {
getListSummary: async (
filter?: ContractListFilter,
): Promise<ContractListSummary> => {
const response = await client.get<ContractListSummary>(C.LIST_SUMMARY, {
params: buildListParams(filter),
});
return unwrap(response.data) as ContractListSummary;
},
list: async (filter?: ContractListFilter): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.BASE, {
params: buildListParams(filter),
});
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
},
getById: async (id: string): Promise<Freight.IContract> => {
const response = await client.get<Freight.IContract>(C.BY_ID(id));
return unwrap(response.data) as Freight.IContract;
},
// ── Staff review ──
staffAccept: (id: string, validityDays: number) =>
postContract<Freight.IContract>(C.STAFF_ACCEPT(id), { validityDays }),
requestChanges: (id: string, note: string) =>
postContract<Freight.IContract>(C.STAFF_REQUEST_CHANGES(id), { note }),
reject: (id: string, reason: string) =>
postContract<Freight.IContract>(C.STAFF_REJECT(id), { reason }),
approveStep: ({
id,
stepId,
requiredRole,
}: {
id: string;
stepId: string;
requiredRole: string;
}) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId), {
requiredRole,
}),
// ── Contract document ──
generateContract: (id: string) =>
postContract<Freight.IContract>(C.CONTRACT_GENERATE(id)),
getContractView: async (id: string): Promise<ContractView> => {
const response = await client.get<ContractView>(C.CONTRACT_VIEW(id));
return unwrap(response.data) as ContractView;
},
signContract: (id: string, payload: SignContractPayload) =>
postContract<Freight.IContract>(C.CONTRACT_SIGN(id), payload),
// ── Pre-booking clearance (Path B — GL ET) ──
getClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_QUEUE);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
},
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
const response = await client.get(C.CLEARANCE(id));
return unwrap(response.data) as Freight.ContractClearanceView;
},
reviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
) =>
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
uploadClearanceOutput: async (
id: string,
files: Record<string, File | null>,
): Promise<Freight.IContract> => {
const form = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) form.append(key, file);
}
const response = await client.post(C.CLEARANCE_OUTPUT_DOCUMENTS(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.IContract;
},
finalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE(id)),
// ── Path A self-clearance (Operations review) ──
getOpsClearanceQueue: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(
C.OPS_CLEARANCE_QUEUE,
);
const data = unwrap(response.data);
return {
items: (data.items ?? []) as Freight.IContract[],
total: data.total ?? 0,
};
},
getClearanceHistory: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.CLEARANCE_HISTORY);
const data = unwrap(response.data);
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},
getOpsClearanceHistory: async (): Promise<PaginatedContracts> => {
const response = await client.get<PaginatedContracts>(C.OPS_CLEARANCE_HISTORY);
const data = unwrap(response.data);
return { items: (data.items ?? []) as Freight.IContract[], total: data.total ?? 0 };
},
opsReviewClearanceDocument: (
id: string,
payload: { fileKey: string; status: "APPROVED" | "QUERIED"; note?: string },
) => postContract<Freight.IContract>(C.OPS_CLEARANCE_REVIEW(id), payload),
opsFinalizeClearance: (id: string) =>
postContract<Freight.IContract>(C.OPS_CLEARANCE_FINALIZE(id)),
// ── Booking under contract (GL ET — Path B) ──
createBookingUnderContract: (
id: string,
payload: Freight.CreateBookingUnderContractDto,
) => postContract<{ id: string; reference: string }>(C.BOOKINGS(id), payload),
/** Remaining bookable quantity per cargo line (GENERAL draw-down cap). */
getCapacity: async (id: string): Promise<Freight.ContractCapacityLine[]> => {
const response = await client.get(C.CAPACITY(id));
return (unwrap(response.data) ?? []) as Freight.ContractCapacityLine[];
},
// ── Shipment requests (GENERAL + customs) ──
/** GL queue of pending shipment requests across contracts. */
getBookingRequestQueue: async (): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUEST_QUEUE);
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
listBookingRequests: async (
id: string,
): Promise<Freight.IBookingRequest[]> => {
const response = await client.get(C.BOOKING_REQUESTS(id));
return (unwrap(response.data) ?? []) as Freight.IBookingRequest[];
},
getBookingRequest: async (
reqId: string,
): Promise<Freight.IBookingRequest> => {
const response = await client.get(C.BOOKING_REQUEST_BY_ID(reqId));
return unwrap(response.data) as Freight.IBookingRequest;
},
acceptBookingRequest: (reqId: string, bookingId: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_ACCEPT(reqId), {
bookingId,
}),
rejectBookingRequest: (reqId: string, note?: string) =>
postContract<Freight.IBookingRequest>(C.BOOKING_REQUEST_REJECT(reqId), {
note,
}),
// ── Clearance milestones ──
listMilestonesForContract: async (
id: string,
): Promise<Freight.IClearanceMilestone[]> => {
const response = await client.get(C.MILESTONES(id));
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
},
listMilestonesForBooking: async (
bookingId: string,
): Promise<Freight.IClearanceMilestone[]> => {
const response = await client.get(C.BOOKING_MILESTONES(bookingId));
return (unwrap(response.data) ?? []) as Freight.IClearanceMilestone[];
},
completeMilestone: (bookingId: string, code: string, note?: string) =>
postContract<Freight.IClearanceMilestone>(
C.COMPLETE_BOOKING_MILESTONE(bookingId, code),
{ note },
),
// ── GL post-booking operational actions ──
assignRisk: (
bookingId: string,
payload: { riskLevel: Freight.CustomsRiskLevel; note?: string },
) =>
postContract<Freight.IClearanceMilestone>(
C.BOOKING_RISK(bookingId),
payload,
),
adviseDuty: (
bookingId: string,
payload: {
amount: number;
currency: string;
declarationSerial?: string;
note?: string;
},
) =>
postContract<Freight.IClearanceMilestone>(
C.BOOKING_DUTY(bookingId),
payload,
),
assignStation: (
bookingId: string,
payload: { stationYardId: string; staffId?: string },
) => postContract(C.BOOKING_STATION_ASSIGN(bookingId), payload),
uploadGlDocuments: async (
bookingId: string,
files: Record<string, File | null>,
): Promise<{ uploaded: number; completedMilestones: string[] }> => {
const form = new FormData();
for (const [key, file] of Object.entries(files)) {
if (file) form.append(key, file);
}
const response = await client.post(C.BOOKING_GL_DOCUMENTS(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as {
uploaded: number;
completedMilestones: string[];
};
},
listIncidents: async (
bookingId: string,
): Promise<Freight.IClearanceIncident[]> => {
const response = await client.get(C.BOOKING_INCIDENTS(bookingId));
return (unwrap(response.data) ?? []) as Freight.IClearanceIncident[];
},
reportIncident: async (
bookingId: string,
payload: {
incidentType: Freight.IncidentType;
description: string;
photos: File[];
},
): Promise<Freight.IClearanceIncident> => {
const form = new FormData();
form.append("incidentType", payload.incidentType);
form.append("description", payload.description);
for (const photo of payload.photos) form.append("photos", photo);
const response = await client.post(C.BOOKING_INCIDENTS(bookingId), form, {
headers: { "Content-Type": "multipart/form-data" },
});
return unwrap(response.data) as Freight.IClearanceIncident;
},
};

View File

@@ -60,7 +60,7 @@ export const firstMileService = {
list: (pageSize = 1000) =>
api.get<FirstMileListResponse>(`${FM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<FirstMileRecord>(FM.BY_ID(id)),
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null }) =>
update: (id: string, data: { status?: FirstMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
api.patch<FirstMileRecord>(FM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<FirstMileRecord>(FM.ACCEPT(bookingReference)),

View File

@@ -0,0 +1,136 @@
import { api as client } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import { unwrap } from '@/utils/endpoint';
import type {
AssignCustomsRiskPayload,
CreateDjiboutiIncidentPayload,
CreateEmptyContainerReturnPayload,
DjiboutiIncident,
EmptyContainerReturn,
ImportCustomsFinalization,
ImportOperationActionPayload,
RecordDeclarationPayload,
UpdateEmptyContainerReturnStatusPayload,
UploadImportCustomsDocumentPayload,
} from '@/types/importOperations';
export const importOperationsService = {
listIncidents: async (bookingId?: string): Promise<DjiboutiIncident[]> => {
const response = await client.get<DjiboutiIncident[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
{ params: { bookingId } },
);
return unwrap(response.data);
},
createIncident: async (
payload: CreateDjiboutiIncidentPayload,
): Promise<DjiboutiIncident> => {
const response = await client.post<DjiboutiIncident>(
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
payload,
);
return unwrap(response.data);
},
getCustoms: async (bookingId: string): Promise<ImportCustomsFinalization> => {
const response = await client.get<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS(bookingId),
);
return unwrap(response.data);
},
uploadCustomsDocument: async (
bookingId: string,
payload: UploadImportCustomsDocumentPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DOCUMENTS(bookingId),
payload,
);
return unwrap(response.data);
},
recordDeclaration: async (
bookingId: string,
payload: RecordDeclarationPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DECLARATION(bookingId),
payload,
);
return unwrap(response.data);
},
notifyDutiesTaxes: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_NOTIFY_DUTIES_TAXES(bookingId),
payload,
);
return unwrap(response.data);
},
markDutiesTaxesPaid: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DUTIES_TAXES_PAID(bookingId),
payload,
);
return unwrap(response.data);
},
assignRisk: async (
bookingId: string,
payload: AssignCustomsRiskPayload,
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RISK(bookingId),
payload,
);
return unwrap(response.data);
},
markReleasePermitted: async (
bookingId: string,
payload: ImportOperationActionPayload = {},
): Promise<ImportCustomsFinalization> => {
const response = await client.post<ImportCustomsFinalization>(
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RELEASE_PERMITTED(bookingId),
payload,
);
return unwrap(response.data);
},
listEmptyReturns: async (): Promise<EmptyContainerReturn[]> => {
const response = await client.get<EmptyContainerReturn[]>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
);
return unwrap(response.data);
},
createEmptyReturn: async (
payload: CreateEmptyContainerReturnPayload,
): Promise<EmptyContainerReturn> => {
const response = await client.post<EmptyContainerReturn>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
payload,
);
return unwrap(response.data);
},
updateEmptyReturnStatus: async (
id: string,
payload: UpdateEmptyContainerReturnStatusPayload,
): Promise<EmptyContainerReturn> => {
const response = await client.post<EmptyContainerReturn>(
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_STATUS(id),
payload,
);
return unwrap(response.data);
},
};

View File

@@ -18,10 +18,10 @@ export interface LastMileBooking {
totalAmount: number;
scheduledDate?: string | null;
company?: { id: string; name?: string; phone?: string | null; contactPersonName?: string | null; contactPersonPhone?: string | null } | null;
serviceType?: { id: string; name?: string } | null;
originYard?: { id: string; name?: string } | null;
destinationYard?: { id: string; name?: string } | null;
cargoType?: { id: string; name?: string } | null;
serviceType?: { id: string; name?: string; label?: string } | null;
originYard?: { id: string; name?: string; label?: string } | null;
destinationYard?: { id: string; name?: string; label?: string } | null;
cargoType?: { id: string; name?: string; label?: string; cargoTypeName?: string } | null;
}
export interface LastMileVehicle {
@@ -60,7 +60,7 @@ export const lastMileService = {
list: (pageSize = 1000) =>
api.get<LastMileListResponse>(`${LM.BASE}?pageSize=${pageSize}`),
getById: (id: string) => api.get<LastMileRecord>(LM.BY_ID(id)),
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null }) =>
update: (id: string, data: { status?: LastMileApiStatus; vehicleId?: string | null; estimatedKm?: number | null; exactKm?: number | null }) =>
api.patch<LastMileRecord>(LM.BY_ID(id), data),
accept: (bookingReference: string) =>
api.post<LastMileRecord>(LM.ACCEPT(encodeURIComponent(bookingReference))),

View File

@@ -4,6 +4,7 @@ import { URL_CONSTANTS } from "@/constants/URLS";
import type {
IOverviewBillingTab,
IOverviewBookingsTab,
IOverviewContractsTab,
IOverviewCustomersTab,
IOverviewDashboard,
IOverviewOperationsTab,
@@ -28,6 +29,13 @@ export const overviewService = {
return unwrap(response);
},
getContractsTab: async (range?: OverviewRange): Promise<IOverviewContractsTab> => {
const response = await client.get<IOverviewContractsTab>(O.CONTRACTS, {
params: range ? { range } : undefined,
});
return unwrap(response);
},
getBillingTab: async (range?: OverviewRange): Promise<IOverviewBillingTab> => {
const response = await client.get<IOverviewBillingTab>(O.BILLING, {
params: range ? { range } : undefined,

View File

@@ -0,0 +1,35 @@
import { api } from '../auth/http';
export interface Rate {
id: string;
rateType: string;
appliesTo: string;
trigger: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
currency: string;
rateValue: string;
rateUnit: string;
status: string;
proposedByStaffId: string;
approvedByCeoId: string | null;
approvedAt: string | null;
effectiveFrom: string;
effectiveTo: string | null;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
}
export interface RatesListResponse {
data: Rate[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
export const ratesService = {
list: (pageSize = 1000) =>
api.get<RatesListResponse>(`/rates?pageSize=${pageSize}`),
getByType: (rateType: string) =>
api.get<RatesListResponse>(`/rates?rateType=${rateType}&pageSize=1000`),
};

View File

@@ -1,3 +1,4 @@
import type { Freight } from "@edr/types";
import { api as client } from "../auth/http";
import { unwrap } from "@/utils/endpoint";
import { URL_CONSTANTS } from "@/constants/URLS";
@@ -11,6 +12,9 @@ import type {
CreateTrainSchedulePayload,
EligibleContainerBookingsResponse,
FreightType,
ImportDjiboutiActionPayload,
ImportDjiboutiLoadList,
ImportDjiboutiOperation,
LocomotiveRecord,
PinWagonsPayload,
RecordCheckpointPayload,
@@ -21,6 +25,7 @@ import type {
TrainSchedulePreviewResponse,
TrainSchedulingGlobalRules,
TrainTrackResponse,
UploadImportDjiboutiDocumentPayload,
WagonAllocationAttemptResult,
YardOption,
} from "@/types/trainScheduling";
@@ -127,6 +132,24 @@ export const trainSchedulingService = {
return unwrap(response.data).days;
},
// Cargo-aware day pool (matching wagons + open train capacity). `containers`
// is serialized as a JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const response = await client.get<{ days: string[] }>(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return unwrap(response.data).days;
},
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
const response = await client.post<BatchBoardScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
@@ -261,6 +284,101 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getImportDjiboutiOperation: async (
scheduleId: string,
): Promise<ImportDjiboutiOperation> => {
const response = await client.get<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI(scheduleId),
);
return unwrap(response.data);
},
uploadImportDjiboutiDocument: async (
scheduleId: string,
payload: UploadImportDjiboutiDocumentPayload,
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DOCUMENTS(scheduleId),
payload,
);
return unwrap(response.data);
},
grantImportDjiboutiGatepass: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_GATEPASS_GRANTED(scheduleId),
payload,
);
return unwrap(response.data);
},
markImportDjiboutiReadyForLoading: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_READY_FOR_LOADING(scheduleId),
payload,
);
return unwrap(response.data);
},
confirmImportDjiboutiLoadedOnTrain: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOADED_ON_TRAIN(scheduleId),
payload,
);
return unwrap(response.data);
},
departImportFromDjibouti: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiOperation> => {
const response = await client.post<ImportDjiboutiOperation>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DEPART(scheduleId),
payload,
);
return unwrap(response.data);
},
generateImportDjiboutiLoadList: async (
scheduleId: string,
payload: ImportDjiboutiActionPayload = {},
): Promise<ImportDjiboutiLoadList> => {
const response = await client.post<ImportDjiboutiLoadList>(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST(scheduleId),
payload,
);
return unwrap(response.data);
},
downloadImportDjiboutiLoadListDocument: async (
scheduleId: string,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT(scheduleId),
{ responseType: "blob" },
);
return response.data;
},
downloadExportLoadListDocument: async (
scheduleId: string,
): Promise<Blob> => {
const response = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.EXPORT_LOAD_LIST_DOCUMENT(scheduleId),
{ responseType: "blob" },
);
return response.data;
},
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
const response = await client.get<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),

View File

@@ -149,6 +149,8 @@ export const warehouseService = {
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
bulkMarkInspected: (payload: BulkInspectPayload) =>
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
receivedExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVED_EXPORT),
readyToLoadExport: () =>
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
loadedExport: () =>
@@ -263,6 +265,14 @@ export const warehouseService = {
}),
getInvoice: (id: string) =>
apiClient.get<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.BY_ID(id)),
downloadInvoiceDocument: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.DOCUMENT(id), {
responseType: 'blob',
}),
downloadInvoiceReceipt: (id: string) =>
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVOICES.RECEIPT(id), {
responseType: 'blob',
}),
invoicesForInventory: (inventoryId: string) =>
apiClient.get<WarehouseFeeInvoice[]>(URL_CONSTANTS.WAREHOUSE_INVOICES.FOR_INVENTORY(inventoryId)),
invoicesForBooking: (bookingId: string) =>

View File

@@ -79,7 +79,6 @@ export interface BookingContainerLine {
code?: string;
label?: string;
sizeFt?: number;
isReefer?: boolean;
};
}

View File

@@ -0,0 +1,124 @@
export type DjiboutiIncidentType =
| 'SEAL_BROKEN'
| 'CONTAINER_OPENED'
| 'CONTAINER_DAMAGED'
| 'FLUID_LEAKING'
| 'QUANTITY_MISMATCH'
| 'WEIGHT_MISMATCH'
| 'OTHER';
export interface DjiboutiIncident {
id: string;
bookingId: string;
containerNumber: string | null;
cargoId: string | null;
facility: string | null;
station: string | null;
incidentType: DjiboutiIncidentType;
description: string;
photos: string[];
reportedBy: string | null;
reportedAt: string;
}
export interface CreateDjiboutiIncidentPayload {
bookingId: string;
containerNumber?: string;
cargoId?: string;
facility?: string;
station?: string;
incidentType: DjiboutiIncidentType;
description: string;
photos?: string[];
reportedBy?: string;
reportedAt?: string;
}
export type ImportCustomsDocumentType =
| 'IM4'
| 'IM5'
| 'T1_CLOSURE_PROOF'
| 'TRANSIT_PERMIT_SCREENSHOT'
| 'CUSTOMER_PAYMENT_SLIP'
| 'IMPORT_RELEASE_PERMIT';
export type ImportCustomsRiskLevel = 'GREEN' | 'YELLOW' | 'BLUE' | 'RED';
export interface ImportCustomsFinalization {
id: string;
bookingId: string;
documents: Partial<Record<ImportCustomsDocumentType, string>>;
declarationSerialNumber: string | null;
dutiesTaxesNotifiedAt: string | null;
dutiesTaxesPaidAt: string | null;
customsRisk: ImportCustomsRiskLevel | null;
importReleasePermittedAt: string | null;
completedAt: string | null;
performedBy: string | null;
notes: string | null;
}
export interface ImportOperationActionPayload {
performedBy?: string;
notes?: string;
}
export interface UploadImportCustomsDocumentPayload {
documentType: ImportCustomsDocumentType;
fileId: string;
performedBy?: string;
}
export interface RecordDeclarationPayload {
declarationSerialNumber: string;
performedBy?: string;
}
export interface AssignCustomsRiskPayload {
risk: ImportCustomsRiskLevel;
performedBy?: string;
}
export type EmptyContainerReturnStatus =
| 'RETURNED'
| 'ASSIGNED_STORAGE'
| 'DOCUMENTATION_CLEARED'
| 'WAGON_ALLOCATED'
| 'TRANSPORTED_TO_DJIBOUTI'
| 'HANDOVER_ISSUED'
| 'COMPLETED';
export interface EmptyContainerReturn {
id: string;
containerNumber: string;
bookingId: string | null;
customerId: string | null;
returnDate: string;
facility: string | null;
yard: string | null;
zone: string | null;
condition: string | null;
handoverNote: string | null;
status: EmptyContainerReturnStatus;
wagonAllocationReference: string | null;
performedBy: string | null;
}
export interface CreateEmptyContainerReturnPayload {
containerNumber: string;
bookingId?: string;
customerId?: string;
returnDate?: string;
facility?: string;
yard?: string;
zone?: string;
condition?: string;
handoverNote?: string;
performedBy?: string;
}
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
status: EmptyContainerReturnStatus;
wagonAllocationReference?: string;
handoverNote?: string;
}

View File

@@ -2,6 +2,7 @@ export type {
IOverviewDashboard,
IOverviewKpis,
IOverviewBookingKpis,
IOverviewContractKpis,
IOverviewOperationsKpis,
IOverviewCustomerKpis,
IOverviewBillingKpis,
@@ -11,10 +12,12 @@ export type {
IOverviewPipelineCount,
IOverviewPaymentTrendPoint,
IOverviewRecentBooking,
IOverviewRecentContract,
IOverviewLabelCount,
IOverviewPaymentMethodBreakdown,
IOverviewCurrencyAmount,
IOverviewBookingsTab,
IOverviewContractsTab,
IOverviewBillingTab,
IOverviewOperationsTab,
IOverviewCustomersTab,

View File

@@ -399,6 +399,81 @@ export interface TrainScheduleDetail {
warnings?: string[];
}
export type ImportDjiboutiDocumentType =
| "DELIVERY_ORDER"
| "PORT_INVOICE"
| "DJIBOUTI_T1"
| "ETHIOPIA_T1"
| "RAILWAY_BILL";
export interface ImportDjiboutiDocumentRecord {
fileId?: string | null;
fileUrl?: string | null;
reference?: string | null;
uploadedAt: string;
uploadedBy?: string | null;
notes?: string | null;
}
export interface ImportDjiboutiOperation {
trainScheduleId: string;
trainNumber: string | null;
direction: string | null;
status: {
documentsComplete: boolean;
missingDocuments: ImportDjiboutiDocumentType[];
gatepassGranted: boolean;
readyForLoading: boolean;
loadedOnTrain: boolean;
departedFromDjibouti: boolean;
loadListGenerated: boolean;
};
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
gatepassGrantedAt: string | null;
readyForLoadingAt: string | null;
loadedOnTrainAt: string | null;
departedFromDjiboutiAt: string | null;
loadListGeneratedAt: string | null;
performedBy: string | null;
notes: string | null;
}
export interface UploadImportDjiboutiDocumentPayload {
documentType: ImportDjiboutiDocumentType;
fileId?: string;
fileUrl?: string;
reference?: string;
notes?: string;
performedBy?: string;
}
export interface ImportDjiboutiActionPayload {
notes?: string;
performedBy?: string;
}
export interface ImportDjiboutiLoadList {
generatedAt: string;
trainScheduleId: string;
trainNumber: string | null;
route: string | null;
origin: string | null;
destination: string | null;
totalBookings: number;
wagons: Array<{
sequenceNo: number;
wagonNumber: string | null;
allocations: Array<{
bookingId: string;
bookingReference: string | null;
loadType: string | null;
allocatedWeightTons: number;
containerNumbers: string[];
}>;
}>;
operation: ImportDjiboutiOperation;
}
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
export interface TrackStation {

View File

@@ -341,6 +341,20 @@ export interface ReserveInventoryPayload {
export interface ReleaseOrderPayload {
reference?: string;
releaseDate?: string;
bookingId?: string;
customerId?: string;
truckPlateNumber?: string;
trailerPlateNumber?: string;
driverName?: string;
driverLicense?: string;
driverPhone?: string;
truckType?: string;
containerNumber?: string;
gateInTime?: string;
tareWeight?: number;
grossWeight?: number;
netWeight?: number;
gateOutTime?: string;
}
/** Import branch: proof of delivery captured on customer pickup. */
@@ -356,6 +370,13 @@ export interface EligibleBooking {
reference: string;
customerId: string | null;
customer: string | null;
customerTin: string | null;
customerPhone: string | null;
containerNumber: string | null;
containerQuantity: number | null;
containerPackagingType: string | null;
cargoDescription: string | null;
lastMileRequested: boolean;
direction: string;
origin: string | null;
destination: string | null;
@@ -364,6 +385,16 @@ export interface EligibleBooking {
weight: string | null;
paymentStatus: string;
status: string;
hasFirstMile: boolean;
firstMileRequestId: string | null;
firstMileStatus: string | null;
firstMileVehicleId: string | null;
firstMileTruckPlateNumber: string | null;
firstMileTrailerPlateNumber: string | null;
firstMileDriverName: string | null;
firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null;
}
export interface BulkReceivePayload {
@@ -372,12 +403,46 @@ export interface BulkReceivePayload {
yardId: string;
zoneId: string;
bookingIds: string[];
truckEntrance?: TruckEntrancePayload;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
results: { bookingId: string; status: string; inventoryId?: string; grnNumber?: string; reason?: string }[];
}
export interface TruckEntrancePayload {
ownerName?: string;
consigneeDetails?: string;
edrDigitalBookingId?: string;
tin?: string;
customerPhone?: string;
truckPlateNumber: string;
trailerPlateNumber?: string;
assignedEquipmentNumber?: string;
customsSealNumber?: string;
declarationNumber?: string;
incoterms?: string;
hsCodes?: string;
itemCode?: string;
itemDescription?: string;
packagingType?: string;
unitCount?: number;
grossWeightKg?: number;
netWeightKg?: number;
volumeDimensions?: string;
conditionAtReceipt?: string;
damagedRejectedQuantity?: number;
warehouseCodeLocation?: string;
driverName: string;
driverPhone: string;
driverLicenseNumber?: string;
truckType?: string;
entranceTareWeightKg: number;
exitTareWeightKg?: number;
driverSignatoryName?: string;
warehouseManagerName?: string;
}
export interface LoadPassedExportResult {
@@ -430,6 +495,9 @@ export interface ImportTrain {
totalBookings: number;
totalContainers: number;
totalCargoes: number;
unloadedBookings?: number;
pendingUnloadBookings?: number;
fullyUnloaded?: boolean;
status: string;
}
@@ -510,6 +578,7 @@ export interface ImportTrainItem {
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
@@ -733,8 +802,16 @@ export interface WarehouseFeeInvoice {
id: string;
invoiceNumber: string;
bookingId?: string | null;
bookingReference?: string | null;
customerId?: string | null;
customerName?: string | null;
inventoryId: string;
inventoryReference?: string | null;
inventoryInfo?: string | null;
inventoryStatus?: string | null;
containerNumber?: string | null;
cargoDescription?: string | null;
clearanceStatus?: string | null;
facilityId?: string | null;
warehouseId?: string | null;
yardId?: string | null;
@@ -771,6 +848,8 @@ export interface PayInvoicePayload {
amount: number;
method?: string;
reference?: string;
driverName?: string;
driverPhone?: string;
}
// ── Payloads ───────────────────────────────────────────────────────────────
@@ -823,6 +902,7 @@ export interface ReceiveInventoryPayload {
weight: number;
volume?: number;
notes?: string;
truckEntrance: TruckEntrancePayload;
}
export interface MoveInventoryPayload {