mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat: enhance contract detail page with document grouping and clearance modal
- Added functionality to group contract documents into categories: Contract, Profile, and Clearance. - Implemented a modal for uploading and managing clearance documents. - Created a new ActionNeededSection component to display pending customer actions. - Introduced deriveActionItems function to generate actionable items based on contract and booking statuses. - Developed ContractClearancePanel for handling clearance document uploads and reviews. - Added support for ad-hoc document uploads in the clearance panel.
This commit is contained in:
@@ -294,7 +294,15 @@ export class ContractClearanceService {
|
|||||||
note?: string,
|
note?: string,
|
||||||
): Promise<Contract> {
|
): Promise<Contract> {
|
||||||
const contract = await this.contractsService.findById(contractId);
|
const contract = await this.contractsService.findById(contractId);
|
||||||
if (contract.status !== 'CLEARANCE_UNDER_REVIEW') {
|
// Reviewing is allowed both while the batch is UNDER_REVIEW and after it has
|
||||||
|
// dropped back to AWAITING_CLEARANCE_DOCUMENTS — querying one document flips
|
||||||
|
// the contract to "awaiting" (the customer must re-upload), but the reviewer
|
||||||
|
// may still be working through the rest of the batch. Restricting to
|
||||||
|
// UNDER_REVIEW only would 409 every review after the first query.
|
||||||
|
if (
|
||||||
|
contract.status !== 'CLEARANCE_UNDER_REVIEW' &&
|
||||||
|
contract.status !== 'AWAITING_CLEARANCE_DOCUMENTS'
|
||||||
|
) {
|
||||||
throw new ConflictException(
|
throw new ConflictException(
|
||||||
`Cannot review clearance documents on status "${contract.status}".`,
|
`Cannot review clearance documents on status "${contract.status}".`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState } from "react";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||||
import {
|
import {
|
||||||
|
ActionNeededSection,
|
||||||
FreightVolumeSection,
|
FreightVolumeSection,
|
||||||
HelloSection,
|
HelloSection,
|
||||||
InvoicesSection,
|
InvoicesSection,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
ShipmentsSection,
|
ShipmentsSection,
|
||||||
StatsSection,
|
StatsSection,
|
||||||
} from "./components";
|
} from "./components";
|
||||||
|
import { deriveActionItems } from "./actions";
|
||||||
import { useMyPortalData } from "./hooks";
|
import { useMyPortalData } from "./hooks";
|
||||||
|
|
||||||
export default function MyPortalPage() {
|
export default function MyPortalPage() {
|
||||||
@@ -25,6 +27,7 @@ export default function MyPortalPage() {
|
|||||||
bookingsQuery,
|
bookingsQuery,
|
||||||
dashboardQuery,
|
dashboardQuery,
|
||||||
contractsQuery,
|
contractsQuery,
|
||||||
|
allContracts,
|
||||||
recentContracts,
|
recentContracts,
|
||||||
activeContractsCount,
|
activeContractsCount,
|
||||||
allBookings,
|
allBookings,
|
||||||
@@ -45,6 +48,8 @@ export default function MyPortalPage() {
|
|||||||
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
label: `${PROFILE_TYPE_LABELS[p.type] ?? p.type} · ${p.reference}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const actionItems = deriveActionItems(allContracts, allBookings);
|
||||||
|
|
||||||
const handleBookingClick = (id: string) => {
|
const handleBookingClick = (id: string) => {
|
||||||
navigate(`/bookings/${id}`);
|
navigate(`/bookings/${id}`);
|
||||||
};
|
};
|
||||||
@@ -53,6 +58,8 @@ export default function MyPortalPage() {
|
|||||||
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
<Stack gap="lg" p={{ base: 16, sm: 24, lg: 32 }}>
|
||||||
<HelloSection greeting={greeting} companyName={companyName} />
|
<HelloSection greeting={greeting} companyName={companyName} />
|
||||||
|
|
||||||
|
<ActionNeededSection items={actionItems} />
|
||||||
|
|
||||||
{serviceOptions.length > 1 && (
|
{serviceOptions.length > 1 && (
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||||
|
export interface ActionItem {
|
||||||
|
id: string;
|
||||||
|
/** What the customer must do — drives the icon, label and modal. */
|
||||||
|
kind: "clearance" | "sign" | "book" | "pay";
|
||||||
|
/** The contract/booking reference for display. */
|
||||||
|
reference: string;
|
||||||
|
/** Short human description of the action. */
|
||||||
|
description: string;
|
||||||
|
/** Contract id (clearance/sign/book) or booking id (pay). */
|
||||||
|
targetId: string;
|
||||||
|
/** True for queried clearance (a document was sent back for correction). */
|
||||||
|
urgent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLEARANCE_UPLOAD_STATUSES = [
|
||||||
|
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||||
|
"CLEARANCE_UNDER_REVIEW",
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the list of pending customer actions from the customer's contracts and
|
||||||
|
* bookings, using status alone (no extra per-record fetch). A contract back in
|
||||||
|
* AWAITING_CLEARANCE_DOCUMENTS after review means a document was queried and
|
||||||
|
* needs the customer's attention — flagged urgent.
|
||||||
|
*/
|
||||||
|
export function deriveActionItems(
|
||||||
|
contracts: Freight.IContract[],
|
||||||
|
bookings: Freight.IBooking[],
|
||||||
|
): ActionItem[] {
|
||||||
|
const items: ActionItem[] = [];
|
||||||
|
|
||||||
|
for (const c of contracts) {
|
||||||
|
if (c.status === "CONTRACT_READY") {
|
||||||
|
items.push({
|
||||||
|
id: `sign-${c.id}`,
|
||||||
|
kind: "sign",
|
||||||
|
reference: c.reference,
|
||||||
|
description: "Contract ready to sign",
|
||||||
|
targetId: c.id,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (CLEARANCE_UPLOAD_STATUSES.includes(c.status)) {
|
||||||
|
const queried = c.status === "AWAITING_CLEARANCE_DOCUMENTS";
|
||||||
|
items.push({
|
||||||
|
id: `clearance-${c.id}`,
|
||||||
|
kind: "clearance",
|
||||||
|
reference: c.reference,
|
||||||
|
description: queried
|
||||||
|
? "Clearance document needs correction"
|
||||||
|
: "Upload clearance documents",
|
||||||
|
targetId: c.id,
|
||||||
|
urgent: queried,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Path A transport-only: customer may create the shipment booking.
|
||||||
|
if (
|
||||||
|
!c.customsClearingEnabled &&
|
||||||
|
(c.status === "FULLY_EXECUTED" || c.status === "CONTRACT_ACTIVE")
|
||||||
|
) {
|
||||||
|
items.push({
|
||||||
|
id: `book-${c.id}`,
|
||||||
|
kind: "book",
|
||||||
|
reference: c.reference,
|
||||||
|
description: "Ready to book a shipment",
|
||||||
|
targetId: c.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of bookings) {
|
||||||
|
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
|
||||||
|
const canPay =
|
||||||
|
b.paymentStatus !== "PAID" &&
|
||||||
|
(isGeneral
|
||||||
|
? b.status === "FULLY_EXECUTED"
|
||||||
|
: b.status === "SELECTED_FOR_BATCH");
|
||||||
|
if (canPay) {
|
||||||
|
items.push({
|
||||||
|
id: `pay-${b.id}`,
|
||||||
|
kind: "pay",
|
||||||
|
reference: b.reference,
|
||||||
|
description: "Payment due for this shipment",
|
||||||
|
targetId: b.id,
|
||||||
|
urgent: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Surface the most pressing (urgent) actions first.
|
||||||
|
return items.sort((a, b) => Number(b.urgent ?? 0) - Number(a.urgent ?? 0));
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Badge, Box, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CreditCard,
|
||||||
|
FilePlus2,
|
||||||
|
FileSignature,
|
||||||
|
PackagePlus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||||
|
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
|
||||||
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
|
import { Card } from "./Card";
|
||||||
|
import type { ActionItem } from "../actions";
|
||||||
|
|
||||||
|
const KIND_META: Record<
|
||||||
|
ActionItem["kind"],
|
||||||
|
{ icon: typeof Upload; label: string; color: string }
|
||||||
|
> = {
|
||||||
|
clearance: { icon: Upload, label: "Clearance", color: "edr-green" },
|
||||||
|
sign: { icon: FileSignature, label: "Sign", color: "blue" },
|
||||||
|
book: { icon: PackagePlus, label: "Book", color: "violet" },
|
||||||
|
pay: { icon: CreditCard, label: "Payment", color: "orange" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ActionNeededSectionProps {
|
||||||
|
items: ActionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Home "needs your attention" card. Lists pending customer actions across
|
||||||
|
* contracts and bookings. Clearance and payment open in a modal right here;
|
||||||
|
* sign and book navigate to the relevant page.
|
||||||
|
*/
|
||||||
|
export function ActionNeededSection({ items }: ActionNeededSectionProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [clearanceId, setClearanceId] = useState<string | null>(null);
|
||||||
|
const [payItem, setPayItem] = useState<ActionItem | null>(null);
|
||||||
|
|
||||||
|
// Mirror ReadonlyBookingView: POST /payments/initiate returns the provider's
|
||||||
|
// redirect (clientAction.url); fall back to the public checkout page.
|
||||||
|
const payMutation = useMutation({
|
||||||
|
mutationFn: (method: PaymentMethod) =>
|
||||||
|
api.payments.initiate.call({ bookingId: payItem!.targetId, method }),
|
||||||
|
onSuccess: (data, method) => {
|
||||||
|
const bookingId = payItem!.targetId;
|
||||||
|
const url =
|
||||||
|
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||||
|
? data.clientAction.url
|
||||||
|
: paymentsService.checkoutUrl({ bookingId, method });
|
||||||
|
window.location.href = url;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
const handleClick = (item: ActionItem) => {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "clearance":
|
||||||
|
setClearanceId(item.targetId);
|
||||||
|
break;
|
||||||
|
case "pay":
|
||||||
|
setPayItem(item);
|
||||||
|
break;
|
||||||
|
case "sign":
|
||||||
|
navigate(`/contracts/${item.targetId}/view`);
|
||||||
|
break;
|
||||||
|
case "book":
|
||||||
|
navigate(`/contracts/${item.targetId}/bookings/new`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding={0}>
|
||||||
|
<Group justify="space-between" align="center" px={24} pt={20} pb={12}>
|
||||||
|
<Group gap={8}>
|
||||||
|
<AlertTriangle size={18} className="text-amber-500" />
|
||||||
|
<Text fw={700} fz={16} c="edr-text">
|
||||||
|
Needs your attention
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
<Badge color="orange" variant="light" radius="sm">
|
||||||
|
{items.length}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Stack gap={0}>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const meta = KIND_META[item.kind];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
key={item.id}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
px={24}
|
||||||
|
py={14}
|
||||||
|
style={{
|
||||||
|
borderTop:
|
||||||
|
i === 0
|
||||||
|
? "none"
|
||||||
|
: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => handleClick(item)}
|
||||||
|
className="hover:bg-edr-soft"
|
||||||
|
>
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Box
|
||||||
|
style={{
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: 10,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
background: `var(--mantine-color-${meta.color}-light)`,
|
||||||
|
color: `var(--mantine-color-${meta.color}-filled)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fw={600} fz={14} c="edr-text" truncate>
|
||||||
|
{item.reference}
|
||||||
|
</Text>
|
||||||
|
{item.urgent && (
|
||||||
|
<Badge size="xs" color="red" variant="light" radius="sm">
|
||||||
|
Action required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text fz={12.5} c="dimmed" truncate>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
variant="light"
|
||||||
|
color={meta.color}
|
||||||
|
radius="md"
|
||||||
|
leftSection={<FilePlus2 size={14} />}
|
||||||
|
>
|
||||||
|
{item.kind === "pay"
|
||||||
|
? "Pay"
|
||||||
|
: item.kind === "sign"
|
||||||
|
? "Sign"
|
||||||
|
: item.kind === "book"
|
||||||
|
? "Book"
|
||||||
|
: "Resolve"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={clearanceId !== null}
|
||||||
|
onClose={() => setClearanceId(null)}
|
||||||
|
title={
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Clearance documents
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
>
|
||||||
|
{clearanceId && (
|
||||||
|
<ContractClearancePanel contractId={clearanceId} bare />
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<PaymentMethodModal
|
||||||
|
opened={payItem !== null}
|
||||||
|
onClose={() => {
|
||||||
|
if (!payMutation.isPending) {
|
||||||
|
setPayItem(null);
|
||||||
|
payMutation.reset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
currency={undefined}
|
||||||
|
processing={payMutation.isPending}
|
||||||
|
error={
|
||||||
|
payMutation.isError
|
||||||
|
? payMutation.error instanceof Error
|
||||||
|
? payMutation.error.message
|
||||||
|
: "Could not start payment. Please try again."
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
onConfirm={(method) => payMutation.mutate(method)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { ActionNeededSection } from "./ActionNeededSection";
|
||||||
export { ActivityRow } from "./ActivityRow";
|
export { ActivityRow } from "./ActivityRow";
|
||||||
export { BookingRow } from "./BookingRow";
|
export { BookingRow } from "./BookingRow";
|
||||||
export { Card } from "./Card";
|
export { Card } from "./Card";
|
||||||
|
|||||||
@@ -1,186 +1,43 @@
|
|||||||
import { useMemo, useState } from "react";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
|
||||||
FileButton,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import { ArrowLeft, Upload } from "lucide-react";
|
||||||
AlertCircle,
|
|
||||||
ArrowLeft,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
Download,
|
|
||||||
Eye,
|
|
||||||
FileText,
|
|
||||||
Plus,
|
|
||||||
Upload,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import type { Freight } from "@edr/types";
|
|
||||||
import { isViewable } from "@edr/ui-common";
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { ContractStatusBadge, INK } from "./contract-ui";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
|
||||||
import { BORDER, ContractStatusBadge, INK } from "./contract-ui";
|
|
||||||
|
|
||||||
const GREEN = "#0A6F4D";
|
|
||||||
|
|
||||||
type AdHocDoc = { name: string; file: File | null };
|
|
||||||
|
|
||||||
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
|
||||||
if (doc.reviewStatus === "APPROVED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c={GREEN}>
|
|
||||||
<CheckCircle2 size={15} />
|
|
||||||
<Text fz="12px" fw={600} c={GREEN}>
|
|
||||||
Approved
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.reviewStatus === "QUERIED") {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c="#C0392B">
|
|
||||||
<AlertCircle size={15} />
|
|
||||||
<Text fz="12px" fw={600} c="#C0392B">
|
|
||||||
Queried
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (doc.file) {
|
|
||||||
return (
|
|
||||||
<Group gap={6} c="#2E5B96">
|
|
||||||
<Clock size={15} />
|
|
||||||
<Text fz="12px" fw={600} c="#2E5B96">
|
|
||||||
Pending review
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text fz="12px" fw={600} c="#9AA8B5">
|
|
||||||
Not uploaded
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Path B customer clearance upload on the CONTRACT (doc §8.3). Mirrors the
|
* Customer clearance upload page for a CONTRACT (doc §8.3). The document grid +
|
||||||
* booking `ClearanceFlow` but targets the contract clearance endpoints — no
|
* upload logic live in {@link ContractClearancePanel} so the same workspace can
|
||||||
* booking exists yet. The customer uploads required documents (and duty/tax
|
* render here (full page) or inside the contract action modal.
|
||||||
* slips when advised), Global Logistics reviews them, and after approval GL
|
|
||||||
* creates the booking on the customer's behalf.
|
|
||||||
*/
|
*/
|
||||||
export default function ContractClearanceFlow() {
|
export default function ContractClearanceFlow() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [pending, setPending] = useState<Record<string, File>>({});
|
|
||||||
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 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearanceQuery = useQuery(
|
const { data: clearance } = useQuery(
|
||||||
api.contracts.getClearance.queryOptions({
|
api.contracts.getClearance.queryOptions({
|
||||||
input: { id: id! },
|
input: { id: id! },
|
||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const clearance = clearanceQuery.data;
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
|
||||||
onSuccess: () => {
|
|
||||||
setPending({});
|
|
||||||
setAdHoc([]);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.getClearance.queryKey({ id: id! }),
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: api.contracts.get.queryKey({ id: id! }),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const customerDocs = useMemo(
|
|
||||||
() =>
|
|
||||||
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
|
||||||
[clearance],
|
|
||||||
);
|
|
||||||
const glDocs = useMemo(
|
|
||||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
|
||||||
[clearance],
|
|
||||||
);
|
|
||||||
|
|
||||||
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
|
||||||
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
|
||||||
const isReady =
|
|
||||||
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
|
||||||
status === "SELF_CLEARED" ||
|
|
||||||
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
|
||||||
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
|
||||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
|
||||||
|
|
||||||
// Path B (customs) is reviewed by Global Logistics and GL creates the booking;
|
|
||||||
// Path A self-clearance is reviewed by the Operations team and the customer
|
|
||||||
// creates the booking himself afterward.
|
|
||||||
const customsPath = clearance?.includesCustoms ?? true;
|
|
||||||
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
|
||||||
|
|
||||||
const missingRequired = useMemo(
|
|
||||||
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
|
||||||
[customerDocs, pending],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasStagedFiles =
|
|
||||||
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
|
||||||
|
|
||||||
const canSubmit = isInitialUpload
|
|
||||||
? hasStagedFiles && missingRequired.length === 0
|
|
||||||
: hasStagedFiles;
|
|
||||||
|
|
||||||
const stagePending = (fileKey: string, file: File) =>
|
|
||||||
setPending((p) => ({ ...p, [fileKey]: file }));
|
|
||||||
|
|
||||||
const submitDocuments = () => {
|
|
||||||
const files: Record<string, File | null> = { ...pending };
|
|
||||||
adHoc.forEach((row, i) => {
|
|
||||||
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
|
||||||
});
|
|
||||||
if (Object.keys(files).length === 0) return;
|
|
||||||
uploadMutation.mutate({ id: id!, files });
|
|
||||||
};
|
|
||||||
|
|
||||||
if (clearanceQuery.isLoading) {
|
|
||||||
return (
|
|
||||||
<Center mih={400} p="xl">
|
|
||||||
<Loader color="edr-green" />
|
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ padding: "28px 32px 40px" }}>
|
<Box style={{ padding: "28px 32px 40px" }}>
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{/* Header */}
|
|
||||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||||
<Group gap="md" align="center" wrap="nowrap">
|
<Group gap="md" align="center" wrap="nowrap">
|
||||||
<Button
|
<Button
|
||||||
@@ -212,277 +69,8 @@ export default function ContractClearanceFlow() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
{id && <ContractClearancePanel contractId={id} />}
|
||||||
<Stack gap={0}>
|
|
||||||
{isReady ? (
|
|
||||||
<Alert
|
|
||||||
color="teal"
|
|
||||||
radius="md"
|
|
||||||
icon={<CheckCircle2 size={18} />}
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{customsPath
|
|
||||||
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
|
|
||||||
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
|
||||||
</Alert>
|
|
||||||
) : isUnderReview ? (
|
|
||||||
<Alert
|
|
||||||
color="blue"
|
|
||||||
radius="md"
|
|
||||||
icon={<Clock size={18} />}
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is
|
|
||||||
reviewing your documents. Only re-upload the documents flagged
|
|
||||||
with a query below — approved documents stay as they are.
|
|
||||||
</Alert>
|
|
||||||
) : (
|
|
||||||
<Alert
|
|
||||||
color="yellow"
|
|
||||||
radius="md"
|
|
||||||
icon={<AlertCircle size={18} />}
|
|
||||||
mb="md"
|
|
||||||
>
|
|
||||||
{customsPath
|
|
||||||
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
|
||||||
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isInitialUpload && missingRequired.length > 0 && (
|
|
||||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
|
||||||
<Text fz="12px" c="#9A5B00">
|
|
||||||
Still required:{" "}
|
|
||||||
{missingRequired.map((d) => d.label).join(", ")}
|
|
||||||
</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Required customer documents */}
|
|
||||||
<Stack gap={10}>
|
|
||||||
{customerDocs.map((doc) => (
|
|
||||||
<Box
|
|
||||||
key={doc.fileKey}
|
|
||||||
className="rounded-xl"
|
|
||||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
|
||||||
>
|
|
||||||
<Group justify="space-between" align="center" wrap="nowrap">
|
|
||||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
|
||||||
<Box c="#2E5B96">
|
|
||||||
<FileText size={18} />
|
|
||||||
</Box>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
|
||||||
{doc.label}
|
|
||||||
{doc.required ? " *" : ""}
|
|
||||||
</Text>
|
|
||||||
{doc.file && (
|
|
||||||
<Text fz="12px" c="dimmed" truncate>
|
|
||||||
{doc.file.name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Group>
|
|
||||||
<Group gap={10} wrap="nowrap">
|
|
||||||
<StatusPill doc={doc} />
|
|
||||||
{doc.file &&
|
|
||||||
isViewable({
|
|
||||||
name: doc.file.name,
|
|
||||||
url: fileViewUrl(doc.file.id),
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{doc.file && (
|
|
||||||
<IconSquare
|
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button
|
|
||||||
{...props}
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Upload size={13} />}
|
|
||||||
>
|
|
||||||
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
{doc.reviewStatus === "QUERIED" && doc.note && (
|
|
||||||
<Text fz="12px" c="#C0392B" mt={6}>
|
|
||||||
Query: {doc.note}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
{pending[doc.fileKey] && (
|
|
||||||
<Text fz="12px" c={GREEN} mt={6}>
|
|
||||||
Ready to upload: {pending[doc.fileKey].name}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
{customerDocs.length === 0 && (
|
|
||||||
<Text fz="sm" c="dimmed">
|
|
||||||
No clearance documents are configured for this contract yet.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* GL output documents (read-only). */}
|
|
||||||
{glDocs.length > 0 && (
|
|
||||||
<>
|
|
||||||
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
|
||||||
Customs output documents
|
|
||||||
</Text>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{glDocs.map((doc) => (
|
|
||||||
<Group
|
|
||||||
key={doc.fileKey}
|
|
||||||
justify="space-between"
|
|
||||||
wrap="nowrap"
|
|
||||||
className="rounded-xl"
|
|
||||||
style={{ border: "1px solid #E6ECF2", padding: 10 }}
|
|
||||||
>
|
|
||||||
<Text fz="13px" c="#10202F" truncate>
|
|
||||||
{doc.label}
|
|
||||||
</Text>
|
|
||||||
{doc.file ? (
|
|
||||||
<Group gap={8} wrap="nowrap">
|
|
||||||
{isViewable({
|
|
||||||
name: doc.file.name,
|
|
||||||
url: fileViewUrl(doc.file.id),
|
|
||||||
}) && (
|
|
||||||
<IconSquare
|
|
||||||
icon={<Eye size={15} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: doc.file!.name,
|
|
||||||
url: fileViewUrl(doc.file!.id),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<IconSquare
|
|
||||||
href={fileViewUrl(doc.file.id, true)}
|
|
||||||
icon={<Download size={15} />}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
) : (
|
|
||||||
<Text fz="12px" c="#9AA8B5">
|
|
||||||
Pending
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Ad-hoc / additional documents. */}
|
|
||||||
{canUpload && (
|
|
||||||
<Box mt="lg">
|
|
||||||
<Group justify="space-between" align="center" mb={8}>
|
|
||||||
<Text fz="12.5px" fw={700} c="#10202F">
|
|
||||||
Additional documents
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
size="compact-xs"
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
leftSection={<Plus size={13} />}
|
|
||||||
onClick={() =>
|
|
||||||
setAdHoc((r) => [...r, { name: "", file: null }])
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Add document
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
<Stack gap={8}>
|
|
||||||
{adHoc.map((row, i) => (
|
|
||||||
<Group key={i} gap={8} wrap="nowrap">
|
|
||||||
<TextInput
|
|
||||||
placeholder="Document name"
|
|
||||||
value={row.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setAdHoc((rows) =>
|
|
||||||
rows.map((r, j) =>
|
|
||||||
j === i
|
|
||||||
? { ...r, name: e.currentTarget.value }
|
|
||||||
: r,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ flex: 1 }}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
<FileButton
|
|
||||||
onChange={(f) =>
|
|
||||||
setAdHoc((rows) =>
|
|
||||||
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
accept="application/pdf,image/*"
|
|
||||||
>
|
|
||||||
{(props) => (
|
|
||||||
<Button {...props} variant="default" radius="md">
|
|
||||||
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</FileButton>
|
|
||||||
</Group>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{uploadMutation.isError && (
|
|
||||||
<Alert
|
|
||||||
color="red"
|
|
||||||
radius="md"
|
|
||||||
icon={<AlertCircle size={16} />}
|
|
||||||
mt="md"
|
|
||||||
>
|
|
||||||
{uploadMutation.error instanceof Error
|
|
||||||
? uploadMutation.error.message
|
|
||||||
: "Upload failed. Please try again."}
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canUpload && (
|
|
||||||
<Group justify="flex-end" mt="lg">
|
|
||||||
<Button
|
|
||||||
color="edr-green"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Upload size={16} />}
|
|
||||||
disabled={!canSubmit}
|
|
||||||
loading={uploadMutation.isPending}
|
|
||||||
onClick={submitDocuments}
|
|
||||||
>
|
|
||||||
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{viewer}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
FileButton,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
FileText,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import { isViewable } from "@edr/ui-common";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { IconSquare } from "@/pages/bookings/BookingDetailPage/components/Documents";
|
||||||
|
|
||||||
|
const GREEN = "#0A6F4D";
|
||||||
|
const BORDER = "#E6ECF2";
|
||||||
|
|
||||||
|
type AdHocDoc = { name: string; file: File | null };
|
||||||
|
|
||||||
|
function StatusPill({ doc }: { doc: Freight.ContractClearanceDocument }) {
|
||||||
|
if (doc.reviewStatus === "APPROVED") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} c={GREEN}>
|
||||||
|
<CheckCircle2 size={15} />
|
||||||
|
<Text fz="12px" fw={600} c={GREEN}>
|
||||||
|
Approved
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc.reviewStatus === "QUERIED") {
|
||||||
|
return (
|
||||||
|
<Group gap={6} c="#C0392B">
|
||||||
|
<AlertCircle size={15} />
|
||||||
|
<Text fz="12px" fw={600} c="#C0392B">
|
||||||
|
Query
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (doc.file) {
|
||||||
|
return (
|
||||||
|
<Text fz="12px" fw={600} c="#6B7C8E">
|
||||||
|
Uploaded
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContractClearancePanelProps {
|
||||||
|
contractId: string;
|
||||||
|
/** Show the loading state without the surrounding Paper (e.g. inside a modal). */
|
||||||
|
bare?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The clearance document workspace for a contract: shows the customer-input doc
|
||||||
|
* grid + GL output docs, lets the customer upload / re-upload (only queried docs
|
||||||
|
* after first submission), and surfaces query notes. Rendered both on the
|
||||||
|
* standalone clearance page and inside the contract action modal. See
|
||||||
|
* docs/new-doc.md §8.3.
|
||||||
|
*/
|
||||||
|
export function ContractClearancePanel({
|
||||||
|
contractId,
|
||||||
|
bare,
|
||||||
|
}: ContractClearancePanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [pending, setPending] = useState<Record<string, File>>({});
|
||||||
|
const [adHoc, setAdHoc] = useState<AdHocDoc[]>([]);
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
|
||||||
|
const clearanceQuery = useQuery(
|
||||||
|
api.contracts.getClearance.queryOptions({
|
||||||
|
input: { id: contractId },
|
||||||
|
enabled: !!contractId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const clearance = clearanceQuery.data;
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
...api.contracts.uploadClearanceDocuments.mutationOptions(),
|
||||||
|
onSuccess: () => {
|
||||||
|
setPending({});
|
||||||
|
setAdHoc([]);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.getClearance.queryKey({ id: contractId }),
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: api.contracts.get.queryKey({ id: contractId }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const customerDocs = useMemo(
|
||||||
|
() =>
|
||||||
|
(clearance?.documents ?? []).filter((d) => d.uploadedBy === "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
const glDocs = useMemo(
|
||||||
|
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "customer"),
|
||||||
|
[clearance],
|
||||||
|
);
|
||||||
|
|
||||||
|
const status = clearance?.clearanceStatus ?? "AWAITING_DOCUMENTS";
|
||||||
|
const isUnderReview = status === "DOCUMENTS_UNDER_REVIEW";
|
||||||
|
const isReady =
|
||||||
|
status === "CLEARANCE_READY_FOR_BOOKING" ||
|
||||||
|
status === "SELF_CLEARED" ||
|
||||||
|
status === "ACTIVE_SHIPMENT_IN_PROGRESS";
|
||||||
|
const canUpload = status === "AWAITING_DOCUMENTS" || isUnderReview;
|
||||||
|
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||||
|
|
||||||
|
const customsPath = clearance?.includesCustoms ?? true;
|
||||||
|
const reviewer = customsPath ? "Global Logistics" : "the Operations team";
|
||||||
|
|
||||||
|
const missingRequired = useMemo(
|
||||||
|
() => customerDocs.filter((d) => d.required && !d.file && !pending[d.fileKey]),
|
||||||
|
[customerDocs, pending],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasStagedFiles =
|
||||||
|
Object.keys(pending).length > 0 || adHoc.some((r) => r.file);
|
||||||
|
|
||||||
|
const canSubmit = isInitialUpload
|
||||||
|
? hasStagedFiles && missingRequired.length === 0
|
||||||
|
: hasStagedFiles;
|
||||||
|
|
||||||
|
const stagePending = (fileKey: string, file: File) =>
|
||||||
|
setPending((p) => ({ ...p, [fileKey]: file }));
|
||||||
|
|
||||||
|
const submitDocuments = () => {
|
||||||
|
const files: Record<string, File | null> = { ...pending };
|
||||||
|
adHoc.forEach((row, i) => {
|
||||||
|
if (row.file) files[`custom_${Date.now()}_${i}`] = row.file;
|
||||||
|
});
|
||||||
|
if (Object.keys(files).length === 0) return;
|
||||||
|
uploadMutation.mutate({ id: contractId, files });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (clearanceQuery.isLoading) {
|
||||||
|
return (
|
||||||
|
<Center mih={bare ? 200 : 400} p="xl">
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<Stack gap={0}>
|
||||||
|
{isReady ? (
|
||||||
|
<Alert color="teal" radius="md" icon={<CheckCircle2 size={18} />} mb="md">
|
||||||
|
{customsPath
|
||||||
|
? "Your clearance documents are approved. Global Logistics will create your booking — you will be notified when payment is due."
|
||||||
|
: "Your clearance documents are approved. You can now create a shipment booking under this contract."}
|
||||||
|
</Alert>
|
||||||
|
) : isUnderReview ? (
|
||||||
|
<Alert color="blue" radius="md" icon={<Clock size={18} />} mb="md">
|
||||||
|
{reviewer.charAt(0).toUpperCase() + reviewer.slice(1)} is reviewing
|
||||||
|
your documents. Only re-upload the documents flagged with a query
|
||||||
|
below — approved documents stay as they are.
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Alert color="yellow" radius="md" icon={<AlertCircle size={18} />} mb="md">
|
||||||
|
{customsPath
|
||||||
|
? "Upload every required clearance document (marked *) below to start the review. Global Logistics will clear your shipment and create the booking for you."
|
||||||
|
: "This service does not include EDR customs clearance — clear the cargo yourself and upload every required clearance document (marked *) below. The Operations team will review them before you can book a shipment."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isInitialUpload && missingRequired.length > 0 && (
|
||||||
|
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||||
|
<Text fz="12px" c="#9A5B00">
|
||||||
|
Still required: {missingRequired.map((d) => d.label).join(", ")}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Required customer documents */}
|
||||||
|
<Stack gap={10}>
|
||||||
|
{customerDocs.map((doc) => (
|
||||||
|
<Box
|
||||||
|
key={doc.fileKey}
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ border: `1px solid ${BORDER}`, padding: 12 }}
|
||||||
|
>
|
||||||
|
<Group justify="space-between" align="center" wrap="nowrap">
|
||||||
|
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<Box c="#2E5B96">
|
||||||
|
<FileText size={18} />
|
||||||
|
</Box>
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz="13.5px" fw={600} c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
{doc.required ? " *" : ""}
|
||||||
|
</Text>
|
||||||
|
{doc.file && (
|
||||||
|
<Text fz="12px" c="dimmed" truncate>
|
||||||
|
{doc.file.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={10} wrap="nowrap">
|
||||||
|
<StatusPill doc={doc} />
|
||||||
|
{doc.file &&
|
||||||
|
isViewable({
|
||||||
|
name: doc.file.name,
|
||||||
|
url: fileViewUrl(doc.file.id),
|
||||||
|
}) && (
|
||||||
|
<IconSquare
|
||||||
|
icon={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.file!.name,
|
||||||
|
url: fileViewUrl(doc.file!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{doc.file && (
|
||||||
|
<IconSquare
|
||||||
|
href={fileViewUrl(doc.file.id, true)}
|
||||||
|
icon={<Download size={15} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canUpload && doc.reviewStatus !== "APPROVED" && (
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) => f && stagePending(doc.fileKey, f)}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button
|
||||||
|
{...props}
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Upload size={13} />}
|
||||||
|
>
|
||||||
|
{pending[doc.fileKey] ? "Selected" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
{doc.reviewStatus === "QUERIED" && doc.note && (
|
||||||
|
<Text fz="12px" c="#C0392B" mt={6}>
|
||||||
|
Query: {doc.note}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{pending[doc.fileKey] && (
|
||||||
|
<Text fz="12px" c={GREEN} mt={6}>
|
||||||
|
Ready to upload: {pending[doc.fileKey].name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{customerDocs.length === 0 && (
|
||||||
|
<Text fz="sm" c="dimmed">
|
||||||
|
No clearance documents are configured for this contract yet.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
{/* GL output documents (read-only). */}
|
||||||
|
{glDocs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Text fz="12.5px" fw={700} c="#10202F" mt="lg" mb={8}>
|
||||||
|
Customs output documents
|
||||||
|
</Text>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{glDocs.map((doc) => (
|
||||||
|
<Group
|
||||||
|
key={doc.fileKey}
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
className="rounded-xl"
|
||||||
|
style={{ border: `1px solid ${BORDER}`, padding: 10 }}
|
||||||
|
>
|
||||||
|
<Text fz="13px" c="#10202F" truncate>
|
||||||
|
{doc.label}
|
||||||
|
</Text>
|
||||||
|
{doc.file ? (
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{isViewable({
|
||||||
|
name: doc.file.name,
|
||||||
|
url: fileViewUrl(doc.file.id),
|
||||||
|
}) && (
|
||||||
|
<IconSquare
|
||||||
|
icon={<Eye size={15} />}
|
||||||
|
onClick={() =>
|
||||||
|
view({
|
||||||
|
name: doc.file!.name,
|
||||||
|
url: fileViewUrl(doc.file!.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<IconSquare
|
||||||
|
href={fileViewUrl(doc.file.id, true)}
|
||||||
|
icon={<Download size={15} />}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
<Text fz="12px" c="#9AA8B5">
|
||||||
|
Pending
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Ad-hoc / additional documents. */}
|
||||||
|
{canUpload && (
|
||||||
|
<Box mt="lg">
|
||||||
|
<Group justify="space-between" align="center" mb={8}>
|
||||||
|
<Text fz="12.5px" fw={700} c="#10202F">
|
||||||
|
Additional documents
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size="compact-xs"
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<Plus size={13} />}
|
||||||
|
onClick={() => setAdHoc((r) => [...r, { name: "", file: null }])}
|
||||||
|
>
|
||||||
|
Add document
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<Stack gap={8}>
|
||||||
|
{adHoc.map((row, i) => (
|
||||||
|
<Group key={i} gap={8} wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Document name"
|
||||||
|
value={row.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setAdHoc((rows) =>
|
||||||
|
rows.map((r, j) =>
|
||||||
|
j === i ? { ...r, name: e.currentTarget.value } : r,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
radius="md"
|
||||||
|
/>
|
||||||
|
<FileButton
|
||||||
|
onChange={(f) =>
|
||||||
|
setAdHoc((rows) =>
|
||||||
|
rows.map((r, j) => (j === i ? { ...r, file: f } : r)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
accept="application/pdf,image/*"
|
||||||
|
>
|
||||||
|
{(props) => (
|
||||||
|
<Button {...props} variant="default" radius="md">
|
||||||
|
{row.file ? row.file.name.slice(0, 14) : "Choose file"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</FileButton>
|
||||||
|
</Group>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploadMutation.isError && (
|
||||||
|
<Alert color="red" radius="md" icon={<AlertCircle size={16} />} mt="md">
|
||||||
|
{uploadMutation.error instanceof Error
|
||||||
|
? uploadMutation.error.message
|
||||||
|
: "Upload failed. Please try again."}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canUpload && (
|
||||||
|
<Group justify="flex-end" mt="lg">
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Upload size={16} />}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
loading={uploadMutation.isPending}
|
||||||
|
onClick={submitDocuments}
|
||||||
|
>
|
||||||
|
{isInitialUpload ? "Submit documents" : "Re-upload documents"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{viewer}
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (bare) return body;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper withBorder radius={20} p="lg" style={{ borderColor: BORDER }}>
|
||||||
|
{body}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContractClearancePanel;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -36,10 +36,15 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Weight,
|
Weight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { isViewable } from "@edr/ui-common";
|
import { Modal } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { isViewable, type ViewableFile } from "@edr/ui-common";
|
||||||
|
import type { Freight } from "@edr/types";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { fileViewUrl } from "@/constants/apiConfig";
|
import { fileViewUrl } from "@/constants/apiConfig";
|
||||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||||
|
import { labelForDocCode } from "@/pages/bookings/resubmit";
|
||||||
|
import { ContractClearancePanel } from "./ContractClearancePanel";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
@@ -63,11 +68,66 @@ const CLEARANCE_UPLOAD_STATUSES = [
|
|||||||
"CLEARANCE_READY_FOR_BOOKING",
|
"CLEARANCE_READY_FOR_BOOKING",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type ContractFile = NonNullable<Freight.IContract["files"]>[number];
|
||||||
|
|
||||||
|
// Onboarding / company-profile document codes seeded in file-upload-settings.
|
||||||
|
// These get attached to the contract at creation and belong under "Profile
|
||||||
|
// documents" rather than the clearance set.
|
||||||
|
const PROFILE_DOC_CODES = new Set([
|
||||||
|
"tin_certificate",
|
||||||
|
"commercial_license",
|
||||||
|
"business_license",
|
||||||
|
"investment_license",
|
||||||
|
"national_id",
|
||||||
|
"national_id_passport",
|
||||||
|
"passport",
|
||||||
|
]);
|
||||||
|
|
||||||
|
interface DocGroup {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
files: ContractFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a contract's files into display sections by their `code`: the generated
|
||||||
|
* contract PDF, company profile / onboarding documents, and clearance documents
|
||||||
|
* (everything else — the clearance set uses dynamic per-contract codes). Empty
|
||||||
|
* groups are dropped so the tab only renders sections that have files.
|
||||||
|
*/
|
||||||
|
function groupContractDocuments(files: ContractFile[]): DocGroup[] {
|
||||||
|
const contract: ContractFile[] = [];
|
||||||
|
const profile: ContractFile[] = [];
|
||||||
|
const clearance: ContractFile[] = [];
|
||||||
|
for (const f of files) {
|
||||||
|
if (f.code === "contract") contract.push(f);
|
||||||
|
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
|
||||||
|
else clearance.push(f);
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ key: "contract", title: "Contract document", files: contract },
|
||||||
|
{ key: "profile", title: "Profile documents", files: profile },
|
||||||
|
{ key: "clearance", title: "Clearance documents", files: clearance },
|
||||||
|
].filter((g) => g.files.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ContractDetailPage() {
|
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 { view, viewer } = useFileViewer();
|
||||||
|
const [clearanceOpen, clearanceModal] = useDisclosure(false);
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Deep link from the home "needs attention" card: /contracts/:id?action=clearance
|
||||||
|
// opens the clearance step modal directly.
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get("action") === "clearance") {
|
||||||
|
clearanceModal.open();
|
||||||
|
searchParams.delete("action");
|
||||||
|
setSearchParams(searchParams, { replace: true });
|
||||||
|
}
|
||||||
|
}, [searchParams, clearanceModal, setSearchParams]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: contract,
|
data: contract,
|
||||||
@@ -126,6 +186,7 @@ export default function ContractDetailPage() {
|
|||||||
const routes = contract.routes ?? [];
|
const routes = contract.routes ?? [];
|
||||||
const pricing = contract.pricingBreakdown;
|
const pricing = contract.pricingBreakdown;
|
||||||
const files = contract.files ?? [];
|
const files = contract.files ?? [];
|
||||||
|
const docGroups = groupContractDocuments(files);
|
||||||
|
|
||||||
const canSign = contract.status === "CONTRACT_READY";
|
const canSign = contract.status === "CONTRACT_READY";
|
||||||
const customsPath = contract.customsClearingEnabled;
|
const customsPath = contract.customsClearingEnabled;
|
||||||
@@ -197,9 +258,11 @@ export default function ContractDetailPage() {
|
|||||||
radius="md"
|
radius="md"
|
||||||
size="md"
|
size="md"
|
||||||
leftSection={<Upload size={16} />}
|
leftSection={<Upload size={16} />}
|
||||||
onClick={() => navigate(`/contracts/${contract.id}/clearance`)}
|
onClick={clearanceModal.open}
|
||||||
>
|
>
|
||||||
Upload clearance documents
|
{contract.status === "CLEARANCE_UNDER_REVIEW"
|
||||||
|
? "Manage clearance documents"
|
||||||
|
: "Upload clearance documents"}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -610,74 +673,23 @@ export default function ContractDetailPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap={10}>
|
<Stack gap="lg">
|
||||||
{files.map((file) => (
|
{docGroups.map((group) => (
|
||||||
<Group
|
<Stack key={group.key} gap={8}>
|
||||||
key={file.id}
|
<Group justify="space-between" align="center">
|
||||||
justify="space-between"
|
<Text fz={12} fw={700} c="dimmed" tt="uppercase">
|
||||||
wrap="nowrap"
|
{group.title}
|
||||||
p="sm"
|
</Text>
|
||||||
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||||
>
|
{group.files.length}
|
||||||
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
</Badge>
|
||||||
<FileText
|
|
||||||
size={16}
|
|
||||||
color={MUTED}
|
|
||||||
style={{ flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Box style={{ minWidth: 0 }}>
|
|
||||||
<Text
|
|
||||||
fz={14}
|
|
||||||
fw={600}
|
|
||||||
style={{ color: INK }}
|
|
||||||
truncate
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
<Text fz={12} c="dimmed">
|
|
||||||
{file.mimeType?.split("/")[1]?.toUpperCase() ??
|
|
||||||
"FILE"}
|
|
||||||
{file.size
|
|
||||||
? ` · ${(file.size / 1024).toFixed(0)} KB`
|
|
||||||
: ""}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={8} wrap="nowrap">
|
<Stack gap={10}>
|
||||||
{isViewable({
|
{group.files.map((file) => (
|
||||||
name: file.name,
|
<DocFileRow key={file.id} file={file} onView={view} />
|
||||||
url: fileViewUrl(file.id),
|
))}
|
||||||
mimeType: file.mimeType,
|
</Stack>
|
||||||
}) && (
|
</Stack>
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="edr-green"
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Eye size={14} />}
|
|
||||||
onClick={() =>
|
|
||||||
view({
|
|
||||||
name: file.name,
|
|
||||||
url: fileViewUrl(file.id),
|
|
||||||
mimeType: file.mimeType,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
View
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
component="a"
|
|
||||||
href={fileViewUrl(file.id, true)}
|
|
||||||
variant="default"
|
|
||||||
size="xs"
|
|
||||||
radius="md"
|
|
||||||
leftSection={<Download size={14} />}
|
|
||||||
>
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Group>
|
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
@@ -771,6 +783,22 @@ export default function ContractDetailPage() {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</Stack>
|
||||||
{viewer}
|
{viewer}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={clearanceOpen}
|
||||||
|
onClose={clearanceModal.close}
|
||||||
|
title={
|
||||||
|
<Text fw={700} fz={16}>
|
||||||
|
Clearance documents
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
size="xl"
|
||||||
|
radius="md"
|
||||||
|
centered
|
||||||
|
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||||
|
>
|
||||||
|
<ContractClearancePanel contractId={contract.id} bare />
|
||||||
|
</Modal>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -863,6 +891,77 @@ function SectionLabel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One document row in the Documents tab: the file's kind (passport, business
|
||||||
|
* license, contract, …) derived from its `code` as the primary label, the
|
||||||
|
* stored filename and size as secondary text, and view / download actions that
|
||||||
|
* stream through the API by file id.
|
||||||
|
*/
|
||||||
|
function DocFileRow({
|
||||||
|
file,
|
||||||
|
onView,
|
||||||
|
}: {
|
||||||
|
file: ContractFile;
|
||||||
|
onView: (f: ViewableFile) => void;
|
||||||
|
}) {
|
||||||
|
const kind = labelForDocCode(file.code);
|
||||||
|
return (
|
||||||
|
<Group
|
||||||
|
justify="space-between"
|
||||||
|
wrap="nowrap"
|
||||||
|
p="sm"
|
||||||
|
style={{ borderRadius: 12, border: `1px solid ${BORDER}` }}
|
||||||
|
>
|
||||||
|
<Group gap={12} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
|
<FileText size={16} color={MUTED} style={{ flexShrink: 0 }} />
|
||||||
|
<Box style={{ minWidth: 0 }}>
|
||||||
|
<Text fz={14} fw={600} style={{ color: INK }} truncate>
|
||||||
|
{kind}
|
||||||
|
</Text>
|
||||||
|
<Text fz={12} c="dimmed" truncate>
|
||||||
|
{file.name}
|
||||||
|
{file.size ? ` · ${(file.size / 1024).toFixed(0)} KB` : ""}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
{isViewable({
|
||||||
|
name: file.name,
|
||||||
|
url: fileViewUrl(file.id),
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
}) && (
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
color="edr-green"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Eye size={14} />}
|
||||||
|
onClick={() =>
|
||||||
|
onView({
|
||||||
|
name: file.name,
|
||||||
|
url: fileViewUrl(file.id),
|
||||||
|
mimeType: file.mimeType,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href={fileViewUrl(file.id, true)}
|
||||||
|
variant="default"
|
||||||
|
size="xs"
|
||||||
|
radius="md"
|
||||||
|
leftSection={<Download size={14} />}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
|
* One fact inside the unified key-facts card: a soft accent-tinted icon chip,
|
||||||
* an uppercase label, and the value. Hairline dividers between cells make the
|
* an uppercase label, and the value. Hairline dividers between cells make the
|
||||||
|
|||||||
Reference in New Issue
Block a user