mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
changes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user