add file viewer functionality across various components

- Implemented a shared file viewer modal using `useFileViewer` hook to allow inline viewing of documents (images, PDFs, videos, etc.) across the application.
- Updated `ContractClearanceReviewSection`, `ContractRequestDetailPage`, `ContractViewPage`, and booking-related components to utilize the new file viewer for document previews.
- Added "Approve all" button in `ContractClearanceReviewSection` to bulk approve documents.
- Enhanced document action buttons to include view and download options based on file type.
- Introduced `isViewable` utility to determine if a file can be previewed inline.
- Created `FileViewer` component to handle rendering of various file types and added appropriate fallback for unsupported formats.
This commit is contained in:
Marshal
2026-06-27 19:18:43 +00:00
parent e977893888
commit 0ab553bf48
19 changed files with 893 additions and 116 deletions

View File

@@ -1,4 +1,4 @@
import { useNavigate, useParams } from "react-router-dom";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
@@ -12,6 +12,7 @@ import {
Receipt,
RefreshCw,
Route as RouteIcon,
ShieldCheck,
Snowflake,
} from "lucide-react";
import {
@@ -25,10 +26,12 @@ import {
Loader,
Paper,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import "@/components/overview/overview.css";
import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -37,12 +40,21 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import {
useContractDetail,
useContractMutations,
} from "@/hooks/contracts/useContracts";
// Statuses in the pre-booking clearance phase — the Clearance Review tab shows.
const CLEARANCE_REVIEW_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
@@ -66,6 +78,18 @@ export default function ContractRequestDetailPage() {
isFetching,
} = useContractDetail(id);
const mutations = useContractMutations(id ?? "");
const [searchParams, setSearchParams] = useSearchParams();
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
const setTab = (tab: string) =>
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (tab === "details") next.delete("tab");
else next.set("tab", tab);
return next;
},
{ replace: true },
);
if (isLoading) {
return (
@@ -132,6 +156,13 @@ export default function ContractRequestDetailPage() {
contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE";
const showClearanceTab = CLEARANCE_REVIEW_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
// phase, fall back to details so we never show an empty tab.
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—");
@@ -216,9 +247,38 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description}
/>
{showClearanceTab && (
<Tabs
value={currentTab}
onChange={(v) => setTab(v ?? "details")}
variant="pills"
color="edr-green"
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
>
<Tabs.List>
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
Details
</Tabs.Tab>
<Tabs.Tab
value="clearance"
leftSection={<ShieldCheck size={16} />}
>
Clearance Review
</Tabs.Tab>
</Tabs.List>
</Tabs>
)}
<Grid gap="lg">
{/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? (
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
onChanged={() => refetch()}
/>
) : (
<Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? (
@@ -350,6 +410,7 @@ export default function ContractRequestDetailPage() {
</SectionCard>
) : null}
</Stack>
)}
</Grid.Col>
{/* RIGHT — sticky action rail */}
@@ -359,6 +420,9 @@ export default function ContractRequestDetailPage() {
<ContractActionsToolbar
contract={contract}
mutations={mutations}
onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined
}
/>
{showApprovalCard && (
<ContractApprovalStepsCard

View File

@@ -5,6 +5,7 @@ import {
Box,
Button,
Group,
Image,
Loader,
Modal,
Paper,
@@ -34,6 +35,8 @@ export default function ContractViewPage() {
const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null);
// Offer the staff member's saved signature first; they can draw a fresh one.
const [drawNew, setDrawNew] = useState(false);
const { data, isLoading, isError, refetch } = useQuery({
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
@@ -41,11 +44,16 @@ export default function ContractViewPage() {
enabled: Boolean(id),
});
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({
mutationFn: () =>
contractsService.signContract(id!, {
role: "STAFF",
signatureImageBase64: signatureData ?? "",
signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.",
}),
@@ -61,8 +69,17 @@ export default function ContractViewPage() {
const handlePrint = () => iframeRef.current?.contentWindow?.print();
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => {
if (!signerName.trim() || !signatureData) return;
if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate();
};
@@ -111,13 +128,9 @@ export default function ContractViewPage() {
<Button
color="edr-green"
leftSection={<FileSignature size={16} />}
onClick={() => {
setSignerName("");
setSignatureData(null);
setSignOpen(true);
}}
onClick={openSign}
>
Sign as staff
{usingSaved ? "Approve & sign" : "Sign as staff"}
</Button>
)}
</Group>
@@ -155,7 +168,36 @@ export default function ContractViewPage() {
value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)}
/>
<ContractSignaturePad onChange={setSignatureData} />
{usingSaved ? (
<Stack gap="xs">
<Paper
withBorder
radius="md"
p="xs"
style={{ borderStyle: "dashed" }}
>
<Image
src={savedSignatureImage ?? undefined}
alt="Saved signature"
fit="contain"
h={140}
/>
</Paper>
<Button
variant="subtle"
size="compact-xs"
color="edr-green"
onClick={() => {
setDrawNew(true);
setSignatureData(null);
}}
>
Draw a new signature instead
</Button>
</Stack>
) : (
<ContractSignaturePad onChange={setSignatureData} />
)}
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}>
Cancel
@@ -163,10 +205,14 @@ export default function ContractViewPage() {
<Button
color="edr-green"
loading={signMutation.isPending}
disabled={!signerName.trim() || !signatureData}
disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
}
onClick={confirmSign}
>
Confirm signature
{usingSaved ? "Approve & sign" : "Confirm signature"}
</Button>
</Group>
</Stack>