Enhance contract clearance features with review timestamps and staff identifiers; improve file handling in controllers and views for better user experience.

This commit is contained in:
Marshal
2026-06-27 19:30:42 +00:00
parent 0ab553bf48
commit 7a2a55d94b
8 changed files with 282 additions and 123 deletions

View File

@@ -19,12 +19,14 @@ import {
import {
AlertCircle,
CheckCircle2,
Clock,
Download,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
Upload,
UserCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
@@ -46,6 +48,11 @@ export interface ContractClearanceReviewSectionProps {
* output upload step. Routes review/finalize to the Operations endpoints.
*/
selfClear?: boolean;
/**
* Clearance is finalized — render the document outcomes (approved / queried,
* by whom, when) but hide all approve / query / finalize actions.
*/
readOnly?: boolean;
}
const STATUS_META: Record<
@@ -57,22 +64,38 @@ const STATUS_META: Record<
PENDING: { label: "Pending", color: "gray" },
};
function formatReviewedAt(value?: string | null): string | null {
if (!value) return null;
const d = new Date(value);
if (Number.isNaN(d.getTime())) return null;
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
/**
* GL-ET pre-booking clearance review for a CONTRACT (Path B): approve / query
* each customer document, upload GL output documents and finalize once every
* required document is approved → CLEARANCE_READY_FOR_BOOKING.
* Pre-booking clearance review for a CONTRACT. Approve / query each customer
* document, upload GL output documents, and finalize once every required
* document is approved. When `readOnly` it becomes an audit view: approved /
* queried outcomes with reviewer + timestamp, no actions.
*/
export function ContractClearanceReviewSection({
contractId,
onChanged,
hideSummary,
selfClear = false,
readOnly = false,
}: ContractClearanceReviewSectionProps) {
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
const { view, viewer } = useFileViewer();
const reviewerTeam = selfClear ? "Operations" : "Global Logistics";
const { data: clearance, isLoading } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
queryFn: () => contractsService.getClearance(contractId),
@@ -107,6 +130,11 @@ export function ContractClearanceReviewSection({
return { total, approved, queried, pending, pct };
}, [customerDocs]);
// Documents with a file uploaded but not yet approved — "Approve all" targets.
const approvableKeys = customerDocs
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
.map((d) => d.fileKey);
if (isLoading || !clearance) {
return (
<Group justify="center" py="xl" gap={10}>
@@ -116,12 +144,6 @@ export function ContractClearanceReviewSection({
);
}
// Documents that have a file uploaded but are not yet approved — these are the
// ones "Approve all" will action in one click.
const approvableKeys = customerDocs
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
.map((d) => d.fileKey);
const handleReview = (
fileKey: string,
status: "APPROVED" | "QUERIED",
@@ -143,13 +165,17 @@ export function ContractClearanceReviewSection({
<SectionCard
icon={FileText}
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
subtitle={
readOnly
? `Reviewed by the ${reviewerTeam} team.`
: "Approve each document, or open a query to tell the customer what to fix."
}
extra={
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{approvableKeys.length > 0 && (
{!readOnly && approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
@@ -191,6 +217,8 @@ export function ContractClearanceReviewSection({
<DocReviewCard
key={`${doc.settingCode}:${doc.fileKey}`}
doc={doc}
reviewerTeam={reviewerTeam}
readOnly={readOnly}
note={queryNotes[doc.fileKey] ?? ""}
queryOpen={openQuery[doc.fileKey] ?? false}
onToggleQuery={(open) =>
@@ -275,52 +303,56 @@ export function ContractClearanceReviewSection({
Not uploaded
</Text>
)}
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
{!readOnly && (
<FileButton
onChange={(f) =>
f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f }))
}
accept="application/pdf,image/*"
>
{(props) => (
<Button
{...props}
size="compact-xs"
variant="light"
color="edr-green"
leftSection={<Upload size={13} />}
>
{outputFiles[doc.fileKey] ? "Selected" : "Upload"}
</Button>
)}
</FileButton>
)}
</Group>
</Group>
))}
</Stack>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={uploadOutputDocuments.isPending}
onClick={() =>
uploadOutputDocuments.mutate(outputFiles, {
onSuccess: () => {
setOutputFiles({});
onChanged?.();
},
})
}
>
Upload output documents
</Button>
</Group>
{!readOnly && (
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="edr-green"
radius="md"
leftSection={<Upload size={15} />}
disabled={Object.keys(outputFiles).length === 0}
loading={uploadOutputDocuments.isPending}
onClick={() =>
uploadOutputDocuments.mutate(outputFiles, {
onSuccess: () => {
setOutputFiles({});
onChanged?.();
},
})
}
>
Upload output documents
</Button>
</Group>
)}
</SectionCard>
)}
{finalizeClearance.isError && (
{!readOnly && finalizeClearance.isError && (
<Alert color="red" radius="md" icon={<AlertCircle size={16} />}>
{finalizeClearance.error instanceof Error
? finalizeClearance.error.message
@@ -328,39 +360,62 @@ export function ContractClearanceReviewSection({
</Alert>
)}
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
{readOnly ? (
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 70%)",
}}
>
<Group gap={10} wrap="nowrap">
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
<CheckCircle2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
Clearance was finalized by the {reviewerTeam} team. This is a
read-only record of the approved documents.
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {
onSuccess: () => onChanged?.(),
})
}
>
Finalize clearance
</Button>
</Group>
</Paper>
</Paper>
) : (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={clearance.allApproved ? "edr-green" : "gray"}
radius="md"
size={28}
>
<FileCheck2 size={15} />
</ThemeIcon>
<Text fz="12.5px" c="dimmed">
{clearance.allApproved
? "All required documents are approved — you can finalize."
: "Approve every required document to unlock finalization."}
</Text>
</Group>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
disabled={!clearance.allApproved}
loading={finalizeClearance.isPending}
onClick={() =>
finalizeClearance.mutate(undefined, {
onSuccess: () => onChanged?.(),
})
}
>
Finalize clearance
</Button>
</Group>
</Paper>
)}
{viewer}
</Stack>
);
@@ -397,6 +452,8 @@ function StatPill({
function DocReviewCard({
doc,
reviewerTeam,
readOnly,
note,
queryOpen,
onToggleQuery,
@@ -407,6 +464,8 @@ function DocReviewCard({
busy,
}: {
doc: Freight.ContractClearanceDocument;
reviewerTeam: string;
readOnly: boolean;
note: string;
queryOpen: boolean;
onToggleQuery: (open: boolean) => void;
@@ -419,30 +478,37 @@ function DocReviewCard({
const status = doc.reviewStatus ?? "PENDING";
const meta = STATUS_META[status];
const hasFile = !!doc.file;
const isApproved = status === "APPROVED";
const isQueried = status === "QUERIED";
const reviewedAt = formatReviewedAt(doc.reviewedAt);
// Approved cards get a light green gradient + green border so the outcome is
// instantly scannable; queried cards get a soft red; pending stay neutral.
const cardStyle = isApproved
? {
borderColor: "var(--mantine-color-edr-green-3)",
background:
"linear-gradient(135deg, var(--mantine-color-edr-green-0) 0%, #FFFFFF 72%)",
}
: isQueried
? {
borderColor: "var(--mantine-color-red-2)",
background:
"linear-gradient(135deg, var(--mantine-color-red-0) 0%, #FFFFFF 78%)",
}
: { borderColor: "var(--mantine-color-edr-border-6)" };
return (
<Paper
withBorder
radius="md"
p="md"
style={{
borderColor:
status === "QUERIED"
? "var(--mantine-color-red-2)"
: status === "APPROVED"
? "var(--mantine-color-edr-green-2)"
: "var(--mantine-color-edr-border-6)",
}}
>
<Paper withBorder radius="md" p="md" style={cardStyle}>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon
variant="light"
color={hasFile ? "edr-green" : "gray"}
color={isApproved ? "edr-green" : isQueried ? "red" : "gray"}
radius="md"
size={40}
>
<FileText size={19} />
{isApproved ? <CheckCircle2 size={19} /> : <FileText size={19} />}
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text fz="14px" fw={700} c="edr-text" truncate>
@@ -452,11 +518,33 @@ function DocReviewCard({
<Text fz="12px" c="edr-muted" truncate>
{hasFile ? doc.file!.name : "Not uploaded by customer"}
</Text>
{isApproved && (reviewedAt || reviewerTeam) && (
<Group gap={5} wrap="nowrap" mt={3}>
<UserCheck size={12} color="var(--mantine-color-edr-green-7)" />
<Text fz="11.5px" c="edr-green.8" fw={600} truncate>
Approved by {reviewerTeam}
{reviewedAt ? ` · ${reviewedAt}` : ""}
</Text>
</Group>
)}
</Box>
</Group>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color={meta.color} radius="sm">
<Badge
variant="light"
color={meta.color}
radius="sm"
leftSection={
isApproved ? (
<CheckCircle2 size={11} />
) : isQueried ? (
<MessageSquareWarning size={11} />
) : (
<Clock size={11} />
)
}
>
{meta.label}
</Badge>
{hasFile &&
@@ -478,7 +566,7 @@ function DocReviewCard({
</Group>
</Group>
{status === "QUERIED" && doc.note && (
{isQueried && doc.note && (
<Alert
mt="sm"
color="red"
@@ -493,26 +581,26 @@ function DocReviewCard({
</Alert>
)}
{hasFile && (
{!readOnly && hasFile && !isApproved && (
<Box mt="sm">
{!queryOpen ? (
<Group justify="flex-end" gap={8}>
<Button
size="compact-sm"
size="sm"
variant="light"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
leftSection={<MessageSquareWarning size={15} />}
disabled={busy}
onClick={() => onToggleQuery(true)}
>
Open query
</Button>
<Button
size="compact-sm"
size="sm"
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={14} />}
leftSection={<CheckCircle2 size={15} />}
disabled={busy}
onClick={onApprove}
>
@@ -549,7 +637,7 @@ function DocReviewCard({
/>
<Group justify="flex-end" gap={8} mt={8}>
<Button
size="compact-sm"
size="sm"
variant="subtle"
color="gray"
radius="md"
@@ -559,10 +647,10 @@ function DocReviewCard({
Cancel
</Button>
<Button
size="compact-sm"
size="sm"
color="red"
radius="md"
leftSection={<MessageSquareWarning size={14} />}
leftSection={<MessageSquareWarning size={15} />}
loading={busy}
disabled={!note.trim()}
onClick={onQuery}

View File

@@ -47,12 +47,27 @@ import {
useContractMutations,
} from "@/hooks/contracts/useContracts";
// Statuses in the pre-booking clearance phase — the Clearance Review tab shows.
const CLEARANCE_REVIEW_STATUSES = [
// Clearance phase — staff can still ACT (approve / query / finalize).
const CLEARANCE_ACTIVE_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
// see which documents were approved, by whom, and when.
const CLEARANCE_DONE_STATUSES = [
"ACTIVE_SHIPMENT_IN_PROGRESS",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"CONTRACT_CLOSED",
"EXPIRED",
];
// Show the Clearance Review tab in either phase (active or done).
const CLEARANCE_REVIEW_STATUSES = [
...CLEARANCE_ACTIVE_STATUSES,
...CLEARANCE_DONE_STATUSES,
];
function formatDate(value: string | null | undefined): string {
@@ -157,6 +172,8 @@ export default function ContractRequestDetailPage() {
contract.status === "APPROVED_PENDING_SIGNATURE";
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
// Once clearance is finalized the tab is informational only — no approve/query.
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
const selfClear = !contract.customsClearingEnabled;
// If the tab param points at clearance but the contract isn't in a clearance
@@ -276,6 +293,7 @@ export default function ContractRequestDetailPage() {
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
readOnly={clearanceReadOnly}
onChanged={() => refetch()}
/>
) : (