Files
edr-platform/apps/edr-freight-web/portal/src/pages/contracts/ContractDetailPage.tsx

1952 lines
65 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import {
Navigate,
useNavigate,
useParams,
useSearchParams,
} from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
Card,
Center,
FileInput,
Group,
Loader,
Paper,
Progress,
RingProgress,
SimpleGrid,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import {
AlertTriangle,
ArrowLeft,
CalendarClock,
CheckCircle2,
ChevronRight,
Download,
Eye,
FileBadge,
FileSignature,
FileText,
Flame,
Inbox,
Layers,
type LucideIcon,
MapPin,
Package,
PackagePlus,
Ship,
Snowflake,
Upload,
Weight,
} from "lucide-react";
import { Modal } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { isViewable, type ViewableFile } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { clearanceWorkflowFileLabel } from "@edr/types";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { fileViewUrl } from "@/constants/apiConfig";
import { useFileViewer } from "@/hooks/useFileViewer";
import toast from "react-hot-toast";
import { labelForDocCode } from "@/pages/bookings/resubmit";
import { ContractClearancePanel } from "./ContractClearancePanel";
import { ContractClearanceWorkflowBanner } from "./ContractClearanceWorkflowBanner";
import { ClearanceUploadedDocumentsPanel } from "@/components/contracts/ClearanceUploadedDocumentsPanel";
import { formatRateUnit } from "./new-contract-form/unit-rates";
import { getContractBookingAction } from "./contract-booking-action";
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
import {
BORDER,
ContractStatusBadge,
GREEN,
INK,
MetaItem,
MUTED,
} from "./contract-ui";
// Statuses where the customer uploads clearance documents on the contract. Used
// by both paths: Path B (customs, GL-reviewed) and Path A self-clearance
// (non-customs IMPORT/EXPORT, Operations-reviewed).
const CLEARANCE_UPLOAD_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
// Business-license document codes — surfaced as their own section so they stand
// out from the rest of the onboarding/profile set.
const BUSINESS_LICENSE_DOC_CODES = new Set([
"business_license",
"commercial_license",
"investment_license",
]);
// Onboarding / company-profile document codes seeded in file-upload-settings.
// These get attached to the contract at creation and belong under "Profile
// documents" rather than the clearance set.
const PROFILE_DOC_CODES = new Set([
"tin_certificate",
"national_id",
"national_id_passport",
"passport",
]);
interface DocGroup {
key: string;
title: string;
files: ContractFile[];
}
/**
* Split a contract's files into display sections by their `code`: the generated
* contract PDF, company profile / onboarding documents, and clearance documents
* (everything else — the clearance set uses dynamic per-contract codes). Empty
* groups are dropped so the tab only renders sections that have files.
*/
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
for (const f of files) {
// The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else clearance.push(f);
}
return [
{ key: "clearance", title: "Clearance documents", files: clearance },
{ key: "businessLicense", title: "Business license", files: businessLicense },
{ key: "profile", title: "Profile documents", files: profile },
].filter((g) => g.files.length > 0);
}
export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [tab, setTab] = useState<string>("details");
const { view, viewer } = useFileViewer();
const [clearanceOpen, clearanceModal] = useDisclosure(false);
const [searchParams, setSearchParams] = useSearchParams();
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
// opens the clearance step modal directly.
useEffect(() => {
if (searchParams.get("action") === "clearance") {
clearanceModal.open();
searchParams.delete("action");
setSearchParams(searchParams, { replace: true });
}
}, [searchParams, clearanceModal, setSearchParams]);
const {
data: contract,
isLoading,
isError,
} = useQuery(
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
);
// Shipments booked under this contract.
const { data: bookingsPage } = useQuery({
...api.bookings.list.queryOptions({
input: { page: 1, pageSize: 50, sortBy: "createdAt", sortOrder: "DESC" },
}),
enabled: !!id,
});
// Clearance view — drives queries alert, workflow documents, and duty panels.
const inClearance =
!!contract && CLEARANCE_UPLOAD_STATUSES.includes(contract.status);
const isPhasedCustomsClearance =
!!contract &&
contract.customsClearingEnabled &&
contract.contractKind === "ONE_TIME";
const { data: clearanceView, refetch: refetchClearance } = useQuery({
...api.contracts.getClearance.queryOptions({ input: { id: id! } }),
enabled: !!id && (inClearance || isPhasedCustomsClearance),
});
const workflowFiles = clearanceView?.workflowFiles ?? [];
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
const queriedCount = (clearanceView?.documents ?? []).filter(
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
).length;
const showShipmentRequests =
!!contract &&
contract.contractKind === "GENERAL" &&
contract.customsClearingEnabled;
const { data: shipmentRequests = [] } = useQuery({
queryKey: ["contract-booking-requests", id],
queryFn: () => contractsService.listBookingRequests(id!),
enabled: !!id && showShipmentRequests,
});
const activeShipmentRequests = shipmentRequests.filter(
(r) => r.status === "PENDING" || r.status === "ACCEPTED",
);
// Booking windows for this contract's routes — gates the direct "New shipment
// booking" entry so the customer only sees it while a window is open.
// Refetched every minute so "Open now" flips without a manual reload.
const { data: bookingWindows = [] } = useQuery({
...api.bookings.getContractBookingWindows.queryOptions({
input: { contractId: id! },
refetchInterval: 60_000,
}),
enabled: !!id,
});
// Intercity contracts are never window-gated: the shipment rides a passing
// import/export train that staff assign later, so booking is always open.
const bookingWindowOpen =
contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows);
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
// releases its share and the tracker fills back up. Refetched on window focus so
// it reflects newly created / cancelled shipments.
const { data: capacityLines = [] } = useQuery({
queryKey: ["contract-capacity", id],
queryFn: () => contractsService.getCapacity(id!),
enabled: !!id && contract?.contractKind === "GENERAL",
refetchOnWindowFocus: true,
});
const contractBookings = useMemo(
() =>
(bookingsPage?.items ?? []).filter(
(b) => (b as { contractId?: string }).contractId === id,
),
[bookingsPage, id],
);
if (isLoading) {
return (
<Center mih={400} p="xl">
<Stack align="center" gap="md">
<Loader color="edr-green" />
<Text size="sm" c="dimmed">
Loading contract
</Text>
</Stack>
</Center>
);
}
if (isError || !contract) {
return (
<Box p="xl">
<Paper withBorder radius="lg" p="xl" style={{ borderColor: BORDER }}>
<Text fw={700} mb="xs">
Contract not found
</Text>
<Button variant="default" onClick={() => navigate("/contracts")}>
Back to contracts
</Button>
</Paper>
</Box>
);
}
// Not yet submitted (customer saved a draft) or staff returned the contract for
// changes — send the customer to the full edit wizard (edit any term + replace
// documents → submit) rather than the read-only detail.
if (
contract.status === "DRAFT" ||
contract.status === "RENEWAL_DRAFT" ||
contract.status === "CHANGES_REQUESTED"
) {
return <Navigate to={`/contracts/${contract.id}/edit`} replace />;
}
const isContainer = contract.freightType === "CONTAINER";
const isGeneral = contract.contractKind === "GENERAL";
const routes = contract.routes ?? [];
const pricing = contract.pricingBreakdown;
const files = contract.files ?? [];
const docGroups = groupContractDocuments(files);
// The generated contract PDF — surfaced via a dedicated "View contract" button
// in the header (it's excluded from the Documents tab groups).
const contractPdf = files.find((f) => f.code === "contract");
const hasContractDocument = Boolean(contractPdf || contract.contractGeneratedAt);
const downloadContractPdf = async () => {
if (!contract?.id) return;
try {
const blob = await contractsService.downloadContractDocument(contract.id);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`;
a.click();
URL.revokeObjectURL(url);
} catch {
toast.error("Could not download contract PDF.");
}
};
const canSign = contract.status === "CONTRACT_READY";
const customsPath = contract.customsClearingEnabled;
// Only the NON-customs (Path A) customer books himself — once the contract is
// executed after self-clearance. Customs (Path B) bookings are created by
// Global Logistics on the customer's behalf, so the customer gets no booking
// button on a customs contract.
const clearanceFinalized =
contract.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING" ||
contract.status === "CLEARANCE_READY_FOR_BOOKING";
const bookingAction = getContractBookingAction(contract, contractBookings);
// Whether the customer may open a new self-service booking. Derived from the
// shared booking-action helper so it honours the ONE_TIME single-slot rule:
// once a non-terminal booking exists on a ONE_TIME contract there is no free
// slot, so the action is "none" and no booking button is shown.
const canBookShipment =
bookingAction.kind === "book" || bookingAction.kind === "rebook";
const canRequestShipment = bookingAction.kind === "request";
// Customs + clearance finalized: GL is preparing the booking — surface a
// status notice instead of any action.
const glPreparingBooking = customsPath && clearanceFinalized;
// The customer uploads clearance documents while in a clearance status, until
// clearance is finalized.
const canUploadClearance =
CLEARANCE_UPLOAD_STATUSES.includes(contract.status) && !clearanceFinalized;
return (
<Box style={{ padding: "28px 32px 40px" }}>
<Stack gap="lg">
{/* Header */}
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Group gap="md" align="center" wrap="nowrap">
<Button
variant="subtle"
color="gray"
radius="md"
px={8}
onClick={() => navigate("/contracts")}
>
<ArrowLeft size={18} />
</Button>
<div>
<Group gap={10} align="center">
<Title order={2} fw={800} fz={22} style={{ color: INK }}>
{contract.reference}
</Title>
<ContractStatusBadge status={contract.status} />
</Group>
<Text size="sm" c="dimmed" mt={2}>
{isGeneral ? "General contract" : "One-time contract"} ·{" "}
{isContainer ? "Containerised" : "Bulk"} ·{" "}
{contract.tradeDirection ?? "—"}
</Text>
</div>
</Group>
<Group gap="sm">
{canSign ? (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<FileSignature size={16} />}
onClick={() => navigate(`/contracts/${contract.id}/view`)}
>
View &amp; sign contract
</Button>
) : (
contractPdf && (
<Button
variant="default"
radius="md"
size="md"
leftSection={<FileText size={16} />}
onClick={() =>
view({
name: contractPdf.name,
url: fileViewUrl(contractPdf.id),
mimeType: contractPdf.mimeType,
})
}
>
View contract
</Button>
)
)}
{hasContractDocument && (
<Button
variant="default"
radius="md"
size="md"
leftSection={<Download size={16} />}
onClick={() => void downloadContractPdf()}
>
Download PDF
</Button>
)}
{canRequestShipment && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() => navigate(bookingAction.to)}
>
Request shipment
</Button>
)}
{canBookShipment &&
(bookingWindowOpen ? (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(`/contracts/${contract.id}/bookings/new`)
}
>
New shipment booking
</Button>
) : (
<Paper
withBorder
radius="md"
px="md"
py={10}
maw={420}
style={{ borderColor: BORDER, background: "#F8FAFC" }}
>
<Group gap={10} align="flex-start" wrap="nowrap">
<CalendarClock
size={16}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
</Paper>
))}
{glPreparingBooking && (
<Badge
size="lg"
radius="md"
variant="light"
color="teal"
leftSection={<CheckCircle2 size={14} />}
>
Global Logistics is creating your booking
</Badge>
)}
{canUploadClearance && (
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<Upload size={16} />}
onClick={clearanceModal.open}
>
{contract.status === "CLEARANCE_UNDER_REVIEW"
? "Manage clearance documents"
: "Upload clearance documents"}
</Button>
)}
</Group>
</Group>
{/* Key facts — one premium gradient card */}
<Paper
radius={18}
withBorder
style={{
borderColor: "#DCEBE3",
overflow: "hidden",
background:
"linear-gradient(120deg, #F1FAF5 0%, #F7FCF9 34%, #FFFFFF 78%)",
boxShadow: "0 8px 24px rgba(14,163,113,0.08)",
}}
>
<SimpleGrid
cols={{ base: 2, sm: 3, xl: 5 }}
spacing={0}
verticalSpacing={0}
>
<FactCell
icon={Layers}
label="Kind"
value={isGeneral ? "General" : "One-Time"}
/>
<FactCell
icon={Package}
label="Cargo"
value={isContainer ? "Container" : "Bulk"}
color="violet"
/>
<FactCell
icon={MapPin}
label="Routes"
value={String(routes.length || 1)}
/>
<FactCell
icon={Ship}
label="Trade"
value={contract.tradeDirection ?? "—"}
color="orange"
/>
<FactCell
icon={CalendarClock}
label="Valid until"
value={
contract.contractValidUntil
? new Date(contract.contractValidUntil).toLocaleDateString()
: "Not active yet"
}
/>
</SimpleGrid>
</Paper>
{/* Tabs: Details · Documents · Bookings (pill style, like the
backoffice booking-requests page; each tab shows a count badge). */}
<Tabs
value={tab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
keepMounted={false}
>
<Tabs.List
mb="lg"
style={{
display: "flex",
flexWrap: "wrap",
gap: 8,
padding: 6,
background: "#F1F5F9",
borderRadius: 16,
border: "1px solid #E2E8F0",
width: "fit-content",
}}
>
<DetailTab
value="details"
active={tab === "details"}
icon={<FileText size={16} />}
label="Details"
/>
<DetailTab
value="documents"
active={tab === "documents"}
icon={<Download size={16} />}
label="Documents"
count={files.length + (isPhasedCustomsClearance ? workflowFileCount : 0)}
/>
<DetailTab
value="bookings"
active={tab === "bookings"}
icon={<Package size={16} />}
label="Bookings"
count={contractBookings.length}
/>
</Tabs.List>
{/* ── Details tab ───────────────────────────────────────────── */}
<Tabs.Panel value="details">
<Stack gap="lg">
{/* Summary meta */}
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group gap={48} wrap="wrap">
<MetaItem
label="Primary route"
icon={<MapPin size={15} color={MUTED} />}
value={`${routes[0]?.originYard?.label ?? "—"}${
routes[0]?.destinationYard?.label ?? "—"
}`}
/>
<MetaItem
label="Customs clearance"
icon={<FileText size={15} color={MUTED} />}
value={customsPath ? "Included (Global Logistics)" : "Not included"}
/>
<MetaItem
label="Payment currency"
value={contract.paymentCurrency ?? "—"}
/>
</Group>
</Paper>
{/* Clearance notice (both paths) */}
{queriedCount > 0 && (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: "#F0B4B4",
background: "#FDF4F4",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: "#D64545",
}}
/>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Group gap={10} align="flex-start" wrap="nowrap">
<AlertTriangle size={18} color="#D64545" style={{ marginTop: 2 }} />
<div>
<Text fw={700} fz={15} c="#7A1F1F">
{queriedCount} document{queriedCount > 1 ? "s" : ""} need
correction
</Text>
<Text fz={13} c="#9A4A4A" mt={2}>
A reviewer sent back document
{queriedCount > 1 ? "s" : ""} with a query. Re-upload the
corrected file{queriedCount > 1 ? "s" : ""} to continue.
</Text>
</div>
</Group>
<Button
color="red"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
Upload corrected documents
</Button>
</Group>
</Paper>
)}
{customsPath && contract.contractKind === "ONE_TIME" ? (
<ContractClearanceWorkflowBanner contract={contract} />
) : null}
{clearanceView?.riskLevel ? (
<Paper
withBorder
radius="lg"
p="md"
style={{ borderColor: BORDER, background: "#FBFDFC" }}
>
<Group gap={10} align="center">
<Text fw={700} fz={14} c={INK}>
Customs risk level
</Text>
<Badge
color={CUSTOMS_RISK_COLOR[clearanceView.riskLevel] ?? "gray"}
variant="filled"
radius="sm"
>
{clearanceView.riskLevel}
</Badge>
{clearanceView.riskAssignedAt ? (
<Text fz={12} c="dimmed">
assigned {new Date(clearanceView.riskAssignedAt).toLocaleString()}
</Text>
) : null}
</Group>
</Paper>
) : null}
{clearanceView?.secondDuty?.advised && clearanceView?.linkedBookingId ? (
<SecondDutyDueCard
duty={clearanceView.secondDuty}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{clearanceView?.finalInvoice && clearanceView?.linkedBookingId ? (
<FinalInvoiceDueCard
invoice={clearanceView.finalInvoice}
bookingId={clearanceView.linkedBookingId}
onView={view}
onChanged={() => void refetchClearance()}
/>
) : null}
{canUploadClearance && (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: "#CDEBDD",
background: "#F6FBF8",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: GREEN,
}}
/>
<Group gap={10} align="center" mb={6}>
<Upload size={16} color={GREEN} />
<Text fw={700} fz={15} c={INK}>
{customsPath
? "Customs clearance shipment"
: "Customs clearance required"}
</Text>
</Group>
<Text fz={13} c="dimmed">
{customsPath
? contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload your clearance documents so Global Logistics can review them. Once they finalize the clearance you can create your shipment booking."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "Global Logistics is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your documents are cleared. You can now create a shipment booking under this contract."
: contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "This service does not include EDR customs clearance. Clear the cargo yourself and upload your clearance documents so the Operations team can review them before you book a shipment."
: contract.status === "CLEARANCE_UNDER_REVIEW"
? "The Operations team is reviewing your clearance documents. Re-upload any queried documents to proceed."
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
</Text>
{contract.status !== "CLEARANCE_READY_FOR_BOOKING" && (
<Button
mt="md"
color="edr-green"
radius="md"
size="sm"
leftSection={<Upload size={15} />}
onClick={clearanceModal.open}
>
{contract.status === "AWAITING_CLEARANCE_DOCUMENTS"
? "Upload documents"
: "Manage documents"}
</Button>
)}
</Paper>
)}
{/* Unit-rate schedule */}
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb={4}>
<SectionLabel>Pricing schedule</SectionLabel>
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Unit rates
</Badge>
</Group>
<Text fz={13} c="dimmed" mb="md">
Per-unit rates frozen at submission. The final amount on each shipment
is computed from the quantities you ship.
</Text>
{pricing && pricing.lineItems.length > 0 ? (
<Stack gap={10}>
{pricing.lineItems.map((item) => (
<Group
key={item.code}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={600} style={{ color: INK }} truncate>
{item.label}
</Text>
{item.containerSize && (
<Text fz={12} c="dimmed">
{item.containerSize}
</Text>
)}
</Box>
<Text fz={14} fw={700} style={{ color: GREEN }}>
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
<Text span fz={12} fw={600} c="dimmed">
/ {formatRateUnit(item.unit)}
</Text>
</Text>
</Group>
))}
</Stack>
) : (
<Text fz={13} c="dimmed">
No pricing schedule available yet.
</Text>
)}
</Card>
{/* Routes + cargo scope */}
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Routes</SectionLabel>
<Badge size="sm" variant="light" color="violet" radius="sm">
{routes.length || 1}
</Badge>
</Group>
<Stack gap={10}>
{routes.length === 0 && (
<Text fz={13} c="dimmed">
No routes recorded.
</Text>
)}
{routes.map((route) => (
<Group
key={route.id}
gap={12}
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<MapPin size={16} color={MUTED} style={{ flexShrink: 0 }} />
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{route.originYard?.label ?? route.originYardId} {" "}
{route.destinationYard?.label ?? route.destinationYardId}
</Text>
{route.km != null && (
<Text fz={12} c="dimmed">
{route.km} km
</Text>
)}
</Box>
</Group>
))}
</Stack>
</Card>
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Cargo scope</SectionLabel>
<Stack gap={10}>
{(contract.cargoScope ?? []).map((scope) => (
<Group
key={scope.id}
gap={12}
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
{isContainer ? (
<Package size={16} color={MUTED} style={{ flexShrink: 0 }} />
) : (
<Weight size={16} color={MUTED} style={{ flexShrink: 0 }} />
)}
<Text fz={14} fw={600} style={{ color: INK }}>
{scope.containerSize ??
scope.cargoFreeText ??
"Bulk commodity"}
</Text>
</Group>
))}
{(contract.cargoScope ?? []).length === 0 && (
<Text fz={13} c="dimmed">
No cargo scope recorded.
</Text>
)}
<Group gap={10} mt={4}>
{contract.isHazardous && (
<Badge
leftSection={<Flame size={12} />}
variant="light"
color="red"
radius="sm"
>
Hazardous
</Badge>
)}
{contract.isReefer && (
<Badge
leftSection={<Snowflake size={12} />}
variant="light"
color="blue"
radius="sm"
>
Refrigerated
</Badge>
)}
</Group>
</Stack>
</Card>
</SimpleGrid>
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
Fills as shipments consume capacity; empties again when a shipment is
cancelled/rejected/expired (backend releases it). */}
{isGeneral && capacityLines.length > 0 && (
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Contract capacity</SectionLabel>
<Stack gap="lg">
{capacityLines.map((line, i) => {
const cap = line.cap ?? 0;
const booked = line.booked ?? 0;
const remaining = line.remaining ?? Math.max(0, cap - booked);
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
const unit = capacityUnitLabel(contract, line);
const label = isContainer
? `${line.containerSize ?? "Containers"}`
: (contract.cargoScope ?? []).find(
(s) => s.cargoTypeId === line.cargoTypeId,
)?.cargoType?.cargoTypeName ??
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
"Bulk commodity";
return (
<Group
key={line.containerSize ?? line.cargoTypeId ?? i}
align="center"
wrap="nowrap"
gap="lg"
>
<RingProgress
size={72}
thickness={8}
roundCaps
sections={[
{
value: remainingPct,
color: remaining === 0 ? "red" : GREEN,
},
]}
label={
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
{remainingPct}%
</Text>
}
/>
<Box style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" mb={6} wrap="nowrap">
<Group gap={8} wrap="nowrap">
{isContainer ? (
<Package size={16} color={MUTED} />
) : (
<Weight size={16} color={MUTED} />
)}
<Text fz={14} fw={600} style={{ color: INK }}>
{label}
</Text>
</Group>
<Text fz={13} c="dimmed">
{booked} / {cap} {unit} booked
</Text>
</Group>
<Progress
value={usedPct}
size="md"
radius="xl"
color={remaining === 0 ? "red" : GREEN}
/>
<Text fz={12} c="dimmed" mt={6}>
{remaining} {unit} remaining
</Text>
</Box>
</Group>
);
})}
</Stack>
</Card>
)}
{/* Signatures */}
{(contract.signatures ?? []).length > 0 && (
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<SectionLabel mb="md">Signatures</SectionLabel>
<Stack gap={10}>
{(contract.signatures ?? []).map((sig) => (
<Group
key={sig.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
>
<Group gap={12} wrap="nowrap">
<CheckCircle2
size={16}
color={GREEN}
style={{ flexShrink: 0 }}
/>
<Box>
<Text fz={14} fw={600} style={{ color: INK }}>
{sig.signerDisplayName}
</Text>
<Text fz={12} c="dimmed">
{sig.role}
</Text>
</Box>
</Group>
<Text fz={12} c="dimmed">
{new Date(sig.signedAt).toLocaleDateString()}
</Text>
</Group>
))}
</Stack>
</Card>
)}
</Stack>
</Tabs.Panel>
{/* ── Documents tab ─────────────────────────────────────────── */}
<Tabs.Panel value="documents">
<Stack gap="lg">
{isPhasedCustomsClearance ? (
<ClearanceUploadedDocumentsPanel
files={workflowFiles}
tradeDirection={contract.tradeDirection ?? "IMPORT"}
onView={view}
onDownload={async (f) => {
try {
const a = document.createElement("a");
a.href = fileViewUrl(f.id, true);
a.download = f.name;
a.click();
} catch {
toast.error("Could not download file.");
}
}}
/>
) : null}
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Contract documents</SectionLabel>
{contract.contractGeneratedAt && (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Generated
</Badge>
)}
</Group>
{docGroups.length === 0 ? (
<Stack align="center" gap={10} py={48}>
<Box
style={{
width: 56,
height: 56,
borderRadius: 16,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#F1F5F9",
}}
>
<FileText size={26} color={MUTED} style={{ opacity: 0.6 }} />
</Box>
<Text fz={14} fw={600} style={{ color: INK }}>
No documents yet
</Text>
<Text fz={13} c="dimmed" ta="center" maw={420}>
The signed contract and any uploaded clearance documents will
appear here.
</Text>
</Stack>
) : (
<Stack gap="xl">
{docGroups.map((group) => {
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
return (
<Stack key={group.key} gap={10}>
<Group gap={10} align="center">
<Box
style={{
width: 28,
height: 28,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `${accent}14`,
color: accent,
}}
>
<Icon size={15} />
</Box>
<Text fz={13} fw={700} style={{ color: INK }}>
{group.title}
</Text>
<Badge
size="sm"
variant="light"
color="gray"
radius="sm"
>
{group.files.length}
</Badge>
</Group>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow
key={file.id}
file={file}
onView={view}
/>
))}
</Stack>
</Stack>
);
})}
</Stack>
)}
</Card>
</Stack>
</Tabs.Panel>
{/* ── Bookings tab ──────────────────────────────────────────── */}
<Tabs.Panel value="bookings">
{showShipmentRequests && (
<Card
withBorder
radius="lg"
p="lg"
mb="md"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Shipment requests</SectionLabel>
{canRequestShipment && (
<Button
color="edr-green"
radius="md"
size="xs"
leftSection={<PackagePlus size={14} />}
onClick={() => navigate(bookingAction.to)}
>
New request
</Button>
)}
</Group>
{activeShipmentRequests.length === 0 ? (
<Text fz={13} c="dimmed">
{canRequestShipment
? "No pending shipment requests. Submit a request when you are ready to ship."
: "Shipment requests appear here once the contract is active."}
</Text>
) : (
<Stack gap={10}>
{activeShipmentRequests.map((req) => (
<Group
key={req.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: req.createdBookingId ? "pointer" : "default",
}}
onClick={() => {
if (req.createdBookingId) {
navigate(`/bookings/${req.createdBookingId}`);
}
}}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<CalendarClock
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={700} style={{ color: INK }} truncate>
{req.reference}
</Text>
<Text fz={12} c="dimmed">
{req.scheduledDate
? `Preferred date: ${req.scheduledDate}`
: "No preferred date"}
{req.notes ? ` · ${req.notes}` : ""}
</Text>
</Box>
</Group>
<Group gap="xs" wrap="nowrap">
<Badge
size="sm"
radius="md"
variant="light"
color={
req.status === "ACCEPTED"
? "teal"
: req.status === "REJECTED"
? "red"
: "yellow"
}
>
{req.status === "ACCEPTED" && req.createdBookingId
? "Booking created"
: req.status}
</Badge>
{req.createdBookingId ? (
<ChevronRight size={16} color={MUTED} />
) : null}
</Group>
</Group>
))}
</Stack>
)}
</Card>
)}
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Bookings under this contract</SectionLabel>
{canBookShipment && bookingWindowOpen && (
<Button
color="edr-green"
radius="md"
size="xs"
leftSection={<PackagePlus size={14} />}
onClick={() =>
navigate(`/contracts/${contract.id}/bookings/new`)
}
>
New booking
</Button>
)}
</Group>
{canBookShipment && !bookingWindowOpen && (
<Group gap={8} align="flex-start" wrap="nowrap" mb="md">
<CalendarClock
size={15}
color={MUTED}
style={{ flexShrink: 0, marginTop: 2 }}
/>
<Text fz={13} c="dimmed">
{closedWindowMessage(bookingWindows)}
</Text>
</Group>
)}
{contractBookings.length === 0 ? (
<Stack align="center" gap={10} py="xl">
<Inbox size={26} color={MUTED} style={{ opacity: 0.5 }} />
<Text fz={13} c="dimmed" ta="center" maw={380}>
{canBookShipment
? "No bookings yet. Use “New booking” to ship against this contract."
: customsPath
? "No bookings yet. After your clearance documents are approved, Global Logistics creates the booking on your behalf."
: canUploadClearance
? "No bookings yet. After the Operations team approves your clearance documents, you can create a booking here."
: "Bookings appear here once the contract is fully executed."}
</Text>
</Stack>
) : (
<Stack gap={10}>
{contractBookings.map((booking) => (
<Group
key={booking.id}
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
cursor: "pointer",
}}
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Package
size={16}
color={MUTED}
style={{ flexShrink: 0 }}
/>
<Box style={{ minWidth: 0 }}>
<Text
fz={14}
fw={700}
style={{ color: INK }}
truncate
>
{booking.reference}
</Text>
{booking.scheduledDate && (
<Text fz={12} c="dimmed" truncate>
Ship{" "}
{new Date(
booking.scheduledDate,
).toLocaleDateString()}
</Text>
)}
</Box>
</Group>
<ContractStatusBadge status={booking.status} />
</Group>
))}
</Stack>
)}
</Card>
</Tabs.Panel>
</Tabs>
</Stack>
{viewer}
<Modal
opened={clearanceOpen}
onClose={clearanceModal.close}
title={
<Text fw={700} fz={16}>
Clearance documents
</Text>
}
size="xl"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
<ContractClearancePanel
contractId={contract.id}
tradeDirection={contract.tradeDirection ?? "IMPORT"}
bare
/>
</Modal>
</Box>
);
}
// Subtle card elevation shared across the detail page sections.
const CARD_SHADOW = "0 1px 2px rgba(16,24,40,0.04)";
// Accent hues matching the contract-ui StatCard rail palette.
const KEY_FACT_ACCENT: Record<string, string> = {
"edr-green": GREEN,
violet: "#6A40B8",
orange: "#C77F09",
};
// Per-section accent + icon for the Documents tab groups.
const DOC_GROUP_ACCENT: Record<string, string> = {
clearance: "#C77F09",
businessLicense: "#0A6F4D",
profile: "#2B6CB0",
};
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
clearance: Upload,
businessLicense: FileBadge,
profile: FileText,
};
/**
* A pill-style detail tab matching the backoffice booking-requests tabs: an
* icon, a label, and an always-visible count badge (shows 0 when empty).
*/
function DetailTab({
value,
active,
icon,
label,
count,
}: {
value: string;
active: boolean;
icon: React.ReactNode;
label: string;
count?: number;
}) {
return (
<Tabs.Tab
value={value}
leftSection={icon}
rightSection={
count !== undefined ? (
<Badge
size="sm"
radius="sm"
variant={active ? "white" : "light"}
color={active ? "edr-green" : "gray"}
styles={
active
? {
root: {
background: "rgba(255,255,255,0.92)",
color: "#15805F",
},
}
: undefined
}
>
{count}
</Badge>
) : undefined
}
styles={{
tab: {
borderRadius: 11,
padding: "9px 16px",
fontWeight: 600,
},
}}
>
{label}
</Tabs.Tab>
);
}
/** A small uppercase eyebrow used as a section heading. */
function SectionLabel({
children,
mb,
}: {
children: React.ReactNode;
mb?: number | string;
}) {
return (
<Text
fz={11}
fw={700}
tt="uppercase"
c="dimmed"
mb={mb}
style={{ letterSpacing: "0.06em" }}
>
{children}
</Text>
);
}
/**
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
*/
function capacityUnitLabel(
contract: Freight.IContract,
line: Freight.ContractCapacityLine,
): string {
if (contract.freightType === "CONTAINER") return "containers";
const scope =
(contract.cargoScope ?? []).find(
(s) => s.cargoTypeId === line.cargoTypeId,
) ?? (contract.cargoScope ?? [])[0];
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
}
/**
* One document row in the Documents tab: the file's kind (passport, business
* license, contract, …) derived from its `code` as the primary label, the
* stored filename and size as secondary text, and view / download actions that
* stream through the API by file id.
*/
/** Extension → a small colored type chip (PDF red, image green, etc.). */
function fileTypeChip(name: string, mimeType?: string | null): {
ext: string;
color: string;
} {
const dot = name.lastIndexOf(".");
let ext = dot >= 0 ? name.slice(dot + 1).toUpperCase() : "";
if (!ext && mimeType) ext = mimeType.split("/")[1]?.toUpperCase() ?? "FILE";
if (!ext) ext = "FILE";
const color =
ext === "PDF"
? "#D64545"
: ["PNG", "JPG", "JPEG", "GIF", "WEBP", "SVG"].includes(ext)
? "#2F9E6E"
: ["DOC", "DOCX"].includes(ext)
? "#2B6CB0"
: ["XLS", "XLSX", "CSV"].includes(ext)
? "#2F855A"
: ["MP4", "WEBM", "MOV"].includes(ext)
? "#7A40C8"
: "#6B7C8E";
return { ext: ext.slice(0, 4), color };
}
function DocFileRow({
file,
onView,
}: {
file: ContractFile;
onView: (f: ViewableFile) => void;
}) {
const kind =
clearanceWorkflowFileLabel(file.code) ?? labelForDocCode(file.code);
const { ext, color } = fileTypeChip(file.name, file.mimeType);
const viewable = isViewable({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
});
return (
<Group
justify="space-between"
wrap="nowrap"
p="sm"
style={{
borderRadius: 14,
border: `1px solid ${BORDER}`,
background: "#fff",
transition: "border-color 120ms ease, box-shadow 120ms ease",
}}
className="hover:border-edr-green-3 hover:shadow-sm"
>
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 10,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: `${color}14`,
color,
}}
>
<FileText size={16} />
<Text fz={8} fw={800} mt={1} style={{ letterSpacing: "0.04em" }}>
{ext}
</Text>
</Box>
<Box style={{ minWidth: 0 }}>
<Text fz={14} fw={600} style={{ color: INK }} truncate>
{kind}
</Text>
<Text fz={12} c="dimmed" truncate>
{file.name}
{file.size ? ` · ${(file.size / 1024).toFixed(0)} KB` : ""}
</Text>
</Box>
</Group>
<Group gap={8} wrap="nowrap">
{viewable && (
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Eye size={14} />}
onClick={() =>
onView({
name: file.name,
url: fileViewUrl(file.id),
mimeType: file.mimeType,
})
}
>
View
</Button>
)}
<Button
component="a"
href={fileViewUrl(file.id, true)}
variant="default"
size="xs"
radius="md"
leftSection={<Download size={14} />}
>
Download
</Button>
</Group>
</Group>
);
}
/**
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
* an uppercase label, and the value. Hairline dividers between cells make the
* row read as a single card rather than five tiles.
*/
function FactCell({
icon: Icon,
label,
value,
color = "edr-green",
}: {
icon: LucideIcon;
label: string;
value: React.ReactNode;
color?: string;
}) {
const accent = KEY_FACT_ACCENT[color] ?? GREEN;
return (
<Group
gap={12}
align="center"
wrap="nowrap"
p="lg"
style={{
minWidth: 0,
borderRight: `1px solid rgba(16,24,40,0.06)`,
borderBottom: `1px solid rgba(16,24,40,0.06)`,
}}
>
<Box
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: accent,
background: `${accent}14`,
}}
>
<Icon size={19} strokeWidth={2} />
</Box>
<Box style={{ minWidth: 0 }}>
<Text
fz={10.5}
fw={700}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: "0.06em" }}
>
{label}
</Text>
<Text
fz={16}
fw={800}
lh={1.2}
mt={3}
truncate
style={{ color: INK, letterSpacing: "-0.01em" }}
>
{value}
</Text>
</Box>
</Group>
);
}
/**
* Post-offload final invoice from GL Djibouti (export): shows the due amount +
* invoice document; the customer pays offline and attaches the payment slip
* here, then GL confirms and the badge flips to PAID.
*/
function FinalInvoiceDueCard({
invoice,
bookingId,
onView,
onChanged,
}: {
invoice: NonNullable<Freight.ContractClearanceView["finalInvoice"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = invoice.status === "PAID";
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Final invoice paid" : "Final invoice due"} {" "}
{invoice.invoiceNumber}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{invoice.status}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{invoice.totalAmount.toLocaleString()} {invoice.currency}
</Text>
{invoice.description ? (
<Text fz={13} c="dimmed" mt={2}>
{invoice.description}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Pay the amount above and attach your payment slip Global
Logistics will confirm the payment.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{invoice.invoiceFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.invoiceFile!.name,
url: invoice.invoiceFile!.url,
})
}
>
View invoice
</Button>
) : null}
{invoice.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({
name: invoice.slipFile!.name,
url: invoice.slipFile!.url,
})
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={
invoice.slipFile ? "Replace payment slip" : "Attach payment slip"
}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadFinalInvoiceSlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(
e instanceof Error ? e.message : "Upload failed",
);
} finally {
setUploading(false);
}
}}
>
{invoice.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}
const CUSTOMS_RISK_COLOR: Record<string, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Post-arrival additional duty/tax round (import): GL advises an extra amount
* with a notice; the customer pays offline and attaches another slip here.
*/
function SecondDutyDueCard({
duty,
bookingId,
onView,
onChanged,
}: {
duty: NonNullable<Freight.ContractClearanceView["secondDuty"]>;
bookingId: string;
onView: (file: { name: string; url: string }) => void;
onChanged: () => void;
}) {
const [slip, setSlip] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const paid = duty.paid;
return (
<Paper
withBorder
radius="lg"
p="lg"
style={{
borderColor: paid ? "#CDEBDD" : "#F2D9A6",
background: paid ? "#F6FBF8" : "#FFFBF2",
position: "relative",
overflow: "hidden",
}}
>
<Box
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: 3,
background: paid ? GREEN : "#E3A93C",
}}
/>
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<div>
<Group gap={8} align="center">
<FileBadge size={16} color={paid ? GREEN : "#B07C1F"} />
<Text fw={700} fz={15} c={INK}>
{paid ? "Additional duty & tax paid" : "Additional duty & tax due"}
</Text>
<Badge color={paid ? "edr-green" : "yellow"} variant="light" radius="sm">
{paid ? "PAID" : "DUE"}
</Badge>
</Group>
<Text fz={20} fw={800} mt={6} c={INK}>
{(duty.amount ?? 0).toLocaleString()} {duty.currency ?? ""}
</Text>
{duty.declarationSerial ? (
<Text fz={13} c="dimmed" mt={2}>
Payment code: {duty.declarationSerial}
</Text>
) : null}
{!paid ? (
<Text fz={13} c="#9A6B1F" mt={6}>
Customs advised additional duty/tax after arrival. Pay the amount
above and attach your payment slip.
</Text>
) : null}
</div>
<Stack gap="xs" miw={260}>
{duty.noticeFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.noticeFile!.name, url: duty.noticeFile!.url })
}
>
View duty notice
</Button>
) : null}
{duty.slipFile ? (
<Button
variant="default"
radius="md"
size="sm"
leftSection={<Eye size={15} />}
onClick={() =>
onView({ name: duty.slipFile!.name, url: duty.slipFile!.url })
}
>
View payment slip
</Button>
) : null}
{!paid ? (
<>
<FileInput
placeholder={duty.slipFile ? "Replace payment slip" : "Attach payment slip"}
value={slip}
onChange={setSlip}
size="sm"
radius="md"
/>
<Button
color="edr-green"
radius="md"
size="sm"
loading={uploading}
disabled={!slip}
leftSection={<Upload size={15} />}
onClick={async () => {
if (!slip) return;
setUploading(true);
try {
await contractsService.uploadSecondDutySlip(bookingId, slip);
setSlip(null);
toast.success("Payment slip attached");
onChanged();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Upload failed");
} finally {
setUploading(false);
}
}}
>
{duty.slipFile ? "Replace slip" : "Submit payment slip"}
</Button>
</>
) : null}
</Stack>
</Group>
</Paper>
);
}