add dispute functionality for contract duty and implement collection dates

This commit is contained in:
Marshal
2026-07-26 16:58:51 +00:00
parent 9b13fa2ac6
commit 5e10c97294
74 changed files with 3684 additions and 342 deletions

View File

@@ -0,0 +1,237 @@
import {
Badge,
Button,
Group,
Loader,
Modal,
Paper,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Download, Eye, History, Upload } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { isViewable } from "@edr/ui-common";
import { downloadBookingFile, fetchViewableFile } from "@/services/files.service";
export interface ClearanceDocumentVersionsModalProps {
contractId: string;
/** The document being inspected; null closes the modal. */
doc: { fileKey: string; label: string } | null;
onClose: () => void;
/** Hide the replace form (finalized clearance, read-only viewers). */
canReplace?: boolean;
onReplaced?: () => void;
onView?: (file: { name: string; url: string }) => void;
}
const fmt = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
/**
* Version history of one clearance document, and the way to add a version.
*
* Staff can correct a document without bouncing it back to the customer, but
* the customer's original is never overwritten — it drops down this list as a
* superseded version, stamped with who replaced it and why. The corrected file
* comes back unreviewed, so it still has to be approved before finalizing.
*/
export function ClearanceDocumentVersionsModal({
contractId,
doc,
onClose,
canReplace = false,
onReplaced,
onView,
}: ClearanceDocumentVersionsModalProps) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [reason, setReason] = useState("");
const { data: versions = [], isLoading } = useQuery({
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
queryFn: () =>
contractsService.getClearanceDocumentVersions(contractId, doc!.fileKey),
enabled: Boolean(doc),
});
const replace = useMutation({
mutationFn: () =>
contractsService.replaceClearanceDocument(
contractId,
doc!.fileKey,
file!,
reason.trim(),
),
onSuccess: async () => {
toast.success("Document replaced — the previous version is kept on file");
setFile(null);
setReason("");
await queryClient.invalidateQueries({
queryKey: ["contracts", "clearance-doc-versions", contractId, doc?.fileKey],
});
await queryClient.invalidateQueries({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
});
onReplaced?.();
},
});
const close = () => {
setFile(null);
setReason("");
onClose();
};
return (
<Modal
opened={Boolean(doc)}
onClose={close}
size="lg"
radius="md"
title={
<Group gap={8}>
<History size={16} />
<Text fw={700}>{doc?.label ?? "Document"} version history</Text>
</Group>
}
>
<Stack gap="md">
{isLoading ? (
<Group justify="center" py="lg">
<Loader size="sm" />
</Group>
) : versions.length === 0 ? (
<Text size="sm" c="dimmed">
Nothing uploaded under this document yet.
</Text>
) : (
<Stack gap={8}>
{versions.map((v, index) => (
<Paper
key={v.id}
withBorder
radius="md"
p="sm"
bg={v.isCurrent ? "var(--mantine-color-edr-green-0)" : undefined}
>
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={2} style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} truncate>
{v.name}
</Text>
{v.isCurrent ? (
<Badge size="xs" color="edr-green" variant="light">
Current
</Badge>
) : index === versions.length - 1 ? (
<Badge size="xs" color="blue" variant="light">
Original
</Badge>
) : (
<Badge size="xs" color="gray" variant="light">
Superseded
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
Uploaded {fmt(v.uploadedAt)}
{v.replacedAt ? ` · replaced ${fmt(v.replacedAt)}` : ""}
</Text>
{v.replaceReason ? (
<Text size="xs" c="orange.8">
Reason: {v.replaceReason}
</Text>
) : null}
</Stack>
<Group gap={6} wrap="nowrap">
{isViewable({ name: v.name, url: "" }) && onView ? (
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
void fetchViewableFile(v.id, v.name).then(onView)
}
>
View
</Button>
) : null}
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Download size={13} />}
onClick={() => void downloadBookingFile(v.id, v.name)}
>
Download
</Button>
</Group>
</Group>
</Paper>
))}
</Stack>
)}
{canReplace ? (
<Paper withBorder radius="md" p="md" bg="var(--mantine-color-gray-0)">
<Stack gap="sm">
<Text size="sm" fw={700}>
Replace this document
</Text>
<Text size="xs" c="dimmed">
Use this for a correction you can make yourself. The customer&apos;s
copy stays in the history above, and the new file has to be
approved before clearance is finalized.
</Text>
<PhasedFileDropzone
label="Corrected document"
value={file}
onChange={setFile}
replaceMode
/>
<Textarea
label="Why is it being replaced?"
placeholder="e.g. customer sent page 2 only — attached the full signed copy"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
loading={replace.isPending}
disabled={!file || !reason.trim()}
onClick={() => replace.mutate()}
>
Replace document
</Button>
</Group>
</Stack>
</Paper>
) : null}
</Stack>
</Modal>
);
}
export default ClearanceDocumentVersionsModal;

View File

