increase JSON body size limit, update awaiting shipment endpoint handling, and enhance contract document editor UI

This commit is contained in:
Marshal
2026-07-28 06:16:56 +00:00
parent f40a079420
commit 8319aad5c3
7 changed files with 101 additions and 127 deletions

View File

@@ -9,6 +9,7 @@ import {
Group,
Loader,
Modal,
ScrollArea,
// Select, // ponytail: unused now the validity dropdown below is commented out
Stack,
Text,
@@ -274,17 +275,63 @@ export function ContractDocumentEditorModal({
? draft?.nextApproverRole
? `Only the current approver (${draft.nextApproverRole}) can edit this document right now.`
: "This document can no longer be edited — the contract has advanced beyond approval."
: "Articles come from this contract's template. Set the validity dates, then accept."}
: "This document is read-only — it is accepted exactly as the template produced it. Set the validity dates, then accept."}
</Alert>
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
{/* Accept is a REVIEW step: the document is shown exactly as the
template produced it, with nothing editable. Any wording change
belongs to the template or to the separate edit action. */}
{mode === "accept" ? (
<ScrollArea.Autosize mah={340} type="auto">
<Stack gap="sm" pr="sm">
<Text fw={700} fz={15}>
{documentTitle || "Contract document"}
</Text>
{whereasClauses.length > 0 && (
<Stack gap={4}>
{whereasClauses.map((clause, i) => (
<Text key={i} fz={13} c="dimmed">
WHEREAS {clause}
</Text>
))}
</Stack>
)}
{articles.length === 0 ? (
<Text fz={13} c="dimmed">
This template carries no articles.
</Text>
) : (
articles.map((article, index) => (
<Box key={article.id}>
<Text fz={13} fw={700}>
Article {index + 1}
{article.title ? `${article.title}` : ""}
</Text>
<Text
fz={12.5}
c="dimmed"
style={{ whiteSpace: "pre-wrap" }}
>
{article.body}
</Text>
</Box>
))
)}
</Stack>
</ScrollArea.Autosize>
) : (
<TextInput
label="Document title"
placeholder="e.g. Bulk Cargo Transportation and Customs Clearance Services"
value={documentTitle}
onChange={(e) => setDocumentTitle(e.currentTarget.value)}
disabled={locked}
/>
)}
{mode !== "accept" && (
<Box>
<Group justify="space-between" mb={6}>
<Text size="sm" fw={600}>
@@ -340,6 +387,7 @@ export function ContractDocumentEditorModal({
</Stack>
)}
</Box>
)}
{/* Article editing is hidden for now (frontend only) — staff accept the
contract on the template's articles as-is. The articles themselves

View File

@@ -2,7 +2,7 @@ import { useRef, useState } from "react";
import { Box, Button, Group, Image, Paper, Stack, Text } from "@mantine/core";
import { RefreshCw, Stamp, X } from "lucide-react";
const MAX_STAMP_MB = 5;
const MAX_STAMP_MB = 10;
export interface StampUploadProps {
/** Stamp image as a data URL, or null when none is attached yet. */

View File

@@ -53,17 +53,9 @@ export function useContractClearanceQueue(enabled = true) {
});
}
/**
* GL worklist: executed customs contracts still waiting for their shipment
* instance to be opened (clearance itself lives on the booking).
*/
export function useAwaitingShipmentContracts(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.clearanceQueue("AWAITING_SHIPMENT"),
queryFn: () => contractsService.getAwaitingShipmentContracts(),
enabled,
});
}
// The awaiting-shipment worklist hook was removed with the clearance hub's
// "Start shipment" dialog — nothing calls GET /contracts/awaiting-shipment any
// more. The endpoint still exists server-side if the worklist comes back.
export function useContractClearanceHistory(enabled = true) {
return useQuery({

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -50,9 +50,20 @@ export default function DocumentClearanceDetailPage() {
const params = useParams<{ id?: string; bookingId?: string }>();
const id = params.id ?? params.bookingId;
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
// The same shipment is opened from several worklists (GL Ethiopia clearance,
// the Operations clearance-documents hub, shipment requests…), so "back" is
// whichever list sent us here. Deep links have no sender: fall back to the
// hub this user actually works in.
const backTo =
(location.state as { from?: string } | null)?.from ??
(hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions)
? "/dashboard/contracts/clearance"
: "/dashboard/contracts/clearance-documents");
const { data: booking } = useBookingDetail(id);
const {
data: clearance,
@@ -147,9 +158,9 @@ export default function DocumentClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: "Not found" },
]}
/>
@@ -165,9 +176,9 @@ export default function DocumentClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={backTo}
breadcrumbs={[
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
{ label: "Document Clearance", href: backTo },
{ label: reference },
]}
meta={

View File

@@ -1,6 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import {
ActionIcon,
Badge,
@@ -139,6 +139,7 @@ export default function DocumentClearanceListPage({
opsMode?: boolean;
}) {
const navigate = useNavigate();
const location = useLocation();
const [pageTab, setPageTab] = useState<PageTab>("queue");
const [activeTab, setActiveTab] = useState<ClearanceTabKey>("all");
const [query, setQuery] = useState("");
@@ -205,8 +206,13 @@ export default function DocumentClearanceListPage({
}, [rows, pagination.pageIndex, pagination.pageSize]);
const openDetail = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
[navigate],
// `from` so the detail page's Back returns to this list, whichever route
// it is mounted at (ops self-clearance review, history, …).
(id: string) =>
navigate(`/dashboard/clearance/${id}`, {
state: { from: location.pathname },
}),
[navigate, location.pathname],
);
const statusBadge = isHistory ? (

View File

@@ -253,7 +253,13 @@ export default function ClearanceDocumentsPage() {
columns={bookingColumns}
data={bookingRows}
status={tableStatus}
onRowClick={(row) => navigate(`/dashboard/clearance/${row.id}`)}
// `from` so the detail page's Back returns to THIS hub, not
// to whichever worklist the fallback would guess.
onRowClick={(row) =>
navigate(`/dashboard/clearance/${row.id}`, {
state: { from: "/dashboard/contracts/clearance-documents" },
})
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,

View File

@@ -8,7 +8,6 @@ import {
Card,
Group,
Menu,
Modal,
Stack,
Text,
TextInput,
@@ -37,15 +36,13 @@ import {
type ColumnDef,
} from "@edr/ui-common";
import type { Freight } from "@edr/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { useQuery } from "@tanstack/react-query";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import { KpiStrip } from "@/components/page/KpiStrip";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { useAuth } from "@/auth/useAuth";
import { useAwaitingShipmentContracts } from "@/hooks/contracts/useContracts";
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
import type { BookingDetail } from "@/types/booking";
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
@@ -77,12 +74,12 @@ function CustomsBadge({ customs }: { customs: boolean }) {
/**
* Document Clearance hub (GL Ethiopia). Clearance always runs on the SHIPMENT:
* every row here is a booking instance in phased customs clearance, whatever
* kind of contract it draws on. Contracts appear only in the "Start shipment"
* dialog — a one-time customs contract has no instance until GL opens one.
* kind of contract it draws on. The "Start shipment" dialog (and its
* awaiting-shipment contract list) was removed — shipments are opened from the
* contract itself, not from this hub.
*/
export default function ContractClearanceListPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { user } = useAuth();
// Opening/creating a booking under a contract is a GL Ethiopia action — never
// available to Djibouti GL.
@@ -91,7 +88,6 @@ export default function ContractClearanceListPage() {
!isDjiboutiGl(user);
const [query, setQuery] = useState("");
const [startOpen, setStartOpen] = useState(false);
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const {
@@ -102,9 +98,6 @@ export default function ContractClearanceListPage() {
refetch,
} = useBookingEtClearanceQueue(true);
const { data: awaitingShipment = [], refetch: refetchAwaiting } =
useAwaitingShipmentContracts(canCreateBooking);
// Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so
// the queue shows what each shipment was requested for.
@@ -168,27 +161,13 @@ export default function ContractClearanceListPage() {
[allRows],
);
const initiate = useMutation({
mutationFn: (contract: Freight.IContract) =>
contractsService.initiateBookingUnderContract(
contract.id,
(contract.routes?.length ?? 0) > 1 ? contract.routes![0].id : undefined,
),
onSuccess: (booking) => {
toast.success(
`Shipment ${booking.reference} opened — the customer can now upload the clearance documents.`,
);
setStartOpen(false);
void refetchAwaiting();
void queryClient.invalidateQueries({ queryKey: ["bookings"] });
void refetch();
navigate(`/dashboard/clearance/${booking.id}`);
},
onError: (e: Error) => toast.error(e.message || "Could not open the shipment"),
});
const openBooking = useCallback(
(id: string) => navigate(`/dashboard/clearance/${id}`),
// `from` so the detail page's Back returns to this hub.
(id: string) =>
navigate(`/dashboard/clearance/${id}`, {
state: { from: "/dashboard/contracts/clearance" },
}),
[navigate],
);
@@ -210,17 +189,6 @@ export default function ContractClearanceListPage() {
}
action={
<Group gap="sm" wrap="nowrap">
{canCreateBooking ? (
<Button
radius="md"
color="edr-green"
leftSection={<PackagePlus size={16} />}
onClick={() => setStartOpen(true)}
>
Start shipment
{awaitingShipment.length > 0 ? ` (${awaitingShipment.length})` : ""}
</Button>
) : null}
<ActionIcon
variant="default"
size="lg"
@@ -322,63 +290,6 @@ export default function ContractClearanceListPage() {
</Card>
</Stack>
<Modal
opened={startOpen}
onClose={() => setStartOpen(false)}
title={<Text fw={700}>Start a customs shipment</Text>}
size="lg"
radius="md"
centered
>
<Stack gap="sm">
<Text size="sm" c="dimmed">
Executed one-time customs contracts with no open shipment. Opening one
creates the shipment instance the customer uploads his clearance
documents on you approve them and complete the booking here.
</Text>
{awaitingShipment.length === 0 ? (
<Stack align="center" gap={8} py={32}>
<ThemeIcon variant="light" color="gray" radius="xl" size={44}>
<Inbox size={20} />
</ThemeIcon>
<Text c="dimmed" size="sm">
Every executed customs contract already has a shipment.
</Text>
</Stack>
) : (
awaitingShipment.map((c) => (
<Card key={c.id} withBorder radius="md" p="sm">
<Group justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<FileText size={14} className="shrink-0 opacity-70" />
<Text fw={600} size="sm" truncate>
{c.reference}
</Text>
</Group>
<Text size="xs" c="dimmed" truncate>
{c.isGovernment
? (c.governmentInstitution ?? "Government")
: (c.company?.name ?? "—")}{" "}
· {c.tradeDirection ?? "—"} · {c.freightType ?? "—"}
</Text>
</Box>
<Button
size="compact-sm"
radius="md"
color="edr-green"
leftSection={<PackagePlus size={14} />}
loading={initiate.isPending && initiate.variables?.id === c.id}
onClick={() => initiate.mutate(c)}
>
Open shipment
</Button>
</Group>
</Card>
))
)}
</Stack>
</Modal>
</PageContainer>
);
}