This commit is contained in:
marshal
2026-07-01 20:55:17 +03:00
parent 612df8daff
commit 9c18d086d7
112 changed files with 5654 additions and 1370 deletions

View File

@@ -0,0 +1,133 @@
import {
Badge,
Box,
Button,
Group,
Paper,
Stack,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { Download, Eye, FileText } from "lucide-react";
import type { Freight } from "@edr/types";
import { isViewable } from "@edr/ui-common";
const CATEGORY_LABELS: Record<
Freight.ClearanceWorkflowFileCategory,
string
> = {
declaration: "Declaration",
duty: "Duty & taxes",
transit: "Transit",
djibouti: "Djibouti",
};
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
"declaration",
"duty",
"transit",
"djibouti",
];
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
customer: "You",
gl_et: "GL Ethiopia",
gl_dj: "GL Djibouti",
};
export function ClearanceWorkflowFilesSection({
files,
onView,
onDownload,
title = "Customs documents",
}: {
files: Freight.ClearanceWorkflowFile[];
onView: (file: { name: string; url: string; mimeType?: string }) => void;
onDownload?: (file: { id: string; name: string }) => void;
title?: string;
}) {
if (files.length === 0) return null;
const grouped = CATEGORY_ORDER.map((category) => ({
category,
label: CATEGORY_LABELS[category],
items: files.filter((f) => f.category === category),
})).filter((g) => g.items.length > 0);
return (
<Paper withBorder radius="lg" p="lg">
<Text fw={700} size="sm" mb="md">
{title}
</Text>
<Stack gap="md">
{grouped.map((group) => (
<Box key={group.category}>
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
{group.label}
</Text>
<Stack gap={8}>
{group.items.map((item) => {
const file = item.file;
if (!file) return null;
const canPreview = isViewable({ name: file.name, url: file.url });
return (
<Paper key={item.code} withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap">
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
<FileText size={17} />
</ThemeIcon>
<Box style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{item.label}
</Text>
<Group gap={6} wrap="nowrap" mt={2}>
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
{OWNER_LABELS[item.uploadedBy]}
</Badge>
<Text size="xs" c="dimmed" truncate>
{file.name}
</Text>
</Group>
</Box>
</Group>
<Group gap={6} wrap="nowrap">
{canPreview ? (
<Tooltip label="Preview">
<Button
size="compact-xs"
variant="default"
radius="md"
leftSection={<Eye size={13} />}
onClick={() =>
onView({ name: file.name, url: file.url })
}
>
View
</Button>
</Tooltip>
) : null}
{onDownload ? (
<Button
size="compact-xs"
variant="light"
radius="md"
leftSection={<Download size={13} />}
onClick={() => onDownload({ id: file.id, name: file.name })}
>
Download
</Button>
) : null}
</Group>
</Group>
</Paper>
);
})}
</Stack>
</Box>
))}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,77 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Text, type ButtonProps } from "@mantine/core";
import { AlertCircle, Upload } from "lucide-react";
import { api } from "@/services/api";
import { ContractClearancePanel } from "@/pages/contracts/ContractClearancePanel";
import { ModalSafeWrapper } from "./ModalSafeWrapper";
import { useDisclosure } from "@mantine/hooks";
interface ContractClearanceActionProps {
contractId: string;
label?: string;
size?: ButtonProps["size"];
urgent?: boolean;
}
export function ContractClearanceAction({
contractId,
label: labelProp,
size = "xs",
urgent = false,
}: ContractClearanceActionProps) {
const [opened, { open, close }] = useDisclosure(false);
const { data: clearance } = useQuery({
...api.contracts.getClearance.queryOptions({ input: { id: contractId } }),
enabled: opened,
});
const label = useMemo(() => {
if (labelProp) return labelProp;
const docs = clearance?.documents ?? [];
const queried = docs.filter(
(d) => d.uploadedBy === "customer" && d.reviewStatus === "QUERIED",
).length;
if (queried > 0) return "Update clearance";
return urgent ? "Upload clearance" : "Manage clearance";
}, [labelProp, clearance, urgent]);
const Icon = urgent || label.includes("Update") ? AlertCircle : Upload;
return (
<ModalSafeWrapper>
<Button
size={size}
radius="md"
fw={700}
fz={13}
color="edr-green"
leftSection={<Icon size={14} />}
onClick={(e) => {
e.stopPropagation();
open();
}}
>
{label}
</Button>
<Modal
opened={opened}
onClose={close}
title={
<Text fw={700} fz={16}>
Clearance documents
</Text>
}
size="xl"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
<ContractClearancePanel contractId={contractId} bare />
</Modal>
</ModalSafeWrapper>
);
}

View File

@@ -0,0 +1,97 @@
import { Button, Group, type ButtonProps } from "@mantine/core";
import type { ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import type { Freight } from "@edr/types";
import { PayNowButton } from "@/pages/bookings/payments/PayNowButton";
import { ContractClearanceAction } from "./ContractClearanceAction";
import { deriveContractCustomerAction } from "./deriveContractCustomerAction";
interface ContractCustomerActionProps {
contract: Freight.IContract;
bookings: Freight.IBooking[];
size?: ButtonProps["size"];
/** Extra props for list-row button styling (ContractsList). */
listStyle?: boolean;
}
export function ContractCustomerAction({
contract,
bookings,
size = "xs",
listStyle = false,
}: ContractCustomerActionProps) {
const navigate = useNavigate();
const action = deriveContractCustomerAction(contract, bookings);
const buttonStyles = listStyle
? {
root: {
fontWeight: 600,
fontSize: 13,
paddingInline: 14,
whiteSpace: "nowrap" as const,
boxShadow: action.primary
? "0 1px 2px rgba(14,163,113,0.25)"
: "none",
},
}
: undefined;
if (action.type === "clearance") {
return (
<ContractClearanceAction
contractId={action.contractId}
label={action.label}
size={size}
urgent={action.urgent}
/>
);
}
if (action.type === "pay") {
return <PayNowButton booking={action.booking} label={action.label} size={size} />;
}
const Icon = action.icon;
const variant = action.primary ? "filled" : "light";
return (
<Button
size={size}
radius="md"
h={listStyle ? 34 : undefined}
variant={variant}
color="edr-green"
leftSection={<Icon size={15} />}
onClick={(e) => {
e.stopPropagation();
navigate(action.to);
}}
styles={buttonStyles}
fw={listStyle ? undefined : 700}
fz={listStyle ? undefined : 13}
>
{action.label}
</Button>
);
}
/** Action column cell: doc button + primary customer action. */
export function ContractCustomerActionCell({
contract,
bookings,
docButton,
}: {
contract: Freight.IContract;
bookings: Freight.IBooking[];
docButton: ReactNode;
}) {
return (
<Group gap={8} wrap="nowrap" justify="flex-end">
{docButton}
<ContractCustomerAction contract={contract} bookings={bookings} size="sm" listStyle />
</Group>
);
}

View File

@@ -0,0 +1,15 @@
import { Box } from "@mantine/core";
import type { ReactNode } from "react";
/**
* Wrap inline row actions that open Mantine modals. Modals portal to document
* body but React synthetic events still bubble through the component tree —
* without this, clicks inside the modal can trigger a parent row's navigate.
*/
export function ModalSafeWrapper({ children }: { children: ReactNode }) {
return (
<Box component="span" onClick={(e) => e.stopPropagation()}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,190 @@
import type { Freight } from "@edr/types";
import type { LucideIcon } from "lucide-react";
import {
CreditCard,
Eye,
FileSignature,
PackagePlus,
PencilLine,
RotateCcw,
Upload,
} from "lucide-react";
import { getContractBookingAction } from "@/pages/contracts/contract-booking-action";
const CLEARANCE_IN_PROGRESS_STATUSES = [
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
];
export function contractNeedsClearanceAction(c: Freight.IContract): {
show: boolean;
urgent: boolean;
} {
const clearance = (c as { clearanceStatus?: string }).clearanceStatus;
const ready =
clearance === "CLEARANCE_READY_FOR_BOOKING" ||
clearance === "SELF_CLEARED" ||
clearance === "ACTIVE_SHIPMENT_IN_PROGRESS";
if (ready) return { show: false, urgent: false };
const awaiting =
c.status === "AWAITING_CLEARANCE_DOCUMENTS" ||
clearance === "AWAITING_DOCUMENTS";
const inProgress =
CLEARANCE_IN_PROGRESS_STATUSES.includes(c.status) ||
clearance === "AWAITING_DOCUMENTS" ||
clearance === "DOCUMENTS_UNDER_REVIEW";
return { show: inProgress, urgent: awaiting };
}
export type ContractCustomerAction =
| {
type: "sign";
label: string;
to: string;
primary: boolean;
icon: LucideIcon;
}
| {
type: "navigate";
label: string;
to: string;
primary: boolean;
icon: LucideIcon;
}
| {
type: "clearance";
contractId: string;
label: string;
primary: boolean;
icon: LucideIcon;
urgent: boolean;
}
| {
type: "pay";
booking: Freight.IBooking;
label: string;
primary: boolean;
icon: LucideIcon;
};
function findPayableBookingForContract(
contractId: string,
bookings: Freight.IBooking[],
): Freight.IBooking | null {
return (
bookings.find((b) => {
if (b.contractId !== contractId) return false;
if (b.paymentStatus === "PAID") return false;
const isGeneral = b.bookingType === "GENERAL_CONTRACT";
return isGeneral
? b.status === "FULLY_EXECUTED"
: b.status === "SELECTED_FOR_BATCH";
}) ?? null
);
}
/** Single best customer action for a contract row (list / home). */
export function deriveContractCustomerAction(
contract: Freight.IContract,
bookings: Freight.IBooking[],
): ContractCustomerAction {
const id = contract.id;
if (contract.status === "CONTRACT_READY") {
return {
type: "sign",
label: "View & sign",
to: `/contracts/${id}/view`,
primary: true,
icon: FileSignature,
};
}
if (contract.status === "CHANGES_REQUESTED") {
return {
type: "navigate",
label: "Edit & resubmit",
to: `/contracts/${id}`,
primary: true,
icon: PencilLine,
};
}
const payable = findPayableBookingForContract(id, bookings);
if (payable) {
return {
type: "pay",
booking: payable,
label: "Pay now",
primary: true,
icon: CreditCard,
};
}
const clr = contractNeedsClearanceAction(contract);
if (clr.show) {
return {
type: "clearance",
contractId: id,
label: clr.urgent ? "Upload clearance" : "Update clearance",
primary: true,
icon: Upload,
urgent: clr.urgent,
};
}
if (
contract.customsClearingEnabled &&
["CLEARANCE_READY_FOR_BOOKING", "ACTIVE_SHIPMENT_IN_PROGRESS"].includes(
contract.status,
)
) {
return {
type: "navigate",
label: "View",
to: `/contracts/${id}`,
primary: false,
icon: Eye,
};
}
const bookingAction = getContractBookingAction(contract, bookings);
if (bookingAction.kind === "book") {
return {
type: "navigate",
label: "Book shipment",
to: bookingAction.to,
primary: true,
icon: PackagePlus,
};
}
if (bookingAction.kind === "rebook") {
return {
type: "navigate",
label: "Re-book shipment",
to: bookingAction.to,
primary: true,
icon: RotateCcw,
};
}
if (bookingAction.kind === "request") {
return {
type: "navigate",
label: "Request shipment",
to: bookingAction.to,
primary: true,
icon: PackagePlus,
};
}
return {
type: "navigate",
label: "View",
to: `/contracts/${id}`,
primary: false,
icon: Eye,
};
}