mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
increase JSON body size limit, update awaiting shipment endpoint handling, and enhance contract document editor UI
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user