mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 19:58:11 +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:
@@ -20,7 +20,7 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
@@ -28,9 +28,11 @@ import {
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "./SectionCard";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
export interface ClearanceReviewSectionProps {
|
||||
bookingId: string;
|
||||
@@ -66,6 +68,7 @@ export function ClearanceReviewSection({
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: ["clearance", bookingId],
|
||||
@@ -207,6 +210,7 @@ export function ClearanceReviewSection({
|
||||
note: queryNotes[doc.fileKey],
|
||||
})
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewMutation.isPending}
|
||||
/>
|
||||
))
|
||||
@@ -233,18 +237,46 @@ export function ClearanceReviewSection({
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: doc.file.url,
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: doc.file!.url,
|
||||
})
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
@@ -325,6 +357,7 @@ export function ClearanceReviewSection({
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -366,6 +399,7 @@ function DocReviewCard({
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ClearanceDocument;
|
||||
@@ -375,6 +409,7 @@ function DocReviewCard({
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
@@ -420,22 +455,22 @@ function DocReviewCard({
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile && (
|
||||
<Tooltip label="Open document">
|
||||
<Button
|
||||
component="a"
|
||||
href={doc.file!.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ExternalLink size={13} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile &&
|
||||
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({ name: doc.file!.name, url: doc.file!.url })
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
FileSignature,
|
||||
MessageSquareWarning,
|
||||
PackagePlus,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
XCircle,
|
||||
Zap,
|
||||
@@ -29,12 +30,23 @@ type Mutations = ReturnType<typeof useContractMutations>;
|
||||
interface ContractActionsToolbarProps {
|
||||
contract: Freight.IContract;
|
||||
mutations: Mutations;
|
||||
/** Switch the detail page to its Clearance Review tab. */
|
||||
onReviewClearance?: () => void;
|
||||
}
|
||||
|
||||
// Contract is in the pre-booking clearance phase — staff can review the
|
||||
// customer's uploaded documents.
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
|
||||
export function ContractActionsToolbar({
|
||||
contract,
|
||||
mutations,
|
||||
onReviewClearance,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
@@ -62,12 +74,12 @@ export function ContractActionsToolbar({
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
// The contract is generated automatically when the final approval lands. We
|
||||
// only surface a manual "Generate" fallback if that auto-generation failed —
|
||||
// i.e. the contract is approved but no document was produced yet.
|
||||
// Generation only becomes available once EVERY approval step is complete and
|
||||
// the contract reaches APPROVED. While any step is still pending the contract
|
||||
// stays in PENDING_APPROVAL, so this button does not appear after only the
|
||||
// first (line-staff) approval — the director step must land first.
|
||||
const needsManualGenerate =
|
||||
["APPROVED", "APPROVED_PENDING_SIGNATURE"].includes(status) &&
|
||||
!contract.contractGeneratedAt;
|
||||
status === "APPROVED" && !contract.contractGeneratedAt;
|
||||
// Signing now happens on the contract VIEW page (staff must open and read the
|
||||
// generated contract before signing) — no sign button in this toolbar.
|
||||
const canViewContract =
|
||||
@@ -77,6 +89,14 @@ export function ContractActionsToolbar({
|
||||
status === "CLEARANCE_READY_FOR_BOOKING" &&
|
||||
contract.customsClearingEnabled &&
|
||||
canCreateContractBooking(user);
|
||||
// Show "Review clearance" while the contract is in the document-review phase.
|
||||
// Reviewer = GL (Path B / customs) or Operations (Path A / no customs).
|
||||
const canReviewClearance =
|
||||
Boolean(onReviewClearance) &&
|
||||
CLEARANCE_REVIEW_STATUSES.includes(status);
|
||||
const clearanceReviewer = contract.customsClearingEnabled
|
||||
? "Review clearance (GL)"
|
||||
: "Review clearance (Ops)";
|
||||
|
||||
return (
|
||||
<SectionCard icon={Zap} title="Staff actions">
|
||||
@@ -125,7 +145,7 @@ export function ContractActionsToolbar({
|
||||
loading={mutations.generateContract.isPending}
|
||||
onClick={() => mutations.generateContract.mutate()}
|
||||
>
|
||||
Re-generate contract
|
||||
Generate contract
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -142,6 +162,18 @@ export function ContractActionsToolbar({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canReviewClearance && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
onClick={onReviewClearance}
|
||||
>
|
||||
{clearanceReviewer}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCreateBooking && (
|
||||
<Button
|
||||
fullWidth
|
||||
@@ -158,7 +190,8 @@ export function ContractActionsToolbar({
|
||||
{!canAccept &&
|
||||
!needsManualGenerate &&
|
||||
!canViewContract &&
|
||||
!canCreateBooking && (
|
||||
!canCreateBooking &&
|
||||
!canReviewClearance && (
|
||||
<Text size="sm" c="dimmed">
|
||||
No staff actions available for this status. Monitor until the
|
||||
workflow advances.
|
||||
|
||||
@@ -20,18 +20,20 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileCheck2,
|
||||
FileText,
|
||||
MessageSquareWarning,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractClearanceMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
|
||||
export interface ContractClearanceReviewSectionProps {
|
||||
contractId: string;
|
||||
@@ -69,13 +71,14 @@ export function ContractClearanceReviewSection({
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
const [outputFiles, setOutputFiles] = useState<Record<string, File>>({});
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: clearance, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(contractId),
|
||||
queryFn: () => contractsService.getClearance(contractId),
|
||||
});
|
||||
|
||||
const { reviewDocument, uploadOutputDocuments, finalizeClearance } =
|
||||
const { reviewDocument, approveAll, uploadOutputDocuments, finalizeClearance } =
|
||||
useContractClearanceMutations(contractId, selfClear);
|
||||
|
||||
const customerDocs = useMemo(
|
||||
@@ -113,6 +116,12 @@ export function ContractClearanceReviewSection({
|
||||
);
|
||||
}
|
||||
|
||||
// Documents that have a file uploaded but are not yet approved — these are the
|
||||
// ones "Approve all" will action in one click.
|
||||
const approvableKeys = customerDocs
|
||||
.filter((d) => d.file && d.reviewStatus !== "APPROVED")
|
||||
.map((d) => d.fileKey);
|
||||
|
||||
const handleReview = (
|
||||
fileKey: string,
|
||||
status: "APPROVED" | "QUERIED",
|
||||
@@ -136,9 +145,24 @@ export function ContractClearanceReviewSection({
|
||||
title="Customer documents"
|
||||
subtitle="Approve each document, or open a query to tell the customer what to fix."
|
||||
extra={
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
{approvableKeys.length > 0 && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<FileCheck2 size={14} />}
|
||||
loading={approveAll.isPending}
|
||||
disabled={reviewDocument.isPending}
|
||||
onClick={() => approveAll.mutate(approvableKeys)}
|
||||
>
|
||||
Approve all ({approvableKeys.length})
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap={12}>
|
||||
@@ -179,6 +203,7 @@ export function ContractClearanceReviewSection({
|
||||
onQuery={() =>
|
||||
handleReview(doc.fileKey, "QUERIED", queryNotes[doc.fileKey])
|
||||
}
|
||||
onView={view}
|
||||
busy={reviewDocument.isPending}
|
||||
/>
|
||||
))
|
||||
@@ -205,18 +230,46 @@ export function ContractClearanceReviewSection({
|
||||
</Group>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{doc.file ? (
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
<>
|
||||
{isViewable({
|
||||
name: doc.file.name,
|
||||
url: doc.file.url,
|
||||
}) && (
|
||||
<Tooltip label="View">
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
view({
|
||||
name: doc.file!.name,
|
||||
url: doc.file!.url,
|
||||
})
|
||||
}
|
||||
c="edr-green"
|
||||
style={{
|
||||
display: "flex",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
<Eye size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Download">
|
||||
<Box
|
||||
component="a"
|
||||
href={doc.file.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
c="edr-green"
|
||||
style={{ display: "flex" }}
|
||||
>
|
||||
<Download size={15} />
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : (
|
||||
<Text fz="12px" c="edr-muted">
|
||||
Not uploaded
|
||||
@@ -308,6 +361,7 @@ export function ContractClearanceReviewSection({
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
{viewer}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -349,6 +403,7 @@ function DocReviewCard({
|
||||
onNote,
|
||||
onApprove,
|
||||
onQuery,
|
||||
onView,
|
||||
busy,
|
||||
}: {
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
@@ -358,6 +413,7 @@ function DocReviewCard({
|
||||
onNote: (v: string) => void;
|
||||
onApprove: () => void;
|
||||
onQuery: () => void;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const status = doc.reviewStatus ?? "PENDING";
|
||||
@@ -403,22 +459,22 @@ function DocReviewCard({
|
||||
<Badge variant="light" color={meta.color} radius="sm">
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{hasFile && (
|
||||
<Tooltip label="Open document">
|
||||
<Button
|
||||
component="a"
|
||||
href={doc.file!.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<ExternalLink size={13} />}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hasFile &&
|
||||
isViewable({ name: doc.file!.name, url: doc.file!.url }) && (
|
||||
<Tooltip label="Preview document">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() =>
|
||||
onView({ name: doc.file!.name, url: doc.file!.url })
|
||||
}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
|
||||
@@ -227,6 +227,29 @@ export function useContractClearanceMutations(
|
||||
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({
|
||||
mutationFn: (files: Record<string, File | null>) =>
|
||||
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. */
|
||||
|
||||
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 {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
@@ -25,10 +26,12 @@ import {
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
@@ -37,12 +40,21 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
// Statuses in the pre-booking clearance phase — the Clearance Review tab shows.
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
];
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
@@ -66,6 +78,18 @@ export default function ContractRequestDetailPage() {
|
||||
isFetching,
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (tab === "details") next.delete("tab");
|
||||
else next.set("tab", tab);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -132,6 +156,13 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED" ||
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
: (contract.companyId ?? "—");
|
||||
@@ -216,9 +247,38 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
{routes.length === 0 ? (
|
||||
@@ -350,6 +410,7 @@ export default function ContractRequestDetailPage() {
|
||||
</SectionCard>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky action rail */}
|
||||
@@ -359,6 +420,9 @@ export default function ContractRequestDetailPage() {
|
||||
<ContractActionsToolbar
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
onReviewClearance={
|
||||
showClearanceTab ? () => setTab("clearance") : undefined
|
||||
}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
<ContractApprovalStepsCard
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
@@ -34,6 +35,8 @@ export default function ContractViewPage() {
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Offer the staff member's saved signature first; they can draw a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: [...QUERY_KEYS.CONTRACTS.byId(id ?? ""), "contract-view"],
|
||||
@@ -41,11 +44,16 @@ export default function ContractViewPage() {
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const savedSignatureImage = data?.savedSignature?.signatureImageUrl ?? null;
|
||||
const usingSaved = Boolean(savedSignatureImage) && !drawNew;
|
||||
|
||||
const signMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.signContract(id!, {
|
||||
role: "STAFF",
|
||||
signatureImageBase64: signatureData ?? "",
|
||||
signatureImageBase64: usingSaved
|
||||
? (savedSignatureImage as string)
|
||||
: (signatureData ?? ""),
|
||||
signerDisplayName: signerName.trim(),
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
@@ -61,8 +69,17 @@ export default function ContractViewPage() {
|
||||
|
||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
setDrawNew(false);
|
||||
setSignOpen(true);
|
||||
};
|
||||
|
||||
const confirmSign = () => {
|
||||
if (!signerName.trim() || !signatureData) return;
|
||||
if (!signerName.trim()) return;
|
||||
const image = usingSaved ? savedSignatureImage : signatureData;
|
||||
if (!image) return;
|
||||
signMutation.mutate();
|
||||
};
|
||||
|
||||
@@ -111,13 +128,9 @@ export default function ContractViewPage() {
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<FileSignature size={16} />}
|
||||
onClick={() => {
|
||||
setSignerName("");
|
||||
setSignatureData(null);
|
||||
setSignOpen(true);
|
||||
}}
|
||||
onClick={openSign}
|
||||
>
|
||||
Sign as staff
|
||||
{usingSaved ? "Approve & sign" : "Sign as staff"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -155,7 +168,36 @@ export default function ContractViewPage() {
|
||||
value={signerName}
|
||||
onChange={(e) => setSignerName(e.currentTarget.value)}
|
||||
/>
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
{usingSaved ? (
|
||||
<Stack gap="xs">
|
||||
<Paper
|
||||
withBorder
|
||||
radius="md"
|
||||
p="xs"
|
||||
style={{ borderStyle: "dashed" }}
|
||||
>
|
||||
<Image
|
||||
src={savedSignatureImage ?? undefined}
|
||||
alt="Saved signature"
|
||||
fit="contain"
|
||||
h={140}
|
||||
/>
|
||||
</Paper>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="edr-green"
|
||||
onClick={() => {
|
||||
setDrawNew(true);
|
||||
setSignatureData(null);
|
||||
}}
|
||||
>
|
||||
Draw a new signature instead
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<ContractSignaturePad onChange={setSignatureData} />
|
||||
)}
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setSignOpen(false)}>
|
||||
Cancel
|
||||
@@ -163,10 +205,14 @@ export default function ContractViewPage() {
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={signMutation.isPending}
|
||||
disabled={!signerName.trim() || !signatureData}
|
||||
disabled={
|
||||
signMutation.isPending ||
|
||||
!signerName.trim() ||
|
||||
(!usingSaved && !signatureData)
|
||||
}
|
||||
onClick={confirmSign}
|
||||
>
|
||||
Confirm signature
|
||||
{usingSaved ? "Approve & sign" : "Confirm signature"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
Reference in New Issue
Block a user