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

@@ -231,10 +231,14 @@ export class ContractTransitionService {
await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED'); await this.contractsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
// Record who acted on this step, but DO NOT advance the contract status here —
// approving one step (e.g. LINE_STAFF) must not finalize the chain while later
// steps (e.g. DIRECTOR) are still pending. Status only moves to APPROVED once
// every step in the chain is complete; until then the contract stays in
// PENDING_APPROVAL so the next required role can act.
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
const now = new Date(); const now = new Date();
if (requiredRole === 'LINE_STAFF') { if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId; updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now; updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') { } else if (requiredRole === 'DIRECTOR') {
@@ -246,9 +250,7 @@ export class ContractTransitionService {
} }
const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId); const allDone = await this.contractsRepository.allApprovalStepsComplete(contractId);
if (allDone) { updates.status = allDone ? 'APPROVED' : 'PENDING_APPROVAL';
updates.status = 'APPROVED';
}
if (Object.keys(updates).length > 0) { if (Object.keys(updates).length > 0) {
await this.contractsRepository.update(contractId, updates as never); await this.contractsRepository.update(contractId, updates as never);
@@ -368,9 +370,28 @@ export class ContractTransitionService {
options: { signerUserId?: string }, options: { signerUserId?: string },
): Promise<void> { ): Promise<void> {
const role = dto.role as ContractSignerRole; const role = dto.role as ContractSignerRole;
const raw = dto.signatureImageBase64.includes(',')
? dto.signatureImageBase64.split(',')[1]! // Resolve the signature image. The client may send a freshly-drawn image, or
: dto.signatureImageBase64; // omit it to reuse the signer's saved profile signature. Fall back to the
// saved one whenever no image is supplied.
let imageBase64 = dto.signatureImageBase64;
let signerDisplayName = dto.signerDisplayName;
if (!imageBase64 && options.signerUserId) {
const saved = await this.signaturesService.getForUser(options.signerUserId);
if (saved?.signatureImageUrl) {
imageBase64 = saved.signatureImageUrl;
signerDisplayName = signerDisplayName || saved.signerDisplayName;
}
}
if (!imageBase64) {
throw new BadRequestException(
'No signature provided and no saved signature found on the profile.',
);
}
const raw = imageBase64.includes(',')
? imageBase64.split(',')[1]!
: imageBase64;
const buffer = Buffer.from(raw, 'base64'); const buffer = Buffer.from(raw, 'base64');
const sigFile: Express.Multer.File = { const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`, fieldname: `signature_${role.toLowerCase()}`,
@@ -395,17 +416,19 @@ export class ContractTransitionService {
await this.contractsRepository.saveSignature({ await this.contractsRepository.saveSignature({
contractId: contract.id, contractId: contract.id,
role, role,
signerDisplayName: dto.signerDisplayName, signerDisplayName,
signedAt: new Date(), signedAt: new Date(),
signatureFileId: fileRecord.id, signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null, consentText: dto.consentText ?? null,
}); });
if (options.signerUserId) { // Only (re)save the reusable profile signature when the signer drew a NEW
// image. Reusing the saved signature must not rewrite it with itself.
if (options.signerUserId && dto.signatureImageBase64) {
try { try {
await this.signaturesService.upsertForUser({ await this.signaturesService.upsertForUser({
userId: options.signerUserId, userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName, signerDisplayName,
signatureImageBase64: dto.signatureImageBase64, signatureImageBase64: dto.signatureImageBase64,
}); });
} catch (err) { } catch (err) {

View File

@@ -42,6 +42,7 @@ import { ContractTransitionService } from './contract-transition.service';
import { ContractClearanceService } from './contract-clearance.service'; import { ContractClearanceService } from './contract-clearance.service';
import { ContractBookingService } from './contract-booking.service'; import { ContractBookingService } from './contract-booking.service';
import { ClearanceMilestoneService } from './clearance-milestone.service'; import { ClearanceMilestoneService } from './clearance-milestone.service';
import { SignaturesService } from '../signatures/signatures.service';
import { CreateContractDto } from './dto/create-contract.dto'; import { CreateContractDto } from './dto/create-contract.dto';
import { UpdateContractDto } from './dto/update-contract.dto'; import { UpdateContractDto } from './dto/update-contract.dto';
import { FilterContractDto } from './dto/filter-contract.dto'; import { FilterContractDto } from './dto/filter-contract.dto';
@@ -68,6 +69,7 @@ export class ContractsController {
private readonly clearanceService: ContractClearanceService, private readonly clearanceService: ContractClearanceService,
private readonly contractBookingService: ContractBookingService, private readonly contractBookingService: ContractBookingService,
private readonly milestoneService: ClearanceMilestoneService, private readonly milestoneService: ClearanceMilestoneService,
private readonly signaturesService: SignaturesService,
) {} ) {}
@Post() @Post()
@@ -313,6 +315,12 @@ export class ContractsController {
} }
const { view, html, signatures } = const { view, html, signatures } =
await this.transitionService.getContractDocumentView(id); await this.transitionService.getContractDocumentView(id);
// The signer's reusable saved signature (if any) so the sign UI can offer
// "Approve & sign" with the stored image instead of forcing a fresh draw.
const signerId = resolveAuthUserId(user);
const savedSignature = signerId
? await this.signaturesService.getForUser(signerId)
: null;
return { return {
contractId: view.bookingId, contractId: view.bookingId,
reference: view.reference, reference: view.reference,
@@ -325,6 +333,7 @@ export class ContractsController {
canSignStaff: view.canSignStaff, canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument, hasContractDocument: view.hasContractDocument,
signatures, signatures,
savedSignature,
}; };
} }

View File

@@ -6,10 +6,16 @@ export class SignContractDto {
@IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO']) @IsIn(['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'])
role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO'; role!: 'CUSTOMER' | 'STAFF' | 'DIRECTOR' | 'CEO';
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' }) @ApiPropertyOptional({
description:
'PNG signature image as base64 (with or without data URL prefix). ' +
'Optional: when omitted, the signer\'s reusable saved signature from their ' +
'profile is used instead.',
})
@IsOptional()
@IsString() @IsString()
@MinLength(20) @MinLength(20)
signatureImageBase64!: string; signatureImageBase64?: string;
@ApiProperty() @ApiProperty()
@IsString() @IsString()

View File

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

View File

@@ -13,6 +13,7 @@ import {
FileSignature, FileSignature,
MessageSquareWarning, MessageSquareWarning,
PackagePlus, PackagePlus,
ShieldCheck,
Sparkles, Sparkles,
XCircle, XCircle,
Zap, Zap,
@@ -29,12 +30,23 @@ type Mutations = ReturnType<typeof useContractMutations>;
interface ContractActionsToolbarProps { interface ContractActionsToolbarProps {
contract: Freight.IContract; contract: Freight.IContract;
mutations: Mutations; 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. */ /** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({ export function ContractActionsToolbar({
contract, contract,
mutations, mutations,
onReviewClearance,
}: ContractActionsToolbarProps) { }: ContractActionsToolbarProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
@@ -62,12 +74,12 @@ export function ContractActionsToolbar({
} }
const canAccept = status === "SUBMITTED"; const canAccept = status === "SUBMITTED";
// The contract is generated automatically when the final approval lands. We // Generation only becomes available once EVERY approval step is complete and
// only surface a manual "Generate" fallback if that auto-generation failed — // the contract reaches APPROVED. While any step is still pending the contract
// i.e. the contract is approved but no document was produced yet. // 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 = const needsManualGenerate =
["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(status) && status === "APPROVED" && !contract.contractGeneratedAt;
!contract.contractGeneratedAt;
// Signing now happens on the contract VIEW page (staff must open and read the // Signing now happens on the contract VIEW page (staff must open and read the
// generated contract before signing) — no sign button in this toolbar. // generated contract before signing) — no sign button in this toolbar.
const canViewContract = const canViewContract =
@@ -77,6 +89,14 @@ export function ContractActionsToolbar({
status === "CLEARANCE_READY_FOR_BOOKING" && status === "CLEARANCE_READY_FOR_BOOKING" &&
contract.customsClearingEnabled && contract.customsClearingEnabled &&
canCreateContractBooking(user); 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 ( return (
<SectionCard icon={Zap} title="Staff actions"> <SectionCard icon={Zap} title="Staff actions">
@@ -125,7 +145,7 @@ export function ContractActionsToolbar({
loading={mutations.generateContract.isPending} loading={mutations.generateContract.isPending}
onClick={() => mutations.generateContract.mutate()} onClick={() => mutations.generateContract.mutate()}
> >
Re-generate contract Generate contract
</Button> </Button>
)} )}
@@ -142,6 +162,18 @@ export function ContractActionsToolbar({
</Button> </Button>
)} )}
{canReviewClearance && (
<Button
fullWidth
color="edr-green"
variant="light"
leftSection={<ShieldCheck size={16} />}
onClick={onReviewClearance}
>
{clearanceReviewer}
</Button>
)}
{canCreateBooking && ( {canCreateBooking && (
<Button <Button
fullWidth fullWidth
@@ -158,7 +190,8 @@ export function ContractActionsToolbar({
{!canAccept && {!canAccept &&
!needsManualGenerate && !needsManualGenerate &&
!canViewContract && !canViewContract &&
!canCreateBooking && ( !canCreateBooking &&
!canReviewClearance && (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the No staff actions available for this status. Monitor until the
workflow advances. workflow advances.

View File

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

View File

@@ -227,6 +227,29 @@ export function useContractClearanceMutations(
onError: () => toast.error("Could not update document"), onError: () => toast.error("Could not update document"),
}); });
// Approve every still-pending customer document in one click. There is no
// server-side bulk endpoint, so fan out the single-document review calls and
// refresh once after they all settle.
const approveAll = useMutation({
mutationFn: async (fileKeys: string[]) => {
const review = selfClear
? contractsService.opsReviewClearanceDocument
: contractsService.reviewClearanceDocument;
await Promise.all(
fileKeys.map((fileKey) =>
review(contractId, { fileKey, status: "APPROVED" }),
),
);
},
onSuccess: (_d, fileKeys) => {
toast.success(
`${fileKeys.length} document${fileKeys.length === 1 ? "" : "s"} approved`,
);
refresh();
},
onError: () => toast.error("Could not approve all documents"),
});
const uploadOutputDocuments = useMutation({ const uploadOutputDocuments = useMutation({
mutationFn: (files: Record<string, File | null>) => mutationFn: (files: Record<string, File | null>) =>
contractsService.uploadClearanceOutput(contractId, files), contractsService.uploadClearanceOutput(contractId, files),
@@ -256,7 +279,7 @@ export function useContractClearanceMutations(
), ),
}); });
return { reviewDocument, uploadOutputDocuments, finalizeClearance }; return { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance };
} }
/** Complete a post-booking GL milestone. */ /** Complete a post-booking GL milestone. */

View File

@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -1,4 +1,4 @@
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
@@ -12,6 +12,7 @@ import {
Receipt, Receipt,
RefreshCw, RefreshCw,
Route as RouteIcon, Route as RouteIcon,
ShieldCheck,
Snowflake, Snowflake,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -25,10 +26,12 @@ import {
Loader, Loader,
Paper, Paper,
Stack, Stack,
Tabs,
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import "@/components/overview/overview.css";
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
import Breadcrumbs from "@/components/ui/Breadcrumbs"; import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { SectionCard } from "@/components/bookings/detail/SectionCard"; import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -37,12 +40,21 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper"; import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar"; import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard"; import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
import { getContractStatusMeta } from "@/features/contracts/contract-status.config"; import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
import { import {
useContractDetail, useContractDetail,
useContractMutations, useContractMutations,
} from "@/hooks/contracts/useContracts"; } 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 { function formatDate(value: string | null | undefined): string {
if (!value) return "—"; if (!value) return "—";
const d = new Date(value); const d = new Date(value);
@@ -66,6 +78,18 @@ export default function ContractRequestDetailPage() {
isFetching, isFetching,
} = useContractDetail(id); } = useContractDetail(id);
const mutations = useContractMutations(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) { if (isLoading) {
return ( return (
@@ -132,6 +156,13 @@ export default function ContractRequestDetailPage() {
contract.status === "APPROVED" || contract.status === "APPROVED" ||
contract.status === "APPROVED_PENDING_SIGNATURE"; 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 const customerLabel = contract.isGovernment
? (contract.governmentInstitution ?? "Government") ? (contract.governmentInstitution ?? "Government")
: (contract.companyId ?? "—"); : (contract.companyId ?? "—");
@@ -216,9 +247,38 @@ export default function ContractRequestDetailPage() {
description={statusMeta.description} 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"> <Grid gap="lg">
{/* LEFT — primary content */} {/* LEFT — primary content */}
<Grid.Col span={{ base: 12, lg: 8 }}> <Grid.Col span={{ base: 12, lg: 8 }}>
{currentTab === "clearance" ? (
<ContractClearanceReviewSection
contractId={id!}
selfClear={selfClear}
onChanged={() => refetch()}
/>
) : (
<Stack gap="lg"> <Stack gap="lg">
<SectionCard icon={RouteIcon} title="Routes"> <SectionCard icon={RouteIcon} title="Routes">
{routes.length === 0 ? ( {routes.length === 0 ? (
@@ -350,6 +410,7 @@ export default function ContractRequestDetailPage() {
</SectionCard> </SectionCard>
) : null} ) : null}
</Stack> </Stack>
)}
</Grid.Col> </Grid.Col>
{/* RIGHT — sticky action rail */} {/* RIGHT — sticky action rail */}
@@ -359,6 +420,9 @@ export default function ContractRequestDetailPage() {
<ContractActionsToolbar <ContractActionsToolbar
contract={contract} contract={contract}
mutations={mutations} mutations={mutations}
onReviewClearance={
showClearanceTab ? () => setTab("clearance") : undefined
}
/> />
{showApprovalCard && ( {showApprovalCard && (
<ContractApprovalStepsCard <ContractApprovalStepsCard

View File

@@ -5,6 +5,7 @@ import {
Box, Box,
Button, Button,
Group, Group,
Image,
Loader, Loader,
Modal, Modal,
Paper, Paper,
@@ -34,6 +35,8 @@ export default function ContractViewPage() {
const [signOpen, setSignOpen] = useState(false); const [signOpen, setSignOpen] = useState(false);
const [signerName, setSignerName] = useState(""); const [signerName, setSignerName] = useState("");
const [signatureData, setSignatureData] = useState<string | null>(null); 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({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"], queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
@@ -41,11 +44,16 @@ export default function ContractViewPage() {
enabled: Boolean(id), enabled: Boolean(id),
}); });
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
const signMutation = useMutation({ const signMutation = useMutation({
mutationFn: () => mutationFn: () =>
contractsService.signContract(id!, { contractsService.signContract(id!, {
role: "STAFF", role: "STAFF",
signatureImageBase64: signatureData ?? "", signatureImageBase64: usingSaved
? (savedSignatureImage as string)
: (signatureData ?? ""),
signerDisplayName: signerName.trim(), signerDisplayName: signerName.trim(),
consentText: "I confirm this contract on behalf of EDR.", consentText: "I confirm this contract on behalf of EDR.",
}), }),
@@ -61,8 +69,17 @@ export default function ContractViewPage() {
const handlePrint = () => iframeRef.current?.contentWindow?.print(); const handlePrint = () => iframeRef.current?.contentWindow?.print();
const openSign = () => {
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
setSignatureData(null);
setDrawNew(false);
setSignOpen(true);
};
const confirmSign = () => { const confirmSign = () => {
if (!signerName.trim() || !signatureData) return; if (!signerName.trim()) return;
const image = usingSaved ? savedSignatureImage : signatureData;
if (!image) return;
signMutation.mutate(); signMutation.mutate();
}; };
@@ -111,13 +128,9 @@ export default function ContractViewPage() {
<Button <Button
color="edr-green" color="edr-green"
leftSection={<FileSignature size={16} />} leftSection={<FileSignature size={16} />}
onClick={() => { onClick={openSign}
setSignerName("");
setSignatureData(null);
setSignOpen(true);
}}
> >
Sign as staff {usingSaved ? "Approve & sign" : "Sign as staff"}
</Button> </Button>
)} )}
</Group> </Group>
@@ -155,7 +168,36 @@ export default function ContractViewPage() {
value={signerName} value={signerName}
onChange={(e) => setSignerName(e.currentTarget.value)} 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"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSignOpen(false)}> <Button variant="default" onClick={() => setSignOpen(false)}>
Cancel Cancel
@@ -163,10 +205,14 @@ export default function ContractViewPage() {
<Button <Button
color="edr-green" color="edr-green"
loading={signMutation.isPending} loading={signMutation.isPending}
disabled={!signerName.trim() || !signatureData} disabled={
signMutation.isPending ||
!signerName.trim() ||
(!usingSaved && !signatureData)
}
onClick={confirmSign} onClick={confirmSign}
> >
Confirm signature {usingSaved ? "Approve & sign" : "Confirm signature"}
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

View File

@@ -0,0 +1,24 @@
import { useCallback, useState } from "react";
import { FileViewerModal, type ViewableFile } from "@edr/ui-common";
/**
* Drives a single shared {@link FileViewerModal} for a page. Call `view(file)`
* from any file row to open the document inline (pdf / image / video / office /
* text); render `viewer` once near the page root.
*
* const { view, viewer } = useFileViewer();
* <Button onClick={() => view({ name, url, mimeType })}>View</Button>
* {viewer}
*/
export function useFileViewer() {
const [file, setFile] = useState<ViewableFile | null>(null);
const view = useCallback((f: ViewableFile) => setFile(f), []);
const close = useCallback(() => setFile(null), []);
const viewer = (
<FileViewerModal open={file !== null} file={file} onClose={close} />
);
return { view, close, viewer };
}

View File

@@ -1,10 +1,12 @@
import { Box, Group, Text } from "@mantine/core"; import { Box, Group, Text } from "@mantine/core";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { CreditCard, Download } from "lucide-react"; import { CreditCard, Download, Eye } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { paymentsService, type PaymentMethod } from "@/services/payments.service"; import { paymentsService, type PaymentMethod } from "@/services/payments.service";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
@@ -34,6 +36,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
const navigate = useNavigate(); const navigate = useNavigate();
const status = booking.status as string; const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false); const [payModalOpen, setPayModalOpen] = useState(false);
const { view, viewer } = useFileViewer();
// POST /payments/initiate creates the intent and returns the provider's // POST /payments/initiate creates the intent and returns the provider's
// redirect URL (clientAction.url). Send the browser straight there; fall back // redirect URL (clientAction.url). Send the browser straight there; fall back
@@ -159,10 +162,28 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
meta={file.code.replace(/_/g, " ")} meta={file.code.replace(/_/g, " ")}
status="verified" status="verified"
action={ action={
<IconSquare <Group gap={6} wrap="nowrap">
href={file.signedUrl ?? file.url} {isViewable({
icon={<Download size={16} />} name: file.name,
/> url: file.signedUrl ?? file.url,
mimeType: file.mimeType,
}) && (
<IconSquare
icon={<Eye size={16} />}
onClick={() =>
view({
name: file.name,
url: file.signedUrl ?? file.url,
mimeType: file.mimeType,
})
}
/>
)}
<IconSquare
href={file.signedUrl ?? file.url}
icon={<Download size={16} />}
/>
</Group>
} }
/> />
))} ))}
@@ -213,6 +234,7 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
} }
onConfirm={(method) => payMutation.mutate(method)} onConfirm={(method) => payMutation.mutate(method)}
/> />
{viewer}
</PageShell> </PageShell>
); );
} }

View File

@@ -108,9 +108,12 @@ export function DocRow({
export function IconSquare({ export function IconSquare({
icon, icon,
href, href,
onClick,
}: { }: {
icon: ReactNode; icon: ReactNode;
href?: string | null; href?: string | null;
/** When provided (and no href), renders a clickable button square. */
onClick?: () => void;
}) { }) {
const style: React.CSSProperties = { const style: React.CSSProperties = {
flexShrink: 0, flexShrink: 0,
@@ -122,6 +125,7 @@ export function IconSquare({
borderRadius: 8, borderRadius: 8,
border: "1px solid #E6ECF2", border: "1px solid #E6ECF2",
color: "#6B7C8E", color: "#6B7C8E",
cursor: href || onClick ? "pointer" : "default",
}; };
if (href) { if (href) {
return ( return (
@@ -136,6 +140,18 @@ export function IconSquare({
</Box> </Box>
); );
} }
if (onClick) {
return (
<Box
component="button"
type="button"
onClick={onClick}
style={{ ...style, background: "transparent" }}
>
{icon}
</Box>
);
}
return ( return (
<Box component="span" style={style}> <Box component="span" style={style}>
{icon} {icon}

View File

@@ -13,14 +13,17 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Download, Download,
Eye,
FileText, FileText,
Plus, Plus,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { IconSquare } from "../BookingDetailPage/components/Documents"; import { IconSquare } from "../BookingDetailPage/components/Documents";
import { useFileViewer } from "@/hooks/useFileViewer";
import { OperationDatePicker } from "./OperationDatePicker"; import { OperationDatePicker } from "./OperationDatePicker";
import type { ClearanceFlowController } from "./useClearanceFlow"; import type { ClearanceFlowController } from "./useClearanceFlow";
@@ -104,6 +107,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
uploadMutation, uploadMutation,
proceedMutation, proceedMutation,
} = flow; } = flow;
const { view, viewer } = useFileViewer();
if (!clearance) return null; if (!clearance) return null;
@@ -164,6 +168,15 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
</Group> </Group>
<Group gap={10} wrap="nowrap"> <Group gap={10} wrap="nowrap">
<StatusPill doc={doc} /> <StatusPill doc={doc} />
{doc.file &&
isViewable({ name: doc.file.name, url: doc.file.url }) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({ name: doc.file!.name, url: doc.file!.url })
}
/>
)}
{doc.file && ( {doc.file && (
<IconSquare href={doc.file.url} icon={<Download size={15} />} /> <IconSquare href={doc.file.url} icon={<Download size={15} />} />
)} )}
@@ -310,6 +323,7 @@ export function ClearanceFlow({ booking, flow, footer }: ClearanceFlowProps) {
)} )}
{footer} {footer}
{viewer}
</Stack> </Stack>
); );
} }

View File

@@ -22,13 +22,16 @@ import {
CheckCircle2, CheckCircle2,
Clock, Clock,
Download, Download,
Eye,
FileText, FileText,
Plus, Plus,
Upload, Upload,
} from "lucide-react"; } from "lucide-react";
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents"; import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
import { BORDER, ContractStatusBadge, INK } from "./contract-ui"; import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
@@ -88,6 +91,7 @@ export default function ContractClearanceFlow() {
const [pending, setPending] = useState<Record<string, File>>({}); const [pending, setPending] = useState<Record<string, File>>({});
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]); const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
const { view, viewer } = useFileViewer();
const { data: contract } = useQuery( const { data: contract } = useQuery(
api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }), api.contracts.get.queryOptions({ input: { id: id! }, enabled: !!id }),
@@ -280,6 +284,15 @@ export default function ContractClearanceFlow() {
</Group> </Group>
<Group gap={10} wrap="nowrap"> <Group gap={10} wrap="nowrap">
<StatusPill doc={doc} /> <StatusPill doc={doc} />
{doc.file &&
isViewable({ name: doc.file.name, url: doc.file.url }) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({ name: doc.file!.name, url: doc.file!.url })
}
/>
)}
{doc.file && ( {doc.file && (
<IconSquare <IconSquare
href={doc.file.url} href={doc.file.url}
@@ -344,10 +357,26 @@ export default function ContractClearanceFlow() {
{doc.label} {doc.label}
</Text> </Text>
{doc.file ? ( {doc.file ? (
<IconSquare <Group gap={8} wrap="nowrap">
href={doc.file.url} {isViewable({
icon={<Download size={15} />} name: doc.file.name,
/> url: doc.file.url,
}) && (
<IconSquare
icon={<Eye size={15} />}
onClick={() =>
view({
name: doc.file!.name,
url: doc.file!.url,
})
}
/>
)}
<IconSquare
href={doc.file.url}
icon={<Download size={15} />}
/>
</Group>
) : ( ) : (
<Text fz="12px" c="#9AA8B5"> <Text fz="12px" c="#9AA8B5">
Pending Pending
@@ -446,6 +475,7 @@ export default function ContractClearanceFlow() {
</Stack> </Stack>
</Paper> </Paper>
</Stack> </Stack>
{viewer}
</Box> </Box>
); );
} }

View File

@@ -21,6 +21,7 @@ import {
CalendarClock, CalendarClock,
CheckCircle2, CheckCircle2,
Download, Download,
Eye,
FileSignature, FileSignature,
FileText, FileText,
Flame, Flame,
@@ -35,7 +36,9 @@ import {
Upload, Upload,
Weight, Weight,
} from "lucide-react"; } from "lucide-react";
import { isViewable } from "@edr/ui-common";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { useFileViewer } from "@/hooks/useFileViewer";
import { formatRateUnit } from "./new-contract-form/unit-rates"; import { formatRateUnit } from "./new-contract-form/unit-rates";
import { import {
BORDER, BORDER,
@@ -63,6 +66,7 @@ export default function ContractDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const [tab, setTab] = useState<string>("details"); const [tab, setTab] = useState<string>("details");
const { view, viewer } = useFileViewer();
const { const {
data: contract, data: contract,
@@ -638,19 +642,42 @@ export default function ContractDetailPage() {
</Text> </Text>
</Box> </Box>
</Group> </Group>
<Button <Group gap={8} wrap="nowrap">
component="a" {isViewable({
href={file.signedUrl ?? file.url} name: file.name,
target="_blank" url: file.signedUrl ?? file.url,
rel="noopener noreferrer" mimeType: file.mimeType,
variant="light" }) && (
color="edr-green" <Button
size="xs" variant="light"
radius="md" color="edr-green"
leftSection={<Download size={14} />} size="xs"
> radius="md"
Open leftSection={<Eye size={14} />}
</Button> onClick={() =>
view({
name: file.name,
url: file.signedUrl ?? file.url,
mimeType: file.mimeType,
})
}
>
View
</Button>
)}
<Button
component="a"
href={file.signedUrl ?? file.url}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="xs"
radius="md"
leftSection={<Download size={14} />}
>
Open
</Button>
</Group>
</Group> </Group>
))} ))}
</Stack> </Stack>
@@ -744,6 +771,7 @@ export default function ContractDetailPage() {
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>
</Stack> </Stack>
{viewer}
</Box> </Box>
); );
} }

View File

@@ -0,0 +1,307 @@
import { useMemo } from "react";
import {
Box,
Button,
Center,
Group,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
Download,
ExternalLink,
FileArchive,
FileQuestion,
} from "lucide-react";
/** The minimal file shape the viewer needs. */
export interface ViewableFile {
/** Display name (used for the title + extension fallback). */
name: string;
/** Direct URL to the file content. A signed URL is preferred when present. */
url: string;
/** MIME type when known (e.g. "application/pdf", "image/png"). */
mimeType?: string | null;
}
export interface FileViewerModalProps {
/** Whether the modal is open. */
open: boolean;
/** The file to display, or null when nothing is selected. */
file: ViewableFile | null;
/** Close handler. */
onClose: () => void;
}
type ViewerKind =
| "image"
| "video"
| "audio"
| "pdf"
| "office"
| "text"
| "unsupported";
const EXT_KIND: Record<string, ViewerKind> = {
// images
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
bmp: "image",
svg: "image",
// video
mp4: "video",
webm: "video",
ogv: "video",
mov: "video",
m4v: "video",
// audio
mp3: "audio",
wav: "audio",
ogg: "audio",
m4a: "audio",
// documents
pdf: "pdf",
// office — rendered via the Microsoft Office online viewer
doc: "office",
docx: "office",
xls: "office",
xlsx: "office",
ppt: "office",
pptx: "office",
// text
txt: "text",
csv: "text",
json: "text",
log: "text",
md: "text",
};
/** Archives / binaries we deliberately do NOT try to render inline. */
const UNVIEWABLE_EXT = new Set([
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"exe",
"dmg",
"iso",
"bin",
]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
/** Decide how to render a file from its MIME type, falling back to extension. */
export function resolveViewerKind(file: ViewableFile): ViewerKind {
const mime = (file.mimeType ?? "").toLowerCase();
const ext = extOf(file.name);
if (UNVIEWABLE_EXT.has(ext)) return "unsupported";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime === "application/pdf") return "pdf";
if (
mime.includes("word") ||
mime.includes("excel") ||
mime.includes("spreadsheet") ||
mime.includes("powerpoint") ||
mime.includes("presentation") ||
mime.includes("officedocument")
) {
return "office";
}
if (mime.startsWith("text/") || mime === "application/json") return "text";
// Fall back to the file extension when the MIME type is missing/generic.
return EXT_KIND[ext] ?? "unsupported";
}
/** True when a file can be previewed inline (not an archive/binary). */
export function isViewable(file: ViewableFile): boolean {
return resolveViewerKind(file) !== "unsupported";
}
/**
* A wide modal that renders the content of common document types inline —
* images, video, audio, PDFs, Office documents (via the Microsoft online
* viewer) and plain text. Archives and other binaries fall back to a download
* prompt. Use the {@link isViewable} / {@link resolveViewerKind} helpers to gate
* a "view" affordance in the caller.
*/
export function FileViewerModal({ open, file, onClose }: FileViewerModalProps) {
const kind = useMemo(
() => (file ? resolveViewerKind(file) : "unsupported"),
[file],
);
return (
<Modal
opened={open}
onClose={onClose}
title={
<Text fw={700} fz={15} truncate>
{file?.name ?? "Document"}
</Text>
}
size="90%"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
styles={{
content: {
height: "90vh",
display: "flex",
flexDirection: "column",
},
body: { flex: 1, minHeight: 0, display: "flex", padding: 0 },
header: { paddingInline: 16 },
}}
>
{file && (
<Stack gap={0} style={{ flex: 1, minHeight: 0 }}>
<Group
justify="flex-end"
gap="xs"
px="md"
py={8}
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Button
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
size="compact-sm"
variant="default"
leftSection={<ExternalLink size={14} />}
>
Open in new tab
</Button>
<Button
component="a"
href={file.url}
download={file.name}
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<Download size={14} />}
>
Download
</Button>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<FileContent file={file} kind={kind} />
</Box>
</Stack>
)}
</Modal>
);
}
function FileContent({
file,
kind,
}: {
file: ViewableFile;
kind: ViewerKind;
}) {
switch (kind) {
case "image":
return (
<Center p="md" style={{ minHeight: "100%" }}>
<img
src={file.url}
alt={file.name}
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
/>
</Center>
);
case "video":
return (
<Center p="md" style={{ minHeight: "100%", background: "#000" }}>
<video
src={file.url}
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
</Center>
);
case "audio":
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<audio src={file.url} controls style={{ width: "100%", maxWidth: 480 }} />
</Center>
);
case "pdf":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "office":
return (
<iframe
// The Microsoft Office online viewer requires a publicly reachable URL.
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
file.url,
)}`}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "text":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
default:
return <UnsupportedNotice file={file} />;
}
}
function UnsupportedNotice({ file }: { file: ViewableFile }) {
const isArchive = UNVIEWABLE_EXT.has(extOf(file.name));
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<Stack align="center" gap="sm" maw={360} ta="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={56}>
{isArchive ? <FileArchive size={26} /> : <FileQuestion size={26} />}
</ThemeIcon>
<Text fw={600}>This file type cant be previewed</Text>
<Text fz="sm" c="dimmed">
{isArchive
? "Archives need to be downloaded and extracted on your computer."
: "Download the file to open it in a compatible application."}
</Text>
<Button
component="a"
href={file.url}
download={file.name}
mt="xs"
color="edr-green"
leftSection={<Download size={16} />}
>
Download file
</Button>
</Stack>
</Center>
);
}
export default FileViewerModal;

View File

@@ -0,0 +1,7 @@
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./FileViewer";
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
export { default } from "./FileViewer";

View File

@@ -10,6 +10,16 @@ export type { SmartFileInputProps } from "./components/SmartFileInput";
export { default as Modal } from "./components/Modal"; export { default as Modal } from "./components/Modal";
export type { ModalProps } from "./components/Modal"; export type { ModalProps } from "./components/Modal";
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./components/FileViewer";
export type {
FileViewerModalProps,
ViewableFile,
} from "./components/FileViewer";
export { Badge } from "./components/badge"; export { Badge } from "./components/badge";
// export type { BadgeProps } from "./components/badge"; // export type { BadgeProps } from "./components/badge";