mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 14:15:44 +00:00
Merge pull request #983 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -10,7 +10,7 @@ import { DataSource } from 'typeorm';
|
|||||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||||
|
|
||||||
import { YardCountry } from '@edr/types';
|
import { YardCountry } from '@edr/types';
|
||||||
|
//
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
|
|||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Eye,
|
Eye,
|
||||||
FilePen,
|
// FilePen, // ponytail: back with the "Edit contract articles" button
|
||||||
FileSignature,
|
FileSignature,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
PauseCircle,
|
PauseCircle,
|
||||||
@@ -286,9 +286,9 @@ export function ContractActionsToolbar({
|
|||||||
<>
|
<>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{canEditDocument
|
{canEditDocument
|
||||||
? "It is your turn to approve. You can edit the articles before approving — the PDF is generated automatically once the last approver approves."
|
? "It is your turn to approve — the PDF is generated automatically once the last approver approves."
|
||||||
: draft?.nextApproverRole
|
: draft?.nextApproverRole
|
||||||
? `Awaiting ${draft.nextApproverRole}. Only the current approver can edit the document.`
|
? `Awaiting ${draft.nextApproverRole}.`
|
||||||
: "Awaiting approval."}
|
: "Awaiting approval."}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -300,6 +300,9 @@ export function ContractActionsToolbar({
|
|||||||
>
|
>
|
||||||
Preview document
|
Preview document
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* Article editing is hidden for now (frontend only) — the approval
|
||||||
|
chain approves the document as accepted. Uncomment to restore.
|
||||||
|
|
||||||
{canEditDocument && (
|
{canEditDocument && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -314,6 +317,8 @@ export function ContractActionsToolbar({
|
|||||||
Edit contract articles
|
Edit contract articles
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
*/}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -14,18 +14,18 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
TextInput,
|
TextInput,
|
||||||
Tooltip,
|
// Tooltip, // ponytail: back with the article editor block
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
// ArrowDown, // ponytail: back with the article editor block
|
||||||
ArrowUp,
|
// ArrowUp,
|
||||||
FileText,
|
FileText,
|
||||||
Info,
|
Info,
|
||||||
Lock,
|
Lock,
|
||||||
Plus,
|
Plus,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DateInput } from "@mantine/dates";
|
import { DateTimePicker } from "@mantine/dates";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
@@ -44,6 +44,23 @@ function startOfToday(): Date {
|
|||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Local `YYYY-MM-DD` — the shape Mantine hands day cells. */
|
||||||
|
function localDay(date: Date): string {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Print today in bold inside the calendar. `highlightToday` only rings the cell,
|
||||||
|
* which staff read as "disabled" on a picker whose minimum IS today — the weight
|
||||||
|
* makes it obvious the day is pickable.
|
||||||
|
*/
|
||||||
|
function boldToday(date: string) {
|
||||||
|
return date === localDay(new Date())
|
||||||
|
? { style: { fontWeight: 800 } }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
interface EditableArticle {
|
interface EditableArticle {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -125,11 +142,14 @@ export function ContractDocumentEditorModal({
|
|||||||
);
|
);
|
||||||
}, [opened, draft]);
|
}, [opened, draft]);
|
||||||
|
|
||||||
// Accept mode opens on today — a contract never starts in the past, and the
|
// Accept mode opens on NOW — a contract never starts in the past, and the
|
||||||
// pickers below refuse earlier days.
|
// pickers below refuse earlier days. Seconds are dropped so the value matches
|
||||||
|
// what the HH:mm picker shows.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened || mode !== "accept") return;
|
if (!opened || mode !== "accept") return;
|
||||||
setValidityStart(startOfToday());
|
const now = new Date();
|
||||||
|
now.setSeconds(0, 0);
|
||||||
|
setValidityStart(now);
|
||||||
setValidityEnd(null);
|
setValidityEnd(null);
|
||||||
}, [opened, mode]);
|
}, [opened, mode]);
|
||||||
|
|
||||||
@@ -144,29 +164,30 @@ export function ContractDocumentEditorModal({
|
|||||||
// decides per-caller — the client cannot derive this from the contract alone.
|
// decides per-caller — the client cannot derive this from the contract alone.
|
||||||
const locked = mode === "edit" && !draft?.editableByMe;
|
const locked = mode === "edit" && !draft?.editableByMe;
|
||||||
|
|
||||||
const moveArticle = (index: number, delta: number) => {
|
// Article edit handlers — parked with the editor block below.
|
||||||
setArticles((prev) => {
|
// const moveArticle = (index: number, delta: number) => {
|
||||||
const next = [...prev];
|
// setArticles((prev) => {
|
||||||
const target = index + delta;
|
// const next = [...prev];
|
||||||
if (target < 0 || target >= next.length) return prev;
|
// const target = index + delta;
|
||||||
[next[index], next[target]] = [next[target], next[index]];
|
// if (target < 0 || target >= next.length) return prev;
|
||||||
return next;
|
// [next[index], next[target]] = [next[target], next[index]];
|
||||||
});
|
// return next;
|
||||||
};
|
// });
|
||||||
|
// };
|
||||||
|
|
||||||
const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
// const updateArticle = (id: string, patch: Partial<EditableArticle>) =>
|
||||||
setArticles((prev) =>
|
// setArticles((prev) =>
|
||||||
prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
// prev.map((a) => (a.id === id ? { ...a, ...patch } : a)),
|
||||||
);
|
// );
|
||||||
|
|
||||||
const removeArticle = (id: string) =>
|
// const removeArticle = (id: string) =>
|
||||||
setArticles((prev) => prev.filter((a) => a.id !== id));
|
// setArticles((prev) => prev.filter((a) => a.id !== id));
|
||||||
|
|
||||||
const addArticle = () =>
|
// const addArticle = () =>
|
||||||
setArticles((prev) => [
|
// setArticles((prev) => [
|
||||||
...prev,
|
// ...prev,
|
||||||
{ id: newArticleId(), title: "", body: "" },
|
// { id: newArticleId(), title: "", body: "" },
|
||||||
]);
|
// ]);
|
||||||
|
|
||||||
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
const buildSnapshot = (): Freight.IContractDocumentSnapshot => ({
|
||||||
code: draft?.code ?? null,
|
code: draft?.code ?? null,
|
||||||
@@ -253,7 +274,7 @@ export function ContractDocumentEditorModal({
|
|||||||
? draft?.nextApproverRole
|
? draft?.nextApproverRole
|
||||||
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
|
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
|
||||||
: "This document can no longer be edited — the contract has advanced beyond approval."
|
: "This document can no longer be edited — the contract has advanced beyond approval."
|
||||||
: "Edits apply to THIS contract only. The six shared templates are never changed."}
|
: "Articles come from this contract's template. Set the validity dates, then accept."}
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -320,6 +341,11 @@ export function ContractDocumentEditorModal({
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Article editing is hidden for now (frontend only) — staff accept the
|
||||||
|
contract on the template's articles as-is. The articles themselves
|
||||||
|
still ride along in buildSnapshot(), so the generated document is
|
||||||
|
unchanged. Uncomment this block to bring the editor back.
|
||||||
|
|
||||||
<Divider label="Articles" labelPosition="left" />
|
<Divider label="Articles" labelPosition="left" />
|
||||||
|
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -379,7 +405,7 @@ export function ContractDocumentEditorModal({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Article body — each line becomes a numbered clause. Use '- ' for bullets. Placeholders like {{client.companyName}} are supported."
|
placeholder="Article body — each line becomes a numbered clause."
|
||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
styles={{ input: { fontFamily: "var(--mantine-font-family-monospace)" } }}
|
||||||
@@ -404,6 +430,8 @@ export function ContractDocumentEditorModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
*/}
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
{mode === "accept" && (
|
{mode === "accept" && (
|
||||||
@@ -429,21 +457,29 @@ export function ContractDocumentEditorModal({
|
|||||||
</Text>
|
</Text>
|
||||||
)} */}
|
)} */}
|
||||||
<Group grow align="flex-start">
|
<Group grow align="flex-start">
|
||||||
<DateInput
|
<DateTimePicker
|
||||||
label="Start date"
|
label="Start date & time"
|
||||||
placeholder="Contract validity start"
|
placeholder="Contract validity start"
|
||||||
value={validityStart}
|
value={validityStart}
|
||||||
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
|
||||||
|
// Today is the earliest start — and it is ringed in the
|
||||||
|
// calendar so it reads as selectable rather than blocked.
|
||||||
minDate={startOfToday()}
|
minDate={startOfToday()}
|
||||||
maxDate={validityEnd ?? undefined}
|
maxDate={validityEnd ?? undefined}
|
||||||
|
highlightToday
|
||||||
|
getDayProps={boldToday}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
<DateInput
|
<DateTimePicker
|
||||||
label="End date"
|
label="End date & time"
|
||||||
placeholder="Contract validity end"
|
placeholder="Contract validity end"
|
||||||
value={validityEnd}
|
value={validityEnd}
|
||||||
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
|
||||||
minDate={validityStart ?? startOfToday()}
|
minDate={validityStart ?? startOfToday()}
|
||||||
|
highlightToday
|
||||||
|
getDayProps={boldToday}
|
||||||
|
valueFormat="DD MMM YYYY HH:mm"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -95,12 +95,16 @@ export function RequestCustomerCard({ contract }: { contract?: ReqContract | nul
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validity is accepted to the minute, so the expiry reads with its time — a
|
||||||
|
// contract that lapses at 09:00 looks identical to one lapsing at 23:59 without it.
|
||||||
const fmtDate = (iso?: string | null) =>
|
const fmtDate = (iso?: string | null) =>
|
||||||
iso
|
iso
|
||||||
? new Intl.DateTimeFormat("en-GB", {
|
? new Intl.DateTimeFormat("en-GB", {
|
||||||
day: "2-digit",
|
day: "2-digit",
|
||||||
month: "short",
|
month: "short",
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
}).format(new Date(iso))
|
}).format(new Date(iso))
|
||||||
: "—";
|
: "—";
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,21 @@ function formatDate(value: string | null | undefined): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Same, plus the clock — for values the staff pick to the minute. */
|
||||||
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value);
|
||||||
|
return Number.isNaN(d.getTime())
|
||||||
|
? "—"
|
||||||
|
: d.toLocaleString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractRequestDetailPage() {
|
export default function ContractRequestDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -370,7 +385,8 @@ export default function ContractRequestDetailPage() {
|
|||||||
{contract.contractValidUntil ? (
|
{contract.contractValidUntil ? (
|
||||||
<MetaItem
|
<MetaItem
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
text={`Valid until ${formatDate(contract.contractValidUntil)}`}
|
// Validity is accepted to the minute — show the time.
|
||||||
|
text={`Valid until ${formatDateTime(contract.contractValidUntil)}`}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
|
||||||
|
|
||||||
interface ContractCustomerActionProps {
|
interface ContractCustomerActionProps {
|
||||||
@@ -131,7 +132,7 @@ export function InitiateBookingButton({
|
|||||||
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
queryKey: api.contracts.get.queryKey({ id: contract.id }),
|
||||||
});
|
});
|
||||||
toast.success(
|
toast.success(
|
||||||
"Booking initiated — upload your clearance documents to start the review.",
|
`Booking initiated — upload your ${bookingDocNoun(contract)} to start the review.`,
|
||||||
);
|
);
|
||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
navigate(`/bookings/${booking.id}`);
|
navigate(`/bookings/${booking.id}`);
|
||||||
@@ -167,7 +168,7 @@ export function InitiateBookingButton({
|
|||||||
<Text span fw={700} c="#10202F">
|
<Text span fw={700} c="#10202F">
|
||||||
{contract.reference}
|
{contract.reference}
|
||||||
</Text>
|
</Text>
|
||||||
. You'll upload the clearance documents next, and the shipment
|
. You'll upload the {bookingDocNoun(contract)} next, and the shipment
|
||||||
quantity is drawn down from your contract's reserved capacity.
|
quantity is drawn down from your contract's reserved capacity.
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm" mt="lg">
|
<Group justify="flex-end" gap="sm" mt="lg">
|
||||||
|
|||||||
@@ -170,7 +170,9 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
icon: FileUp,
|
icon: FileUp,
|
||||||
iconColor: "edr-amber-text",
|
iconColor: "edr-amber-text",
|
||||||
tile: "edr-amber-soft",
|
tile: "edr-amber-soft",
|
||||||
hint: "Clearance documents needed",
|
// Direction-neutral: a self-clearance shipment collects the customer's own
|
||||||
|
// import/export papers, a customs one collects the clearance set.
|
||||||
|
hint: "Documents needed",
|
||||||
step: "edr-accent",
|
step: "edr-accent",
|
||||||
badgeLabel: "Upload documents",
|
badgeLabel: "Upload documents",
|
||||||
badgeBg: "edr-amber-soft",
|
badgeBg: "edr-amber-soft",
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
import {
|
||||||
|
bookingDocNoun,
|
||||||
|
bookingDocNounCapitalized,
|
||||||
|
getBookingNextAction,
|
||||||
|
} from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
@@ -28,6 +32,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const action = getBookingNextAction(booking);
|
const action = getBookingNextAction(booking);
|
||||||
|
// Self-clearance services collect the customer's own import/export paperwork,
|
||||||
|
// so the copy is named after the direction rather than "clearance".
|
||||||
|
const docNoun = bookingDocNoun(booking);
|
||||||
// BOOK: clearance finished on a bare instance — go straight to the booking
|
// BOOK: clearance finished on a bare instance — go straight to the booking
|
||||||
// form (cargo + shipment day + window check) instead of opening the modal.
|
// form (cargo + shipment day + window check) instead of opening the modal.
|
||||||
const isBookAction = action?.kind === "BOOK" && Boolean(action.to);
|
const isBookAction = action?.kind === "BOOK" && Boolean(action.to);
|
||||||
@@ -46,9 +53,15 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
const summary =
|
const summary =
|
||||||
status === "CLEARANCE_READY" ? (
|
status === "CLEARANCE_READY" ? (
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />}>
|
||||||
{isBookAction
|
{`${
|
||||||
? "Clearance is complete. Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
|
booking.customsClearingEnabled
|
||||||
: "Clearance is complete. Pick a shipment day and proceed to operation."}
|
? "Clearance is complete."
|
||||||
|
: "Your documents are approved."
|
||||||
|
} ${
|
||||||
|
isBookAction
|
||||||
|
? "Book your shipment — enter the cargo details and pick a shipment day inside an open booking window."
|
||||||
|
: "Pick a shipment day and proceed to operation."
|
||||||
|
}`}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||||
@@ -57,8 +70,7 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
|
<Alert color="yellow" radius="md" icon={<Upload size={18} />}>
|
||||||
Upload the required clearance documents so your shipment can be
|
Upload the required {docNoun} so your shipment can be reviewed.
|
||||||
reviewed.
|
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -67,7 +79,7 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
{/* The "Clearance progress" stepper moved into the unified journey
|
{/* The "Clearance progress" stepper moved into the unified journey
|
||||||
wizard at the top of the page — this card keeps only the actions. */}
|
wizard at the top of the page — this card keeps only the actions. */}
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>{bookingDocNounCapitalized(booking)}</CardTitle>
|
||||||
{action && (
|
{action && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -93,7 +105,7 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
<Text fz="12.5px" c="dimmed" mt="sm">
|
<Text fz="12.5px" c="dimmed" mt="sm">
|
||||||
{isBookAction
|
{isBookAction
|
||||||
? "Use “Book” to enter the cargo details and schedule your shipment."
|
? "Use “Book” to enter the cargo details and schedule your shipment."
|
||||||
: `Use “${action?.label ?? "the action button"}” to manage your clearance documents.`}
|
: `Use “${action?.label ?? "the action button"}” to manage your ${docNoun}.`}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{!isBookAction && (
|
{!isBookAction && (
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import { fetchViewableFile, downloadStoredFile } from "@/services/files.service"
|
|||||||
import { warehouseService } from "@/services/warehouse.service";
|
import { warehouseService } from "@/services/warehouse.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
|
||||||
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
import {
|
||||||
|
bookingDocNoun,
|
||||||
|
bookingDocNounCapitalized,
|
||||||
|
getBookingNextAction,
|
||||||
|
} from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
|
||||||
|
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -322,7 +326,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
{hasContract && clearance && (
|
{hasContract && clearance && (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb={4}>
|
<Group justify="space-between" align="center" mb={4}>
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>{bookingDocNounCapitalized(booking)}</CardTitle>
|
||||||
{showManage && (
|
{showManage && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
@@ -366,7 +370,7 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
<Alert color="gray" radius="md" icon={<Info size={16} />}>
|
||||||
No clearance documents are required for this booking.
|
No {bookingDocNoun(booking)} are required for this booking.
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { MoveRight } from "lucide-react";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { contractsService } from "@/services/contracts.service";
|
import { contractsService } from "@/services/contracts.service";
|
||||||
|
import {
|
||||||
|
bookingDocNoun,
|
||||||
|
bookingDocNounCapitalized,
|
||||||
|
} from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ARRIVAL_STAGE,
|
ARRIVAL_STAGE,
|
||||||
@@ -110,12 +114,29 @@ export function StatusHero({
|
|||||||
}
|
}
|
||||||
: isInitiatedInstance
|
: isInitiatedInstance
|
||||||
? {
|
? {
|
||||||
title: "Upload clearance documents",
|
title: `Upload ${bookingDocNoun(booking)}`,
|
||||||
description:
|
description: `Upload the required ${bookingDocNoun(
|
||||||
"Upload the required clearance documents to start the review. Once the review is finalized you can book your shipment.",
|
booking,
|
||||||
|
)} to start the review. Once the review is finalized you can book your shipment.`,
|
||||||
stage,
|
stage,
|
||||||
}
|
}
|
||||||
: isContractDrawdown && status === "FULLY_EXECUTED"
|
: status === "AWAITING_DOCUMENTS"
|
||||||
|
? {
|
||||||
|
title: `Upload ${bookingDocNoun(booking)}`,
|
||||||
|
description: `Upload the required ${bookingDocNoun(
|
||||||
|
booking,
|
||||||
|
)} so your shipment can be reviewed.`,
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: status === "DOCUMENTS_UNDER_REVIEW"
|
||||||
|
? {
|
||||||
|
title: `${bookingDocNounCapitalized(booking)} under review`,
|
||||||
|
description: `Your ${bookingDocNoun(
|
||||||
|
booking,
|
||||||
|
)} are being reviewed. Re-upload any queried documents to proceed.`,
|
||||||
|
stage,
|
||||||
|
}
|
||||||
|
: isContractDrawdown && status === "FULLY_EXECUTED"
|
||||||
? {
|
? {
|
||||||
title: "Accepted by operations",
|
title: "Accepted by operations",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ClearanceFlow } from "./ClearanceFlow";
|
import { ClearanceFlow } from "./ClearanceFlow";
|
||||||
import { getBookingNextAction } from "./bookingNextAction";
|
import {
|
||||||
|
bookingDocNounCapitalized,
|
||||||
|
getBookingNextAction,
|
||||||
|
} from "./bookingNextAction";
|
||||||
import { useClearanceFlow } from "./useClearanceFlow";
|
import { useClearanceFlow } from "./useClearanceFlow";
|
||||||
|
|
||||||
interface BookingActionModalProps {
|
interface BookingActionModalProps {
|
||||||
@@ -59,7 +62,7 @@ function BookingActionModalBody({
|
|||||||
title={
|
title={
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={700} fz={16}>
|
<Text fw={700} fz={16}>
|
||||||
{action?.title ?? "Clearance documents"}
|
{action?.title ?? bookingDocNounCapitalized(booking)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} c="dimmed" ff="monospace">
|
<Text fz={12} c="dimmed" ff="monospace">
|
||||||
{reference}
|
{reference}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||||
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
import { fetchViewableFile, downloadStoredFile } from "@/services/files.service";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { bookingDocNoun } from "./bookingNextAction";
|
||||||
import { OperationDatePicker } from "./OperationDatePicker";
|
import { OperationDatePicker } from "./OperationDatePicker";
|
||||||
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
import { DayAvailabilityHint } from "./DayAvailabilityHint";
|
||||||
import type { ClearanceFlowController } from "./useClearanceFlow";
|
import type { ClearanceFlowController } from "./useClearanceFlow";
|
||||||
@@ -87,7 +88,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due."
|
? "Customs clearance is complete. Global Logistics is completing your booking (cargo details and shipment day) — you will be notified when payment is due."
|
||||||
: clearance.includesCustoms
|
: clearance.includesCustoms
|
||||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
||||||
: "Clearance is ready. You can now proceed to operation."}
|
: "Your documents are approved. You can now proceed to operation."}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
@@ -99,7 +100,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||||
{clearance.includesCustoms
|
{clearance.includesCustoms
|
||||||
? "Upload every required document customs needs (marked *) to start the review. Global Logistics will clear your shipment and return the cleared documents here."
|
? "Upload every required document customs needs (marked *) to start the review. Global Logistics will clear your shipment and return the cleared documents here."
|
||||||
: "Upload every required clearance document (marked *) below to start the review."}
|
: `Upload every required ${bookingDocNoun(booking, { singular: true })} (marked *) below to start the review.`}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -115,7 +116,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
|
|||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Box>
|
<Box>
|
||||||
<Text fz={13} fw={700} c="#10202F">
|
<Text fz={13} fw={700} c="#10202F">
|
||||||
Your clearance documents
|
Your {bookingDocNoun(booking)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={12} c="dimmed" mt={4}>
|
<Text fz={12} c="dimmed" mt={4}>
|
||||||
Upload each required document below. Items marked * are mandatory.
|
Upload each required document below. Items marked * are mandatory.
|
||||||
|
|||||||
@@ -22,29 +22,78 @@ export interface BookingNextAction {
|
|||||||
to?: string;
|
to?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_BY_STATUS: Record<string, BookingNextAction> = {
|
|
||||||
AWAITING_DOCUMENTS: {
|
|
||||||
kind: "UPLOAD_DOCUMENTS",
|
|
||||||
label: "Upload documents",
|
|
||||||
title: "Upload clearance documents",
|
|
||||||
},
|
|
||||||
DOCUMENTS_UNDER_REVIEW: {
|
|
||||||
kind: "FIX_DOCUMENTS",
|
|
||||||
label: "Review documents",
|
|
||||||
title: "Clearance documents",
|
|
||||||
},
|
|
||||||
CLEARANCE_READY: {
|
|
||||||
kind: "SCHEDULE_OPERATION",
|
|
||||||
label: "Schedule & proceed",
|
|
||||||
title: "Schedule your shipment",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActionBooking = Pick<
|
type ActionBooking = Pick<
|
||||||
Freight.IBooking,
|
Freight.IBooking,
|
||||||
"id" | "status" | "contractId" | "totalAmount" | "customsClearingEnabled"
|
| "id"
|
||||||
|
| "status"
|
||||||
|
| "contractId"
|
||||||
|
| "totalAmount"
|
||||||
|
| "customsClearingEnabled"
|
||||||
|
| "tradeDirection"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
/** Anything carrying a service + a lane: a booking or the contract behind it. */
|
||||||
|
type DocNounSource = {
|
||||||
|
customsClearingEnabled?: boolean | null;
|
||||||
|
tradeDirection?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this booking's document set is CALLED to the customer.
|
||||||
|
*
|
||||||
|
* Only a customs (Path B) service collects EDR's clearance set. On a
|
||||||
|
* self-clearance service the customer clears the cargo himself and simply hands
|
||||||
|
* over his own import or export paperwork — calling that "clearance documents"
|
||||||
|
* reads as if EDR were clearing for him, so it is named after the direction.
|
||||||
|
*/
|
||||||
|
export function bookingDocNoun(
|
||||||
|
booking: DocNounSource,
|
||||||
|
{ singular = false }: { singular?: boolean } = {},
|
||||||
|
): string {
|
||||||
|
const kind = booking.customsClearingEnabled
|
||||||
|
? "clearance"
|
||||||
|
: booking.tradeDirection === "IMPORT"
|
||||||
|
? "import"
|
||||||
|
: booking.tradeDirection === "EXPORT"
|
||||||
|
? "export"
|
||||||
|
: "intercity";
|
||||||
|
return `${kind} ${singular ? "document" : "documents"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** {@link bookingDocNoun} for the start of a sentence or a card title. */
|
||||||
|
export function bookingDocNounCapitalized(
|
||||||
|
booking: Pick<ActionBooking, "customsClearingEnabled" | "tradeDirection">,
|
||||||
|
): string {
|
||||||
|
const noun = bookingDocNoun(booking);
|
||||||
|
return noun.charAt(0).toUpperCase() + noun.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionByStatus(booking: ActionBooking): BookingNextAction | null {
|
||||||
|
const noun = bookingDocNoun(booking);
|
||||||
|
switch (booking.status) {
|
||||||
|
case "AWAITING_DOCUMENTS":
|
||||||
|
return {
|
||||||
|
kind: "UPLOAD_DOCUMENTS",
|
||||||
|
label: "Upload documents",
|
||||||
|
title: `Upload ${noun}`,
|
||||||
|
};
|
||||||
|
case "DOCUMENTS_UNDER_REVIEW":
|
||||||
|
return {
|
||||||
|
kind: "FIX_DOCUMENTS",
|
||||||
|
label: "Review documents",
|
||||||
|
title: bookingDocNounCapitalized(booking),
|
||||||
|
};
|
||||||
|
case "CLEARANCE_READY":
|
||||||
|
return {
|
||||||
|
kind: "SCHEDULE_OPERATION",
|
||||||
|
label: "Schedule & proceed",
|
||||||
|
title: "Schedule your shipment",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Initiated instance still carrying no cargo/price (clearance-first flow). */
|
/** Initiated instance still carrying no cargo/price (clearance-first flow). */
|
||||||
function isBareInstance(booking: ActionBooking): boolean {
|
function isBareInstance(booking: ActionBooking): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -83,7 +132,7 @@ export function getBookingNextAction(
|
|||||||
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
to: `/contracts/${booking.contractId}/bookings/${booking.id}/complete`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return ACTION_BY_STATUS[booking.status as string] ?? null;
|
return actionByStatus(booking);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
} from "@/pages/bookings/booking-display";
|
} from "@/pages/bookings/booking-display";
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||||
import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection";
|
import { ContractBookingWindowsSection } from "./ContractBookingWindowsSection";
|
||||||
@@ -1231,7 +1232,7 @@ export default function ContractDetailPage() {
|
|||||||
: customsPath
|
: customsPath
|
||||||
? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it."
|
? "No bookings yet. Global Logistics opens the shipment on your behalf — you upload the clearance documents on it."
|
||||||
: canInitiateBooking
|
: canInitiateBooking
|
||||||
? "No shipments yet. Start one with “Initiate booking” — you upload the clearance documents on that shipment."
|
? `No shipments yet. Start one with “Initiate booking” — you upload the ${bookingDocNoun(contract)} on that shipment.`
|
||||||
: "Bookings appear here once the contract is fully executed."}
|
: "Bookings appear here once the contract is fully executed."}
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
Reference in New Issue
Block a user