Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-21 07:36:09 +00:00
108 changed files with 4744 additions and 2988 deletions

View File

@@ -125,6 +125,7 @@ import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage";
import LoadedInventoryPage from "./pages/warehouses/LoadedInventoryPage";
import LoadingQueuePage from "./pages/warehouses/LoadingQueuePage";
import IntercityPage from "./pages/warehouses/IntercityPage";
import TrucksOnSitePage from "./pages/warehouses/TrucksOnSitePage";
import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage";
import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage";
import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage";
@@ -465,6 +466,13 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
href: "/dashboard/warehouse-dashboard",
icon: <LayoutDashboard />,
},
{
// Yard-wide, not per-direction: the gate sees import and export
// trucks at the same barrier.
label: "Trucks on Site",
href: "/dashboard/trucks-on-site",
icon: <Truck />,
},
{
label: "Warehouses",
href: "/dashboard/warehouses",
@@ -960,6 +968,7 @@ const App = () => {
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
<Route path="loading-queue" element={<LoadingQueuePage />} />
<Route path="intercity" element={<IntercityPage />} />
<Route path="trucks-on-site" element={<TrucksOnSitePage />} />
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
<Route

View File

@@ -1,217 +0,0 @@
import { useMemo, useState } from "react";
import { Check, ShieldCheck } from "lucide-react";
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useAuth } from "@/auth/useAuth";
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import {
buildApproveActionForStep,
canActOnApprovalStep,
getNextPendingApprovalStep,
} from "@/features/bookings/booking-actions.config";
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
import type { BookingApprovalStep, BookingDetail } from "@/types/booking";
import { SectionCard } from "./detail/SectionCard";
type Mutations = ReturnType<typeof useBookingMutations>;
interface ApprovalStepsCardProps {
booking: BookingDetail;
mutations: Mutations;
}
/** Approval chain with inline approve on the current pending step. */
export function ApprovalStepsCard({ booking, mutations }: ApprovalStepsCardProps) {
const { user } = useAuth();
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] = useState<BookingApprovalStep | null>(null);
const steps = useMemo(
() => [...(booking.approvalSteps ?? [])].sort((a, b) => a.stepOrder - b.stepOrder),
[booking.approvalSteps],
);
const nextPending = getNextPendingApprovalStep(steps);
const summary = formatApprovalProgress(booking.status, steps);
const pendingAction = pendingStep ? buildApproveActionForStep(pendingStep) : null;
const openApprove = (step: BookingApprovalStep) => {
setPendingStep(step);
setConfirmOpen(true);
};
const closeApprove = () => {
setConfirmOpen(false);
setPendingStep(null);
};
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ onSuccess: () => closeApprove() },
);
};
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}
steps={steps}
user={user}
isNext={nextPending?.id === step.id}
isPending={mutations.approveStep.isPending}
onApprove={openApprove}
/>
))}
</Stack>
)}
</SectionCard>
<BookingConfirmDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!open) closeApprove();
else setConfirmOpen(true);
}}
action={pendingAction}
reference={booking.reference}
inputValue=""
onInputChange={() => {}}
onConfirm={runApprove}
isPending={mutations.approveStep.isPending}
/>
</>
);
}
function StepRow({
step,
steps,
user,
isNext,
isPending,
onApprove,
}: {
step: BookingApprovalStep;
steps: BookingApprovalStep[];
user: ReturnType<typeof useAuth>["user"];
isNext: boolean;
isPending: boolean;
onApprove: (step: BookingApprovalStep) => void;
}) {
const canApprove = canActOnApprovalStep(user, step, steps);
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.remarks && (
<Text size="xs" c="dimmed" truncate>
{step.remarks}
</Text>
)}
</Box>
</Group>
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
{canApprove && (
<Button
size="compact-sm"
color="edr-green"
leftSection={<Check size={14} />}
disabled={isPending}
onClick={() => onApprove(step)}
>
Approve
</Button>
)}
<Badge variant="light" color={statusColor} size="sm" radius="sm" tt="uppercase">
{step.status}
</Badge>
</Group>
</Group>
);
}

View File

@@ -6,7 +6,6 @@ import { BookingConfirmDialog } from "./BookingConfirmDialog";
import { useBookingActionDialog } from "./useBookingActionDialog";
import { useAuth } from "@/auth/useAuth";
import {
getNextPendingApprovalStep,
isAllocateAction,
isClearanceNavAction,
isContractNavAction,
@@ -37,7 +36,6 @@ export function BookingActionsMenu({
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: row.reference,
approvalSteps: row.approvalSteps,
schedulingStatus: row.schedulingStatus,
customsClearingEnabled: row.customsClearingEnabled,
};
@@ -192,28 +190,6 @@ function ActionDialog({
}}
isPending={flow.mutations.isPending || flow.detailLoading}
confirmDisabled={flow.confirmDisabled}
extra={
flow.detailLoading ? (
<Text size="sm" c="dimmed">
Loading approval steps
</Text>
) : pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(flow.mergedContext.approvalSteps) ? (
<Text
size="sm"
c="orange.9"
p="xs"
style={{
borderRadius: 8,
border: "1px solid var(--mantine-color-orange-2)",
background: "var(--mantine-color-orange-0)",
}}
>
No pending approval step. Refresh the page after staff accept, or reject the
booking.
</Text>
) : null
}
/>
);
}

View File

