Merge branch 'freight_feature/profile' of github.com:Tria-plc/edr-platform into freight_feature/profile

This commit is contained in:
marshal
2026-06-25 05:19:54 +03:00
11 changed files with 1022 additions and 594 deletions

View File

@@ -3,6 +3,8 @@ import { memo } from "react";
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
import { Stepper } from "./Stepper";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
interface BookingRowProps {
booking: any;
@@ -23,6 +25,8 @@ export const BookingRow = memo(function BookingRow({
// instead of navigating to the detail page.
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
// Clearance/operation steps the customer can act on in place via a modal.
const nextAction = getBookingNextAction(booking);
const origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
const dest =
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
@@ -85,6 +89,8 @@ export const BookingRow = memo(function BookingRow({
</Group>
{canPay ? (
<PayNowButton booking={booking} size="sm" />
) : nextAction ? (
<BookingActionButton booking={booking} size="sm" />
) : (
<Group
gap={5}

View File

@@ -1,10 +1,13 @@
import {
ArrowRight,
CalendarClock,
CheckCircle2,
Clock3,
FileCheck2,
FilePen,
FileUp,
MapPin,
ShieldCheck,
Truck,
Wallet,
type LucideIcon,
@@ -161,6 +164,58 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
AWAITING_DOCUMENTS: {
stage: 3,
icon: FileUp,
iconColor: "edr-amber-text",
tile: "edr-amber-soft",
hint: "Clearance documents needed",
step: "edr-accent",
badgeLabel: "Docs needed",
badgeBg: "edr-amber-soft",
badgeText: "edr-amber-text",
badgeDot: "edr-accent",
action: { label: "Upload documents", kind: "amber", icon: ArrowRight },
},
DOCUMENTS_UNDER_REVIEW: {
stage: 3,
icon: ShieldCheck,
iconColor: "edr-blue",
tile: "edr-blue-soft",
hint: "Clearance under review · re-upload any queried docs",
step: "edr-blue-dot",
badgeLabel: "In review",
badgeBg: "edr-blue-soft",
badgeText: "edr-blue",
badgeDot: "edr-blue-dot",
action: { label: "Review documents", kind: "outline" },
},
CLEARANCE_READY: {
stage: 3,
icon: CalendarClock,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Cleared · choose a shipment day to proceed",
step: "edr-green.5",
badgeLabel: "Cleared",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Schedule & proceed", kind: "amber", icon: ArrowRight },
},
OPERATION_REQUESTED: {
stage: 3,
icon: CheckCircle2,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Operation requested · operator taking it forward",
step: "edr-green.5",
badgeLabel: "Operation requested",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "View", kind: "outline" },
},
PNR_GENERATED: {
stage: 3,
icon: FileCheck2,

View File

@@ -1,144 +1,28 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
AlertCircle,
Calendar as CalendarIcon,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import { useMemo, useState } from "react";
import { Alert, Button, Group } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "@/pages/bookings/clearance/ClearanceFlow";
import { useClearanceFlow } from "@/pages/bookings/clearance/useClearanceFlow";
import { CardTitle, SectionCard } from "./layout";
import { IconSquare } from "./Documents";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
/**
* Customer-facing clearance section: shows the resolved document grid, lets the
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
* Customer-facing clearance section on the booking detail page: shows the
* resolved document grid, lets the customer (re)upload pending/queried documents
* plus ad-hoc named documents, and proceed to operation once Global Logistics
* marks the booking CLEARANCE_READY.
*
* The flow body, calendar, and mutations are shared with the home-page action
* modal via `useClearanceFlow` / `ClearanceFlow`.
*/
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
const queryClient = useQueryClient();
const navigate = useNavigate();
const status = booking.status as string;
const flow = useClearanceFlow(booking);
const { data: clearance, isLoading } = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<Array<{ name: string; file: File | null }>>(
[],
);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
if (status === "OPERATION_REQUESTED") {
if (flow.status === "OPERATION_REQUESTED") {
return (
<SectionCard>
<CardTitle>Operation</CardTitle>
@@ -149,477 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
);
}
if (isLoading || !clearance) {
if (flow.isLoading || !flow.clearance) {
return (
<SectionCard>
<CardTitle>Clearance documents</CardTitle>
<Text fz="13px" c="dimmed" mt="sm">
Loading clearance
</Text>
</SectionCard>
);
}
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
function handleSubmit() {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files });
}
return (
<SectionCard>
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{clearance.includesCustoms && (
<Text fz="12px" fw={600} c="#9AA8B5">
Customs clearance
</Text>
)}
</Group>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms
? "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."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{clearance.includesCustoms
? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
: "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{clearance.includesCustoms
? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here."
: "Upload all the required clearance documents below to start the review."}
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) =>
f && setPending((p) => ({ ...p, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="lg" gap="sm">
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={() => flow.submitDocuments()}
loading={flow.uploadMutation.isPending}
disabled={!flow.hasStagedFiles}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
>
Add document
</Button>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
flow.proceedToOperation({
onSuccess: () => navigate(`/bookings/${booking.id}`),
})
}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) =>
setAdHoc((rows) =>
rows.map((r, j) =>
j === i ? { ...r, name: e.currentTarget.value } : r,
),
)
}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
)
}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{isReady && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected.
The operations team assigns the specific train for that day.
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
)}
{proceedMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{proceedMutation.error instanceof Error
? proceedMutation.error.message
: "Could not request the operation. Please try again."}
</Alert>
)}
<Group justify="flex-end" mt="lg" gap="sm">
{canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={uploadMutation.isPending}
disabled={
Object.keys(pending).length === 0 &&
!adHoc.some((r) => r.file)
}
>
Submit documents
</Button>
)}
{isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={() =>
proceedMutation.mutate(
{ id: booking.id, scheduledDate },
{ onSuccess: () => navigate(`/bookings/${booking.id}`) },
)
}
loading={proceedMutation.isPending}
disabled={!scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
</SectionCard>
);
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*/
function OperationDatePicker({
originYardId,
destinationYardId,
value,
onChange,
}: {
originYardId?: string;
destinationYardId?: string;
value: string;
onChange: (date: string) => void;
}) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text
key={i}
ta="center"
fz="10px"
fw={700}
c="#9AA8B5"
>
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,67 @@
import { Button } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { AlertCircle, ArrowRight, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { BookingActionModal } from "./BookingActionModal";
import {
type BookingActionKind,
getBookingNextAction,
} from "./bookingNextAction";
const ICON_BY_KIND: Record<
BookingActionKind,
typeof Upload
> = {
UPLOAD_DOCUMENTS: Upload,
FIX_DOCUMENTS: AlertCircle,
SCHEDULE_OPERATION: ArrowRight,
};
interface BookingActionButtonProps {
booking: Freight.IBooking;
size?: "xs" | "sm";
}
/**
* Self-contained next-action trigger for a My Shipments row. Renders nothing
* when the booking has no customer-actionable clearance/operation step;
* otherwise shows a button that opens the in-place {@link BookingActionModal}.
*
* Drop it into a list row exactly like {@link PayNowButton} — it stops click
* propagation so it never triggers the row's navigation handler.
*/
export function BookingActionButton({
booking,
size = "sm",
}: BookingActionButtonProps) {
const action = getBookingNextAction(booking);
const [opened, { open, close }] = useDisclosure(false);
if (!action) return null;
const Icon = ICON_BY_KIND[action.kind];
return (
<>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<Icon size={14} />}
onClick={(e) => {
// Don't let the surrounding row-click handler fire.
e.stopPropagation();
open();
}}
>
{action.label}
</Button>
<BookingActionModal booking={booking} opened={opened} onClose={close} />
</>
);
}

View File

@@ -0,0 +1,112 @@
import { Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { CheckCircle2, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { ClearanceFlow } from "./ClearanceFlow";
import { getBookingNextAction } from "./bookingNextAction";
import { useClearanceFlow } from "./useClearanceFlow";
interface BookingActionModalProps {
booking: Freight.IBooking;
opened: boolean;
onClose: () => void;
}
/**
* Home-page action modal: runs the full clearance / operation flow for a single
* booking without leaving the My Shipments list. The customer can upload the
* required documents, re-upload queried ones, then pick a shipment day and
* proceed to operation — all in place.
*
* Mounted only while `opened` so the clearance grid is fetched lazily and the
* staged-upload state resets every time the customer reopens it.
*/
export function BookingActionModal({
booking,
opened,
onClose,
}: BookingActionModalProps) {
if (!opened) return null;
return <BookingActionModalBody booking={booking} onClose={onClose} />;
}
function BookingActionModalBody({
booking,
onClose,
}: {
booking: Freight.IBooking;
onClose: () => void;
}) {
const action = getBookingNextAction(booking);
const flow = useClearanceFlow(booking);
const reference = booking.reference;
const handleSubmit = () => flow.submitDocuments();
const handleProceed = () => flow.proceedToOperation({ onSuccess: onClose });
return (
<Modal
opened
onClose={onClose}
centered
size={560}
radius={16}
padding={24}
title={
<Box>
<Text fz={16} fw={800} c="#10202F">
{action?.title ?? "Booking"}
</Text>
<Text fz={12} c="dimmed" ff="monospace">
{reference}
</Text>
</Box>
}
overlayProps={{ backgroundOpacity: 0.5, blur: 4 }}
styles={{ body: { paddingTop: 8 } }}
>
{flow.isLoading || !flow.clearance ? (
<Text fz="13px" c="dimmed" py="md">
Loading clearance
</Text>
) : (
<ClearanceFlow
booking={booking}
flow={flow}
footer={
<Group justify="flex-end" mt="xl" gap="sm">
<Button variant="default" radius="md" onClick={onClose}>
Close
</Button>
{flow.canUpload && (
<Button
color="edr-green"
radius="md"
leftSection={<Upload size={16} />}
onClick={handleSubmit}
loading={flow.uploadMutation.isPending}
disabled={!flow.hasStagedFiles}
>
Submit documents
</Button>
)}
{flow.isReady && (
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={handleProceed}
loading={flow.proceedMutation.isPending}
disabled={!flow.scheduledDate}
>
Proceed to operation
</Button>
)}
</Group>
}
/>
)}
</Modal>
);
}

View File

@@ -0,0 +1,304 @@
import {
Alert,
Box,
Button,
FileButton,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
FileText,
Plus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { IconSquare } from "../BookingDetailPage/components/Documents";
import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow";
const GREEN = "#0A6F4D";
function StatusPill({ doc }: { doc: Freight.ClearanceDocument }) {
if (doc.reviewStatus === "APPROVED") {
return (
<Group gap={6} c={GREEN}>
<CheckCircle2 size={15} />
<Text fz="12px" fw={600} c={GREEN}>
Approved
</Text>
</Group>
);
}
if (doc.reviewStatus === "QUERIED") {
return (
<Group gap={6} c="#C0392B">
<AlertCircle size={15} />
<Text fz="12px" fw={600} c="#C0392B">
Queried
</Text>
</Group>
);
}
if (doc.file) {
return (
<Group gap={6} c="#2E5B96">
<Clock size={15} />
<Text fz="12px" fw={600} c="#2E5B96">
Pending review
</Text>
</Group>
);
}
return (
<Text fz="12px" fw={600} c="#9AA8B5">
Not uploaded
</Text>
);
}
interface ClearanceFlowProps {
booking: Freight.IBooking;
flow: ClearanceFlowController;
/**
* Rendered at the bottom of the flow (the submit / proceed buttons). Host
* supplies this so the detail card and the modal can place actions in their
* own footer chrome.
*/
footer?: React.ReactNode;
}
/**
* Presentational body of the customer clearance/operation flow: the required
* document grid (with re-upload of pending/queried docs), GL output documents,
* ad-hoc documents, and the shipment-day picker once CLEARANCE_READY.
*
* All state lives in the `flow` controller (see `useClearanceFlow`) so this can
* be dropped into either the booking detail card or the home-page action modal.
*/
export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
const {
clearance,
customerDocs,
glDocs,
isReady,
canUpload,
status,
pending,
adHoc,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
scheduledDate,
setScheduledDate,
uploadMutation,
proceedMutation,
} = flow;
if (!clearance) return null;
return (
<Stack gap={0}>
{isReady ? (
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
{clearance.includesCustoms
? "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."}
</Alert>
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
{clearance.includesCustoms
? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
: "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
</Alert>
) : (
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
{clearance.includesCustoms
? "Upload the documents customs needs — Global Logistics will clear your shipment and return the cleared documents here."
: "Upload all the required clearance documents below to start the review."}
</Alert>
)}
<Stack gap={10}>
{customerDocs.map((doc) => (
<Box
key={doc.fileKey}
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box c="#2E5B96">
<FileText size={18} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz="13.5px" fw={600} c="#10202F" truncate>
{doc.label}
{doc.required ? " *" : ""}
</Text>
{doc.file && (
<Text fz="12px" c="dimmed" truncate>
{doc.file.name}
</Text>
)}
</Box>
</Group>
<Group gap={10} wrap="nowrap">
<StatusPill doc={doc} />
{doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
)}
{canUpload && doc.reviewStatus !== "APPROVED" && (
<FileButton
onChange={(f) => f && stagePending(doc.fileKey, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{pending[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
{doc.reviewStatus === "QUERIED" && doc.note && (
<Text fz="12px" c="#C0392B" mt={6}>
Query: {doc.note}
</Text>
)}
{pending[doc.fileKey] && (
<Text fz="12px" c={GREEN} mt={6}>
Ready to upload: {pending[doc.fileKey].name}
</Text>
)}
</Box>
))}
</Stack>
{/* GL output documents (read-only to the customer). */}
{glDocs.length > 0 && (
<>
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
Customs output documents
</Text>
<Stack gap={8}>
{glDocs.map((doc) => (
<Group
key={doc.fileKey}
justify="space-between"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 10 }}
>
<Text fz="13px" c="#10202F" truncate>
{doc.label}
</Text>
{doc.file ? (
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
) : (
<Text fz="12px" c="#9AA8B5">
Pending
</Text>
)}
</Group>
))}
</Stack>
</>
)}
{/* Ad-hoc / additional documents. */}
{canUpload && (
<Box mt="lg">
<Group justify="space-between" align="center" mb={8}>
<Text fz="12.5px" fw={700} c="#10202F">
Additional documents
</Text>
<Button
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Plus size={13} />}
onClick={addAdHocRow}
>
Add document
</Button>
</Group>
<Stack gap={8}>
{adHoc.map((row, i) => (
<Group key={i} gap={8} wrap="nowrap">
<TextInput
placeholder="Document name"
value={row.name}
onChange={(e) => setAdHocName(i, e.currentTarget.value)}
style={{ flex: 1 }}
radius="md"
/>
<FileButton
onChange={(f) => setAdHocFile(i, f)}
accept="application/pdf,image/*"
>
{(props) => (
<Button {...props} variant="default" radius="md">
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
</Button>
)}
</FileButton>
</Group>
))}
</Stack>
</Box>
)}
{uploadMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{uploadMutation.error instanceof Error
? uploadMutation.error.message
: "Upload failed. Please try again."}
</Alert>
)}
{isReady && (
<Box mt="lg">
<Text fz="13px" fw={700} c="#10202F" mb={6}>
Choose your shipment day
</Text>
<Text fz="12px" c="dimmed" mb="sm">
Only days with a scheduled departure on your route can be selected.
The operations team assigns the specific train for that day.
</Text>
<OperationDatePicker
originYardId={booking.originYard?.id}
destinationYardId={booking.destinationYard?.id}
value={scheduledDate}
onChange={setScheduledDate}
/>
</Box>
)}
{proceedMutation.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
{proceedMutation.error instanceof Error
? proceedMutation.error.message
: "Could not request the operation. Please try again."}
</Alert>
)}
{footer}
</Stack>
);
}

View File

@@ -0,0 +1,219 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
interface OperationDatePickerProps {
originYardId?: string;
destinationYardId?: string;
value: string;
onChange: (date: string) => void;
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*
* Shared by the booking detail clearance card and the home-page action modal.
*/
export function OperationDatePicker({
originYardId,
destinationYardId,
value,
onChange,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
enabled: !!originYardId && !!destinationYardId,
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
);
}

View File

@@ -0,0 +1,53 @@
import type { Freight } from "@edr/types";
/**
* The customer-actionable clearance/operation steps a booking can be sitting on.
* These are the statuses where the *customer* must do something next — upload
* documents, re-upload a queried document, or pick a shipment day and proceed
* to operation.
*/
export type BookingActionKind =
| "UPLOAD_DOCUMENTS" // AWAITING_DOCUMENTS — upload the required clearance docs
| "FIX_DOCUMENTS" // DOCUMENTS_UNDER_REVIEW — some docs queried, re-upload them
| "SCHEDULE_OPERATION"; // CLEARANCE_READY — pick a day and proceed to operation
export interface BookingNextAction {
kind: BookingActionKind;
/** Button label shown on the My Shipments row. */
label: string;
/** Modal title. */
title: 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",
},
};
/**
* Resolve the customer's next clearance/operation action for a booking, or
* `null` when there's nothing for them to do at this stage. Pure + cheap so it
* can be called inline while rendering a list row.
*
* Note: `DOCUMENTS_UNDER_REVIEW` always surfaces an action because the customer
* may need to re-upload a queried document; the modal itself shows a read-only
* "under review" state when nothing is actually queried.
*/
export function getBookingNextAction(
booking: Pick<Freight.IBooking, "status">,
): BookingNextAction | null {
return ACTION_BY_STATUS[booking.status as string] ?? null;
}

View File

@@ -0,0 +1,14 @@
export { BookingActionButton } from "./BookingActionButton";
export { BookingActionModal } from "./BookingActionModal";
export { ClearanceFlow } from "./ClearanceFlow";
export { OperationDatePicker } from "./OperationDatePicker";
export {
getBookingNextAction,
type BookingActionKind,
type BookingNextAction,
} from "./bookingNextAction";
export {
useClearanceFlow,
type AdHocDoc,
type ClearanceFlowController,
} from "./useClearanceFlow";

View File

@@ -0,0 +1,139 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { api } from "@/services/api";
import type { Freight } from "@edr/types";
export type AdHocDoc = { name: string; file: File | null };
/**
* Encapsulates everything the customer-facing clearance/operation flow needs:
* the clearance grid query, the staged uploads (keyed pending + ad-hoc docs),
* the chosen shipment day, and the submit / proceed mutations.
*
* Both the booking detail clearance card and the home-page action modal drive
* their UI off this single hook so the behaviour stays in lock-step.
*/
export function useClearanceFlow(booking: Freight.IBooking) {
const queryClient = useQueryClient();
const status = booking.status as string;
const clearanceQuery = useQuery(
api.bookings.getClearance.queryOptions({ input: { id: booking.id } }),
);
const clearance = clearanceQuery.data;
// Pending uploads keyed by fileKey, plus ad-hoc rows (label + file).
const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
// Binding shipment day chosen for the operation request (yyyy-MM-dd).
const [scheduledDate, setScheduledDate] = useState<string>("");
const refresh = () => {
queryClient.invalidateQueries({
queryKey: api.bookings.getClearance.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.get.queryKey({ id: booking.id }),
});
queryClient.invalidateQueries({
queryKey: api.bookings.list.queryKey(),
});
};
const uploadMutation = useMutation({
...api.bookings.submitClearanceDocuments.mutationOptions(),
onSuccess: () => {
setPending({});
setAdHoc([]);
refresh();
},
});
const proceedMutation = useMutation({
...api.bookings.proceedToOperation.mutationOptions(),
onSuccess: () => refresh(),
});
// Only the customer-input documents are uploadable here; GL output docs are
// shown read-only.
const customerDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
[clearance],
);
const glDocs = useMemo(
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy === "gl"),
[clearance],
);
const isReady = status === "CLEARANCE_READY";
const canUpload =
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
const hasStagedFiles =
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
// --- staged-upload mutators ----------------------------------------------
const stagePending = (fileKey: string, file: File) =>
setPending((p) => ({ ...p, [fileKey]: file }));
const addAdHocRow = () => setAdHoc((r) => [...r, { name: "", file: null }]);
const setAdHocName = (index: number, name: string) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, name } : r)),
);
const setAdHocFile = (index: number, file: File | null) =>
setAdHoc((rows) =>
rows.map((r, j) => (j === index ? { ...r, file } : r)),
);
// --- actions --------------------------------------------------------------
const submitDocuments = (opts?: { onSuccess?: () => void }) => {
const files: Record<string, File | null> = { ...pending };
adHoc.forEach((row, i) => {
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
});
if (Object.keys(files).length === 0) return;
uploadMutation.mutate({ id: booking.id, files }, { onSuccess: opts?.onSuccess });
};
const proceedToOperation = (opts?: { onSuccess?: () => void }) => {
if (!scheduledDate) return;
proceedMutation.mutate(
{ id: booking.id, scheduledDate },
{ onSuccess: opts?.onSuccess },
);
};
return {
status,
clearance,
isLoading: clearanceQuery.isLoading,
customerDocs,
glDocs,
isReady,
canUpload,
// staged upload state
pending,
adHoc,
hasStagedFiles,
stagePending,
addAdHocRow,
setAdHocName,
setAdHocFile,
// schedule
scheduledDate,
setScheduledDate,
// mutations
uploadMutation,
proceedMutation,
submitDocuments,
proceedToOperation,
};
}
export type ClearanceFlowController = ReturnType<typeof useClearanceFlow>;

View File

@@ -244,14 +244,10 @@ export const bookingFormSchema = z
message: "Select a shipment date.",
});
}
// Customs clearing agent is required once the customs service is enabled.
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
ctx.addIssue({
code: "custom",
path: ["customsClearingAgent"],
message: "Enter the customs clearing agent.",
});
}
// The customs clearing agent is only the customer's own broker, named when
// the service does NOT bundle customs (EDR/GL handles it otherwise). It is
// never required: when the service includes customs the agent is left blank
// on purpose, so requiring it would silently block submission.
if (data.cargoType === "bulk") {
if (!data.cargoTypePath[0]) {
ctx.addIssue({