mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
add dispute functionality for contract duty and implement collection dates
This commit is contained in:
@@ -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'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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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()}
|
||||
>
|
||||
|
||||
@@ -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…"
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user