From 7a2a55d94b84a928a82f140ae920ede4283273fc Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 27 Jun 2026 19:30:42 +0000 Subject: [PATCH] Enhance contract clearance features with review timestamps and staff identifiers; improve file handling in controllers and views for better user experience. --- .../contracts/contract-clearance.service.ts | 8 + .../src/modules/files/files.controller.ts | 33 +- .../ContractClearanceReviewSection.tsx | 296 ++++++++++++------ .../contracts/ContractRequestDetailPage.tsx | 22 +- .../portal/src/constants/apiConfig.ts | 12 + .../pages/contracts/ContractClearanceFlow.tsx | 19 +- .../pages/contracts/ContractDetailPage.tsx | 11 +- packages/types/src/freight/contracts.ts | 4 + 8 files changed, 282 insertions(+), 123 deletions(-) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts index af1b47aee..a16ccc5c9 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-clearance.service.ts @@ -18,6 +18,10 @@ export interface ContractClearanceDocument { file: { id: string; name: string; url: string } | null; reviewStatus: ContractDocReviewStatus | null; note: string | null; + /** When the review decision (approve/query) was recorded. */ + reviewedAt: string | null; + /** Staff id that recorded the decision (no user directory to resolve names). */ + reviewedByStaffId: string | null; } export interface ContractClearanceView { @@ -77,6 +81,8 @@ export class ContractClearanceService { file: file ? { id: file.id, name: file.name, url: file.url } : null, reviewStatus: review?.status ?? null, note: review?.note ?? null, + reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, + reviewedByStaffId: review?.reviewedByStaffId ?? null, }); } }; @@ -97,6 +103,8 @@ export class ContractClearanceService { file: { id: f.id, name: f.name, url: f.url }, reviewStatus: review?.status ?? null, note: review?.note ?? null, + reviewedAt: review?.reviewedAt ? review.reviewedAt.toISOString() : null, + reviewedByStaffId: review?.reviewedByStaffId ?? null, }); } diff --git a/apps/edr-freight-api/src/modules/files/files.controller.ts b/apps/edr-freight-api/src/modules/files/files.controller.ts index acf274ff0..4ee48c867 100644 --- a/apps/edr-freight-api/src/modules/files/files.controller.ts +++ b/apps/edr-freight-api/src/modules/files/files.controller.ts @@ -1,5 +1,12 @@ -import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common"; -import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Res, +} from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; import { Response } from "express"; import { FilesService } from "./files.service"; @@ -11,18 +18,34 @@ export class FilesController { @Get(":fileId") @ApiOperation({ - summary: "Download a file by ID", + summary: "Stream a file by ID", description: "Global endpoint — streams any uploaded file directly from MinIO by its UUID. " + - "No resource context (e.g. booking ID) required.", + "No resource context (e.g. booking ID) required. Serves inline by default so " + + "the browser can preview it; pass ?download=1 to force a download.", + }) + @ApiQuery({ + name: "download", + required: false, + description: "Set to 1/true to force a download instead of inline preview.", }) async download( @Param("fileId", ParseUUIDPipe) fileId: string, + @Query("download") download: string | undefined, @Res() res: Response, ) { const { stream, record } = await this.filesService.streamById(fileId); + const forceDownload = download === "1" || download === "true"; + const disposition = forceDownload ? "attachment" : "inline"; + res.setHeader("Content-Type", record.mimeType); - res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`); + res.setHeader( + "Content-Disposition", + `${disposition}; filename="${record.name}"`, + ); + // Allow the browser to cache the streamed bytes briefly for smoother + // in-page previews (re-opening the viewer shouldn't re-hit MinIO). + res.setHeader("Cache-Control", "private, max-age=300"); stream.pipe(res); } } diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx index 0c60889f2..89f183a1b 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractClearanceReviewSection.tsx @@ -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>({}); const [openQuery, setOpenQuery] = useState>({}); const [outputFiles, setOutputFiles] = useState>({}); 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 ( @@ -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({ {stats.approved}/{stats.total} approved - {approvableKeys.length > 0 && ( + {!readOnly && approvableKeys.length > 0 && ( - )} - + {!readOnly && ( + + f && setOutputFiles((o) => ({ ...o, [doc.fileKey]: f })) + } + accept="application/pdf,image/*" + > + {(props) => ( + + )} + + )} ))} - - - + {!readOnly && ( + + + + )} )} - {finalizeClearance.isError && ( + {!readOnly && finalizeClearance.isError && ( }> {finalizeClearance.error instanceof Error ? finalizeClearance.error.message @@ -328,39 +360,62 @@ export function ContractClearanceReviewSection({ )} - - - - - + {readOnly ? ( + + + + - {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. - - - + + ) : ( + + + + + + + + {clearance.allApproved + ? "All required documents are approved — you can finalize." + : "Approve every required document to unlock finalization."} + + + + + + )} {viewer} ); @@ -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 ( - + - + {isApproved ? : } @@ -452,11 +518,33 @@ function DocReviewCard({ {hasFile ? doc.file!.name : "Not uploaded by customer"} + {isApproved && (reviewedAt || reviewerTeam) && ( + + + + Approved by {reviewerTeam} + {reviewedAt ? ` · ${reviewedAt}` : ""} + + + )} - + + ) : isQueried ? ( + + ) : ( + + ) + } + > {meta.label} {hasFile && @@ -478,7 +566,7 @@ function DocReviewCard({ - {status === "QUERIED" && doc.note && ( + {isQueried && doc.note && ( )} - {hasFile && ( + {!readOnly && hasFile && !isApproved && ( {!queryOpen ? ( diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index d4f5c5d7c..72b57a2ca 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -222,6 +222,10 @@ export interface ContractClearanceDocument { file: { id: string; name: string; url: string } | null; reviewStatus: ContractDocReviewStatus | null; note: string | null; + /** When the review decision (approve/query) was recorded. */ + reviewedAt?: string | null; + /** Staff id that recorded the decision (no user directory to resolve names). */ + reviewedByStaffId?: string | null; } export interface ContractClearanceView {