mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
Merge pull request #616 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
@@ -36,12 +38,18 @@ import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMile
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
import { RequestedCargoChips } from "@/features/clearance/requestedCargo";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
const id = params.id ?? params.bookingId;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
@@ -58,6 +66,21 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||
|
||||
// The originating shipment request carries the quantities the customer asked
|
||||
// for (per container type, or bulk weight/items). The bare instance itself has
|
||||
// no cargo until GL completes the booking, so surface the request here.
|
||||
const { data: contractRequests } = useQuery({
|
||||
queryKey: ["shipment-requests-for-contract", booking?.contractId],
|
||||
queryFn: () => contractsService.listBookingRequests(booking!.contractId!),
|
||||
enabled: Boolean(booking?.contractId),
|
||||
});
|
||||
const requestedLines = useMemo(
|
||||
() =>
|
||||
(contractRequests ?? []).find((r) => r.createdBookingId === id)
|
||||
?.requestedLines ?? null,
|
||||
[contractRequests, id],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
@@ -76,6 +99,17 @@ export default function DocumentClearanceDetailPage() {
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
|
||||
// Bare initiated instance whose clearance is done: GL completes the booking
|
||||
// (container numbers, VGM, shipment day) via the completion form.
|
||||
// Creating the booking is a GL Ethiopia action — never available to Djibouti GL.
|
||||
const canCompleteBooking =
|
||||
booking?.status === "CLEARANCE_READY" &&
|
||||
Boolean(booking?.contractId) &&
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
!(Number(booking?.totalAmount ?? 0) > 0) &&
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
@@ -146,9 +180,30 @@ export default function DocumentClearanceDetailPage() {
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
action={
|
||||
canCompleteBooking ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||
<ClearanceHero
|
||||
booking={booking}
|
||||
clearance={clearance}
|
||||
stats={stats}
|
||||
requestedLines={requestedLines}
|
||||
/>
|
||||
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
@@ -189,6 +244,10 @@ export default function DocumentClearanceDetailPage() {
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
// A bare initiated instance still has no cargo/price — the
|
||||
// stepper's "Create booking" step must read as NOT-yet-created
|
||||
// so it never claims the booking is done before GL completes it.
|
||||
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
@@ -256,10 +315,12 @@ function ClearanceHero({
|
||||
booking,
|
||||
clearance,
|
||||
stats,
|
||||
requestedLines,
|
||||
}: {
|
||||
booking: ReturnType<typeof useBookingDetail>["data"];
|
||||
clearance: Freight.ClearanceView;
|
||||
stats: { pct: number; approved: number; total: number };
|
||||
requestedLines?: Freight.RequestedShipmentLines | null;
|
||||
}) {
|
||||
const direction = booking?.tradeDirection ?? "—";
|
||||
const origin =
|
||||
@@ -325,6 +386,18 @@ function ClearanceHero({
|
||||
<Progress value={stats.pct} color="edr-green" radius="xl" size="md" />
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
{requestedLines ? (
|
||||
<>
|
||||
<Box my="md" h={1} bg="var(--mantine-color-default-border)" />
|
||||
<Group gap={10} align="center" wrap="wrap">
|
||||
<Text size="xs" fw={700} tt="uppercase" c="dimmed" lts="0.05em">
|
||||
Requested cargo
|
||||
</Text>
|
||||
<RequestedCargoChips lines={requestedLines} size="sm" />
|
||||
</Group>
|
||||
</>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
Banknote,
|
||||
Building2,
|
||||
CalendarClock,
|
||||
CalendarDays,
|
||||
@@ -35,6 +34,7 @@ import {
|
||||
Hash,
|
||||
ListOrdered,
|
||||
ListPlus,
|
||||
ListTree,
|
||||
Mail,
|
||||
MapPin,
|
||||
Package,
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
import type { ContractTemplateArticle } from "@/services/contract-templates.service";
|
||||
|
||||
const BODY_HINT =
|
||||
'One clause per line — clauses are numbered automatically. Prefix a line with "- " to nest it as a bullet under the previous clause. Use the buttons above to drop a placeholder at the cursor — it is filled from the contract when the document is generated.';
|
||||
'One clause per line. Use "New clause" for the next number (1., 2., …), "Sub-clause" for a nested number (1.1, then 1.1.1), and "Bullet" for a • point — the number or bullet is typed for you, just add the text. Placeholders are filled from the contract when the document is generated.';
|
||||
|
||||
interface ArticleDraft {
|
||||
id?: string;
|
||||
@@ -105,12 +105,6 @@ const QUICK_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
icon: CalendarRange,
|
||||
hint: "Year the contract is signed",
|
||||
},
|
||||
{
|
||||
token: "{{pricing.totalAmount}}",
|
||||
label: "Total price",
|
||||
icon: Banknote,
|
||||
hint: "Total contract price from the pricing schedule",
|
||||
},
|
||||
];
|
||||
|
||||
const MORE_PLACEHOLDER_GROUPS: { label: string; items: PlaceholderDef[] }[] = [
|
||||
@@ -243,7 +237,11 @@ const ALL_PLACEHOLDERS: PlaceholderDef[] = [
|
||||
...MORE_PLACEHOLDER_GROUPS.flatMap((g) => g.items),
|
||||
];
|
||||
|
||||
const KNOWN_TOKENS = new Set<string>(ALL_PLACEHOLDERS.map((p) => p.token));
|
||||
const KNOWN_TOKENS = new Set<string>([
|
||||
...ALL_PLACEHOLDERS.map((p) => p.token),
|
||||
// Still filled by the renderer, just no longer offered as an insert button.
|
||||
"{{pricing.totalAmount}}",
|
||||
]);
|
||||
|
||||
/** Any {{…}} tokens in the text the renderer does not know how to fill. */
|
||||
function unknownTokens(text: string): string[] {
|
||||
@@ -253,6 +251,10 @@ function unknownTokens(text: string): string[] {
|
||||
|
||||
interface ParsedClause {
|
||||
text: string;
|
||||
/** Computed outline number, e.g. "3" or "2.1.4". */
|
||||
number: string;
|
||||
/** Nesting level: 1 = clause, 2 = sub-clause (x.y), 3 = x.y.z, … */
|
||||
depth: number;
|
||||
bullets: string[];
|
||||
}
|
||||
|
||||
@@ -262,28 +264,93 @@ interface ParsedBody {
|
||||
clauses: ParsedClause[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Leading outline token on a clause line ("1. ", "2.1 ", "1.1.1) ") — its
|
||||
* segment count sets the depth; the digits themselves are recomputed. Single
|
||||
* segment requires "."/")" so prose like "10 tons…" is untouched; a token may
|
||||
* end the line (empty clause still being typed).
|
||||
*/
|
||||
const CLAUSE_NUMBER_RE = /^(?:(\d+(?:\.\d+)+)[.)]?|(\d+)[.)])(?:\s+|$)/;
|
||||
|
||||
/** Depth of the outline token in a CLAUSE_NUMBER_RE match, else null. */
|
||||
function matchDepth(match: RegExpExecArray | null): number | null {
|
||||
if (!match) return null;
|
||||
const token = match[1] ?? match[2];
|
||||
return Math.min(token.split(".").length, MAX_CLAUSE_DEPTH);
|
||||
}
|
||||
|
||||
/** Deepest supported sub-clause level. */
|
||||
const MAX_CLAUSE_DEPTH = 6;
|
||||
|
||||
/**
|
||||
* Mirror of the API renderer's rules (contract-article.util.ts): one clause per
|
||||
* line, "- " nests a bullet under the previous clause, and a single bullet-less
|
||||
* clause renders as a plain paragraph instead of a numbered list of one.
|
||||
* line; a leading outline number ("2. ", "2.1 ") nests the line as a sub-clause
|
||||
* at that depth and is renumbered sequentially; "- " nests a bullet under the
|
||||
* previous clause; a single un-numbered bullet-less clause renders as a plain
|
||||
* paragraph instead of a numbered list of one.
|
||||
*/
|
||||
function parseArticleBody(body: string): ParsedBody {
|
||||
const clauses: ParsedClause[] = [];
|
||||
const counters: number[] = [];
|
||||
let sawNumberToken = false;
|
||||
for (const raw of body.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("- ") && clauses.length > 0) {
|
||||
clauses[clauses.length - 1].bullets.push(line.slice(2).trim());
|
||||
} else {
|
||||
clauses.push({ text: line.replace(/^- /, ""), bullets: [] });
|
||||
continue;
|
||||
}
|
||||
const cleaned = line.replace(/^- /, "");
|
||||
const match = CLAUSE_NUMBER_RE.exec(cleaned);
|
||||
let depth = matchDepth(match) ?? 1;
|
||||
// A sub-clause can only sit directly under an existing parent.
|
||||
depth = Math.min(depth, counters.length + 1);
|
||||
if (match) sawNumberToken = true;
|
||||
counters.splice(depth);
|
||||
while (counters.length < depth) counters.push(0);
|
||||
counters[depth - 1] += 1;
|
||||
clauses.push({
|
||||
text: match ? cleaned.slice(match[0].length).trim() : cleaned,
|
||||
number: counters.slice(0, depth).join("."),
|
||||
depth,
|
||||
bullets: [],
|
||||
});
|
||||
}
|
||||
if (clauses.length === 1 && clauses[0].bullets.length === 0) {
|
||||
if (
|
||||
clauses.length === 1 &&
|
||||
clauses[0].bullets.length === 0 &&
|
||||
!sawNumberToken
|
||||
) {
|
||||
return { paragraph: clauses[0].text, clauses: [] };
|
||||
}
|
||||
return { clauses };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite the leading outline tokens in a body so every numbered clause line
|
||||
* carries its computed sequential number (stale numbers self-heal). Lines
|
||||
* without a number token and bullet lines pass through untouched.
|
||||
*/
|
||||
function renumberBody(body: string): string {
|
||||
const counters: number[] = [];
|
||||
return body
|
||||
.split("\n")
|
||||
.map((raw) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith("- ")) return raw;
|
||||
const match = CLAUSE_NUMBER_RE.exec(line);
|
||||
let depth = matchDepth(match) ?? 1;
|
||||
depth = Math.min(depth, counters.length + 1);
|
||||
counters.splice(depth);
|
||||
while (counters.length < depth) counters.push(0);
|
||||
counters[depth - 1] += 1;
|
||||
if (!match) return raw;
|
||||
const number = counters.slice(0, depth).join(".");
|
||||
return `${number}. ${line.slice(match[0].length).trim()}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
/** Render clause text with {{placeholders}} highlighted as green chips. */
|
||||
function HighlightedText({ text }: { text: string }) {
|
||||
const parts = text.split(/(\{\{[^{}]+\}\})/g);
|
||||
@@ -632,13 +699,63 @@ function ArticleEditorModal({
|
||||
});
|
||||
};
|
||||
|
||||
const insertLinePrefix = (prefix: string) => {
|
||||
/**
|
||||
* Insert a structured line (clause / sub-clause / bullet) on a fresh line
|
||||
* below the one the caret is on. Clause lines get their outline number typed
|
||||
* in automatically ("3. ", "3.1. ", …) and every numbered line in the body is
|
||||
* renumbered so the text always matches the preview.
|
||||
*/
|
||||
const insertStructuredLine = (kind: "clause" | "sub" | "bullet") => {
|
||||
const el = bodyRef.current;
|
||||
const start = el?.selectionStart ?? body.length;
|
||||
// Start the snippet on its own line unless the caret already is.
|
||||
const needsNewline = start > 0 && body[start - 1] !== "\n";
|
||||
lastFocused.current = "body";
|
||||
insertAtCursor(`${needsNewline ? "\n" : ""}${prefix}`);
|
||||
const caret = el?.selectionStart ?? body.length;
|
||||
// Structured lines never split a sentence — insert after the caret's line.
|
||||
const lineEnd = body.indexOf("\n", caret);
|
||||
const insertAt = lineEnd === -1 ? body.length : lineEnd;
|
||||
const before = body.slice(0, insertAt);
|
||||
const after = body.slice(insertAt); // "" or starts with "\n"
|
||||
|
||||
let prefix: string;
|
||||
if (kind === "bullet") {
|
||||
prefix = "- ";
|
||||
} else {
|
||||
// New clause always starts a fresh top-level number. Sub-clause nests
|
||||
// one level under a clause (1 → 1.1) but adds a SIBLING when the caret
|
||||
// is already on a sub-clause (1.1 → 1.2 → 1.3, not ever-deeper) — a
|
||||
// third level is reached by typing its number (e.g. "1.1.1 ") directly.
|
||||
const above = parseArticleBody(before);
|
||||
const lastDepth = above.paragraph
|
||||
? 1
|
||||
: (above.clauses[above.clauses.length - 1]?.depth ?? 0);
|
||||
const depth =
|
||||
kind === "sub"
|
||||
? lastDepth <= 1
|
||||
? Math.min(lastDepth + 1, MAX_CLAUSE_DEPTH)
|
||||
: lastDepth
|
||||
: 1;
|
||||
// Digits are placeholders — renumberBody assigns the real value.
|
||||
prefix = `${Array.from({ length: depth }, () => "1").join(".")}. `;
|
||||
}
|
||||
|
||||
const beforeLines = before.length > 0 ? before.split("\n") : [];
|
||||
const afterLines =
|
||||
after.length > 0 ? after.slice(1).split("\n") : [];
|
||||
const insertedIdx = beforeLines.length;
|
||||
const joined = [...beforeLines, prefix, ...afterLines].join("\n");
|
||||
const next = kind === "bullet" ? joined : renumberBody(joined);
|
||||
setBody(next);
|
||||
|
||||
// Caret lands at the end of the inserted line, ready for typing.
|
||||
const caretTarget = next
|
||||
.split("\n")
|
||||
.slice(0, insertedIdx + 1)
|
||||
.join("\n").length;
|
||||
requestAnimationFrame(() => {
|
||||
const field = bodyRef.current;
|
||||
if (!field) return;
|
||||
field.focus();
|
||||
field.setSelectionRange(caretTarget, caretTarget);
|
||||
});
|
||||
};
|
||||
|
||||
const parsed = useMemo(() => parseArticleBody(body), [body]);
|
||||
@@ -724,26 +841,60 @@ function ArticleEditorModal({
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
<Tooltip label="Start a new numbered clause" withArrow>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Add structure
|
||||
</Text>
|
||||
<Group gap={6} wrap="wrap">
|
||||
<Tooltip
|
||||
label="New line with the next clause number typed for you (1., 2., 3., …)"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListOrdered size={13} />}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertLinePrefix("")}
|
||||
onClick={() => insertStructuredLine("clause")}
|
||||
>
|
||||
New clause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Nest a bullet under the previous clause" withArrow>
|
||||
<Tooltip
|
||||
label="Numbered point under the current clause — 1.1, then 1.2, 1.3 on each click. For a deeper level type its number yourself (e.g. 1.1.1 )"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListTree size={13} />}
|
||||
disabled={body.trim().length === 0}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertStructuredLine("sub")}
|
||||
>
|
||||
Sub-clause
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label="New line with a bullet (•) under the current clause"
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
leftSection={<ListPlus size={13} />}
|
||||
disabled={body.trim().length === 0}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertLinePrefix("- ")}
|
||||
onClick={() => insertStructuredLine("bullet")}
|
||||
>
|
||||
Bullet
|
||||
</Button>
|
||||
@@ -804,10 +955,10 @@ function ArticleEditorModal({
|
||||
</Text>
|
||||
)}
|
||||
{parsed.clauses.map((clause, i) => (
|
||||
<Box key={i}>
|
||||
<Box key={i} pl={(clause.depth - 1) * 20}>
|
||||
<Text size="sm">
|
||||
<Text component="span" fw={600} c="edr-green.7">
|
||||
{i + 1}.{" "}
|
||||
{clause.number}.{" "}
|
||||
</Text>
|
||||
<HighlightedText text={clause.text} />
|
||||
</Text>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -16,16 +17,18 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
ArrowRight,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
ExternalLink,
|
||||
Eye,
|
||||
FileText,
|
||||
Flag,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
ShieldCheck,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
@@ -46,17 +49,23 @@ 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 {
|
||||
useContractClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingEtClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { FREIGHT_PERMS, hasPermission, isDjiboutiGl } from "@/lib/permissions";
|
||||
import { GlUpcomingWindowsSection } from "@/components/contracts/GlUpcomingWindowsSection";
|
||||
import {
|
||||
RequestedCargoChips,
|
||||
summarizeRequestedCargo,
|
||||
} from "@/features/clearance/requestedCargo";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et" | "shipments";
|
||||
type QueueTab = "all" | "shipments";
|
||||
|
||||
/** Persist the selected queue tab so returning from a detail keeps it. */
|
||||
const QUEUE_TAB_STORAGE_KEY = "edr.clearance.queueTab";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -197,21 +206,35 @@ export default function ContractClearanceListPage() {
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
const canCreateBooking = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.createBooking,
|
||||
);
|
||||
// Creating a booking under a cleared contract is a GL Ethiopia action — never
|
||||
// available to Djibouti GL.
|
||||
const canCreateBooking =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
|
||||
!isDjiboutiGl(user);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "shipments";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(() => {
|
||||
const stored =
|
||||
typeof window !== "undefined"
|
||||
? window.localStorage.getItem(QUEUE_TAB_STORAGE_KEY)
|
||||
: null;
|
||||
return stored === "all" || stored === "shipments" ? stored : defaultQueue;
|
||||
});
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const selectQueueTab = useCallback((tab: QueueTab) => {
|
||||
setQueueTab(tab);
|
||||
if (typeof window !== "undefined") {
|
||||
window.localStorage.setItem(QUEUE_TAB_STORAGE_KEY, tab);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Contract clearance rows feed both the Contracts tab and the header KPIs, so
|
||||
// they load regardless of the active tab.
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(queueTab === "all" || queueTab === "shipments");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
useContractClearanceQueue(true);
|
||||
const {
|
||||
data: bookingQueue,
|
||||
isLoading: bookingsLoading,
|
||||
@@ -220,18 +243,12 @@ export default function ContractClearanceListPage() {
|
||||
refetch: refetchBookings,
|
||||
} = useBookingEtClearanceQueue(queueTab === "shipments");
|
||||
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching =
|
||||
queueTab === "et"
|
||||
? etFetching
|
||||
: queueTab === "shipments"
|
||||
? bookingsFetching
|
||||
: allFetching;
|
||||
const data = allData;
|
||||
const isLoading = queueTab === "shipments" ? bookingsLoading : allLoading;
|
||||
const isError = queueTab === "shipments" ? bookingsError : allError;
|
||||
const isFetching = queueTab === "shipments" ? bookingsFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "shipments") void refetchBookings();
|
||||
if (queueTab === "shipments") void refetchBookings();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
@@ -242,19 +259,8 @@ export default function ContractClearanceListPage() {
|
||||
value: "all",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<ShieldCheck size={15} />
|
||||
<Box visibleFrom="sm">All</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canEt) {
|
||||
opts.push({
|
||||
value: "et",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Flag size={15} />
|
||||
<Box visibleFrom="sm">ET queue</Box>
|
||||
<FileText size={15} />
|
||||
<Box visibleFrom="sm">Contracts</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
@@ -273,9 +279,36 @@ export default function ContractClearanceListPage() {
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance (ET queue).
|
||||
// If a persisted/default tab isn't available for this user, fall back to the
|
||||
// first permitted tab.
|
||||
useEffect(() => {
|
||||
if (
|
||||
queueTabOptions.length > 0 &&
|
||||
!queueTabOptions.some((o) => o.value === queueTab)
|
||||
) {
|
||||
selectQueueTab(queueTabOptions[0].value);
|
||||
}
|
||||
}, [queueTabOptions, queueTab, selectQueueTab]);
|
||||
|
||||
// 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.
|
||||
const { data: requestQueue } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
queryFn: () => contractsService.getBookingRequestQueue(),
|
||||
enabled: queueTab === "shipments",
|
||||
});
|
||||
const requestedByBooking = useMemo(() => {
|
||||
const map = new Map<string, Freight.RequestedShipmentLines>();
|
||||
for (const req of requestQueue ?? []) {
|
||||
if (req.createdBookingId) map.set(req.createdBookingId, req.requestedLines);
|
||||
}
|
||||
return map;
|
||||
}, [requestQueue]);
|
||||
|
||||
// GENERAL-contract shipment bookings in per-booking clearance.
|
||||
const bookingRows = useMemo(() => {
|
||||
const rows = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
const rows: ShipmentBookingRow[] = (bookingQueue ?? []).map((b: BookingDetail) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
customerLabel: b.company?.name ?? b.governmentInstitution ?? "—",
|
||||
@@ -284,6 +317,15 @@ export default function ContractClearanceListPage() {
|
||||
tradeDirection: b.tradeDirection ?? "—",
|
||||
freightType: b.freightType ?? "—",
|
||||
status: b.status,
|
||||
requested: requestedByBooking.get(b.id) ?? null,
|
||||
contractId: b.contractId ?? null,
|
||||
contractReference: b.contractReference ?? null,
|
||||
contractKind: b.contractKind ?? null,
|
||||
customs: b.serviceType?.includesCustoms ?? Boolean(b.customsClearingEnabled),
|
||||
createdAt: b.createdAt ?? null,
|
||||
// A bare initiated instance has no cargo/price yet — GL still has to create
|
||||
// (complete) the booking.
|
||||
bookingCreated: Number(b.totalAmount ?? 0) > 0,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
@@ -291,10 +333,12 @@ export default function ContractClearanceListPage() {
|
||||
(r) =>
|
||||
r.reference.toLowerCase().includes(q) ||
|
||||
r.customerLabel.toLowerCase().includes(q) ||
|
||||
(r.contractReference ?? "").toLowerCase().includes(q) ||
|
||||
r.originLabel.toLowerCase().includes(q) ||
|
||||
r.destinationLabel.toLowerCase().includes(q),
|
||||
r.destinationLabel.toLowerCase().includes(q) ||
|
||||
summarizeRequestedCargo(r.requested).toLowerCase().includes(q),
|
||||
);
|
||||
}, [bookingQueue, query]);
|
||||
}, [bookingQueue, query, requestedByBooking]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -420,7 +464,7 @@ export default function ContractClearanceListPage() {
|
||||
id: "go",
|
||||
size: 150,
|
||||
cell: ({ row }) =>
|
||||
row.original.ready ? (
|
||||
row.original.ready && canCreateBooking ? (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -444,7 +488,7 @@ export default function ContractClearanceListPage() {
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
[navigate, canCreateBooking],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -464,29 +508,16 @@ export default function ContractClearanceListPage() {
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{canCreateBooking ? (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Send size={15} />}
|
||||
onClick={() => navigate("/dashboard/shipment-requests")}
|
||||
>
|
||||
Shipment requests
|
||||
</Button>
|
||||
) : null}
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="md"
|
||||
onClick={() => refetch()}
|
||||
loading={isFetching}
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -525,7 +556,7 @@ export default function ContractClearanceListPage() {
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
setQueueTab(v as QueueTab);
|
||||
selectQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
@@ -600,7 +631,16 @@ export default function ContractClearanceListPage() {
|
||||
rows={bookingRows}
|
||||
loading={bookingsLoading}
|
||||
error={bookingsError}
|
||||
canCreateBooking={canCreateBooking}
|
||||
onOpen={(id) => navigate(`/dashboard/clearance/${id}`)}
|
||||
onCreateBooking={(row) =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
|
||||
)
|
||||
}
|
||||
onViewContract={(contractId) =>
|
||||
navigate(`/dashboard/contracts/clearance/${contractId}`)
|
||||
}
|
||||
/>
|
||||
) : view === "table" ? (
|
||||
<Box style={{ overflowX: "auto" }} px="xs" pb="xs">
|
||||
@@ -650,8 +690,26 @@ interface ShipmentBookingRow {
|
||||
tradeDirection: string;
|
||||
freightType: string;
|
||||
status: string;
|
||||
/** Requested quantities from the originating shipment request. */
|
||||
requested: Freight.RequestedShipmentLines | null;
|
||||
/** Contract this shipment booking was created under. */
|
||||
contractId: string | null;
|
||||
contractReference: string | null;
|
||||
contractKind: "ONE_TIME" | "GENERAL" | null;
|
||||
customs: boolean;
|
||||
createdAt: string | null;
|
||||
/** true once GL has actually created (completed) the booking. */
|
||||
bookingCreated: boolean;
|
||||
}
|
||||
|
||||
const formatDate = (iso: string | null) => {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? "—"
|
||||
: d.toLocaleDateString(undefined, { day: "2-digit", month: "short", year: "numeric" });
|
||||
};
|
||||
|
||||
const prettyStatus = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
@@ -670,13 +728,26 @@ function ShipmentBookingsTable({
|
||||
rows,
|
||||
loading,
|
||||
error,
|
||||
canCreateBooking,
|
||||
onOpen,
|
||||
onCreateBooking,
|
||||
onViewContract,
|
||||
}: {
|
||||
rows: ShipmentBookingRow[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
canCreateBooking: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
onCreateBooking: (row: ShipmentBookingRow) => void;
|
||||
onViewContract: (contractId: string) => void;
|
||||
}) {
|
||||
// A bare initiated instance that has cleared but not yet been created by GL.
|
||||
const isBookable = (r: ShipmentBookingRow) =>
|
||||
canCreateBooking &&
|
||||
Boolean(r.contractId) &&
|
||||
!r.bookingCreated &&
|
||||
r.status === "CLEARANCE_READY";
|
||||
|
||||
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
|
||||
() => [
|
||||
{
|
||||
@@ -699,6 +770,28 @@ function ShipmentBookingsTable({
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
header: () => <span className={bookingTable.headerCell}>Contract</span>,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Stack gap={4} py={2}>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<FileText size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" fw={500} truncate maw={150}>
|
||||
{r.contractReference ?? "—"}
|
||||
</Text>
|
||||
</Group>
|
||||
{r.contractKind ? (
|
||||
<Badge size="xs" variant="default" radius="sm" tt="uppercase">
|
||||
{r.contractKind === "GENERAL" ? "General" : "One-time"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <span className={bookingTable.headerCell}>Route</span>,
|
||||
@@ -725,6 +818,28 @@ function ShipmentBookingsTable({
|
||||
<Badge variant="outline" color="gray" radius="sm">
|
||||
{prettyStatus(row.original.freightType)}
|
||||
</Badge>
|
||||
<CustomsBadge customs={row.original.customs} />
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "requested",
|
||||
header: () => (
|
||||
<span className={bookingTable.headerCell}>Requested cargo</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<RequestedCargoChips lines={row.original.requested} size="sm" />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: () => <span className={bookingTable.headerCell}>Created</span>,
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Calendar size={13} className="shrink-0 text-muted-foreground" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
@@ -732,26 +847,95 @@ function ShipmentBookingsTable({
|
||||
id: "status",
|
||||
header: () => <span className={bookingTable.headerCell}>Status</span>,
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "chevron",
|
||||
header: "",
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge
|
||||
variant="light"
|
||||
color={shipmentStatusColor(row.original.status)}
|
||||
radius="sm"
|
||||
>
|
||||
{prettyStatus(row.original.status)}
|
||||
</Badge>
|
||||
{row.original.bookingCreated ? (
|
||||
<Tooltip label="Booking created by GL Ethiopia" withArrow>
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackagePlus size={11} />}
|
||||
>
|
||||
Booked
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "",
|
||||
size: 200,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
const bookable = isBookable(r);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={6}
|
||||
pr="xs"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{bookable ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
onClick={() => onCreateBooking(r)}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
aria-label="Row actions"
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item leftSection={<Eye size={14} />} onClick={() => onOpen(r.id)}>
|
||||
Open booking
|
||||
</Menu.Item>
|
||||
{bookable ? (
|
||||
<Menu.Item
|
||||
leftSection={<PackagePlus size={14} />}
|
||||
onClick={() => onCreateBooking(r)}
|
||||
>
|
||||
Create booking
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{r.contractId ? (
|
||||
<Menu.Item
|
||||
leftSection={<ExternalLink size={14} />}
|
||||
onClick={() => onViewContract(r.contractId!)}
|
||||
>
|
||||
View contract
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[canCreateBooking, onOpen, onCreateBooking, onViewContract],
|
||||
);
|
||||
|
||||
if (!loading && !error && rows.length === 0) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
@@ -14,9 +14,19 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ClipboardList,
|
||||
FileText,
|
||||
PackagePlus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
@@ -46,6 +56,7 @@ type GlClearanceDetail =
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ClearanceView;
|
||||
booking: BookingDetail;
|
||||
};
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
@@ -70,6 +81,7 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
reference: booking.reference,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
clearance,
|
||||
booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -77,6 +89,8 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
|
||||
@@ -125,6 +139,20 @@ export default function GlClearanceDetailPage() {
|
||||
? (data.clearance.vesselDepartureDate ?? null)
|
||||
: null;
|
||||
|
||||
// The shipment booking instance backing this clearance (per-booking GENERAL
|
||||
// customs). Bare until GL completes it: no cargo, no price.
|
||||
const shipmentBooking = data.kind === "booking" ? data.booking : null;
|
||||
const bookingCompleted = Number(shipmentBooking?.totalAmount ?? 0) > 0;
|
||||
// Import boundary (DO collected) / export boundary (release) reached →
|
||||
// clearance is ready and GL creates the real booking. Show the create-booking
|
||||
// CTA here so the GL user who finishes the DJ step isn't left without a next
|
||||
// action. Permission-gated so only booking creators (GL Ethiopia) see it.
|
||||
const canCompleteBooking =
|
||||
shipmentBooking?.status === "CLEARANCE_READY" &&
|
||||
Boolean(shipmentBooking?.contractId) &&
|
||||
!bookingCompleted &&
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.createBooking);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -144,6 +172,7 @@ export default function GlClearanceDetailPage() {
|
||||
<Group gap="sm">
|
||||
{isImport ? (
|
||||
<Button
|
||||
variant={canCompleteBooking ? "default" : "filled"}
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canUploadDo}
|
||||
@@ -153,6 +182,7 @@ export default function GlClearanceDetailPage() {
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant={canCompleteBooking ? "default" : "filled"}
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setUploadKind("ro")}
|
||||
@@ -160,6 +190,19 @@ export default function GlClearanceDetailPage() {
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
{canCompleteBooking && shipmentBooking ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<PackagePlus size={16} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Create booking
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
@@ -209,7 +252,15 @@ export default function GlClearanceDetailPage() {
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : linkedBookingId}
|
||||
bookingCreated={data.kind === "booking" || Boolean(linkedBookingId)}
|
||||
// For a per-booking instance, "created" means COMPLETED (has
|
||||
// cargo/price), not merely that a booking row exists — a bare
|
||||
// instance is not yet a real booking. Contract-level clearance
|
||||
// keeps its linked-booking signal.
|
||||
bookingCreated={
|
||||
data.kind === "booking"
|
||||
? bookingCompleted
|
||||
: Boolean(linkedBookingId)
|
||||
}
|
||||
bookingMilestones={
|
||||
data.kind === "booking"
|
||||
? (data.clearance.milestones ?? [])
|
||||
|
||||
@@ -402,7 +402,7 @@ export default function ShipmentRequestsPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Shipment Requests"
|
||||
subtitle="Customer requests to ship under general customs contracts. Accept one to create the booking and start its clearance."
|
||||
subtitle="Customer requests to ship under general customs contracts. Each request starts its booking's clearance immediately — complete the booking from the clearance page once it is ready."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -14,6 +14,7 @@ import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
|
||||
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
|
||||
import FleetToolbar from "@/components/fleet/FleetToolbar";
|
||||
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
|
||||
import WagonYardWorkspaceModal from "@/components/wagons/WagonYardWorkspaceModal";
|
||||
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
|
||||
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -46,6 +47,7 @@ const FleetResourcePage = () => {
|
||||
const [assigningDriver, setAssigningDriver] = useState<FleetRecord | null>(null);
|
||||
const [historyTarget, setHistoryTarget] = useState<FleetRecord | null>(null);
|
||||
const [selectedDriver, setSelectedDriver] = useState<string>("");
|
||||
const [wagonWorkspaceOpen, setWagonWorkspaceOpen] = useState(false);
|
||||
const { viewMode, setViewMode } = useFleetViewMode(slug);
|
||||
|
||||
const serverListFilters = useMemo((): FleetListFilters | undefined => {
|
||||
@@ -369,12 +371,25 @@ const FleetResourcePage = () => {
|
||||
{config.subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
{slug === "wagons" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
|
||||
@@ -389,31 +404,28 @@ const FleetResourcePage = () => {
|
||||
onViewModeChange={setViewMode}
|
||||
filters={
|
||||
listFilterSelects ? (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
<Group gap="sm" wrap="wrap" align="center">
|
||||
{listFilterSelects.map((filter) => (
|
||||
<Group key={filter.key} gap={4} wrap="wrap">
|
||||
<Text size="xs" fw={500} c="dimmed">{filter.label}:</Text>
|
||||
<Group gap={4} wrap="wrap">
|
||||
{filter.data.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant={filter.value === option.value ? "filled" : "outline"}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
[filter.key]: option.value,
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
</Group>
|
||||
<Select
|
||||
key={filter.key}
|
||||
aria-label={filter.label}
|
||||
placeholder={filter.data[0]?.label ?? filter.label}
|
||||
data={filter.data}
|
||||
value={filter.value}
|
||||
onChange={(value) => {
|
||||
setListFilterValues((prev) => ({
|
||||
...prev,
|
||||
[filter.key]: value ?? "ALL",
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}}
|
||||
size="sm"
|
||||
radius="lg"
|
||||
w={200}
|
||||
searchable={filter.data.length > 8}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||
/>
|
||||
))}
|
||||
</Group>
|
||||
) : hasStatusColumn && statusFilterOptions.length > 1 ? (
|
||||
@@ -586,6 +598,13 @@ const FleetResourcePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonYardWorkspaceModal
|
||||
opened={wagonWorkspaceOpen}
|
||||
onClose={() => setWagonWorkspaceOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{slug === "wagons" ? (
|
||||
<WagonMovementHistoryModal
|
||||
opened={Boolean(historyTarget)}
|
||||
|
||||
Reference in New Issue
Block a user