mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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()}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
// export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
* bytes through `GET /api/files/:id` (served from MinIO with backend
|
||||
* credentials) instead of a presigned MinIO URL — the latter is not reachable
|
||||
* from the browser and breaks on the minio-js port-443 signature quirk. Serves
|
||||
* inline for preview by default; pass `download` to force a save dialog.
|
||||
*/
|
||||
export function fileViewUrl(fileId: string, download = false): string {
|
||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||
import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
|
||||
@@ -285,17 +286,23 @@ export default function ContractClearanceFlow() {
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<StatusPill doc={doc} />
|
||||
{doc.file &&
|
||||
isViewable({ name: doc.file.name, url: doc.file.url }) && (
|
||||
isViewable({
|
||||
name: doc.file.name,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({ name: doc.file!.name, url: doc.file!.url })
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{doc.file && (
|
||||
<IconSquare
|
||||
href={doc.file.url}
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
)}
|
||||
@@ -360,20 +367,20 @@ export default function ContractClearanceFlow() {
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: doc.file.url,
|
||||
url: fileViewUrl(doc.file.id),
|
||||
}) && (
|
||||
<IconSquare
|
||||
icon={<Eye size={15} />}
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: doc.file!.url,
|
||||
url: fileViewUrl(doc.file!.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<IconSquare
|
||||
href={doc.file.url}
|
||||
href={fileViewUrl(doc.file.id, true)}
|
||||
icon={<Download size={15} />}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
import { api } from "@/services/api";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import {
|
||||
@@ -645,7 +646,7 @@ export default function ContractDetailPage() {
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isViewable({
|
||||
name: file.name,
|
||||
url: file.signedUrl ?? file.url,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
}) && (
|
||||
<Button
|
||||
@@ -657,7 +658,7 @@ export default function ContractDetailPage() {
|
||||
onClick={() =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: file.signedUrl ?? file.url,
|
||||
url: fileViewUrl(file.id),
|
||||
mimeType: file.mimeType,
|
||||
})
|
||||
}
|
||||
@@ -667,15 +668,13 @@ export default function ContractDetailPage() {
|
||||
)}
|
||||
<Button
|
||||
component="a"
|
||||
href={file.signedUrl ?? file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={fileViewUrl(file.id, true)}
|
||||
variant="default"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Download size={14} />}
|
||||
>
|
||||
Open
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user