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

@@ -20,7 +20,7 @@ import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
@@ -28,9 +28,11 @@ import {
} from "lucide-react";
import toast from "react-hot-toast";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "./SectionCard";
import { bookingsService } from "@/services/bookings.service";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ClearanceReviewSectionProps {
bookingId: string;
@@ -66,6 +68,7 @@ export function ClearanceReviewSection({
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 { data: clearance, isLoading } = useQuery({
queryKey: ["clearance", bookingId],
@@ -207,6 +210,7 @@ export function ClearanceReviewSection({
note: queryNotes[doc.fileKey],
})
}
onView={view}
busy={reviewMutation.isPending}
/>
))
@@ -233,18 +237,46 @@ export function ClearanceReviewSection({
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
<>
{isViewable({
name: doc.file.name,
url: doc.file.url,
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: doc.file!.url,
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
@@ -325,6 +357,7 @@ export function ClearanceReviewSection({
</Button>
</Group>
</Paper>
{viewer}
</Stack>
);
}
@@ -366,6 +399,7 @@ function DocReviewCard({
onNote,
onApprove,
onQuery,
onView,
busy,
}: {
doc: Freight.ClearanceDocument;
@@ -375,6 +409,7 @@ function DocReviewCard({
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
@@ -420,22 +455,22 @@ function DocReviewCard({
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile && (
<Tooltip label="Open document">
<Button
component="a"
href={doc.file!.url}
target="_blank"
rel="noreferrer"
size="compact-xs"
variant="default"
radius="md"
leftSection={<ExternalLink size={13} />}
>
View
</Button>
</Tooltip>
)}
{hasFile &&
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({ name: doc.file!.name, url: doc.file!.url })
}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -13,6 +13,7 @@ import {
FileSignature,
MessageSquareWarning,
PackagePlus,
ShieldCheck,
Sparkles,
XCircle,
Zap,
@@ -29,12 +30,23 @@ type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps {
contract: Freight.IContract;
mutations: Mutations;
/** Switch the detail page to its Clearance Review tab. */
onReviewClearance?: () => void;
}
// Contract is in the pre-booking clearance phase — staff can review the
// customer's uploaded documents.
const CLEARANCE_REVIEW_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
mutations,
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { user } = useAuth();
@@ -62,12 +74,12 @@ export function ContractActionsToolbar({
}
const canAccept = status === "SUBMITTED";
// The contract is generated automatically when the final approval lands. We
// only surface a manual "Generate" fallback if that auto-generation failed —
// i.e. the contract is approved but no document was produced yet.
// Generation only becomes available once EVERY approval step is complete and
// the contract reaches APPROVED. While any step is still pending the contract
// stays in PENDING_APPROVAL, so this button does not appear after only the
// first (line-staff) approval — the director step must land first.
const needsManualGenerate =
["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(status) &&
!contract.contractGeneratedAt;
status === "APPROVED" && !contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the
// generated contract before signing) — no sign button in this toolbar.
const canViewContract =
@@ -77,6 +89,14 @@ export function ContractActionsToolbar({
status === "CLEARANCE_READY_FOR_BOOKING" &&
contract.customsClearingEnabled &&
canCreateContractBooking(user);
// Show "Review clearance" while the contract is in the document-review phase.
// Reviewer = GL (Path B / customs) or Operations (Path A / no customs).
const canReviewClearance =
Boolean(onReviewClearance) &&
CLEARANCE_REVIEW_STATUSES.includes(status);
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
return (
<SectionCard icon={Zap} title="Staff actions">
@@ -125,7 +145,7 @@ export function ContractActionsToolbar({
loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()}
>
Re-generate contract
Generate contract
</Button>
)}
@@ -142,6 +162,18 @@ export function ContractActionsToolbar({
</Button>
)}
{canReviewClearance && (
<Button
fullWidth
color="edr-green"
variant="light"
leftSection={<ShieldCheck size={16} />}
onClick={onReviewClearance}
>
{clearanceReviewer}
</Button>
)}
{canCreateBooking && (
<Button
fullWidth
@@ -158,7 +190,8 @@ export function ContractActionsToolbar({
{!canAccept &&
!needsManualGenerate &&
!canViewContract &&
!canCreateBooking && (
!canCreateBooking &&
!canReviewClearance && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.

View File

@@ -20,18 +20,20 @@ import {
AlertCircle,
CheckCircle2,
Download,
ExternalLink,
Eye,
FileCheck2,
FileText,
MessageSquareWarning,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { contractsService } from "@/services/contracts.service";
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
import { useFileViewer } from "@/hooks/useFileViewer";
export interface ContractClearanceReviewSectionProps {
contractId: string;
@@ -69,13 +71,14 @@ export function ContractClearanceReviewSection({
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 { data: clearance, isLoading } = useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
queryFn: () => contractsService.getClearance(contractId),
});
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
const { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance } =
useContractClearanceMutations(contractId, selfClear);
const customerDocs = useMemo(
@@ -113,6 +116,12 @@ 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",
@@ -136,9 +145,24 @@ export function ContractClearanceReviewSection({
title="Customer documents"
subtitle="Approve each document, or open a query to tell the customer what to fix."
extra={
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
<Group gap={10} wrap="nowrap" align="center">
<Text size="xs" c="dimmed" fw={600}>
{stats.approved}/{stats.total} approved
</Text>
{approvableKeys.length > 0 && (
<Button
size="compact-sm"
color="edr-green"
radius="md"
leftSection={<FileCheck2 size={14} />}
loading={approveAll.isPending}
disabled={reviewDocument.isPending}
onClick={() => approveAll.mutate(approvableKeys)}
>
Approve all ({approvableKeys.length})
</Button>
)}
</Group>
}
>
<Stack gap={12}>
@@ -179,6 +203,7 @@ export function ContractClearanceReviewSection({
onQuery={() =>
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
}
onView={view}
busy={reviewDocument.isPending}
/>
))
@@ -205,18 +230,46 @@ export function ContractClearanceReviewSection({
</Group>
<Group gap={8} wrap="nowrap">
{doc.file ? (
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
<>
{isViewable({
name: doc.file.name,
url: doc.file.url,
}) && (
<Tooltip label="View">
<Box
component="button"
type="button"
onClick={() =>
view({
name: doc.file!.name,
url: doc.file!.url,
})
}
c="edr-green"
style={{
display: "flex",
background: "transparent",
border: "none",
cursor: "pointer",
}}
>
<Eye size={15} />
</Box>
</Tooltip>
)}
<Tooltip label="Download">
<Box
component="a"
href={doc.file.url}
target="_blank"
rel="noreferrer"
c="edr-green"
style={{ display: "flex" }}
>
<Download size={15} />
</Box>
</Tooltip>
</>
) : (
<Text fz="12px" c="edr-muted">
Not uploaded
@@ -308,6 +361,7 @@ export function ContractClearanceReviewSection({
</Button>
</Group>
</Paper>
{viewer}
</Stack>
);
}
@@ -349,6 +403,7 @@ function DocReviewCard({
onNote,
onApprove,
onQuery,
onView,
busy,
}: {
doc: Freight.ContractClearanceDocument;
@@ -358,6 +413,7 @@ function DocReviewCard({
onNote: (v: string) => void;
onApprove: () => void;
onQuery: () => void;
onView: (file: { name: string; url: string }) => void;
busy: boolean;
}) {
const status = doc.reviewStatus ?? "PENDING";
@@ -403,22 +459,22 @@ function DocReviewCard({
<Badge variant="light" color={meta.color} radius="sm">
{meta.label}
</Badge>
{hasFile && (
<Tooltip label="Open document">
<Button
component="a"
href={doc.file!.url}
target="_blank"
rel="noreferrer"
size="compact-xs"
variant="default"
radius="md"
leftSection={<ExternalLink size={13} />}
>
View
</Button>
</Tooltip>
)}
{hasFile &&
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
<Tooltip label="Preview document">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({ name: doc.file!.name, url: doc.file!.url })
}
>
View
</Button>
</Tooltip>
)}
</Group>
</Group>