mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 11:55:42 +00:00
feat: implement clearance flow for booking operations
- Update booking form schema to remove customs clearing agent requirement. - Add BookingActionButton component to trigger actions for bookings. - Create BookingActionModal for handling document uploads and operation scheduling. - Implement ClearanceFlow component to manage document statuses and uploads. - Add OperationDatePicker for selecting shipment days. - Introduce bookingNextAction utility to determine next actions based on booking status. - Create useClearanceFlow hook to encapsulate clearance logic and state management. - Export new components and utilities from the clearance module.
This commit is contained in:
@@ -3,6 +3,8 @@ import { memo } from "react";
|
|||||||
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
import { ACTION_PROPS, STATUS_CONFIG, cv } from "../constants";
|
||||||
import { Stepper } from "./Stepper";
|
import { Stepper } from "./Stepper";
|
||||||
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
|
||||||
|
import { BookingActionButton } from "@/pages/bookings/clearance/BookingActionButton";
|
||||||
|
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
|
|
||||||
interface BookingRowProps {
|
interface BookingRowProps {
|
||||||
booking: any;
|
booking: any;
|
||||||
@@ -23,6 +25,8 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
// instead of navigating to the detail page.
|
// instead of navigating to the detail page.
|
||||||
const canPay =
|
const canPay =
|
||||||
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
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 origin = booking.originYard?.label ?? booking.originYard?.code ?? "—";
|
||||||
const dest =
|
const dest =
|
||||||
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
booking.destinationYard?.label ?? booking.destinationYard?.code ?? "—";
|
||||||
@@ -85,6 +89,8 @@ export const BookingRow = memo(function BookingRow({
|
|||||||
</Group>
|
</Group>
|
||||||
{canPay ? (
|
{canPay ? (
|
||||||
<PayNowButton booking={booking} size="sm" />
|
<PayNowButton booking={booking} size="sm" />
|
||||||
|
) : nextAction ? (
|
||||||
|
<BookingActionButton booking={booking} size="sm" />
|
||||||
) : (
|
) : (
|
||||||
<Group
|
<Group
|
||||||
gap={5}
|
gap={5}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
|
CalendarClock,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Clock3,
|
Clock3,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
FilePen,
|
FilePen,
|
||||||
|
FileUp,
|
||||||
MapPin,
|
MapPin,
|
||||||
|
ShieldCheck,
|
||||||
Truck,
|
Truck,
|
||||||
Wallet,
|
Wallet,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
@@ -161,6 +164,58 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
|
|||||||
badgeDot: "edr-green.5",
|
badgeDot: "edr-green.5",
|
||||||
action: { label: "View", kind: "outline" },
|
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: {
|
PNR_GENERATED: {
|
||||||
stage: 3,
|
stage: 3,
|
||||||
icon: FileCheck2,
|
icon: FileCheck2,
|
||||||
|
|||||||
@@ -1,144 +1,28 @@
|
|||||||
import {
|
import { Alert, Button, Group } from "@mantine/core";
|
||||||
Alert,
|
import { CheckCircle2, Upload } from "lucide-react";
|
||||||
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 { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
|
||||||
import type { Freight } from "@edr/types";
|
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 { 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-facing clearance section on the booking detail page: shows the
|
||||||
* customer (re)upload pending/queried documents plus ad-hoc named documents, and
|
* resolved document grid, lets the customer (re)upload pending/queried documents
|
||||||
* proceed to operation once Global Logistics marks the booking CLEARANCE_READY.
|
* 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 }) {
|
export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const flow = useClearanceFlow(booking);
|
||||||
|
|
||||||
const { data: clearance, isLoading } = useQuery(
|
if (flow.status === "OPERATION_REQUESTED") {
|
||||||
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") {
|
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Operation</CardTitle>
|
<CardTitle>Operation</CardTitle>
|
||||||
@@ -149,477 +33,56 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading || !clearance) {
|
if (flow.isLoading || !flow.clearance) {
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
<Text fz="13px" c="dimmed" mt="sm">
|
|
||||||
Loading clearance…
|
|
||||||
</Text>
|
|
||||||
</SectionCard>
|
</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 (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="md">
|
<Group justify="space-between" align="center" mb="md">
|
||||||
<CardTitle>Clearance documents</CardTitle>
|
<CardTitle>Clearance documents</CardTitle>
|
||||||
{clearance.includesCustoms && (
|
|
||||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
|
||||||
Customs clearance
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{isReady ? (
|
<ClearanceFlow
|
||||||
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
booking={booking}
|
||||||
{clearance.includesCustoms
|
flow={flow}
|
||||||
? "Customs clearance is complete and your cleared documents are available below. You can now proceed to operation."
|
footer={
|
||||||
: "Clearance is ready. You can now proceed to operation."}
|
<Group justify="flex-end" mt="lg" gap="sm">
|
||||||
</Alert>
|
{flow.canUpload && (
|
||||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
<Button
|
||||||
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
color="edr-green"
|
||||||
{clearance.includesCustoms
|
radius="md"
|
||||||
? "Global Logistics is reviewing your documents and will clear your shipment. Queried documents below need to be re-uploaded."
|
leftSection={<Upload size={16} />}
|
||||||
: "Our team is reviewing your documents. Queried documents below need to be re-uploaded."}
|
onClick={() => flow.submitDocuments()}
|
||||||
</Alert>
|
loading={flow.uploadMutation.isPending}
|
||||||
) : (
|
disabled={!flow.hasStagedFiles}
|
||||||
<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 }}
|
|
||||||
>
|
>
|
||||||
<Text fz="13px" c="#10202F" truncate>
|
Submit documents
|
||||||
{doc.label}
|
</Button>
|
||||||
</Text>
|
)}
|
||||||
{doc.file ? (
|
{flow.isReady && (
|
||||||
<IconSquare href={doc.file.url} icon={<Download size={15} />} />
|
<Button
|
||||||
) : (
|
color="edr-green"
|
||||||
<Text fz="12px" c="#9AA8B5">
|
radius="md"
|
||||||
Pending
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
</Text>
|
onClick={() =>
|
||||||
)}
|
flow.proceedToOperation({
|
||||||
</Group>
|
onSuccess: () => navigate(`/bookings/${booking.id}`),
|
||||||
))}
|
})
|
||||||
</Stack>
|
}
|
||||||
</>
|
loading={flow.proceedMutation.isPending}
|
||||||
)}
|
disabled={!flow.scheduledDate}
|
||||||
|
>
|
||||||
{/* Ad-hoc / additional documents. */}
|
Proceed to operation
|
||||||
{canUpload && (
|
</Button>
|
||||||
<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>
|
|
||||||
</Group>
|
</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>
|
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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>;
|
||||||
@@ -244,14 +244,10 @@ export const bookingFormSchema = z
|
|||||||
message: "Select a shipment date.",
|
message: "Select a shipment date.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Customs clearing agent is required once the customs service is enabled.
|
// The customs clearing agent is only the customer's own broker, named when
|
||||||
if (data.customsClearingEnabled && !data.customsClearingAgent.trim()) {
|
// the service does NOT bundle customs (EDR/GL handles it otherwise). It is
|
||||||
ctx.addIssue({
|
// never required: when the service includes customs the agent is left blank
|
||||||
code: "custom",
|
// on purpose, so requiring it would silently block submission.
|
||||||
path: ["customsClearingAgent"],
|
|
||||||
message: "Enter the customs clearing agent.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (data.cargoType === "bulk") {
|
if (data.cargoType === "bulk") {
|
||||||
if (!data.cargoTypePath[0]) {
|
if (!data.cargoTypePath[0]) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
|
|||||||
Reference in New Issue
Block a user