@@ -24,6 +24,7 @@ import {
Eye,
FileCheck2,
FileText,
History,
MessageSquareWarning,
Upload,
UserCheck,
@@ -31,6 +32,7 @@ import {
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { ClearanceDocumentVersionsModal } from "@/components/contracts/ClearanceDocumentVersionsModal";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
@@ -114,6 +116,11 @@ export function ContractClearanceReviewSection({
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [uploadingKey, setUploadingKey] = useState<string | null>(null);
// Document whose version history is open (also hosts the replace form).
const [historyDoc, setHistoryDoc] = useState<{
fileKey: string;
label: string;
} | null>(null);
const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
@@ -264,6 +271,9 @@ export function ContractClearanceReviewSection({
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
}
onView={view}
onHistory={() =>
setHistoryDoc({ fileKey: doc.fileKey, label: doc.label })
}
busy={reviewDocument.isPending}
/>
))
@@ -487,6 +497,18 @@ export function ContractClearanceReviewSection({
</Group>
</Paper>
)}
{/* Version history + in-place correction. Replacing is blocked in the same
situations queries are (finalized clearance / read-only audit view) —
documents must not move once the gate has closed. */}
<ClearanceDocumentVersionsModal
contractId={contractId}
doc={historyDoc}
canReplace={!readOnly && !queriesLocked}
onClose={() => setHistoryDoc(null)}
onReplaced={onChanged}
onView={view}
/>
{viewer}
</Stack>
);
@@ -534,6 +556,7 @@ function DocReviewCard({
onApprove,
onQuery,
onView,
onHistory,
busy,
}: {
doc: Freight.ContractClearanceDocument;
@@ -548,6 +571,7 @@ function DocReviewCard({
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
onHistory: () => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
@@ -660,6 +684,21 @@ function DocReviewCard({
</Button>
</Tooltip>
)}
{/* Every version ever stored under this key — the customer original
plus any staff correction — and where a correction is made. */}
{hasFile && (
<Tooltip label="Versions & replace">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<History size={13} />}
onClick={onHistory}
>
History
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -1,6 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { History } from "lucide-react";
import { Badge, Group, Loader, Stack, Text, Timeline } from "@mantine/core";
import { History, User } from "lucide-react";
import {
Avatar,
Badge,
Group,
Loader,
Stack,
Text,
Timeline,
Tooltip,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
@@ -8,6 +17,8 @@ import { SectionCard } from "@/components/bookings/detail/SectionCard";
interface ContractRevisionTimelineProps {
contractId: string;
/** Rendered as a plain block instead of a SectionCard (own-tab layout). */
bare?: boolean;
}
type Change = Freight.IContractDocumentChange;
@@ -21,8 +32,26 @@ const CHANGE_STYLES: Record<Change["kind"], { color: string; label: string }> =
ARTICLE_REORDERED: { color: "gray", label: "Reordered" },
DOCUMENT_TITLE_CHANGED: { color: "grape", label: "Title" },
WHEREAS_CHANGED: { color: "teal", label: "Recitals" },
FIELD_CHANGED: { color: "orange", label: "Field" },
};
/** Initials for the actor avatar — "Abenezer Haile" → "AH". */
function initials(name: string): string {
return name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
/** Role slugs arrive like "-marketing-director-"; render them readably. */
function prettyRole(role: string): string {
const cleaned = role.replace(/^-+|-+$/g, "").replace(/[-_]+/g, " ").trim();
if (!cleaned) return role;
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
}
/** What the change applies to — an article title, or the document itself. */
function changeSubject(change: Change): string {
switch (change.kind) {
@@ -40,6 +69,8 @@ function changeSubject(change: Change): string {
return `${change.fromTitle}” → “${change.title}`;
case "ARTICLE_REORDERED":
return `${change.title} (${change.fromOrder}${change.toOrder})`;
case "FIELD_CHANGED":
return `${change.label}: ${change.from ?? "—"}${change.to ?? "—"}`;
default:
return change.title;
}
@@ -53,82 +84,131 @@ function formatWhen(iso: string): string {
});
}
/** "3 hours ago" — the at-a-glance read; the exact stamp sits beside it. */
function formatAgo(iso: string): string {
const seconds = Math.round((Date.now() - new Date(iso).getTime()) / 1000);
if (seconds < 60) return "just now";
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
["year", 31536000],
["month", 2592000],
["day", 86400],
["hour", 3600],
["minute", 60],
];
const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
for (const [unit, secondsPerUnit] of units) {
if (seconds >= secondsPerUnit) {
return rtf.format(-Math.floor(seconds / secondsPerUnit), unit);
}
}
return "just now";
}
/**
* Audit trail of edits to the contract document. The document stays editable
* through the approval chain, so this is the record of who changed what.
*/
export function ContractRevisionTimeline({
contractId,
bare = false,
}: ContractRevisionTimelineProps) {
const { data: revisions, isLoading } = useQuery({
queryKey: ["contracts", contractId, "document-revisions"],
queryFn: () => contractsService.getContractDocumentRevisions(contractId),
});
return (
<SectionCard icon={History} title="Document history">
{isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading history
</Text>
</Group>
) : !revisions?.length ? (
<Text size="sm" c="dimmed">
No edits recorded yet. Changes made to the contract articles during
approval will appear here.
</Text>
) : (
<Timeline
active={revisions.length}
bulletSize={18}
lineWidth={2}
color="edr-green"
>
{revisions.map((revision) => (
<Timeline.Item
key={revision.id}
title={
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={600}>
{revision.actorRole ?? "Staff"}
</Text>
<Text size="xs" c="dimmed">
{formatWhen(revision.createdAt)}
</Text>
</Group>
}
>
<Stack gap={6} mt={4}>
{revision.summary && (
<Text size="xs" c="dimmed">
{revision.summary}
</Text>
const body = isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading history
</Text>
</Group>
) : !revisions?.length ? (
<Stack gap={4} align="center" py="xl">
<History size={26} color="var(--mantine-color-gray-5)" />
<Text size="sm" fw={500}>
No edits recorded yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={420}>
Every change to this contract its articles during review and approval,
or its details while the customer can still edit it is logged here
with who made it and when.
</Text>
</Stack>
) : (
<Timeline
active={revisions.length}
bulletSize={28}
lineWidth={2}
color="edr-green"
>
{revisions.map((revision) => {
const who = revision.actorName?.trim();
const role = revision.actorRole ? prettyRole(revision.actorRole) : null;
return (
<Timeline.Item
key={revision.id}
bullet={
<Avatar size={26} radius="xl" color="edr-green" variant="light">
<Text size="10px" fw={700}>
{who ? initials(who) : <User size={13} />}
</Text>
</Avatar>
}
title={
<Group gap="xs" wrap="wrap" align="baseline">
<Text size="sm" fw={600}>
{who ?? role ?? "Unknown user"}
</Text>
{role && who && (
<Badge size="xs" variant="light" color="gray" radius="sm">
{role}
</Badge>
)}
{revision.changes.map((change, index) => {
const style = CHANGE_STYLES[change.kind];
return (
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
<Badge
size="xs"
variant="light"
color={style?.color ?? "gray"}
style={{ flexShrink: 0 }}
>
{style?.label ?? change.kind}
</Badge>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
</Group>
);
})}
</Stack>
</Timeline.Item>
))}
</Timeline>
)}
<Tooltip label={formatWhen(revision.createdAt)} withArrow>
<Text size="xs" c="dimmed">
{formatAgo(revision.createdAt)}
</Text>
</Tooltip>
</Group>
}
>
<Stack gap={6} mt={6} pb="xs">
{revision.summary && (
<Text size="xs" c="dimmed">
{revision.summary}
</Text>
)}
{revision.changes.map((change, index) => {
const style = CHANGE_STYLES[change.kind];
return (
<Group key={index} gap="xs" wrap="nowrap" align="flex-start">
<Badge
size="xs"
variant="light"
color={style?.color ?? "gray"}
style={{ flexShrink: 0 }}
>
{style?.label ?? change.kind}
</Badge>
<Text size="xs" style={{ lineHeight: 1.5 }}>
{changeSubject(change)}
</Text>
</Group>
);
})}
</Stack>
</Timeline.Item>
);
})}
</Timeline>
);
if (bare) return body;
return (
<SectionCard icon={History} title="Change history">
{body}
</SectionCard>
);
}

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
import { Group } from "@mantine/core";
import { DateInput } from "@mantine/dates";
/** `YYYY-MM-DD` in local time — the API column is a DATE, so no UTC shift. */
export function toIsoDate(value: Date | null): string | null {
if (!value) return null;
const tz = value.getTimezoneOffset() * 60000;
return new Date(value.getTime() - tz).toISOString().slice(0, 10);
}
export interface DoCollectionDates {
vesselArrival: Date | null;
doCollected: Date | null;
}
/** Both dates present and the DO not collected before the vessel docked. */
export function doDatesComplete(dates: DoCollectionDates): boolean {
if (!dates.vesselArrival || !dates.doCollected) return false;
return (toIsoDate(dates.doCollected) ?? "") >= (toIsoDate(dates.vesselArrival) ?? "");
}
/**
* The two mandatory dates Djibouti GL records with a Delivery Order. Shared by
* the DO upload modal and the inline DO step so neither surface can post an
* upload the API will reject.
*/
export function DoCollectionDateFields({
value,
onChange,
}: {
value: DoCollectionDates;
onChange: (next: DoCollectionDates) => void;
}) {
// Only complain once the user has actually entered the earlier date.
const outOfOrder =
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
return (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
label="Vessel arrival date"
placeholder="Select date"
value={value.vesselArrival}
onChange={(v) =>
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
}
maxDate={new Date()}
size="sm"
required
withAsterisk
/>
<DateInput
label="DO collected date"
placeholder="Select date"
value={value.doCollected}
onChange={(v) =>
onChange({ ...value, doCollected: v ? new Date(v) : null })
}
minDate={value.vesselArrival ?? undefined}
maxDate={new Date()}
size="sm"
required
withAsterisk
error={outOfOrder ? "Cannot be before the vessel arrival date." : undefined}
/>
</Group>
);
}
/** Local state helper — both surfaces need the same seed-from-server logic. */
export function useDoCollectionDates(seed?: {
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
}) {
return useState<DoCollectionDates>(() => ({
vesselArrival: seed?.vesselArrivalDate ? new Date(seed.vesselArrivalDate) : null,
doCollected: seed?.doCollectedDate ? new Date(seed.doCollectedDate) : null,
}));
}

View File

@@ -4,6 +4,12 @@ import { DateInput } from "@mantine/dates";
import { Ship, Upload } from "lucide-react";
import toast from "react-hot-toast";
import {
DoCollectionDateFields,
doDatesComplete,
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
import { contractsService } from "@/services/contracts.service";
@@ -20,6 +26,9 @@ export interface GlClearanceUploadModalProps {
isBooking: boolean;
workflowFiles?: Freight.ClearanceWorkflowFile[];
vesselDepartureDate?: string | null;
/** Previously recorded DO dates, so a replace opens pre-filled. */
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
onSuccess?: () => void;
onPreview?: (file: { name: string; url: string }) => void;
}
@@ -32,6 +41,8 @@ export function GlClearanceUploadModal({
isBooking,
workflowFiles = [],
vesselDepartureDate,
vesselArrivalDate,
doCollectedDate,
onSuccess,
onPreview,
}: GlClearanceUploadModalProps) {
@@ -39,6 +50,10 @@ export function GlClearanceUploadModal({
const [vesselDate, setVesselDate] = useState<Date | null>(
vesselDepartureDate ? new Date(vesselDepartureDate) : null,
);
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
// Earliest selectable vessel date (today, local) — refreshed on each open.
const todayISODate = useMemo(() => {
@@ -65,15 +80,22 @@ export function GlClearanceUploadModal({
toast.error("Vessel departure date is required.");
return;
}
if (isDo && !doDatesComplete(doDates)) {
toast.error("Vessel arrival date and DO collected date are both required.");
return;
}
setLoading(true);
try {
if (isDo) {
const iso = vesselDate ? vesselDate.toISOString().slice(0, 10) : undefined;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file, iso);
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file, iso);
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");
} else {
@@ -113,7 +135,7 @@ export function GlClearanceUploadModal({
<Stack gap="md">
<Text size="sm" c="dimmed">
{isDo
? "Upload the Djibouti Delivery Order (DO) for this import shipment."
? "Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected. Both dates are required."
: "Upload the Release Order and confirm the vessel departure date."}
</Text>
@@ -127,14 +149,7 @@ export function GlClearanceUploadModal({
required
/>
) : (
<DateInput
label="Vessel arrival date (optional)"
value={vesselDate}
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
minDate={todayISODate}
size="sm"
clearable
/>
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
)}
<PhasedFileDropzone
@@ -154,7 +169,11 @@ export function GlClearanceUploadModal({
<Button
color="edr-green"
loading={loading}
disabled={!file || (isRo && !vesselDate)}
disabled={
!file ||
(isRo && !vesselDate) ||
(isDo && !doDatesComplete(doDates))
}
leftSection={<Upload size={16} />}
onClick={() => void submit()}
>

View File

@@ -19,6 +19,7 @@ import {
Loader,
Modal,
Paper,
SegmentedControl,
Select,
Stack,
Switch,
@@ -269,6 +270,9 @@ export default function GlCreateBookingForm() {
const [scheduledDate, setScheduledDate] = useState("");
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
const [notes, setNotes] = useState("");
// The customer states the billing currency on their shipment request — GL
// books in it. Intercity is always ETB (the API enforces this too).
const [paymentCurrency, setPaymentCurrency] = useState<"USD" | "ETB">("USD");
// What the containers carry — captured per booking (moved off the contract).
const [cargoDescription, setCargoDescription] = useState("");
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
@@ -439,6 +443,9 @@ export default function GlCreateBookingForm() {
}
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.paymentCurrency === "USD" || bookingRequest.paymentCurrency === "ETB") {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
if (bookingRequest.notes) setNotes(bookingRequest.notes);
}, [bookingRequest, prefilled]);
@@ -834,6 +841,7 @@ export default function GlCreateBookingForm() {
const payload: Freight.CreateBookingUnderContractDto = {
...(contractRouteId ? { contractRouteId } : {}),
paymentCurrency,
// Intercity bookings carry no date — staff assign a passing train later.
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
@@ -1672,6 +1680,30 @@ export default function GlCreateBookingForm() {
)}
<StepCard>
<Box mb="md">
<Text size="sm" fw={600} mb={4}>
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isIntercity
? "Intercity shipments are invoiced in ETB."
: bookingRequest?.paymentCurrency
? "Requested by the customer on their shipment request."
: "The contract is quoted in USD — pick the currency this shipment is invoiced in."}
</Text>
<SegmentedControl
value={isIntercity ? "ETB" : paymentCurrency}
onChange={(v) => setPaymentCurrency(v as "USD" | "ETB")}
disabled={isIntercity}
data={[
{ label: "USD", value: "USD" },
{ label: "ETB", value: "ETB" },
]}
color="edr-green"
radius={10}
/>
</Box>
<Textarea
label="Additional notes"
placeholder="Any special instructions for this shipment…"

View File

@@ -15,6 +15,7 @@ import {
TextInput,
} from "@mantine/core";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import {
TransitPermitMultiUpload,
type TransitPermitUploadedRow,
@@ -24,6 +25,7 @@ import {
CheckCircle2,
Clock,
FileText,
MessageSquareWarning,
PackageCheck,
Receipt,
ShieldAlert,
@@ -37,6 +39,12 @@ import toast from "react-hot-toast";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { ExportClearanceStepper } from "@/components/contracts/ExportClearanceStepper";
import { PhasedDocumentUploadField } from "@/components/contracts/PhasedDocumentUploadField";
import {
DoCollectionDateFields,
doDatesComplete,
toIsoDate,
useDoCollectionDates,
} from "@/components/contracts/DoCollectionDateFields";
import {
findWorkflowFile,
PhasedUploadedFileRow,
@@ -53,6 +61,8 @@ export type ClearanceViewLike = Pick<
| "nextAction"
| "dutyRequired"
| "dutyAdvice"
| "dutyDispute"
| "transitAssignee"
| "roHold"
| "roHoldReason"
| "milestones"
@@ -69,6 +79,8 @@ export type ClearanceViewLike = Pick<
| "offloaded"
| "finalInvoice"
| "vesselDepartureDate"
| "vesselArrivalDate"
| "doCollectedDate"
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
@@ -332,8 +344,22 @@ export function PhasedClearanceActionPanel({
)
}
>
{/* Djibouti must name the transit officer first — the declaration
is filed against whoever handles the shipment there, and the
API refuses the upload until the name is in. */}
{showEt &&
canEt &&
!isBooking &&
!clearance.transitAssignee?.name &&
!isMilestoneDone(clearance.milestones, "DECLARED") ? (
<TransitAssigneePanel
contractId={entityId}
transitAssignee={clearance.transitAssignee}
side="ET"
onChanged={onChanged}
/>
) : showEt &&
canEt &&
!clearance.bookingReady &&
(activeStep >= 1 ||
isMilestoneDone(clearance.milestones, "DECLARED")) ? (
@@ -504,6 +530,8 @@ export function PhasedClearanceActionPanel({
isBooking={isBooking}
workflowFiles={workflowFiles}
replaceMode={isMilestoneDone(clearance.milestones, "DO_COLLECTED")}
vesselArrivalDate={clearance.vesselArrivalDate}
doCollectedDate={clearance.doCollectedDate}
onChanged={onChanged}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
@@ -1569,9 +1597,35 @@ function DutyStep({
const noticeFile = findWorkflowFile(workflowFiles, "duty_tax_notice");
const hasExistingNotice = Boolean(noticeFile);
const dispute = clearance.dutyDispute;
return (
<Stack gap="md">
{/* The customer rejected the last advice — their words drive the
correction, so they lead the step. */}
{dispute ? (
<Alert
color="orange"
radius="md"
icon={<MessageSquareWarning size={16} />}
title={
dispute.rounds > 1
? `Customer asked for a correction (round ${dispute.rounds})`
: "Customer asked for a correction"
}
>
<Stack gap={4}>
<Text size="sm" style={{ whiteSpace: "pre-wrap" }}>
{dispute.note}
</Text>
<Text size="xs" c="dimmed">
Raised {new Date(dispute.raisedAt).toLocaleString()} re-advise
below to send a corrected notice.
</Text>
</Stack>
</Alert>
) : null}
{noticeFile ? (
<Stack gap={8}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase">
@@ -1765,6 +1819,8 @@ function DeliveryOrderStep({
onChanged,
workflowFiles = [],
replaceMode = false,
vesselArrivalDate,
doCollectedDate,
onViewFile,
onDownloadFile,
}: {
@@ -1773,12 +1829,18 @@ function DeliveryOrderStep({
onChanged?: () => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
replaceMode?: boolean;
vesselArrivalDate?: string | null;
doCollectedDate?: string | null;
onViewFile?: (file: { name: string; url: string }) => void;
onDownloadFile?: (file: { id: string; name: string }) => void;
}) {
const [files, setFiles] = useState<Record<string, File | null>>({
delivery_order: null,
});
const [doDates, setDoDates] = useDoCollectionDates({
vesselArrivalDate,
doCollectedDate,
});
const [loading, setLoading] = useState(false);
const hasFile = Boolean(files.delivery_order);
@@ -1790,20 +1852,27 @@ function DeliveryOrderStep({
workflowFiles={workflowFiles}
replaceMode={replaceMode}
loading={loading}
disabled={!hasFile}
helperText="Upload the Djibouti Delivery Order (DO) for this shipment."
disabled={!hasFile || !doDatesComplete(doDates)}
helperText="Upload the Djibouti Delivery Order (DO) and record when the vessel arrived and when the DO was collected."
submitLabel={replaceMode ? "Replace DO" : "Upload DO"}
extraFields={
<DoCollectionDateFields value={doDates} onChange={setDoDates} />
}
onViewFile={onViewFile}
onDownloadFile={onDownloadFile}
onSubmit={async () => {
const file = files.delivery_order;
if (!file) return;
if (!file || !doDatesComplete(doDates)) return;
const dates = {
vesselArrivalDate: toIsoDate(doDates.vesselArrival)!,
doCollectedDate: toIsoDate(doDates.doCollected)!,
};
setLoading(true);
try {
if (isBooking) {
await bookingsService.uploadDeliveryOrder(entityId, file);
await bookingsService.uploadDeliveryOrder(entityId, file, dates);
} else {
await contractsService.uploadDeliveryOrder(entityId, file);
await contractsService.uploadDeliveryOrder(entityId, file, dates);
}
setFiles({ delivery_order: null });
toast.success(replaceMode ? "Delivery Order updated" : "Delivery Order uploaded");

View File

@@ -1,5 +1,6 @@
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
import { FileText, Upload } from "lucide-react";
import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
@@ -11,6 +12,8 @@ export interface PhasedDocumentUploadFieldProps {
onChange: (key: string, file: File | null) => void;
workflowFiles?: Freight.ClearanceWorkflowFile[];
helperText: string;
/** Extra inputs rendered above the dropzones (e.g. required DO dates). */
extraFields?: ReactNode;
replaceMode?: boolean;
loading?: boolean;
disabled?: boolean;
@@ -27,6 +30,7 @@ export function PhasedDocumentUploadField({
onChange,
workflowFiles = [],
helperText,
extraFields,
replaceMode = false,
loading = false,
disabled = false,
@@ -87,6 +91,7 @@ export function PhasedDocumentUploadField({
</Group>
<Stack gap="md">
{extraFields}
{fields.map((f) => (
<PhasedFileDropzone
key={f.key}

View File

@@ -0,0 +1,246 @@
import type { Freight } from "@edr/types";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Textarea,
} from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, Clock, Send, UserCheck } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { contractsService } from "@/services/contracts.service";
export interface TransitAssigneePanelProps {
contractId: string;
transitAssignee: Freight.ContractClearanceView["transitAssignee"];
/**
* ET asks and waits; DJ answers with a name. The same state renders from both
* desks — only the action on offer differs.
*/
side: "ET" | "DJ";
/** Hide the action (read-only audit view, or the user lacks the permission). */
readOnly?: boolean;
onChanged?: () => void;
}
const fmt = (iso?: string | null) =>
iso
? new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
: "—";
/**
* The transit-assignee handshake that gates the customs declaration.
*
* GL Ethiopia cannot file a declaration until Djibouti says who will handle the
* shipment on their side, so ET raises the ask here and Djibouti answers with a
* name (free text — the officer is not a platform user). Djibouti can send a
* different name later; the newest one wins and Ethiopia is notified again.
*/
export function TransitAssigneePanel({
contractId,
transitAssignee,
side,
readOnly = false,
onChanged,
}: TransitAssigneePanelProps) {
const [note, setNote] = useState("");
const [assignee, setAssignee] = useState(transitAssignee?.name ?? "");
const [changing, setChanging] = useState(false);
const request = useMutation({
mutationFn: () => contractsService.requestTransitAssignee(contractId, note.trim()),
onSuccess: () => {
toast.success("Request sent to GL Djibouti");
setNote("");
onChanged?.();
},
});
const assign = useMutation({
mutationFn: () =>
contractsService.assignTransitAssignee(contractId, assignee.trim()),
onSuccess: () => {
toast.success("Transit assignee sent to GL Ethiopia");
setChanging(false);
onChanged?.();
},
});
const requested = Boolean(transitAssignee?.requestedAt);
const assigned = Boolean(transitAssignee?.name);
// Settled state — both desks see the same line; DJ keeps a way to change it.
if (assigned && !changing) {
return (
<Alert
color="edr-green"
radius="md"
icon={<CheckCircle2 size={16} />}
title="Transit assignee confirmed"
>
<Group justify="space-between" wrap="wrap" gap="sm">
<Text size="sm">
<Text span fw={700}>
{transitAssignee!.name}
</Text>{" "}
will handle this shipment in transit · assigned{" "}
{fmt(transitAssignee!.assignedAt)}
</Text>
{side === "DJ" && !readOnly ? (
<Button
size="compact-xs"
variant="light"
radius="md"
onClick={() => {
setAssignee(transitAssignee!.name ?? "");
setChanging(true);
}}
>
Change
</Button>
) : null}
</Group>
</Alert>
);
}
// Djibouti's desk: answer the ask.
if (side === "DJ") {
if (!requested) {
return (
<Alert color="gray" radius="md" icon={<Clock size={16} />}>
GL Ethiopia has not requested a transit assignee for this clearance yet.
</Alert>
);
}
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group gap={8}>
<UserCheck size={16} />
<Text fw={700} size="sm">
{changing ? "Change the transit assignee" : "Assign a transit officer"}
</Text>
<Badge size="xs" color="orange" variant="light">
Requested {fmt(transitAssignee?.requestedAt)}
</Badge>
</Group>
{transitAssignee?.requestNote ? (
<Text size="sm" c="dimmed" style={{ whiteSpace: "pre-wrap" }}>
GL Ethiopia: {transitAssignee.requestNote}
</Text>
) : null}
<TextInput
label="Transit officer"
description="Name of the person handling this shipment in Djibouti"
placeholder="e.g. Ahmed Bourhan"
value={assignee}
onChange={(e) => setAssignee(e.currentTarget.value)}
disabled={readOnly}
/>
<Group justify="flex-end" gap="sm">
{changing ? (
<Button variant="default" radius="md" onClick={() => setChanging(false)}>
Cancel
</Button>
) : null}
<Button
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
loading={assign.isPending}
disabled={readOnly || !assignee.trim()}
onClick={() => assign.mutate()}
>
Send assignment
</Button>
</Group>
</Stack>
</Paper>
);
}
// Ethiopia's desk: raise the ask, then wait.
if (requested) {
return (
<Alert
color="orange"
radius="md"
icon={<Clock size={16} />}
title="Waiting for GL Djibouti"
>
<Stack gap="sm" align="flex-start">
<Text size="sm">
Requested {fmt(transitAssignee?.requestedAt)}. The customs declaration
opens once Djibouti names the transit officer.
</Text>
{!readOnly ? (
<Button
size="compact-xs"
variant="light"
color="orange"
radius="md"
loading={request.isPending}
onClick={() => request.mutate()}
>
Send a reminder
</Button>
) : null}
</Stack>
</Alert>
);
}
return (
<Paper withBorder radius="md" p="md">
<Stack gap="sm">
<Group gap={8}>
<UserCheck size={16} />
<Text fw={700} size="sm">
Request a transit assignee
</Text>
</Group>
<Text size="xs" c="dimmed">
GL Djibouti must name the officer handling this shipment in transit
before the customs declaration can be filed.
</Text>
<Textarea
label="Note for GL Djibouti (optional)"
placeholder="Anything they need to know to pick the right officer"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
disabled={readOnly}
/>
<Group justify="flex-end">
<Button
color="edr-green"
radius="md"
leftSection={<Send size={15} />}
loading={request.isPending}
disabled={readOnly}
onClick={() => request.mutate()}
>
Request assignee
</Button>
</Group>
</Stack>
</Paper>
);
}
export default TransitAssigneePanel;

View File

@@ -186,6 +186,16 @@ export const URL_CONSTANTS = {
CLEARANCE_QUEUE: "/contracts/clearance/queue",
CLEARANCE: (id: string) => `/contracts/${id}/clearance`,
CLEARANCE_REVIEW: (id: string) => `/contracts/${id}/clearance/review`,
/** Pre-declaration transit-assignee handshake (ET asks, DJ answers). */
CLEARANCE_TRANSIT_ASSIGNEE_REQUEST: (id: string) =>
`/contracts/${id}/clearance/transit-assignee/request`,
CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN: (id: string) =>
`/contracts/${id}/clearance/transit-assignee/assign`,
/** Staff corrects a clearance document in place; the old version is kept. */
CLEARANCE_DOC_REPLACE: (id: string, fileKey: string) =>
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/replace`,
CLEARANCE_DOC_VERSIONS: (id: string, fileKey: string) =>
`/contracts/${id}/clearance/documents/${encodeURIComponent(fileKey)}/versions`,
CLEARANCE_OUTPUT_DOCUMENTS: (id: string) =>
`/contracts/${id}/clearance/output-documents`,
CLEARANCE_FINALIZE: (id: string) => `/contracts/${id}/clearance/finalize`,

View File

@@ -14,6 +14,7 @@ import {
FileText,
Files,
Flame,
History,
LayoutGrid,
Package,
Receipt,
@@ -295,7 +296,9 @@ export default function ContractRequestDetailPage() {
? "documents"
: requestedTab === "customer"
? "customer"
: "details";
: requestedTab === "history"
? "history"
: "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
@@ -475,6 +478,9 @@ export default function ContractRequestDetailPage() {
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
Customer
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<History size={16} />}>
History
</Tabs.Tab>
</Tabs.List>
</Tabs>
@@ -488,7 +494,6 @@ export default function ContractRequestDetailPage() {
onView={handleViewFile}
onDownload={handleDownloadFile}
/>
<ContractRevisionTimeline contractId={contract.id} />
<ContractDocumentsCard
files={profileDocuments}
title="Customer profile documents"
@@ -509,6 +514,14 @@ export default function ContractRequestDetailPage() {
/>
) : null}
</Stack>
) : currentTab === "history" ? (
<SectionCard
icon={History}
title="Change history"
subtitle="Every recorded edit to this contract — who changed what, and when."
>
<ContractRevisionTimeline contractId={contract.id} bare />
</SectionCard>
) : currentTab === "customer" ? (
<ContractCustomerCard contract={contract} />
) : (

View File

@@ -38,6 +38,7 @@ import {
type GlClearanceUploadKind,
} from "@/components/contracts/GlClearanceUploadModal";
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
import { TransitAssigneePanel } from "@/components/contracts/TransitAssigneePanel";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
@@ -235,6 +236,23 @@ export default function GlClearanceDetailPage() {
</Tabs.List>
<Tabs.Panel value="workflow">
{/* GL Ethiopia cannot file the customs declaration until this desk
names the officer handling the shipment in transit, so the ask
sits above everything else on the page. */}
{data.kind === "contract" ? (
<Box mb="md">
<TransitAssigneePanel
contractId={id!}
transitAssignee={data.clearance.transitAssignee}
side="DJ"
readOnly={
!hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions)
}
onChanged={() => void refetch()}
/>
</Box>
) : null}
<Grid>
<Grid.Col span={{ base: 12, lg: 7 }}>
{data.kind === "booking" ? (
@@ -345,6 +363,8 @@ export default function GlClearanceDetailPage() {
isBooking={data.kind === "booking"}
workflowFiles={workflowFiles}
vesselDepartureDate={vesselDepartureDate}
vesselArrivalDate={data.clearance.vesselArrivalDate}
doCollectedDate={data.clearance.doCollectedDate}
onSuccess={() => void refetch()}
onPreview={view}
/>

View File

@@ -390,11 +390,12 @@ export const bookingsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<BookingDetail> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});

View File

@@ -126,6 +126,23 @@ export interface SignContractPayload {
consentText?: string;
}
/**
* One stored version of a clearance document. The current row plus every
* superseded upload — staff corrections never erase what the customer sent.
*/
export interface ClearanceDocumentVersion {
id: string;
name: string;
url: string;
size: number;
mimeType: string;
uploadedAt: string;
isCurrent: boolean;
replacedAt: string | null;
replacedByUserId: string | null;
replaceReason: string | null;
}
async function postContract<T>(url: string, body?: unknown): Promise<T> {
const response = await client.post<T>(url, body ?? {});
return unwrap(response.data);
@@ -297,6 +314,50 @@ export const contractsService = {
) =>
postContract<Freight.IContract>(C.CLEARANCE_REVIEW(id), payload),
/** GL ET asks Djibouti to name the officer handling the shipment in transit. */
requestTransitAssignee: (id: string, note?: string) =>
postContract<Freight.IContract>(
C.CLEARANCE_TRANSIT_ASSIGNEE_REQUEST(id),
{ note },
),
/** GL Djibouti names (or changes) that officer — unblocks the declaration. */
assignTransitAssignee: (id: string, assignee: string) =>
postContract<Freight.IContract>(C.CLEARANCE_TRANSIT_ASSIGNEE_ASSIGN(id), {
assignee,
}),
/**
* Replace a clearance document in place. The customer's original is retired
* into the version history rather than overwritten, and the new file comes
* back unreviewed so it still has to be approved.
*/
replaceClearanceDocument: async (
id: string,
fileKey: string,
file: File,
reason: string,
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
form.append("reason", reason);
const response = await client.post(
C.CLEARANCE_DOC_REPLACE(id, fileKey),
form,
{ headers: { "Content-Type": "multipart/form-data" } },
);
return unwrap(response.data) as Freight.IContract;
},
/** Every stored version of one clearance document, newest first. */
getClearanceDocumentVersions: async (
id: string,
fileKey: string,
): Promise<ClearanceDocumentVersion[]> => {
const response = await client.get(C.CLEARANCE_DOC_VERSIONS(id, fileKey));
return (unwrap(response.data) as ClearanceDocumentVersion[]) ?? [];
},
uploadClearanceOutput: async (
id: string,
files: Record<string, File | null>,
@@ -390,11 +451,12 @@ export const contractsService = {
uploadDeliveryOrder: async (
id: string,
file: File,
vesselDepartureDate?: string,
dates: { vesselArrivalDate: string; doCollectedDate: string },
): Promise<Freight.IContract> => {
const form = new FormData();
form.append("file", file);
if (vesselDepartureDate) form.append("vesselDepartureDate", vesselDepartureDate);
form.append("vesselArrivalDate", dates.vesselArrivalDate);
form.append("doCollectedDate", dates.doCollectedDate);
const response = await client.post(C.CLEARANCE_DELIVERY_ORDER(id), form, {
headers: { "Content-Type": "multipart/form-data" },
});