@@ -1,29 +0,0 @@
import { formatApprovalProgress } from "@/features/bookings/approval-progress";
import type { BookingListRow } from "@/types/booking";
import { cn } from "@/lib/utils";
interface BookingApprovalProgressCellProps {
row: BookingListRow;
}
export function BookingApprovalProgressCell({ row }: BookingApprovalProgressCellProps) {
const summary = formatApprovalProgress(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

@@ -1,69 +0,0 @@
import { CheckCircle, Clock, XCircle } from "lucide-react";
import { Group, Text, Badge, Timeline } from "@mantine/core";
import { SectionCard } from "./SectionCard";
import {
approvalStatusColor,
formatDateTime,
type BookingApprovalStepView,
} from "./booking-detail.styles";
export interface BookingApprovalCardProps {
steps: BookingApprovalStepView[];
approvedCount: number;
}
/** Vertical timeline of the booking's approval chain. */
export function BookingApprovalCard({ steps, approvedCount }: BookingApprovalCardProps) {
return (
<SectionCard
icon={CheckCircle}
title="Approval Workflow"
accent="edr-green"
extra={
<Badge color="edr-green" variant="light" radius="sm">
{approvedCount} / {steps.length} approved
</Badge>
}
>
<Timeline active={approvedCount - 1} bulletSize={26} lineWidth={2} color="edr-green">
{steps.map((step) => (
<Timeline.Item
key={step.id}
color={approvalStatusColor(step.status)}
bullet={
step.status === "APPROVED" ? (
<CheckCircle size={14} />
) : step.status === "REJECTED" ? (
<XCircle size={14} />
) : (
<Clock size={14} />
)
}
title={
<Group gap="sm">
<Text fw={600} size="sm">
{step.requiredRole.replace(/_/g, " ")}
</Text>
<Badge
color={approvalStatusColor(step.status)}
size="xs"
radius="sm"
variant="light"
>
{step.status}
</Badge>
</Group>
}
>
{step.actionedAt && (
<Text size="xs" c="dimmed">
{formatDateTime(step.actionedAt)}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
</SectionCard>
);
}

View File

@@ -102,14 +102,6 @@ export interface BookingContainerView {
};
}
export interface BookingApprovalStepView {
id: string;
stepOrder: number;
requiredRole: string;
status: string;
actionedAt?: string | null;
}
export interface BookingReviewNoteView {
id: string;
note: string;
@@ -150,7 +142,6 @@ export interface BookingDetailView {
cargoType?: BookingNamedRefView;
shippingLine?: BookingNamedRefView;
bookingContainers?: BookingContainerView[];
approvalSteps?: BookingApprovalStepView[];
reviewNotes?: BookingReviewNoteView[];
files?: BookingFileView[];
}

View File

@@ -10,7 +10,6 @@ export * from "./BookingLifecycleStepper";
export * from "./BookingRouteCard";
export * from "./BookingContainersCard";
export * from "./BookingContainerUnitsCard";
export * from "./BookingApprovalCard";
export * from "./BookingReviewNotesCard";
export * from "./BookingPaymentCard";
export * from "./BookingPaymentCountdownCard";

View File

@@ -2,12 +2,11 @@ import { useCallback, useState } from "react";
import {
getBookingActions,
getNextPendingApprovalStep,
type BookingActionContext,
type BookingActionDef,
} from "@/features/bookings/booking-actions.config";
import { useAuth } from "@/auth/useAuth";
import { useBookingDetail, useBookingMutations } from "@/hooks/bookings/useBookings";
import { useBookingMutations } from "@/hooks/bookings/useBookings";
/** A contract validity window must be a whole number of days, 1365. */
function isValidValidityDays(value: string): boolean {
@@ -24,22 +23,11 @@ export function useBookingActionDialog(
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const needsApprovalSteps =
pendingAction?.id === "approve" || pendingAction?.id === "rejectApproval";
// Bookings no longer have an approval chain, so the dialog needs nothing
// beyond the list-row context it was handed.
const detailLoading = false;
const needsApprovalContext =
context.status === "PENDING_APPROVAL" ||
context.status === "APPROVED_PENDING_SIGNATURE";
const { data: detail, isLoading: detailLoading } = useBookingDetail(
needsApprovalSteps || needsApprovalContext ? bookingId : undefined,
);
const mergedContext: BookingActionContext = {
...context,
approvalSteps: detail?.approvalSteps ?? context.approvalSteps,
reference: detail?.reference ?? context.reference,
};
const mergedContext: BookingActionContext = { ...context };
const { user } = useAuth();
const mutations = useBookingMutations(bookingId);
@@ -86,24 +74,6 @@ export function useBookingActionDialog(
{ onSuccess },
);
break;
case "approve": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.approveStep.mutate(
{ stepId: step.id, requiredRole: step.requiredRole },
{ onSuccess },
);
break;
}
case "rejectApproval": {
const step = getNextPendingApprovalStep(mergedContext.approvalSteps);
if (!step) return;
mutations.rejectStep.mutate(
{ stepId: step.id, reason: inputValue.trim() },
{ onSuccess },
);
break;
}
case "viewContract":
break;
case "startTransit":
@@ -122,16 +92,12 @@ export function useBookingActionDialog(
pendingAction,
inputValue,
selectedFile,
mergedContext.approvalSteps,
mutations,
closeDialog,
]);
const confirmDisabled =
mutations.isPending ||
(needsApprovalSteps && detailLoading) ||
(pendingAction?.id === "approve" &&
!getNextPendingApprovalStep(mergedContext.approvalSteps)) ||
(pendingAction?.input === "file" && !selectedFile) ||
(pendingAction?.input === "reason" && !inputValue.trim()) ||
(pendingAction?.input === "note" && !inputValue.trim()) ||

View File

@@ -4,11 +4,10 @@ import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
FileCheck,
Eye,
FilePen,
FileSignature,
MessageSquareWarning,
RefreshCw,
ShieldCheck,
XCircle,
Zap,
@@ -16,8 +15,10 @@ import {
import type { Freight } from "@edr/types";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ContractDocumentEditorModal } from "@/components/contracts/ContractDocumentEditorModal";
import { ContractPreviewModal } from "@/components/contracts/ContractPreviewModal";
import type { useContractMutations } from "@/hooks/contracts/useContracts";
/** Dropdown-settings code holding the admin-configured contract validity days. */
@@ -51,11 +52,21 @@ export function ContractActionsToolbar({
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false);
const [changesOpen, setChangesOpen] = useState(false);
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
const { data: draft } = useQuery({
queryKey: ["contracts", contract.id, "document-draft"],
queryFn: () => contractsService.getContractDocumentDraft(contract.id),
enabled: contract.status === "PENDING_APPROVAL",
staleTime: 0,
});
// 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({
@@ -87,18 +98,11 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
// While the contract is PENDING_APPROVAL and NO approver has acted yet, staff
// can edit this contract's articles and (re)generate its PDF. The first
// approval action locks the document.
const docLocked =
status !== "PENDING_APPROVAL" ||
(contract.approvalSteps ?? []).some((s) => s.status !== "PENDING");
const canEditGenerate = status === "PENDING_APPROVAL" && !docLocked;
const documentGenerated = Boolean(contract.contractGeneratedAt);
// Legacy fallback: if a contract ever lands on APPROVED without a document
// (older flow), still offer a manual generate that moves it to CONTRACT_READY.
const needsManualGenerate =
status === "APPROVED" && !contract.contractGeneratedAt;
// The document stays editable for the whole approval chain, but only by the
// approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it.
const canEditDocument = Boolean(draft?.editableByMe);
const inApproval = status === "PENDING_APPROVAL";
// 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 =
@@ -154,56 +158,41 @@ export function ContractActionsToolbar({
</>
)}
{canEditGenerate && (
{inApproval && (
<>
<Text size="xs" c="dimmed">
{documentGenerated
? "Document generated. Approvers can now review it. You can still edit and regenerate until the first approval."
: "Review the contract document, edit its articles if needed, then generate it so approvers can review."}
{canEditDocument
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
: draft?.nextApproverRole
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
: "Awaiting approval."}
</Text>
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
leftSection={<Eye size={16} />}
onClick={() => setPreviewOpen(true)}
>
Edit contract articles
</Button>
<Button
fullWidth
color="edr-green"
leftSection={
documentGenerated ? (
<RefreshCw size={16} />
) : (
<FileCheck size={16} />
)
}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
{documentGenerated ? "Regenerate contract" : "Generate contract"}
Preview document
</Button>
{canEditDocument && (
<Button
fullWidth
variant="light"
color="gray"
leftSection={<FilePen size={16} />}
onClick={() => {
setEditorMode("edit");
setEditorOpen(true);
}}
>
Edit contract articles
</Button>
)}
</>
)}
{needsManualGenerate && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<FileCheck size={16} />}
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Generate contract
</Button>
)}
{canViewContract && (
<Button
fullWidth
@@ -233,8 +222,7 @@ export function ContractActionsToolbar({
the customer creates the booking in the portal. */}
{!canAccept &&
!canEditGenerate &&
!needsManualGenerate &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
<Text size="sm" c="dimmed">
@@ -267,6 +255,12 @@ export function ContractActionsToolbar({
}
/>
<ContractPreviewModal
opened={previewOpen}
onClose={() => setPreviewOpen(false)}
contractId={contract.id}
/>
{/* Request changes */}
<Modal
opened={changesOpen}

View File

@@ -1,5 +1,5 @@
import { useMemo, useState } from "react";
import { AlertTriangle, Check, FileCheck, ShieldCheck, X } from "lucide-react";
import { AlertTriangle, Check, ShieldCheck, X } from "lucide-react";
import {
Stack,
Group,
@@ -31,7 +31,6 @@ export function ContractApprovalStepsCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingStep, setPendingStep] =
useState<Freight.IContractApprovalStep | null>(null);
const [needsGenerateOpen, setNeedsGenerateOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectStepRow, setRejectStepRow] =
useState<Freight.IContractApprovalStep | null>(null);
@@ -48,17 +47,9 @@ export function ContractApprovalStepsCard({
const nextPending = steps.find((s) => s.status === "PENDING");
const summary = formatContractApprovalProgress(contract.status, steps);
// Approvers must review the GENERATED contract document before approving. If
// it has not been generated yet, block the approval and tell staff to generate
// it first (via "Generate contract" in Staff actions) — mirrors the server
// guard so the user sees a clear reason, not a generic failure toast.
const documentGenerated = Boolean(contract.contractGeneratedAt);
// Approvers review a live preview of the document; there is no PDF to
// generate first — the final approval is what produces it.
const openApprove = (step: Freight.IContractApprovalStep) => {
if (contract.status === "PENDING_APPROVAL" && !documentGenerated) {
setNeedsGenerateOpen(true);
return;
}
setPendingStep(step);
setConfirmOpen(true);
};
@@ -71,7 +62,7 @@ export function ContractApprovalStepsCard({
const runApprove = () => {
if (!pendingStep) return;
mutations.approveStep.mutate(
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
{ stepId: pendingStep.id },
{ onSuccess: () => closeApprove() },
);
};
@@ -192,46 +183,6 @@ export function ContractApprovalStepsCard({
</Stack>
</Modal>
<Modal
opened={needsGenerateOpen}
onClose={() => setNeedsGenerateOpen(false)}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-orange-6)" />
<Text fw={700}>Generate the contract first</Text>
</Group>
}
radius="md"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The contract document for{" "}
<Text span fw={600} c="dark">
{contract.reference}
</Text>{" "}
has not been generated yet. Approvers must review the generated
document before it can be approved.
</Text>
<Text size="sm" c="dimmed">
Use{" "}
<Text span fw={600} c="dark">
Generate contract
</Text>{" "}
in the Staff actions panel edit the articles first if needed then
return here to approve.
</Text>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<FileCheck size={16} />}
onClick={() => setNeedsGenerateOpen(false)}
>
Got it
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={rejectOpen}

View File

@@ -116,7 +116,9 @@ export function ContractDocumentEditorModal({
}
}, [mode, validityDays, validityOptions]);
const locked = mode === "edit" && Boolean(draft?.locked);
// Editing rights belong to the approver whose turn it is, so the server
// decides per-caller — the client cannot derive this from the contract alone.
const locked = mode === "edit" && !draft?.editableByMe;
const moveArticle = (index: number, delta: number) => {
setArticles((prev) => {
@@ -215,7 +217,9 @@ export function ContractDocumentEditorModal({
icon={locked ? <Lock size={16} /> : <Info size={16} />}
>
{locked
? "This document is locked — an approver has already acted, so it can no longer be edited."
? draft?.nextApproverRole
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
: "This document can no longer be edited — the contract has advanced beyond approval."
: "Edits apply to THIS contract only. The six shared templates are never changed."}
</Alert>

View File

@@ -0,0 +1,78 @@
import { useQuery } from "@tanstack/react-query";
import { Alert, Group, Loader, Modal, Text } from "@mantine/core";
import { Info } from "lucide-react";
import { contractsService } from "@/services/contracts.service";
interface ContractPreviewModalProps {
opened: boolean;
onClose: () => void;
contractId: string;
}
/**
* Live preview of the contract document. Renders server-side HTML, not the
* stored PDF — the PDF is only produced once the final approver approves, so
* before that this is the document. Served in an iframe so the contract's own
* styles stay sandboxed away from the app.
*/
export function ContractPreviewModal({
opened,
onClose,
contractId,
}: ContractPreviewModalProps) {
const { data, isLoading, isError } = useQuery({
queryKey: ["contracts", contractId, "contract-view"],
queryFn: () => contractsService.getContractView(contractId),
enabled: opened,
// The document changes as approvers edit it, so never serve a stale render.
staleTime: 0,
});
return (
<Modal
opened={opened}
onClose={onClose}
size="xl"
title="Contract document preview"
>
<Alert
icon={<Info size={16} />}
color="blue"
variant="light"
mb="sm"
p="xs"
>
<Text size="xs">
Draft preview. The PDF is generated automatically once the final
approver approves.
</Text>
</Alert>
{isLoading ? (
<Group gap="xs" py="xl" justify="center">
<Loader size="sm" />
<Text size="sm" c="dimmed">
Rendering document
</Text>
</Group>
) : isError || !data?.html ? (
<Text size="sm" c="red">
The document could not be rendered. Check that the contract has a
template and try again.
</Text>
) : (
<iframe
srcDoc={data.html}
title="Contract document preview"
style={{
width: "100%",
minHeight: "70vh",
border: "none",
background: "white",
}}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,134 @@
import { useQuery } from "@tanstack/react-query";
import { History } from "lucide-react";
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
interface ContractRevisionTimelineProps {
contractId: string;
}
type Change = Freight.IContractDocumentChange;
/** Badge colour + verb per change kind, so a revision reads at a glance. */
const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> = {
ARTICLE_ADDED: { color: "green", label: "Added" },
ARTICLE_REMOVED: { color: "red", label: "Removed" },
ARTICLE_RENAMED: { color: "violet", label: "Renamed" },
ARTICLE_BODY_CHANGED: { color: "blue", label: "Edited" },
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
};
/** What the change applies to — an article title, or the document itself. */
function changeSubject(change: Change): string {
switch (change.kind) {
case "DOCUMENT_TITLE_CHANGED":
return change.fromTitle
? `${change.fromTitle}” → “${change.title}`
: change.title;
case "WHEREAS_CHANGED": {
const parts: string[] = [];
if (change.added) parts.push(`+${change.added}`);
if (change.removed) parts.push(`${change.removed}`);
return parts.join(" ") || "changed";
}
case "ARTICLE_RENAMED":
return `${change.fromTitle}” → “${change.title}`;
case "ARTICLE_REORDERED":
return `${change.title} (${change.fromOrder}${change.toOrder})`;
default:
return change.title;
}
}
function formatWhen(iso: string): string {
const date = new Date(iso);
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
/**
* Audit trail of edits to the contract document. The document stays editable
* through the approval chain, so this is the record of who changed what.
*/
export function ContractRevisionTimeline({
contractId,
}: ContractRevisionTimelineProps) {
const { data: revisions, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-revisions"],
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
});
return (
<SectionCard icon={History} title="Document history">
{isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading history
</Text>
</Group>
) : !revisions?.length ? (
<Text size="sm" c="dimmed">
No edits recorded yet. Changes made to the contract articles during
approval will appear here.
</Text>
) : (
<Timeline
active={revisions.length}
bulletSize={18}
lineWidth={2}
color="edr-green"
>
{revisions.map((revision) => (
<Timeline.Item
key={revision.id}
title={
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600}>
{revision.actorRole ?? "Staff"}
</Text>
<Text size="xs" c="dimmed">
{formatWhen(revision.createdAt)}
</Text>
</Group>
}
>
<Stack gap={6} mt={4}>
{revision.summary && (
<Text size="xs" c="dimmed">
{revision.summary}
</Text>
)}
{revision.changes.map((change, index) => {
const style = CHANGE_STYLES[change.kind];
return (
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
<Badge
size="xs"
variant="light"
color={style?.color ?? "gray"}
style={{ flexShrink: 0 }}
>
{style?.label ?? change.kind}
</Badge>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
</Group>
);
})}
</Stack>
</Timeline.Item>
))}
</Timeline>
)}
</SectionCard>
);
}

View File

@@ -29,7 +29,10 @@ const parseError = (error: unknown, fallback: string) => {
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
// A capacity axis can be null when the schedule's locomotive has no limit
// configured for it — render "—" instead of crashing on toFixed.
function fmt(n: number | null | undefined): string {
if (n == null) return "—";
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
@@ -43,13 +46,22 @@ function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.wagons == null ? "gray" : capacity.wagons > 0 ? "teal" : "red"}
>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.weightTons == null ? "gray" : capacity.weightTons > 0 ? "teal" : "red"}
>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
<Badge
variant="light"
color={capacity.lengthMeters == null ? "gray" : capacity.lengthMeters > 0 ? "teal" : "red"}
>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>

View File

@@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
const availableCount = availableWagons.length;
const assignedCount = assignedWagons.length;
const otherCount = otherWagons.length;
// Split "Other" so a coupled wagon is visible as such. The Available/Assigned
// buckets deliberately count only UNCOUPLED wagons (see above), so a yard
// holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" —
// accurate for shunting, but unreadable unless the other 53 are named.
const onTrainCount = useMemo(
() => matching.filter((w) => w.trainId != null).length,
[matching],
);
const destinationYardOptions = useMemo(
() =>
@@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
</div>
</Group>
<Group gap="lg" wrap="wrap">
<LegendDot color="teal" label="Available" value={availableCount} />
<LegendDot color="blue" label="Assigned" value={assignedCount} />
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
<LegendDot color="teal" label="Available in yard" value={availableCount} />
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
{onTrainCount > 0 ? (
<LegendDot color="gray" label="On train" value={onTrainCount} />
) : null}
{otherCount - onTrainCount > 0 ? (
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
) : null}
</Group>
</Group>

View File

@@ -0,0 +1,113 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Table, Text } from "@mantine/core";
import { warehouseService } from "@/services/warehouse.service";
/**
* The trucks carrying one booking's cargo, and which containers ride each.
*
* A self-haul booking can have several trucks, each with 12 containers, but
* the inventory table has one row per inventory item — so which container sits
* on which truck was never visible without opening a document. Fetched lazily:
* only an expanded row costs a request.
*/
export function TruckBreakdownRow({
bookingId,
colSpan,
}: {
bookingId: string;
colSpan: number;
}) {
const { data: trucks = [], isLoading } = useQuery({
queryKey: ["booking-customer-trucks", bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId),
});
return (
<Table.Tr>
<Table.Td colSpan={colSpan} bg="var(--mantine-color-gray-0)">
{isLoading ? (
<Group gap="xs" py="xs">
<Loader size="xs" />
<Text size="xs" c="dimmed">
Loading trucks
</Text>
</Group>
) : trucks.length === 0 ? (
<Text size="xs" c="dimmed" py="xs">
No customer trucks assigned to this booking.
</Text>
) : (
<Table verticalSpacing={4} withRowBorders={false}>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Text size="xs" c="dimmed">
Truck
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Driver
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Type
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Containers
</Text>
</Table.Th>
<Table.Th>
<Text size="xs" c="dimmed">
Status
</Text>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{trucks.map((truck) => (
<Table.Tr key={truck.id}>
<Table.Td>
<Text size="xs" fw={600}>
{truck.plateNumber}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.driverName}</Text>
</Table.Td>
<Table.Td>
<Text size="xs">{truck.truckType}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry loose tonnage, not containers. */}
<Text size="xs">
{truck.containers?.length
? truck.containers.map((c) => c.containerNumber).join(", ")
: "Bulk"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="xs"
radius="sm"
variant="light"
color={truck.arrivedAt ? "edr-green" : "gray"}
>
{truck.arrivedAt
? `Arrived ${new Date(truck.arrivedAt).toLocaleString()}`
: "Not arrived"}
</Badge>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Table.Td>
</Table.Tr>
);
}

View File

@@ -13,6 +13,19 @@ import { ReceiveInventoryModal } from './ReceiveInventoryModal';
interface WarehouseInfoCardProps {
bookingId: string;
bookingReference?: string;
/**
* Booking payment status. Export cargo is received into the warehouse only
* after the booking is paid — receiving an unpaid booking starts storage and
* GRN against cargo the customer has not settled. Optional so existing callers
* that do not have the booking to hand keep their current behaviour.
*/
paymentStatus?: string | null;
/**
* IMPORT | EXPORT | DOMESTIC. The payment gate is export-only: import cargo
* arrives OFF a train, so blocking its receive would strand cargo already at
* the yard.
*/
tradeDirection?: string | null;
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
@@ -28,7 +41,12 @@ function Row({ label, value }: { label: string; value: React.ReactNode }) {
);
}
export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfoCardProps) {
export function WarehouseInfoCard({
bookingId,
bookingReference,
paymentStatus,
tradeDirection,
}: WarehouseInfoCardProps) {
const [modalOpen, setModalOpen] = useState(false);
const { data, isLoading } = useQuery(
api.warehouses.listInventory.queryOptions({ input: { filter: { bookingId } } }),
@@ -46,6 +64,13 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
const wagon = scheduleView?.wagon;
const isLoadedOrDispatched =
latest?.status === 'LOADED' || latest?.status === 'DISPATCHED';
// Export only, and only when we were actually told the status — an absent prop
// means the caller cannot answer, and guessing "unpaid" would disable a valid
// action. Mirrors the server guard on receive().
const awaitingPayment =
tradeDirection?.toUpperCase() === 'EXPORT' &&
paymentStatus != null &&
paymentStatus.toUpperCase() !== 'PAID';
return (
<Card withBorder radius="md" padding="lg">
@@ -127,8 +152,12 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
)}
<Tooltip
label="This booking is already received at the warehouse"
disabled={!latest}
label={
latest
? 'This booking is already received at the warehouse'
: 'This booking is not paid yet — cargo can only be received once payment is settled'
}
disabled={!latest && !awaitingPayment}
withArrow
>
{/* span wrapper so the tooltip still fires on the disabled button */}
@@ -138,7 +167,7 @@ export function WarehouseInfoCard({ bookingId, bookingReference }: WarehouseInfo
leftSection={<PackagePlus size={16} />}
onClick={() => setModalOpen(true)}
fullWidth
disabled={Boolean(latest)}
disabled={Boolean(latest) || awaitingPayment}
>
{latest ? 'Received At Warehouse' : 'Receive At Warehouse'}
</Button>

View File

@@ -1,6 +1,6 @@
import { useState, type MouseEvent } from 'react';
import { Fragment, useState, type MouseEvent } from 'react';
import { ActionIcon, Badge, Button, Checkbox, Group, Table, Text, Tooltip } from '@mantine/core';
import { ArrowRightLeft, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { ArrowRightLeft, ChevronDown, ChevronRight, ClipboardList, Coins, Download, Eye, FileText, History, MapPin } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
@@ -10,6 +10,7 @@ import {
type WarehouseInventoryItem,
} from '@/types/warehouse';
import { InventoryStatusBadge } from './badges';
import { TruckBreakdownRow } from './TruckBreakdownRow';
import { extractDownloadErrorMessage, formatDate, formatNumber, humanizeEnum } from './options';
import { openPdfBlob } from './pdf';
@@ -120,6 +121,16 @@ export function WarehouseInventoryTable({
someSelected,
}: WarehouseInventoryTableProps) {
const selectable = Boolean(onToggleSelect);
// Bookings whose truck breakdown is open. Expanded rows fetch on demand, so a
// closed table costs nothing extra.
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const toggleExpanded = (bookingId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(bookingId)) next.delete(bookingId);
else next.add(bookingId);
return next;
});
if (items.length === 0) {
return (
@@ -144,6 +155,8 @@ export function WarehouseInventoryTable({
/>
</Table.Th>
)}
{/* Expander for the per-truck breakdown. */}
<Table.Th w={32} />
<Table.Th>Booking</Table.Th>
<Table.Th>GRN</Table.Th>
<Table.Th>Facility</Table.Th>
@@ -174,8 +187,16 @@ export function WarehouseInventoryTable({
(!item.booking?.tradeDirection || item.booking.tradeDirection === 'IMPORT');
const handoverReference = handoverDocumentReference(item);
// Only offer the breakdown where there is one: a plate means at
// least one customer truck is on the booking.
const hasCustomerTrucks = Boolean(
item.bookingId && item.booking?.customerTruckPlateNumber?.trim(),
);
const isExpanded = Boolean(item.bookingId && expanded.has(item.bookingId));
return (
<Table.Tr key={item.id}>
<Fragment key={item.id}>
<Table.Tr>
{selectable && (
<Table.Td>
<Checkbox
@@ -185,6 +206,23 @@ export function WarehouseInventoryTable({
/>
</Table.Td>
)}
<Table.Td>
{hasCustomerTrucks ? (
<Tooltip
label={isExpanded ? 'Hide trucks' : 'Show which containers ride which truck'}
withArrow
>
<ActionIcon
variant="subtle"
size="sm"
aria-label={isExpanded ? 'Hide trucks' : 'Show trucks'}
onClick={() => toggleExpanded(item.bookingId as string)}
>
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</ActionIcon>
</Tooltip>
) : null}
</Table.Td>
<Table.Td>
{item.bookingReference || item.booking?.reference || item.bookingId ? (
<Tooltip label={item.bookingId ?? ''} withArrow disabled={!item.bookingId}>
@@ -309,6 +347,15 @@ export function WarehouseInventoryTable({
</Group>
</Table.Td>
</Table.Tr>
{isExpanded && item.bookingId ? (
<TruckBreakdownRow
bookingId={item.bookingId}
// Expander + every data column + actions, plus the checkbox
// when the table is selectable.
colSpan={selectable ? 14 : 13}
/>
) : null}
</Fragment>
);
})}
</Table.Tbody>

View File

@@ -127,10 +127,6 @@ export const URL_CONSTANTS = {
`/bookings/${id}/staff/request-changes`,
STAFF_REJECT: (id: string) => `/bookings/${id}/staff/reject`,
GOVERNMENT_EXPEDITE: (id: string) => `/bookings/${id}/government-expedite`,
APPROVE_STEP: (id: string, stepId: string) =>
`/bookings/${id}/approval-steps/${stepId}/approve`,
REJECT_STEP: (id: string, stepId: string) =>
`/bookings/${id}/approval-steps/${stepId}/reject`,
CONTRACT_GENERATE: (id: string) => `/bookings/${id}/contract/generate`,
CONTRACT_VIEW: (id: string) => `/bookings/${id}/contract/view`,
CONTRACT_DOCUMENT: (id: string) => `/bookings/${id}/contract/document`,
@@ -181,6 +177,8 @@ export const URL_CONSTANTS = {
CONTRACT_DOCUMENT: (id: string) => `/contracts/${id}/contract/document`,
CONTRACT_SIGN: (id: string) => `/contracts/${id}/contract/sign`,
CONTRACT_DOCUMENT_DRAFT: (id: string) => `/contracts/${id}/document/draft`,
CONTRACT_DOCUMENT_REVISIONS: (id: string) =>
`/contracts/${id}/document/revisions`,
CONTRACT_DOCUMENT_ARTICLES: (id: string) =>
`/contracts/${id}/document/articles`,
CLEARANCE_QUEUE: "/contracts/clearance/queue",
@@ -441,6 +439,7 @@ export const URL_CONSTANTS = {
APPROVAL_RULES: "/approval-rules",
APPROVAL_RULE_BY_ID: (id: string) => `/approval-rules/${id}`,
APPROVAL_RULES_CHAIN: "/approval-rules/chain",
APPROVAL_RULES_POSITION_TYPES: "/approval-rules/position-types",
},
RATE_MATRIX: {
BASE: "/api/rate-matrices",
@@ -495,6 +494,7 @@ export const URL_CONSTANTS = {
RESERVE: "/warehouse-inventory/reserve",
ARRIVAL_QUEUE: "/warehouse-inventory/arrival-queue",
OPS_STATS: "/warehouse-inventory/ops-stats",
TRUCKS_ON_SITE: "/warehouse-inventory/trucks-on-site",
THROUGHPUT: (granularity: 'week' | 'month' | 'year') =>
`/warehouse-inventory/throughput?granularity=${granularity}`,
DWELL_STATS: "/warehouse-inventory/dwell-stats",

View File

@@ -1,79 +0,0 @@
import type { BookingApprovalStep, BookingStatus } from "@/types/booking";
import { getNextPendingApprovalStep } from "@/features/bookings/booking-actions.config";
export interface ApprovalProgressSummary {
label: string;
detail: string;
complete: boolean;
}
/** Compact approval chain summary for list rows and badges. */
export function formatApprovalProgress(
status: BookingStatus | string,
steps?: BookingApprovalStep[] | 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",
"PAID",
"COMPLETED",
].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 = getNextPendingApprovalStep(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

@@ -14,18 +14,12 @@ import {
hasPermission,
isFreightApprovalAdmin,
} from "@/lib/permissions";
import type {
BookingApprovalStep,
BookingDetail,
BookingStatus,
} from "@/types/booking";
import type { BookingDetail, BookingStatus } from "@/types/booking";
export type BookingActionId =
| "accept"
| "requestChanges"
| "reject"
| "approve"
| "rejectApproval"
| "viewContract"
| "signContractStaff"
| "reviewClearance"
@@ -62,7 +56,6 @@ export type BookingActionContext = Pick<
BookingDetail,
| "status"
| "paymentCurrency"
| "approvalSteps"
| "reference"
| "schedulingStatus"
| "customsClearingEnabled"
@@ -86,39 +79,6 @@ export function canAllocateBooking(
);
}
export function getNextPendingApprovalStep(
steps?: BookingApprovalStep[] | null,
): BookingApprovalStep | undefined {
if (!steps?.length) return undefined;
return [...steps]
.sort((a, b) => a.stepOrder - b.stepOrder)
.find((s) => s.status === "PENDING");
}
function approvalActions(
steps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
const next = getNextPendingApprovalStep(steps);
if (!next) return [];
return [
buildApproveActionForStep(next),
{
id: "rejectApproval",
label: "Reject approval",
shortLabel: "Reject",
description: "Reject at the current approval step",
confirmTitle: "Reject at approval step?",
confirmDescription:
"The booking will be marked rejected. This action cannot be undone from the UI.",
variant: "destructive",
icon: XCircle,
input: "reason",
inputLabel: "Rejection reason",
inputPlaceholder: "Explain why this booking is rejected…",
},
];
}
const SUBMITTED_ACTIONS: BookingActionDef[] = [
{
id: "accept",
@@ -232,7 +192,6 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
accept: FREIGHT_PERMS.bookings.staffAccept,
requestChanges: FREIGHT_PERMS.bookings.requestChanges,
reject: FREIGHT_PERMS.bookings.reject,
rejectApproval: FREIGHT_PERMS.bookings.rejectApproval,
viewContract: FREIGHT_PERMS.bookings.view,
signContractStaff: FREIGHT_PERMS.bookings.signStaff,
reviewClearance: FREIGHT_PERMS.bookings.reviewDocuments,
@@ -244,55 +203,12 @@ const ACTION_PERMISSION: Partial<Record<BookingActionId, string>> = {
cancel: FREIGHT_PERMS.bookings.cancel,
};
const approvePermissionForRole = (role: string): string | undefined => {
if (role === "LINE_STAFF") return FREIGHT_PERMS.bookings.approveLineStaff;
if (role === "DIRECTOR") return FREIGHT_PERMS.bookings.approveDirector;
if (role === "CEO") return FREIGHT_PERMS.bookings.approveCeo;
return undefined;
};
/** True when this step is the current pending step and the user may approve it. */
export function canActOnApprovalStep(
user: AuthUser | null | undefined,
step: BookingApprovalStep,
steps?: BookingApprovalStep[] | null,
): boolean {
if (step.status !== "PENDING") return false;
const next = getNextPendingApprovalStep(steps);
if (!next || next.id !== step.id) return false;
if (isFreightApprovalAdmin(user)) return true;
const perm = approvePermissionForRole(step.requiredRole);
return perm ? hasPermission(user, perm) : false;
}
export function buildApproveActionForStep(
step: BookingApprovalStep,
): BookingActionDef {
return {
id: "approve",
label: `Approve (${step.requiredRole})`,
shortLabel: "Approve",
description: `Complete step ${step.stepOrder} as ${step.requiredRole}`,
confirmTitle: `Approve as ${step.requiredRole}?`,
confirmDescription:
"This records your approval and advances the booking to the next step in the chain.",
variant: "default",
icon: Check,
primary: true,
};
}
function filterActionsByUser(
actions: BookingActionDef[],
user: AuthUser | null | undefined,
approvalSteps?: BookingApprovalStep[] | null,
): BookingActionDef[] {
if (!user) return [];
const next = getNextPendingApprovalStep(approvalSteps);
return actions.filter((action) => {
if (action.id === "approve" && next) {
return canActOnApprovalStep(user, next, approvalSteps);
}
const perm = ACTION_PERMISSION[action.id];
return perm ? hasPermission(user, perm) : true;
});
@@ -303,7 +219,7 @@ export function getBookingActions(
ctx: BookingActionContext,
user?: AuthUser | null,
): BookingActionDef[] {
const { status, approvalSteps } = ctx;
const { status } = ctx;
let actions: BookingActionDef[];
@@ -313,8 +229,6 @@ export function getBookingActions(
break;
case "PENDING_APPROVAL":
case "APPROVED_PENDING_SIGNATURE":
actions = withCancel(approvalActions(approvalSteps));
break;
case "APPROVED":
actions = [CANCEL_ACTION];
break;
@@ -370,7 +284,7 @@ export function getBookingActions(
}
if (user === undefined) return actions;
return filterActionsByUser(actions, user, approvalSteps);
return filterActionsByUser(actions, user);
}
/** Opens contract page without confirmation dialog. */
@@ -392,7 +306,6 @@ export function listRowHasActions(
row: {
status: BookingStatus;
paymentCurrency: string;
approvalSteps?: BookingApprovalStep[] | null;
customsClearingEnabled?: boolean;
},
user?: AuthUser | null,
@@ -402,7 +315,6 @@ export function listRowHasActions(
status: row.status,
paymentCurrency: row.paymentCurrency,
reference: "",
approvalSteps: row.approvalSteps ?? undefined,
schedulingStatus: row.status,
customsClearingEnabled: row.customsClearingEnabled,
},

View File

@@ -20,7 +20,6 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")
: labelFromRef(booking.company, booking.companyId ?? undefined),
@@ -46,6 +45,8 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
consolidationPartnerId: booking.consolidationPartnerId ?? null,
consolidationPartnerReference: booking.consolidationPartner?.reference ?? null,
customsClearingEnabled: booking.customsClearingEnabled ?? false,
bookingKind:
booking.contractKind === "GENERAL" ? "GENERAL_CONTRACT" : "ONE_TIME",
createdAt: booking.createdAt,
};
}

View File

@@ -87,40 +87,6 @@ export function useBookingMutations(bookingId: string) {
},
});
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
api.bookings.approveStep.call({
id: bookingId,
stepId,
requiredRole,
}),
onSuccess: (data) => onSuccess(data, "Approval step completed"),
onError: (error) => toast.error(parseApiError(error, "Failed to approve step")),
});
const rejectStep = useMutation({
mutationFn: ({
stepId,
reason,
}: {
stepId: string;
reason: string;
}) =>
api.bookings.rejectStep.call({
id: bookingId,
stepId,
reason,
}),
onSuccess: (data) => onSuccess(data, "Booking rejected at approval step"),
onError: (error) => toast.error(parseApiError(error, "Failed to reject step")),
});
const generateContract = useMutation({
mutationFn: () => api.bookings.generateContract.call({ id: bookingId }),
onSuccess: (data) => onSuccess(data, "Contract generated"),
@@ -167,8 +133,6 @@ export function useBookingMutations(bookingId: string) {
staffAccept.isPending ||
requestChanges.isPending ||
staffReject.isPending ||
approveStep.isPending ||
rejectStep.isPending ||
generateContract.isPending ||
signContract.isPending ||
payBooking.isPending ||
@@ -182,8 +146,6 @@ export function useBookingMutations(bookingId: string) {
requestChanges,
staffReject,
reviewOperation,
approveStep,
rejectStep,
generateContract,
signContract,
payBooking,

View File

@@ -122,6 +122,15 @@ export function useContractMutations(contractId: string) {
const onSuccess = (data: { id: string }, message: string) => {
toast.success(message);
void invalidateContractDetail(qc, data.id);
// Approving or editing can change who holds document-editing rights (it
// passes to the next approver), and edits add revisions — so both the draft
// and the history are refreshed on every contract mutation.
void qc.invalidateQueries({
queryKey: ["contracts", data.id, "document-draft"],
});
void qc.invalidateQueries({
queryKey: ["contracts", data.id, "document-revisions"],
});
};
const staffAccept = useMutation({
@@ -160,21 +169,16 @@ export function useContractMutations(contractId: string) {
});
const approveStep = useMutation({
mutationFn: ({
stepId,
requiredRole,
}: {
stepId: string;
requiredRole: string;
}) =>
contractsService.approveStep({ id: contractId, stepId, requiredRole }),
// The server derives the required role from the step itself, so the client
// does not send one.
mutationFn: ({ stepId }: { stepId: string }) =>
contractsService.approveStep({ id: contractId, stepId }),
onSuccess: (data) => {
// The document is generated at the accept stage and reviewed during
// approval, so the final approval moves the contract straight to
// CONTRACT_READY on the server — no client-side generate call here.
// The final approval is what generates the PDF and moves the contract to
// CONTRACT_READY — before that approvers review a live preview.
const message =
data.status === "CONTRACT_READY"
? "Final approval complete — contract ready to sign"
? "Final approval complete — contract generated and ready to sign"
: "Approval step completed";
onSuccess(data, message);
},

View File

@@ -9,7 +9,10 @@ import {
type SubmitPriorityRuleChangePayload,
type SubmitRateChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import {
LEGACY_APPROVAL_ROLES,
RULE_ENGINE_SELECT_NONE,
} from "@/pages/ruleEngine/config/resources";
import type {
RuleEngineRecord,
RuleEngineResourceSlug,
@@ -155,6 +158,33 @@ export const useContainerTypeOptions = (
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
});
/**
* Approval-step role options, sourced from the live IAM position types. The
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
* rule still stored against one of them renders its label instead of an empty
* select; a position type that reuses one of those values wins the dedupe.
*/
export const useApprovalRoleOptions = (enabled = true) =>
useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("approval-rules", {
positionTypes: true,
}),
queryFn: () => ruleEngineService.getApprovalPositionTypes(),
enabled,
select: (rows): { label: string; value: string }[] => {
const byValue = new Map<string, { label: string; value: string }>();
for (const row of rows) {
const value = String(row?.value ?? "").trim();
if (!value) continue;
byValue.set(value, { label: String(row.label ?? "").trim() || value, value });
}
for (const legacy of LEGACY_APPROVAL_ROLES) {
if (!byValue.has(legacy.value)) byValue.set(legacy.value, legacy);
}
return [...byValue.values()];
},
});
/** A yard option that remembers its country, so callers can filter by leg. */
export interface YardOption {
label: string;

View File

@@ -154,6 +154,15 @@ export function useWarehouseOpsStats() {
});
}
/** Trucks in the yard right now — refreshes with the rest of the ops widgets. */
export function useTrucksOnSite() {
return useQuery({
queryKey: ['warehouse-inventory', 'trucks-on-site'],
queryFn: () => warehouseService.trucksOnSite().then((r) => r.data),
refetchInterval: DASHBOARD_REFETCH_MS,
});
}
/** How often the live warehouse dashboard widgets auto-refresh (ms). */
export const DASHBOARD_REFETCH_MS = 60_000;

View File

@@ -4,7 +4,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import {
BookingApprovalCard,
BookingContainersCard,
BookingDetailToolbar,
BookingDocumentsCard,
@@ -75,29 +74,6 @@ const BookingDetailPage = () => {
containerType: { label: "20FT Standard", sizeFt: 20 },
},
],
approvalSteps: [
{
id: "1",
stepOrder: 1,
requiredRole: "LINE_STAFF",
status: "APPROVED",
actionedAt: "2026-06-05T11:00:00Z",
},
// {
// id: "2",
// stepOrder: 2,
// requiredRole: "DIRECTOR",
// status: "APPROVED",
// actionedAt: "2026-06-05T13:30:00Z",
// },
{
id: "3",
stepOrder: 3,
requiredRole: "CEO",
status: "APPROVED",
actionedAt: "2026-06-05T15:45:00Z",
},
],
reviewNotes: [
{
id: "1",
@@ -119,11 +95,6 @@ const BookingDetailPage = () => {
],
};
const approvalSteps = booking.approvalSteps ?? [];
const approvedCount = approvalSteps.filter(
(s) => s.status === "APPROVED",
).length;
return (
<div style={detailStyles.page}>
<Container size="xxl" py="lg">
@@ -136,13 +107,6 @@ const BookingDetailPage = () => {
{ label: booking.reference },
]}
/>
{/*
<BookingDetailHeader
booking={booking}
approvedCount={approvedCount}
totalSteps={totalSteps}
/> */}
<BookingLifecycleStepper status={booking.status} />
<Grid>
@@ -164,10 +128,6 @@ const BookingDetailPage = () => {
await allocateMutation.mutateAsync({ allocations });
}}
/>
<BookingApprovalCard
steps={approvalSteps}
approvedCount={approvedCount}
/>
<BookingReviewNotesCard notes={booking.reviewNotes ?? []} />
</Stack>
</Grid.Col>

View File

@@ -23,7 +23,6 @@ import {
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { ApprovalStepsCard } from "@/components/bookings/ApprovalStepsCard";
import { BookingActionsToolbar } from "@/components/bookings/BookingActionsToolbar";
import { BookingPricingSummary } from "@/components/bookings/BookingPricingSummary";
import { BookingWorkflowStepper } from "@/components/bookings/BookingWorkflowStepper";
@@ -130,10 +129,6 @@ export default function BookingRequestDetailPage() {
const row = toBookingListRow(booking);
const statusMeta = getStatusMeta(booking.status);
const showApprovalCard =
booking.status === "PENDING_APPROVAL" ||
booking.status === "APPROVED_PENDING_SIGNATURE";
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
// bookings are handled in the Global Logistics clearance queue instead.
const showClearanceTab =
@@ -265,6 +260,8 @@ export default function BookingRequestDetailPage() {
<WarehouseInfoCard
bookingId={booking.id}
bookingReference={booking.reference}
paymentStatus={booking.paymentStatus}
tradeDirection={booking.tradeDirection}
/>
</Box>
<BookingActionsToolbar
@@ -283,9 +280,6 @@ export default function BookingRequestDetailPage() {
View document clearance
</Button>
)}
{showApprovalCard && (
<ApprovalStepsCard booking={booking} mutations={mutations} />
)}
</Stack>
</Box>
</Grid.Col>

View File

@@ -7,7 +7,6 @@ import {
MultiSelect,
Select,
Stack,
Tabs,
Text,
TextInput,
} from "@mantine/core";
@@ -33,7 +32,6 @@ import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu";
import { BookingApprovalProgressCell } from "@/components/bookings/BookingApprovalProgressCell";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
// BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs.
@@ -60,10 +58,10 @@ import {
type ColumnDef,
} from "@edr/ui-common";
/** The two booking-kind tabs: one-time vs general-contract bookings. */
type BookingKindTab = "ONE_TIME" | "GENERAL_CONTRACT";
/** Booking kind: one-time vs general-contract bookings. Now a filter, not a tab. */
type BookingKind = "ONE_TIME" | "GENERAL_CONTRACT";
const BOOKING_KIND_TABS: { value: BookingKindTab; label: string }[] = [
const BOOKING_KIND_OPTIONS: { value: BookingKind; label: string }[] = [
{ value: "ONE_TIME", label: "One-time booking" },
{ value: "GENERAL_CONTRACT", label: "General booking" },
];
@@ -128,9 +126,9 @@ export default function BookingRequestsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
// Booking-kind tabs (one-time vs general contract) replace the old status tabs.
const [kindTab, setKindTab] = useState<BookingKindTab>("ONE_TIME");
// Per-tab filter controls (empty/null = "all").
// Booking kind is a filter now — one list holds both kinds (null = "all").
const [kindFilter, setKindFilter] = useState<BookingKind | null>(null);
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
@@ -158,9 +156,9 @@ export default function BookingRequestsPage() {
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
// React Query cache key per kind tab.
tab: kindTab,
bookingType: kindTab,
// React Query cache key per kind selection ("ALL" when unfiltered).
tab: kindFilter ?? "ALL",
...(kindFilter ? { bookingType: kindFilter } : {}),
// Server-side free-text search (booking ref, customer, contract ref).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}),
@@ -182,7 +180,7 @@ export default function BookingRequestsPage() {
}, [
pagination.pageIndex,
pagination.pageSize,
kindTab,
kindFilter,
debouncedQuery,
statusFilter,
directionFilter,
@@ -226,6 +224,7 @@ export default function BookingRequestsPage() {
}, [setPagination, pagination.pageSize]);
const activeFilterCount =
(kindFilter ? 1 : 0) +
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
@@ -237,6 +236,7 @@ export default function BookingRequestsPage() {
(scheduledFrom || scheduledTo ? 1 : 0);
const clearFilters = useCallback(() => {
setKindFilter(null);
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
@@ -327,6 +327,23 @@ export default function BookingRequestsPage() {
);
},
},
{
id: "bookingKind",
header: () => <span className={bookingTable.headerCell}>Type</span>,
cell: ({ row }) => {
const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT";
return (
<div className="py-1">
<Badge
variant={isGeneral ? "secondary" : "outline"}
className="h-5 px-1.5 text-[10px] font-medium"
>
{isGeneral ? "General" : "One-time"}
</Badge>
</div>
);
},
},
{
id: "route",
header: () => <span className={bookingTable.headerCell}>Route</span>,
@@ -376,13 +393,6 @@ export default function BookingRequestsPage() {
cellClassName: "min-w-[11rem]",
},
},
{
id: "approval",
header: () => (
<span className={bookingTable.headerCell}>Approval</span>
),
cell: ({ row }) => <BookingApprovalProgressCell row={row.original} />,
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
@@ -482,22 +492,6 @@ export default function BookingRequestsPage() {
/>
*/}
<Tabs
value={kindTab}
onChange={(value) => {
setKindTab((value as BookingKindTab) ?? "ONE_TIME");
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
>
<Tabs.List>
{BOOKING_KIND_TABS.map((t) => (
<Tabs.Tab key={t.value} value={t.value}>
{t.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -535,6 +529,18 @@ export default function BookingRequestsPage() {
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<Select
placeholder="All booking types"
data={BOOKING_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter((v as BookingKind | null) ?? null);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 190 }}
/>
<MultiSelect
placeholder={statusFilter.length ? undefined : "All statuses"}
data={STATUS_OPTIONS}

View File

@@ -0,0 +1,84 @@
/*
* Scoped to .edr-booking-requests-table — the DataTable container div on the
* backoffice booking-requests list only; no other DataTable is affected.
* Mirrors the portal's /bookings table (bookings-table.css): horizontal
* scroll on the container, a sticky header row, and a sticky/shadowed
* action column — with a compact 4060px column width band (content
* beyond that is clipped with an ellipsis) instead of the portal's
* content-sized columns.
*/
.edr-booking-requests-table {
overflow-x: auto;
}
/*
* width: max-content — the table is exactly as wide as its columns need,
* never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-booking-requests-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
/* Compact column band: 40px floor, 60px ceiling, ellipsis past that. */
.edr-booking-requests-table th,
.edr-booking-requests-table td:not([colspan]) {
min-width: 40px;
max-width: 60px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/*
* Full-width rows (loading skeleton / error / empty state) span every
* column via colspan — leave their sizing and wrapping alone.
*/
.edr-booking-requests-table td[colspan] {
max-width: none;
white-space: normal;
}
/* Sticky header row. */
.edr-booking-requests-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Fixed, sticky action column. Overrides the inline width DataTable stamps
* from tanstack's column size (`size: 140` on the actions column) — hence
* !important. `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-booking-requests-table th:last-child,
.edr-booking-requests-table td:last-child:not([colspan]) {
width: 60px !important;
min-width: 60px;
max-width: 60px;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-booking-requests-table td:last-child:not([colspan]) {
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-booking-requests-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-booking-requests-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -50,6 +50,7 @@ import { ContractActionsToolbar } from "@/components/contracts/ContractActionsTo
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
import {
ContractCustomerCard,
ContractDocumentsCard,
@@ -493,6 +494,7 @@ export default function ContractRequestDetailPage() {
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractRevisionTimeline contractId={contract.id} />
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"

View File

@@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [
{ label: "Out of service", value: "OUT_OF_SERVICE" },
];
// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here:
// staff still need to search for assigned wagons.
const WAGON_STATUS_OPTIONS = [
{ label: "Available", value: Freight.WagonStatus.Available },
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
@@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [
{ label: "Detained", value: Freight.WagonStatus.Detained },
];
// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted
// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a
// train, never by editing it directly. Setting it by hand produced wagons that
// claim to be assigned while coupled to nothing, which the yard workspace then
// counts as in-yard stock.
const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter(
(o) => o.value !== Freight.WagonStatus.Assigned,
);
@@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS },
{ name: "notes", label: "Notes", type: "textarea" },
],
emptyValues: {

View File

@@ -36,6 +36,7 @@ import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import {
useApprovalChain,
useApprovalRoleOptions,
useCargoLeafOptions,
useCargoTypeParentOptions,
useContainerTypeOptions,
@@ -247,6 +248,13 @@ const RuleEngineResourcePage = () => {
);
const { data: yardOptions, isLoading: yardOptionsLoading } =
useYardOptions(usesYardField);
const usesApprovalRoleField = Boolean(
config?.formFields.some(
(f) => f.name === "requiredRole" || f.name === "blocksRole",
),
);
const { data: approvalRoleOptions, isLoading: approvalRoleOptionsLoading } =
useApprovalRoleOptions(usesApprovalRoleField);
// Full rule list backing the auto-filled "min wagon count": the next range
// always continues the chain for the selected type (per currency), so the
@@ -321,6 +329,20 @@ const RuleEngineResourcePage = () => {
options: wagonTypeOptions ?? [],
};
}
// Approval steps are configured against live IAM position types; until
// they load, the static legacy list on the field config stands in so an
// existing row's role still shows a label.
if (field.name === "requiredRole" || field.name === "blocksRole") {
if (!approvalRoleOptions) return field;
const includeNone = field.name === "blocksRole";
return {
...field,
type: "select" as const,
options: includeNone
? [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...approvalRoleOptions]
: approvalRoleOptions,
};
}
// Each end of the leg only offers yards in the country that end of the
// trade actually sits in, so an import can't be configured as if it
// started inland. Resolved per keystroke because the legal set changes
@@ -336,7 +358,7 @@ const RuleEngineResourcePage = () => {
}
return field;
});
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, isPriorityRules, allPriorityRules, editing, editingId]);
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions, yardOptions, approvalRoleOptions, isPriorityRules, allPriorityRules, editing, editingId]);
const rows = data?.items ?? [];
const meta = data?.meta;
@@ -765,7 +787,8 @@ const RuleEngineResourcePage = () => {
(usesCargoTypeField && cargoLeafOptionsLoading) ||
(usesLiveRateField && liveRateOptionsLoading) ||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
(usesYardField && yardOptionsLoading)
(usesYardField && yardOptionsLoading) ||
(usesApprovalRoleField && approvalRoleOptionsLoading)
}
positionOptions={!editing ? createPositionOptions : undefined}
positionLoading={createPositionLoading}

View File

@@ -118,10 +118,16 @@ const YARD_COUNTRIES = [
{ label: "Djibouti", value: "Djibouti" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
{ label: "CEO", value: "CEO" },
/**
* The three role strings the approval chain was hardcoded to before it was
* driven by IAM position types. Kept only so rows still stored against them
* render a readable label instead of a blank select — the live options come
* from GET /approval-rules/position-types (see `useApprovalRoleOptions`).
*/
export const LEGACY_APPROVAL_ROLES = [
{ label: "Line staff (legacy)", value: "LINE_STAFF" },
{ label: "Director (legacy)", value: "DIRECTOR" },
{ label: "CEO (legacy)", value: "CEO" },
];
/**
@@ -718,7 +724,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Required role",
type: "select",
required: true,
options: APPROVAL_ROLES,
// Replaced at render time with live IAM position types (+ legacy values).
options: LEGACY_APPROVAL_ROLES,
},
{ name: "actionLabel", label: "Action label", type: "text", required: true },
{
@@ -726,7 +733,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
label: "Blocks role",
type: "select",
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
// Replaced at render time with live IAM position types (+ legacy values).
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...LEGACY_APPROVAL_ROLES],
},
],
},

View File

@@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios";
import {
ArrowRight,
Ban,
// Ban, — used only by the commented-out "Cancel schedule" row action
CalendarClock,
Clock,
Eye,
@@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() {
}),
);
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
// const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
// API rejects them, so keep them out of the picker entirely.
@@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() {
Booking window settings
</Menu.Item>
) : null}
{/* Cancel schedule — hidden for now (frontend only; the
cancelSchedule mutation is untouched). Restore by
uncommenting.
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
<Menu.Item
color="red"
@@ -487,6 +490,7 @@ export default function TrainScheduleV2ListPage() {
Cancel schedule
</Menu.Item>
) : null}
*/}
</Menu.Dropdown>
</Menu>
</Group>
@@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() {
},
},
];
}, [navigate, cancel.isPending, cancel, toast]);
}, [navigate, toast]);
const handleCreate = async () => {
if (!routeId || !scheduleDate || !trainId) {

View File

@@ -0,0 +1,176 @@
import { useMemo, useState } from "react";
import {
Alert,
Badge,
Group,
SegmentedControl,
Table,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { Search } from "lucide-react";
import { PageContainer, PageHeader } from "@/components/page";
import { useTrucksOnSite } from "@/hooks/useWarehouses";
import type { TruckOnSite } from "@/types/warehouse";
/**
* Every truck inside the yard right now, across all bookings.
*
* The gate's question is "which trucks are here", not "which bookings have
* trucks" — the ops dashboard could only count them, never open the list. Both
* haulage paths appear because the same barrier handles both: a customer's own
* truck and an EDR last-mile truck.
*/
/** How long the truck has been on site — the number the gate actually chases. */
function dwell(arrivedAt: string | null): string {
if (!arrivedAt) return "—";
const minutes = Math.floor((Date.now() - new Date(arrivedAt).getTime()) / 60_000);
if (minutes < 1) return "just now";
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ${minutes % 60}m`;
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
}
/** Long dwell means a truck is sitting at the gate — worth flagging, not hiding. */
const LONG_DWELL_HOURS = 4;
function isLongDwell(arrivedAt: string | null): boolean {
if (!arrivedAt) return false;
return Date.now() - new Date(arrivedAt).getTime() > LONG_DWELL_HOURS * 3_600_000;
}
function Rows({ rows }: { rows: TruckOnSite[] }) {
if (rows.length === 0) {
return (
<Alert variant="light" color="gray">
No trucks on site.
</Alert>
);
}
return (
<Table.ScrollContainer minWidth={980}>
<Table striped highlightOnHover verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Plate</Table.Th>
<Table.Th>Haulage</Table.Th>
<Table.Th>Driver</Table.Th>
<Table.Th>Truck type</Table.Th>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Containers</Table.Th>
<Table.Th>On site</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => (
<Table.Tr key={`${row.source}-${row.assignmentId}`}>
<Table.Td>
<Text size="sm" fw={600}>
{row.plateNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
radius="sm"
variant="light"
color={row.source === "CUSTOMER" ? "blue" : "edr-green"}
>
{row.source === "CUSTOMER" ? "Customer" : "EDR"}
</Badge>
</Table.Td>
<Table.Td>
<Text size="sm">{row.driverName ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.truckType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.bookingReference ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customerName ?? "—"}</Text>
</Table.Td>
<Table.Td>
{/* Bulk trucks carry no containers — they haul loose tonnage. */}
<Text size="sm">{row.containers ?? "Bulk"}</Text>
</Table.Td>
<Table.Td>
{isLongDwell(row.arrivedAt) ? (
<Tooltip
label={`On site over ${LONG_DWELL_HOURS}h — arrived ${new Date(row.arrivedAt as string).toLocaleString()}`}
withArrow
>
<Text size="sm" c="red" fw={600}>
{dwell(row.arrivedAt)}
</Text>
</Tooltip>
) : (
<Text size="sm">{dwell(row.arrivedAt)}</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
export default function TrucksOnSitePage() {
const { data: trucks = [], isLoading } = useTrucksOnSite();
const [source, setSource] = useState<"ALL" | "CUSTOMER" | "EDR">("ALL");
const [search, setSearch] = useState("");
const rows = useMemo(() => {
const term = search.trim().toLowerCase();
return trucks
.filter((t) => source === "ALL" || t.source === source)
.filter((t) =>
!term
? true
: [t.plateNumber, t.driverName, t.bookingReference, t.customerName, t.containers]
.some((field) => field?.toLowerCase().includes(term)),
);
}, [trucks, source, search]);
const customerCount = trucks.filter((t) => t.source === "CUSTOMER").length;
const edrCount = trucks.length - customerCount;
return (
<PageContainer>
<PageHeader
title="Trucks on site"
subtitle="Arrived at the yard and not yet left — customer self-haul and EDR last-mile."
/>
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
<SegmentedControl
size="xs"
value={source}
onChange={(v) => setSource(v as typeof source)}
data={[
{ label: `All (${trucks.length})`, value: "ALL" },
{ label: `Customer (${customerCount})`, value: "CUSTOMER" },
{ label: `EDR (${edrCount})`, value: "EDR" },
]}
/>
<TextInput
size="xs"
w={280}
placeholder="Plate, driver, booking, container…"
leftSection={<Search size={14} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Group>
{isLoading ? <Text size="sm">Loading</Text> : <Rows rows={rows} />}
</PageContainer>
);
}

View File

@@ -133,9 +133,7 @@ import { endpoint } from "@/utils/endpoint";
import {
BookingListFilter,
bookingsService,
type ApproveStepPayload,
type PaginatedBookings,
type RejectStepPayload,
} from "./bookings.service";
import { cargoTypesService } from "./cargo-types.service";
import {
@@ -2482,18 +2480,6 @@ export const api = {
bookingsService.reviewOperation(id, decision, { note }),
),
approveStep: endpoint<ApproveStepPayload, BookingDetail>(
"bookings",
"approveStep",
(payload) => bookingsService.approveStep(payload),
),
rejectStep: endpoint<RejectStepPayload, BookingDetail>(
"bookings",
"rejectStep",
(payload) => bookingsService.rejectStep(payload),
),
generateContract: endpoint<{ id: string }, BookingDetail>(
"bookings",
"generateContract",

View File

@@ -72,18 +72,6 @@ export interface BookingListSummary {
tabs: BookingListSummaryTabs;
}
export interface ApproveStepPayload {
id: string;
stepId: string;
requiredRole: string;
}
export interface RejectStepPayload {
id: string;
stepId: string;
reason: string;
}
export interface ContractView {
bookingId: string;
reference: string;
@@ -255,12 +243,6 @@ export const bookingsService = {
...options,
}),
approveStep: ({ id, stepId, requiredRole }: ApproveStepPayload) =>
postBooking<BookingDetail>(B.APPROVE_STEP(id, stepId), { requiredRole }),
rejectStep: ({ id, stepId, reason }: RejectStepPayload) =>
postBooking<BookingDetail>(B.REJECT_STEP(id, stepId), { reason }),
generateContract: (id: string) =>
postBooking<BookingDetail>(B.CONTRACT_GENERATE(id)),

View File

@@ -196,6 +196,14 @@ export const contractsService = {
return unwrap(response.data) as Freight.IContractDocumentDraft;
},
/** Audit trail of edits to this contract's document, newest first. */
getContractDocumentRevisions: async (
id: string,
): Promise<Freight.IContractDocumentRevision[]> => {
const response = await client.get(C.CONTRACT_DOCUMENT_REVISIONS(id));
return unwrap(response.data) as Freight.IContractDocumentRevision[];
},
/** Save this contract's edited document articles (never touches the templates). */
updateContractDocument: async (
id: string,
@@ -214,18 +222,12 @@ export const contractsService = {
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,
}),
/**
* Approve the next pending step. The server resolves the step's required role
* and authorizes against it — the client never declares its own role.
*/
approveStep: ({ id, stepId }: { id: string; stepId: string }) =>
postContract<Freight.IContract>(C.APPROVE_STEP(id, stepId)),
rejectStep: ({
id,

View File

@@ -72,6 +72,12 @@ export interface SubmitRateChangePayload {
update: Record<string, unknown>;
}
/** One selectable IAM position type, as returned by /approval-rules/position-types. */
export interface ApprovalPositionType {
label: string;
value: string;
}
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -368,6 +374,28 @@ export const ruleEngineService = {
return unwrap(response.data) as RateChangeRequest;
},
/**
* IAM position types that an approval step can require/block. Replaces the
* old hardcoded LINE_STAFF/DIRECTOR/CEO triple — the chain is configured from
* whatever positions IAM actually defines.
*/
getApprovalPositionTypes: async (): Promise<ApprovalPositionType[]> => {
const response = await client.get(
URL_CONSTANTS.RULE_ENGINE.APPROVAL_RULES_POSITION_TYPES,
);
const body = unwrap(response.data) as unknown;
if (Array.isArray(body)) return body as ApprovalPositionType[];
if (
body &&
typeof body === "object" &&
"data" in body &&
Array.isArray((body as { data: unknown }).data)
) {
return (body as { data: ApprovalPositionType[] }).data;
}
return [];
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {

View File

@@ -5,6 +5,7 @@ import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
import type {
ZoneOccupancy,
TruckOnSite,
WarehouseOpsStats,
WarehouseThroughputPoint,
WarehouseDwellStats,
@@ -404,6 +405,9 @@ export const warehouseService = {
),
opsStats: () =>
apiClient.get<WarehouseOpsStats>(URL_CONSTANTS.WAREHOUSE_INVENTORY.OPS_STATS),
/** Trucks inside the yard right now — the list behind the trucksOnSite figure. */
trucksOnSite: () =>
apiClient.get<TruckOnSite[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.TRUCKS_ON_SITE),
throughput: (granularity: 'week' | 'month' | 'year') =>
apiClient.get<WarehouseThroughputPoint[]>(
URL_CONSTANTS.WAREHOUSE_INVENTORY.THROUGHPUT(granularity),

View File

@@ -101,16 +101,6 @@ export interface BookingContainerLine {
units?: BookingContainerUnit[];
}
export interface BookingApprovalStep {
id: string;
stepOrder: number;
requiredRole: string;
blocksRole?: string | null;
status: "PENDING" | "APPROVED" | "REJECTED" | "SKIPPED";
actionedAt?: string | null;
remarks?: string | null;
}
export interface BookingNextStep {
action: string;
description: string;
@@ -219,7 +209,6 @@ export interface BookingDetail {
cargoType?: BookingNamedRef;
shippingLine?: BookingNamedRef;
bookingContainers?: BookingContainerLine[];
approvalSteps?: BookingApprovalStep[];
reviewNotes?: BookingReviewNote[];
files?: BookingFile[];
cargoModifiers?: Array<{
@@ -236,7 +225,6 @@ export interface BookingListRow {
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;
scheduledDate: string;
totalAmount: number;
@@ -255,5 +243,11 @@ export interface BookingListRow {
consolidationPartnerId?: string | null;
consolidationPartnerReference?: string | null;
customsClearingEnabled?: boolean;
/**
* Derived booking kind for the list "Type" column. Mirrors the server's
* bookingType filter: bookings under a GENERAL contract are general,
* everything else is one-time.
*/
bookingKind?: "ONE_TIME" | "GENERAL_CONTRACT";
createdAt: string;
}

View File

@@ -879,9 +879,12 @@ export interface CompositionRemovalEntry {
// the train's remaining wagon/weight/length capacity.
export interface IntercityCapacity {
wagons: number;
weightTons: number;
lengthMeters: number;
// Each axis can be null when the schedule's train/locomotive has no limit
// configured for it (e.g. no max length on the loco) — the API passes the
// gap through rather than inventing a number.
wagons: number | null;
weightTons: number | null;
lengthMeters: number | null;
}
export interface IntercityBookingRow {

View File

@@ -1121,6 +1121,24 @@ export interface WarehouseOpsStats {
itemsAging: number;
}
/**
* One truck inside the yard. Both haulage paths appear here because the gate
* handles both: `CUSTOMER` is the customer's own truck, `EDR` a last-mile truck.
*/
export interface TruckOnSite {
source: "CUSTOMER" | "EDR";
assignmentId: string;
plateNumber: string | null;
driverName: string | null;
truckType: string | null;
arrivedAt: string | null;
bookingId: string;
bookingReference: string | null;
customerName: string | null;
/** Comma-separated container numbers; null for bulk. */
containers: string | null;
}
/** One bucket of the received-vs-dispatched throughput time series. */
export interface WarehouseThroughputPoint {
periodStart: string;