mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
24
apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
Normal file
24
apps/edr-freight-web/backoffice/src/hooks/useFileViewer.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
24
apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
Normal file
24
apps/edr-freight-web/portal/src/hooks/useFileViewer.tsx
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal file
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal 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 can’t 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;
|
||||||
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export {
|
||||||
|
FileViewerModal,
|
||||||
|
isViewable,
|
||||||
|
resolveViewerKind,
|
||||||
|
} from "./FileViewer";
|
||||||
|
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
|
||||||
|
export { default } from "./FileViewer";
|
||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user