Merge pull request #458 from Tria-plc/freight_feature/usermanagement

refactor: remove gate pass granting logic from clearance services and UI
This commit is contained in:
marshal
2026-07-04 10:45:22 +03:00
committed by GitHub
13 changed files with 136 additions and 784 deletions

View File

@@ -14,7 +14,7 @@ import {
Text,
Textarea,
} from "@mantine/core";
import { DateInput, DateTimePicker } from "@mantine/dates";
import { DateInput } from "@mantine/dates";
import {
AlertTriangle,
CheckCircle2,
@@ -397,15 +397,10 @@ export function ExportClearanceStepper({
<Stepper.Step
label="Gate pass"
description="GL Djibouti grants after arrival"
description="Secured on the train schedule after arrival"
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
>
<GatepassStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showDj && canDj}
onChanged={onChanged}
/>
<GatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
@@ -491,27 +486,19 @@ function ConfirmExportReleaseFallback({
);
}
function GatepassStep({
bookingId,
clearance,
canAct,
onChanged,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
onChanged?: () => void;
}) {
const [opened, setOpened] = useState(false);
const [at, setAt] = useState<Date | null>(new Date());
const [loading, setLoading] = useState(false);
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
*/
function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass granted${
doneLabel={`Gate pass secured${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
@@ -526,68 +513,21 @@ function GatepassStep({
done={false}
pendingLabel={
arrived
? "Train arrived — GL Djibouti can grant the gate pass."
? "Train arrived — secure the gate pass on the train schedule."
: "Available once the train arrives at Djibouti."
}
doneLabel=""
/>
{canAct && bookingId ? (
<>
<Button
color="edr-green"
leftSection={<Truck size={16} />}
disabled={!arrived}
onClick={() => {
setAt(new Date());
setOpened(true);
}}
>
Grant gate pass
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Grant gate pass</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<DateTimePicker
label="Gate pass time"
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
Cancel
</Button>
<Button
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.grantGatepass(
bookingId,
(at ?? new Date()).toISOString(),
);
toast.success("Gate pass granted");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Grant
</Button>
</Group>
</Stack>
</Modal>
</>
{scheduleId ? (
<Button
component="a"
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="light"
color="edr-green"
leftSection={<Truck size={16} />}
>
Secure gate pass on train schedule
</Button>
) : null}
</Stack>
);

View File

@@ -4,7 +4,6 @@ import {
Badge,
Button,
Group,
Modal,
NumberInput,
Paper,
SegmentedControl,
@@ -15,7 +14,6 @@ import {
Text,
TextInput,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
import {
TransitPermitMultiUpload,
@@ -536,17 +534,12 @@ export function PhasedClearanceActionPanel({
<Stepper.Step
label="Gate pass"
description="GL Djibouti grants after wagon allocation"
description="Secured on the train schedule after wagon allocation"
icon={
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
}
>
<ImportGatepassStep
bookingId={actionBookingId}
clearance={clearance}
canAct={showDj && canDj}
onChanged={onChanged}
/>
<ImportGatepassStep clearance={clearance} />
</Stepper.Step>
<Stepper.Step
@@ -816,28 +809,19 @@ function ImportT1Section({
);
}
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
function ImportGatepassStep({
bookingId,
clearance,
canAct,
onChanged,
}: {
bookingId: string | null;
clearance: ClearanceViewLike;
canAct: boolean;
onChanged?: () => void;
}) {
const [opened, setOpened] = useState(false);
const [at, setAt] = useState<Date | null>(new Date());
const [loading, setLoading] = useState(false);
/**
* Gate pass status, read-only. Secured on the train schedule's "Save as
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
*/
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
const scheduleId = clearance.train?.scheduleId ?? null;
if (clearance.gatepassGranted) {
return (
<StepStatus
done
pendingLabel=""
doneLabel={`Gate pass granted${
doneLabel={`Gate pass secured${
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
}`}
/>
@@ -852,68 +836,21 @@ function ImportGatepassStep({
done={false}
pendingLabel={
wagonAllocated
? "Wagons allocated — GL Djibouti can grant the gate pass."
? "Wagons allocated — secure the gate pass on the train schedule."
: "Available once wagons are allocated."
}
doneLabel=""
/>
{canAct && bookingId ? (
<>
<Button
color="edr-green"
leftSection={<Truck size={16} />}
disabled={!wagonAllocated}
onClick={() => {
setAt(new Date());
setOpened(true);
}}
>
Grant gate pass
</Button>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={<Text fw={700}>Grant gate pass</Text>}
radius="md"
size="sm"
>
<Stack gap="md">
<DateTimePicker
label="Gate pass time"
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
Cancel
</Button>
<Button
color="edr-green"
loading={loading}
onClick={async () => {
setLoading(true);
try {
await contractsService.grantGatepass(
bookingId,
(at ?? new Date()).toISOString(),
);
toast.success("Gate pass granted");
setOpened(false);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setLoading(false);
}
}}
>
Grant
</Button>
</Group>
</Stack>
</Modal>
</>
{scheduleId ? (
<Button
component="a"
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="light"
color="edr-green"
leftSection={<Truck size={16} />}
>
Secure gate pass on train schedule
</Button>
) : null}
</Stack>
);

View File

@@ -70,7 +70,6 @@ export const QUERY_KEYS = {
["contracts", "clearance-queue", region ?? "ET"] as const,
clearanceHistory: (region?: string) =>
["contracts", "clearance-history", region ?? "ET"] as const,
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
milestones: (id: string) => ["contracts", "milestones", id] as const,
capacity: (id: string) => ["contracts", "capacity", id] as const,
bookingMilestones: (bookingId: string) =>

View File

@@ -232,11 +232,6 @@ export const URL_CONSTANTS = {
`/contracts/bookings/${bookingId}/t1-documents`,
BOOKING_T1_CLOSE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/t1-close`,
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
BOOKING_GATEPASS: (bookingId: string) =>
`/contracts/bookings/${bookingId}/gatepass`,
BOOKING_FINAL_INVOICE: (bookingId: string) =>
`/contracts/bookings/${bookingId}/final-invoice`,
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>

View File

@@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) {
});
}
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
export function useDjClearanceSchedules(enabled = true) {
return useQuery({
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
queryFn: () => contractsService.getDjClearanceSchedules(),
enabled,
});
}
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
export function useOpsClearanceQueue(enabled = true) {
return useQuery({

View File

@@ -1,326 +1,65 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Badge,
Button,
Card,
Group,
Loader,
Modal,
Stack,
Tabs,
Text,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import type { Freight } from "@edr/types";
import toast from "react-hot-toast";
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
import { ChevronRight, Ship } from "lucide-react";
import { PageContainer } from "@/components/page/PageContainer";
import { PageHeader } from "@/components/page/PageHeader";
import {
useDjClearanceQueue,
useDjClearanceSchedules,
} from "@/hooks/contracts/useContracts";
import { contractsService } from "@/services/contracts.service";
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
export default function GlDjiboutiClearanceListPage() {
const navigate = useNavigate();
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
const schedulesQuery = useDjClearanceSchedules();
const contractItems = contractQueue?.items ?? [];
const scheduleItems = schedulesQuery.data ?? [];
const [gatepassTarget, setGatepassTarget] =
useState<Freight.DjClearanceSchedule | null>(null);
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
const [granting, setGranting] = useState(false);
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
() => [
{
header: "Train",
accessorKey: "trainNumber",
cell: ({ row }) => (
<Text size="sm" fw={700}>
{row.original.trainNumber ?? "—"}
</Text>
),
},
{
header: "Route",
id: "route",
cell: ({ row }) => (
<Text size="sm">
{row.original.origin ?? "—"} {row.original.destination ?? "—"}
</Text>
),
},
{
header: "Scheduled departure",
id: "scheduled",
cell: ({ row }) => (
<Text size="sm">
{row.original.scheduledDepartureDate
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
: "—"}
</Text>
),
},
{
header: "Departed",
id: "departed",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualDepartureAt
? new Date(row.original.actualDepartureAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Arrived",
id: "arrived",
cell: ({ row }) => (
<Text size="sm">
{row.original.actualArrivalAt
? new Date(row.original.actualArrivalAt).toLocaleString()
: "—"}
</Text>
),
},
{
header: "Status",
accessorKey: "status",
cell: ({ row }) => (
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
{row.original.status}
</Badge>
),
},
{
header: "Customs bookings",
id: "customs",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green" radius="sm">
{bookings.length}
</Badge>
{directions.map((d) => (
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
{d}
</Badge>
))}
</Group>
);
},
},
{
header: "Gate pass",
id: "gatepass",
cell: ({ row }) => {
const bookings = row.original.customsBookings;
const allGranted =
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
if (allGranted) {
return (
<Badge variant="light" color="edr-green" radius="sm">
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
</Badge>
);
}
return (
<Button
size="xs"
color="edr-green"
leftSection={<Truck size={14} />}
onClick={(e) => {
e.stopPropagation();
setGatepassAt(new Date());
setGatepassTarget(row.original);
}}
>
Gate pass
</Button>
);
},
},
],
[],
);
return (
<PageContainer>
<PageHeader
title="GL Djibouti — Clearance"
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
subtitle="Customs contracts handed off to Djibouti GL."
/>
<Tabs defaultValue="contracts" keepMounted={false}>
<Tabs.List mb="md">
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
Schedules ({scheduleItems.length})
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="contracts">
{contractsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
</Text>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
)}
</Tabs.Panel>
<Tabs.Panel value="schedules">
<DataTable
columns={columns}
data={scheduleItems}
status={
schedulesQuery.isLoading
? "loading"
: schedulesQuery.isError
? "error"
: "success"
}
error={
schedulesQuery.isError
? {
message: "Failed to load train schedules.",
onRetry: () => void schedulesQuery.refetch(),
}
: undefined
}
emptyMessage="No train schedules carry customs bookings yet."
/>
</Tabs.Panel>
</Tabs>
<Modal
opened={gatepassTarget != null}
onClose={() => setGatepassTarget(null)}
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>
Gate pass train {gatepassTarget?.trainNumber ?? ""}
{contractsLoading ? (
<Group justify="center" py={60}>
<Loader color="edr-green" />
</Group>
) : (
<Stack gap="sm">
{contractItems.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
No Djibouti customs contracts yet.
</Text>
</Group>
}
radius="md"
size="sm"
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Grants the gate pass for all{" "}
{gatepassTarget?.customsBookings.length ?? 0} customs booking
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
train.
</Text>
<DateTimePicker
label="Gate pass time"
value={gatepassAt}
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
required
/>
<Group justify="flex-end">
<Button
variant="default"
onClick={() => setGatepassTarget(null)}
disabled={granting}
>
Cancel
</Button>
<Button
color="edr-green"
loading={granting}
leftSection={<Truck size={16} />}
onClick={async () => {
if (!gatepassTarget) return;
setGranting(true);
try {
const result = await contractsService.grantScheduleGatepass(
gatepassTarget.id,
(gatepassAt ?? new Date()).toISOString(),
);
if (result.skipped.length > 0) {
toast.error(
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
);
} else {
toast.success(
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
);
}
setGatepassTarget(null);
void schedulesQuery.refetch();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
} finally {
setGranting(false);
}
}}
>
Grant gate pass
</Button>
</Group>
) : (
contractItems.map((c) => (
<Card
key={c.id}
withBorder
radius="md"
padding="md"
style={{ cursor: "pointer" }}
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="sm">
<Ship size={18} className="text-[color:var(--freight-brand)]" />
<div>
<Text fw={700}>{c.reference}</Text>
<Text size="sm" c="dimmed">
{c.tradeDirection} · {c.status}
</Text>
</div>
</Group>
<Group gap="xs">
<Badge variant="light" color="edr-green">
Contract
</Badge>
<ChevronRight size={18} className="text-muted-foreground" />
</Group>
</Group>
</Card>
))
)}
</Stack>
</Modal>
)}
</PageContainer>
);
}
function statusColor(status: string): string {
switch (status) {
case "SCHEDULED":
return "blue";
case "DISPATCHED":
return "yellow";
case "ARRIVED":
return "edr-green";
default:
return "gray";
}
}

View File

@@ -391,35 +391,6 @@ export const contractsService = {
return unwrap(response.data) as Freight.ClearanceT1State;
},
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
return unwrap(response.data) as Freight.DjClearanceSchedule[];
},
/** Gate pass for every customs booking on a train schedule (captures time). */
grantScheduleGatepass: async (
scheduleId: string,
gatepassAt?: string,
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
gatepassAt,
});
return unwrap(response.data) as {
granted: number;
skipped: Array<{ bookingId: string; error: string }>;
};
},
/** Gate pass for a single customs booking (captures time). */
grantGatepass: async (
bookingId: string,
gatepassAt?: string,
): Promise<{ bookingId: string; gatepassAt: string }> => {
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
},
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
sendFinalInvoice: async (
bookingId: string,