Marshaling document Receive Export and import handover to customer

This commit is contained in:
hagiye
2026-06-29 13:48:58 +03:00
310 changed files with 35735 additions and 5958 deletions

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

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