feat(bookings): two-level clearance charges (port + misc) billed to customer with invoices

This commit is contained in:
Marshal
2026-08-20 05:48:16 +00:00
committed by Hagernesh
parent a809215e74
commit c889797870
19 changed files with 663 additions and 16 deletions

View File

@@ -0,0 +1,116 @@
import { useQuery } from "@tanstack/react-query";
import { Badge, Group, Loader, Paper, Text, Timeline } from "@mantine/core";
import {
CheckCircle2,
CircleDot,
FileText,
MessageSquareWarning,
Receipt,
Send,
Ship,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { bookingsService } from "@/services/bookings.service";
import { formatDateTime } from "@/lib/format";
/** Icon + color per action family; unknown actions fall back to a neutral dot. */
function eventMeta(action: string): { icon: typeof Upload; color: string } {
if (action === "DOC_APPROVED" || action.endsWith("_ACCEPTED") || action.endsWith("_FINALIZED") || action.endsWith("_CONFIRMED"))
return { icon: CheckCircle2, color: "edr-green" };
if (action === "DOC_QUERIED" || action.includes("CHANGE_REQUESTED") || action.includes("AMENDMENT"))
return { icon: MessageSquareWarning, color: "red" };
if (action.startsWith("CHARGE_"))
return { icon: Receipt, color: action === "CHARGE_PAID" ? "edr-green" : "orange" };
if (action.includes("TRANSIT_ASSIGNEE")) return { icon: UserCheck, color: "blue" };
if (action.includes("ORDER")) return { icon: Ship, color: "blue" };
if (action.includes("SENT")) return { icon: Send, color: "blue" };
if (action.includes("UPLOAD") || action.includes("SUBMITTED"))
return { icon: Upload, color: "blue" };
if (action.includes("DOC")) return { icon: FileText, color: "gray" };
return { icon: CircleDot, color: "gray" };
}
const ACTOR_BADGE: Record<
Freight.ClearanceHistoryEvent["actorType"],
{ label: string; color: string }
> = {
STAFF: { label: "Staff", color: "blue" },
CUSTOMER: { label: "Customer", color: "grape" },
SYSTEM: { label: "System", color: "gray" },
};
/**
* Full per-booking clearance action trail: document reviews, phased workflow
* steps (transit, declaration, duty, DO/RO, permits) and customer charges —
* every event with who did it and when, newest first.
*/
export function ClearanceHistoryTab({ bookingId }: { bookingId: string }) {
const { data: events, isLoading } = useQuery({
queryKey: ["clearance-history", bookingId],
queryFn: () => bookingsService.getClearanceHistory(bookingId),
});
if (isLoading) {
return (
<Group justify="center" py="xl" gap={10}>
<Loader size="sm" color="edr-green" />
<Text c="dimmed">Loading history</Text>
</Group>
);
}
if (!events || events.length === 0) {
return (
<Paper withBorder radius="md" p="lg">
<Text size="sm" c="dimmed">
No clearance actions recorded yet. Actions from now on approvals,
queries, workflow steps, charges appear here automatically.
</Text>
</Paper>
);
}
return (
<Paper withBorder radius="md" p="lg" maw={760}>
<Timeline bulletSize={22} lineWidth={2} active={events.length - 1} color="gray">
{events.map((ev) => {
const meta = eventMeta(ev.action);
const Icon = meta.icon;
const actor = ACTOR_BADGE[ev.actorType];
const note =
typeof ev.metadata?.note === "string" ? ev.metadata.note : null;
return (
<Timeline.Item
key={ev.id}
color={meta.color}
bullet={<Icon size={12} />}
title={
<Group gap={8} wrap="wrap">
<Text fz="13px" fw={600} c="edr-text" lh={1.35}>
{ev.label}
</Text>
<Badge size="xs" variant="light" color={actor.color} radius="sm">
{actor.label}
</Badge>
</Group>
}
>
<Text fz="11.5px" c="dimmed">
{ev.actorName ? `${ev.actorName} · ` : ""}
{formatDateTime(ev.at)}
</Text>
{note ? (
<Text fz="12px" c="red.8" mt={2}>
{note}
</Text>
) : null}
</Timeline.Item>
);
})}
</Timeline>
</Paper>
);
}

View File

@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import { Badge, Stack, Tabs, Text } from "@mantine/core";
import { AlertTriangle, FileText, Receipt, Share2, ShieldAlert } from "lucide-react";
import { AlertTriangle, FileText, History, Receipt, Share2, ShieldAlert } from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
@@ -10,6 +10,7 @@ import { AssignRiskCard } from "@/components/contracts/gl-actions/AssignRiskCard
import { IncidentReportCard } from "@/components/contracts/gl-actions/IncidentReportCard";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
export interface ClearanceOpsTabsProps {
@@ -112,6 +113,11 @@ export function ClearanceOpsTabs({
Customer charges
</Tabs.Tab>
) : null}
{bookingId && showExchange ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{showOpsTabs && canOps && riskMs ? (
<Tabs.Tab value="risk" leftSection={<ShieldAlert size={14} />}>
Risk assignment
@@ -153,6 +159,12 @@ export function ClearanceOpsTabs({
</Tabs.Panel>
) : null}
{bookingId && showExchange ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={bookingId} />
</Tabs.Panel>
) : null}
{showOpsTabs && canOps && riskMs && bookingId ? (
<Tabs.Panel value="risk">
<SectionCard icon={ShieldAlert} title="Customs risk" accent="edr-green">

View File

@@ -20,6 +20,7 @@ import {
AlertTriangle,
ClipboardList,
FileText,
History,
Receipt,
Share2,
Upload,
@@ -38,6 +39,7 @@ import { ContractClearanceReviewSection } from "@/components/contracts/ContractC
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
import { GlExchangePanel } from "@/components/contracts/GlExchangePanel";
import { ClearanceChargesTab } from "@/components/contracts/ClearanceChargesTab";
import { ClearanceHistoryTab } from "@/components/contracts/ClearanceHistoryTab";
import {
GlClearanceUploadModal,
type GlClearanceUploadKind,
@@ -254,6 +256,11 @@ export default function GlClearanceDetailPage() {
Customer charges
</Tabs.Tab>
) : null}
{data.kind === "booking" ? (
<Tabs.Tab value="history" leftSection={<History size={14} />}>
History
</Tabs.Tab>
) : null}
{incidentBookingId ? (
<Tabs.Tab value="incidents" leftSection={<AlertTriangle size={14} />}>
Incidents
@@ -382,6 +389,12 @@ export default function GlClearanceDetailPage() {
</Tabs.Panel>
) : null}
{data.kind === "booking" ? (
<Tabs.Panel value="history">
<ClearanceHistoryTab bookingId={id!} />
</Tabs.Panel>
) : null}
{incidentBookingId ? (
<Tabs.Panel value="incidents">
<SectionCard icon={AlertTriangle} title="Incident reports" accent="edr-green">

View File

@@ -432,6 +432,14 @@ export const bookingsService = {
return unwrap(response.data) as Freight.ClearanceView;
},
/** Clearance action history — reviews, workflow steps, charges (newest first). */
getClearanceHistory: async (
id: string,
): Promise<Freight.ClearanceHistoryEvent[]> => {
const response = await client.get(`/bookings/${id}/clearance/history`);
return unwrap(response.data) as Freight.ClearanceHistoryEvent[];
},
// ── Clearance charges (post-finalization customer billing) ──
getClearanceCharges: async (id: string): Promise<Freight.ClearanceCharge[]> => {
const response = await client.get(`/bookings/${id}/clearance/charges`);