mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 08:48:11 +00:00
fix
This commit is contained in:
@@ -294,16 +294,18 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
</Button>
|
||||
{booking.customsClearingEnabled && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`)
|
||||
}
|
||||
>
|
||||
View document clearance
|
||||
</Button>
|
||||
)}
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -25,27 +25,39 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { useBookingMilestones } from "@/hooks/contracts/useContracts";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useBookingDetail } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function DocumentClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const params = useParams<{ id?: string; bookingId?: string }>();
|
||||
const id = params.id ?? params.bookingId;
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: booking } = useBookingDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["clearance", id],
|
||||
queryFn: () => bookingsService.getClearance(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const { data: bookingMilestones } = useBookingMilestones(id);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const docs = (clearance?.documents ?? []).filter(
|
||||
(d) => d.uploadedBy === "customer",
|
||||
@@ -59,6 +71,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
const isPhasedGeneral =
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -76,9 +102,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Clearance not found"
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: "Not found" },
|
||||
]}
|
||||
/>
|
||||
@@ -94,9 +120,9 @@ export default function DocumentClearanceDetailPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={reference}
|
||||
backTo="/dashboard/clearance"
|
||||
backTo="/dashboard/contracts/clearance"
|
||||
breadcrumbs={[
|
||||
{ label: "Document Clearance", href: "/dashboard/clearance" },
|
||||
{ label: "Document Clearance", href: "/dashboard/contracts/clearance" },
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
@@ -124,60 +150,103 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — document review (shared with the Marketing booking detail) */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance as Freight.ContractClearanceView}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{/* RIGHT — sticky progress gauge */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
<ClearanceOpsTabs
|
||||
bookingId={id}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(id)}
|
||||
showWorkflowFilesTab={isPhasedGeneral}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||
<ClearanceReviewSection
|
||||
bookingId={id!}
|
||||
hideSummary
|
||||
approvalsLocked={isPhasedGeneral && docsPhaseComplete}
|
||||
queriesLocked={queriesLocked}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
|
||||
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline milestones={clearance.milestones} />
|
||||
) : null}
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,6 @@ interface RefCargoChild {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
show_free_text_box?: boolean;
|
||||
}
|
||||
interface RefCargoGroup {
|
||||
id: string;
|
||||
@@ -307,20 +306,18 @@ export default function NewBookingPage() {
|
||||
[refData?.containers],
|
||||
);
|
||||
|
||||
const { cargoData, freeTextById } = useMemo(() => {
|
||||
const { cargoData } = useMemo(() => {
|
||||
const groups = refData?.cargo_type ?? [];
|
||||
const freeText = new Map<string, boolean>();
|
||||
const data = groups.map((g) => {
|
||||
if (g.children?.length) {
|
||||
g.children.forEach((c) => freeText.set(c.id, Boolean(c.show_free_text_box)));
|
||||
return { group: g.name, items: g.children.map((c) => ({ value: c.id, label: c.name })) };
|
||||
}
|
||||
return { value: g.id, label: g.name };
|
||||
});
|
||||
return { cargoData: data, freeTextById: freeText };
|
||||
return { cargoData: data };
|
||||
}, [refData?.cargo_type]);
|
||||
|
||||
const showFreeText = cargoTypeId ? freeTextById.get(cargoTypeId) : false;
|
||||
const showFreeText = freightType === "BULK" && Boolean(cargoTypeId);
|
||||
|
||||
// ---- derived totals ----
|
||||
const totalContainers = lines.reduce((s, l) => s + (l.quantity || 0), 0);
|
||||
@@ -376,7 +373,7 @@ export default function NewBookingPage() {
|
||||
lastMileDeliveryAddress: lastMileDeliveryAddress.trim() || undefined,
|
||||
cargoTotalWeightVgm,
|
||||
cargoTypeId: freightType === "BULK" ? cargoTypeId : undefined,
|
||||
cargoFreeText: freightType === "BULK" && showFreeText ? cargoFreeText.trim() || undefined : undefined,
|
||||
cargoFreeText: freightType === "BULK" ? cargoFreeText.trim() || undefined : undefined,
|
||||
containers:
|
||||
freightType === "CONTAINER"
|
||||
? lines.map((l) => ({
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { CalendarClock, Pencil } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { api } from "@/services/api";
|
||||
import ManageDropdownOptionsDialog from "@/pages/dropdown_settings/ManageDropdownOptionsDialog";
|
||||
|
||||
const CONTRACT_VALIDITY_PERIODS_CODE = "contract_validity_periods";
|
||||
|
||||
/**
|
||||
* Admin UI for contract validity options used when staff accepts a submitted
|
||||
* contract (SUBMITTED → PENDING_APPROVAL). Backed by dropdown_settings.
|
||||
*/
|
||||
export default function ContractValidityPeriodsPage() {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
const { data: setting, isLoading, isError } = useQuery(
|
||||
api.dropdownSettings.getByCode.queryOptions({
|
||||
input: { code: CONTRACT_VALIDITY_PERIODS_CODE },
|
||||
}),
|
||||
);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
[...(setting?.children ?? [])].sort(
|
||||
(a, b) => (a.order ?? 0) - (b.order ?? 0),
|
||||
),
|
||||
[setting],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Breadcrumbs
|
||||
items={[
|
||||
{ label: "Configuration", href: "/dashboard/configuration" },
|
||||
{ label: "Contract validity periods" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Stack gap="lg" mt="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Stack gap={4}>
|
||||
<Title order={2}>Contract validity periods</Title>
|
||||
<Text size="sm" c="dimmed" maw={560}>
|
||||
Options shown when line staff accepts a submitted contract. Each
|
||||
value is the number of days the contract stays valid from the
|
||||
accept date.
|
||||
</Text>
|
||||
</Stack>
|
||||
{setting && (
|
||||
<Button
|
||||
leftSection={<Pencil size={16} />}
|
||||
color="edr-green"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
Edit options
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader color="edr-green" />
|
||||
</Center>
|
||||
) : isError || !setting ? (
|
||||
<Text c="dimmed">
|
||||
Could not load contract validity settings. Ensure{" "}
|
||||
<Text span ff="monospace" size="sm">
|
||||
{CONTRACT_VALIDITY_PERIODS_CODE}
|
||||
</Text>{" "}
|
||||
is seeded in dropdown settings.
|
||||
</Text>
|
||||
) : options.length === 0 ? (
|
||||
<Stack align="center" gap="md" py="xl">
|
||||
<CalendarClock size={32} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No validity periods configured yet.</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => setEditOpen(true)}
|
||||
>
|
||||
Add options
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Label</Table.Th>
|
||||
<Table.Th>Days (value)</Table.Th>
|
||||
<Table.Th>Order</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{options.map((opt) => (
|
||||
<Table.Tr key={opt.id}>
|
||||
<Table.Td>{opt.label}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm">
|
||||
{opt.value}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{opt.order ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
color={opt.disabled ? "gray" : "edr-green"}
|
||||
variant="light"
|
||||
>
|
||||
{opt.disabled ? "Disabled" : "Active"}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
|
||||
{setting ? (
|
||||
<ManageDropdownOptionsDialog
|
||||
setting={setting}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -23,23 +23,33 @@ import {
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { ClearanceOpsTabs } from "@/components/contracts/ClearanceOpsTabs";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import {
|
||||
useBookingMilestones,
|
||||
useContractDetail,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
@@ -59,9 +69,40 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Customs (Path B) hub. The customer always creates the booking in the portal
|
||||
// after GL finalizes clearance — there is no GL "Create booking" action here.
|
||||
const ready = clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const phasedCustoms =
|
||||
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const ready = clearance?.bookingReady === true;
|
||||
const docReviewLocked = phasedCustoms
|
||||
? docsPhaseComplete
|
||||
: clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const shipmentLocked = Boolean(
|
||||
contract?.status &&
|
||||
[
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
"CONTRACT_ACTIVE",
|
||||
"CONTRACT_CLOSED",
|
||||
"EXPIRED",
|
||||
].includes(contract.status),
|
||||
);
|
||||
const linkedBookingId = useMemo(() => {
|
||||
const cycle = contract?.clearanceCycles?.find(
|
||||
(c) => c.cycleNumber === (contract?.clearanceCycleNumber ?? 1),
|
||||
);
|
||||
return cycle?.bookingId ?? undefined;
|
||||
}, [contract]);
|
||||
const bookingAlreadyCreated = Boolean(linkedBookingId) || shipmentLocked;
|
||||
const canCreateBooking = ready && !bookingAlreadyCreated;
|
||||
const reviewReadOnly = shipmentLocked;
|
||||
const queriesLocked = Boolean(clearance?.preClearanceFinalized);
|
||||
const bookingHref = `/dashboard/contracts/${id}/create-booking`;
|
||||
|
||||
const { data: bookingMilestones, refetch: refetchBookingMilestones } =
|
||||
useBookingMilestones(linkedBookingId);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -95,6 +136,8 @@ export default function ContractClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const workflowFiles = clearance.workflowFiles ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -109,14 +152,23 @@ export default function ContractClearanceDetailPage() {
|
||||
{ label: reference },
|
||||
]}
|
||||
meta={
|
||||
ready ? (
|
||||
bookingAlreadyCreated ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
) : ready ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
leftSection={<PackageCheck size={13} />}
|
||||
>
|
||||
Ready — customer books
|
||||
Ready — create booking
|
||||
</Badge>
|
||||
) : clearance.allApproved ? (
|
||||
<Badge
|
||||
@@ -142,75 +194,139 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{ready ? (
|
||||
{bookingAlreadyCreated ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Shipment booking created"
|
||||
>
|
||||
GL Ethiopia has created the shipment booking for this contract.
|
||||
{linkedBookingId ? (
|
||||
<>
|
||||
{" "}
|
||||
<Text
|
||||
component={Link}
|
||||
to={`/dashboard/bookings/${linkedBookingId}/clearance`}
|
||||
inherit
|
||||
fw={600}
|
||||
c="blue.7"
|
||||
>
|
||||
View booking →
|
||||
</Text>
|
||||
</>
|
||||
) : null}
|
||||
</Alert>
|
||||
) : canCreateBooking ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
icon={<PackageCheck size={16} />}
|
||||
title="Clearance finalized"
|
||||
title="Clearance complete"
|
||||
>
|
||||
Customs clearance is complete. The customer can now create the
|
||||
shipment booking from the portal — no further action is needed here.
|
||||
Pre-booking clearance is complete. GL Ethiopia can create the shipment booking.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={ready}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<ClearanceOpsTabs
|
||||
bookingId={linkedBookingId}
|
||||
milestones={bookingMilestones}
|
||||
showOpsTabs={Boolean(linkedBookingId)}
|
||||
showWorkflowFilesTab={phasedCustoms}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
clearanceTab={
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
/>
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Stack gap="md">
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
bookingId={linkedBookingId}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="ET"
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
bookingCreateHref={canCreateBooking ? bookingHref : undefined}
|
||||
bookingCreated={bookingAlreadyCreated}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Stack align="center" gap="sm">
|
||||
<RingProgress
|
||||
size={140}
|
||||
thickness={12}
|
||||
roundCaps
|
||||
sections={[{ value: stats.pct, color: "edr-green" }]}
|
||||
label={
|
||||
<Stack gap={0} align="center">
|
||||
<Text fw={800} fz={26} lh={1}>
|
||||
{stats.pct}%
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
approved
|
||||
</Text>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
<Group gap="lg" justify="center">
|
||||
<ProgressStat
|
||||
color="edr-green"
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="red"
|
||||
label="Queried"
|
||||
value={stats.queried}
|
||||
/>
|
||||
<ProgressStat
|
||||
color="gray"
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
ArrowRight,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Flag,
|
||||
Inbox,
|
||||
LayoutGrid,
|
||||
PackageCheck,
|
||||
@@ -43,9 +44,15 @@ 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 { useContractClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import {
|
||||
useContractClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -59,8 +66,10 @@ interface ClearanceRow {
|
||||
serviceTypeName: string;
|
||||
customs: boolean;
|
||||
status: string;
|
||||
/** true once GL has finalized clearance — customer now books in the portal. */
|
||||
/** true once GL has finalized clearance — customer may book in the portal. */
|
||||
ready: boolean;
|
||||
/** true once GL Ethiopia created the shipment booking. */
|
||||
bookingCreated: boolean;
|
||||
}
|
||||
|
||||
function yardLabel(
|
||||
@@ -93,6 +102,7 @@ function toClearanceRow(contract: Freight.IContract): ClearanceRow {
|
||||
contract.serviceType?.includesCustoms ?? contract.customsClearingEnabled,
|
||||
status: contract.status,
|
||||
ready: contract.status === "CLEARANCE_READY_FOR_BOOKING",
|
||||
bookingCreated: contract.status === "ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,6 +144,21 @@ function DirectionIcon({ direction }: { direction: string }) {
|
||||
}
|
||||
|
||||
function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
if (row.bookingCreated) {
|
||||
return (
|
||||
<Tooltip label="GL Ethiopia created the shipment booking" withArrow>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<PackagePlus size={12} />}
|
||||
>
|
||||
Booking created
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
if (row.ready) {
|
||||
return (
|
||||
<Tooltip
|
||||
@@ -160,19 +185,61 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Document Clearance hub. Lists every customs (Path B) contract that still needs
|
||||
* customs clearance — awaiting documents, under GL review, or finalized and
|
||||
* waiting for the customer to create the booking in the portal. A single list,
|
||||
* no queue/history/direction tabs.
|
||||
* Document Clearance hub. Lists every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created — stays visible for reference and follow-up.
|
||||
*/
|
||||
export default function ContractClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt = hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions);
|
||||
|
||||
const defaultQueue: QueueTab = canReview ? "all" : "et";
|
||||
const [queueTab, setQueueTab] = useState<QueueTab>(defaultQueue);
|
||||
const [query, setQuery] = useState("");
|
||||
const [view, setView] = useState<ViewMode>("table");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } =
|
||||
useContractClearanceQueue(true);
|
||||
const { data: allData, isLoading: allLoading, isError: allError, isFetching: allFetching, refetch: refetchAll } =
|
||||
useContractClearanceQueue(queueTab === "all");
|
||||
const { data: etData, isLoading: etLoading, isError: etError, isFetching: etFetching, refetch: refetchEt } =
|
||||
useEtClearanceQueue(queueTab === "et");
|
||||
|
||||
const data = queueTab === "et" ? etData : allData;
|
||||
const isLoading = queueTab === "et" ? etLoading : allLoading;
|
||||
const isError = queueTab === "et" ? etError : allError;
|
||||
const isFetching = queueTab === "et" ? etFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview) {
|
||||
opts.push({
|
||||
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>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -183,7 +250,8 @@ export default function ContractClearanceListPage() {
|
||||
() => ({
|
||||
all: allRows.length,
|
||||
ready: allRows.filter((r) => r.ready).length,
|
||||
review: allRows.filter((r) => !r.ready).length,
|
||||
booked: allRows.filter((r) => r.bookingCreated).length,
|
||||
review: allRows.filter((r) => !r.ready && !r.bookingCreated).length,
|
||||
}),
|
||||
[allRows],
|
||||
);
|
||||
@@ -329,7 +397,7 @@ export default function ContractClearanceListPage() {
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title="Document Clearance"
|
||||
subtitle="Review pre-booking customs documents on contracts and finalize clearance. Once finalized, the customer creates the shipment booking in the portal."
|
||||
subtitle="All customs contracts in phased clearance — stays visible after booking is created."
|
||||
meta={
|
||||
<Badge
|
||||
variant="light"
|
||||
@@ -337,7 +405,7 @@ export default function ContractClearanceListPage() {
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={13} />}
|
||||
>
|
||||
{counts.all} need clearance
|
||||
{counts.all} in clearance
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
@@ -358,7 +426,7 @@ export default function ContractClearanceListPage() {
|
||||
loading={isLoading}
|
||||
items={[
|
||||
{
|
||||
label: "Need clearance",
|
||||
label: "In clearance",
|
||||
value: counts.all,
|
||||
icon: Inbox,
|
||||
color: "edr-green",
|
||||
@@ -370,8 +438,8 @@ export default function ContractClearanceListPage() {
|
||||
color: "yellow",
|
||||
},
|
||||
{
|
||||
label: "Ready — customer books",
|
||||
value: counts.ready,
|
||||
label: "Ready / booked",
|
||||
value: counts.ready + counts.booked,
|
||||
icon: PackageCheck,
|
||||
color: "edr-green",
|
||||
},
|
||||
@@ -380,6 +448,20 @@ export default function ContractClearanceListPage() {
|
||||
|
||||
<Card p={0} withBorder shadow="sm" radius="lg">
|
||||
<Stack gap={0}>
|
||||
{queueTabOptions.length > 1 ? (
|
||||
<Box px="md" pt="md">
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
radius="md"
|
||||
value={queueTab}
|
||||
onChange={(v) => {
|
||||
setQueueTab(v as QueueTab);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
data={queueTabOptions}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
<Box px="md" pt="md" pb="sm">
|
||||
<Group justify="space-between" gap="md" wrap="wrap">
|
||||
<TextInput
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
@@ -6,14 +7,19 @@ import {
|
||||
Building2,
|
||||
Calendar,
|
||||
CalendarClock,
|
||||
Download,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Files,
|
||||
Flame,
|
||||
LayoutGrid,
|
||||
Package,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
@@ -31,6 +37,8 @@ import {
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import "@/components/overview/overview.css";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
@@ -41,11 +49,22 @@ import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflow
|
||||
import { ContractActionsToolbar } from "@/components/contracts/ContractActionsToolbar";
|
||||
import { ContractApprovalStepsCard } from "@/components/contracts/ContractApprovalStepsCard";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import {
|
||||
ContractCustomerCard,
|
||||
ContractDocumentsCard,
|
||||
} from "@/components/contracts/detail/ContractDetailTabCards";
|
||||
import { getContractStatusMeta } from "@/features/contracts/contract-status.config";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import {
|
||||
useContractDetail,
|
||||
useContractMutations,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
@@ -94,7 +113,8 @@ export default function ContractRequestDetailPage() {
|
||||
} = useContractDetail(id);
|
||||
const mutations = useContractMutations(id ?? "");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get("tab") === "clearance" ? "clearance" : "details";
|
||||
const { view, viewer } = useFileViewer();
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const setTab = (tab: string) =>
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
@@ -106,6 +126,48 @@ export default function ContractRequestDetailPage() {
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
const handleViewFile = (file: NonNullable<Freight.IContract["files"]>[number]) =>
|
||||
view({
|
||||
name: file.name,
|
||||
url: file.signedUrl ?? file.url,
|
||||
mimeType: file.mimeType,
|
||||
});
|
||||
|
||||
const handleDownloadFile = async (
|
||||
file: NonNullable<Freight.IContract["files"]>[number],
|
||||
) => {
|
||||
try {
|
||||
await downloadBookingFile(file.id, file.name);
|
||||
} catch {
|
||||
toast.error("Could not download file.");
|
||||
}
|
||||
};
|
||||
|
||||
const showClearanceTabQuery = Boolean(
|
||||
contract && CLEARANCE_REVIEW_STATUSES.includes(contract.status),
|
||||
);
|
||||
const { data: clearanceView } = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(id!),
|
||||
enabled: Boolean(id) && showClearanceTabQuery,
|
||||
});
|
||||
|
||||
const downloadContractPdf = async () => {
|
||||
if (!contract?.id) return;
|
||||
try {
|
||||
const blob = await contractsService.downloadContractDocument(contract.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
const contractPdf = contract.files?.find((f) => f.code === "contract");
|
||||
a.download = contractPdf?.name ?? `contract-${contract.reference}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Could not download contract PDF.");
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -172,13 +234,36 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const phasedCustoms =
|
||||
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearanceView?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// If the tab param points at clearance but the contract isn't in a clearance
|
||||
// phase, fall back to details so we never show an empty tab.
|
||||
const currentTab = activeTab === "clearance" && showClearanceTab ? "clearance" : "details";
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
const hasContractDocument = Boolean(
|
||||
contractPdf || contract.contractGeneratedAt,
|
||||
);
|
||||
const canViewSign =
|
||||
(contract.status === "CONTRACT_READY" ||
|
||||
contract.status === "SIGNED_CUSTOMER") &&
|
||||
Boolean(contract.contractGeneratedAt);
|
||||
// Resolve the active tab from the URL, falling back to details when the
|
||||
// requested tab isn't available for this contract (e.g. clearance pre-phase).
|
||||
const currentTab =
|
||||
requestedTab === "documents"
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -254,6 +339,48 @@ export default function ContractRequestDetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
{hasContractDocument && (
|
||||
<Group gap="sm" mt="sm">
|
||||
{canViewSign && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<FileSignature size={15} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/contract-requests/${contract.id}/view`)
|
||||
}
|
||||
>
|
||||
View & sign contract
|
||||
</Button>
|
||||
)}
|
||||
{contractPdf && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<FileText size={15} />}
|
||||
onClick={() =>
|
||||
handleViewFile({
|
||||
...contractPdf,
|
||||
url: fileViewUrl(contractPdf.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
View contract
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
size="compact-sm"
|
||||
radius="lg"
|
||||
leftSection={<Download size={15} />}
|
||||
onClick={() => void downloadContractPdf()}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -264,38 +391,83 @@ export default function ContractRequestDetailPage() {
|
||||
description={statusMeta.description}
|
||||
/>
|
||||
|
||||
{showClearanceTab && (
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<FileText size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs
|
||||
value={currentTab}
|
||||
onChange={(v) => setTab(v ?? "details")}
|
||||
variant="pills"
|
||||
color="edr-green"
|
||||
classNames={{ list: "ov-tablist", tab: "ov-tab" }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="details" leftSection={<LayoutGrid size={16} />}>
|
||||
Details
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<Files size={16} />}
|
||||
rightSection={
|
||||
files.length > 0 ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
{files.length}
|
||||
</Badge>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
)}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
<Stack gap="lg">
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
selfClear={selfClear}
|
||||
readOnly={clearanceReadOnly}
|
||||
phasedCustoms={phasedCustoms}
|
||||
approvalsLocked={clearanceApprovalsLocked}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
{(clearanceView?.workflowFiles?.length ?? 0) > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={clearanceView!.workflowFiles!}
|
||||
title="Customs workflow documents"
|
||||
onView={view}
|
||||
onDownload={(f) => void handleDownloadFile({ id: f.id, name: f.name } as never)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : currentTab === "customer" ? (
|
||||
<ContractCustomerCard contract={contract} />
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={RouteIcon} title="Routes">
|
||||
@@ -453,6 +625,8 @@ export default function ContractRequestDetailPage() {
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -13,18 +13,17 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ArrowLeft, FileSignature, Printer } from "lucide-react";
|
||||
import { ArrowLeft, Download, FileSignature, Printer } from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { ContractSignSuccessModal } from "@/components/contracts/ContractSignSuccessModal";
|
||||
import { ContractSignaturePad } from "@/components/bookings/ContractSignaturePad";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
|
||||
/**
|
||||
* Staff contract preview + sign. Staff must open and read the generated
|
||||
* contract here before signing — there is no sign action on the detail page or
|
||||
* the list table. Signing as STAFF is only possible once the contract has been
|
||||
* generated and is in CONTRACT_READY / SIGNED_CUSTOMER.
|
||||
* contract here before signing.
|
||||
*/
|
||||
export default function ContractViewPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
@@ -33,9 +32,9 @@ export default function ContractViewPage() {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const [signOpen, setSignOpen] = useState(false);
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [signerName, setSignerName] = useState("");
|
||||
const [signatureData, setSignatureData] = useState<string | null>(null);
|
||||
// Offer the staff member's saved signature first; they can draw a fresh one.
|
||||
const [drawNew, setDrawNew] = useState(false);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
@@ -58,8 +57,8 @@ export default function ContractViewPage() {
|
||||
consentText: "I confirm this contract on behalf of EDR.",
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast.success("Contract signed");
|
||||
setSignOpen(false);
|
||||
setSuccessOpen(true);
|
||||
void refetch();
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.byId(id!) });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.CONTRACTS.ROOT });
|
||||
@@ -69,6 +68,21 @@ export default function ContractViewPage() {
|
||||
|
||||
const handlePrint = () => iframeRef.current?.contentWindow?.print();
|
||||
|
||||
const downloadPdf = useCallback(async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const blob = await contractsService.downloadContractDocument(id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `contract-${data?.reference ?? id}.pdf`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
toast.error("Could not download contract PDF.");
|
||||
}
|
||||
}, [id, data?.reference]);
|
||||
|
||||
const openSign = () => {
|
||||
setSignerName(data?.savedSignature?.signerDisplayName ?? "");
|
||||
setSignatureData(null);
|
||||
@@ -124,6 +138,13 @@ export default function ContractViewPage() {
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Download size={16} />}
|
||||
onClick={() => void downloadPdf()}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
{data.canSignStaff && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
@@ -217,6 +238,16 @@ export default function ContractViewPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ContractSignSuccessModal
|
||||
opened={successOpen}
|
||||
reference={data.reference}
|
||||
message="The contract has been counter-signed. The customer will be notified of the next steps."
|
||||
onClose={() => {
|
||||
setSuccessOpen(false);
|
||||
navigate(`/dashboard/contract-requests/${data.contractId}`);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ClipboardList, FileText, Upload } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ClearanceReviewSection } from "@/components/bookings/detail/ClearanceReviewSection";
|
||||
import { ContractClearanceReviewSection } from "@/components/contracts/ContractClearanceReviewSection";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import {
|
||||
GlClearanceUploadModal,
|
||||
type GlClearanceUploadKind,
|
||||
} from "@/components/contracts/GlClearanceUploadModal";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { findWorkflowFile } from "@/components/contracts/PhasedUploadedFileRow";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
|
||||
type GlClearanceDetail =
|
||||
| {
|
||||
kind: "contract";
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ContractClearanceView;
|
||||
}
|
||||
| {
|
||||
kind: "booking";
|
||||
reference: string;
|
||||
tradeDirection: string;
|
||||
clearance: Freight.ClearanceView;
|
||||
};
|
||||
|
||||
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
|
||||
try {
|
||||
const [clearance, contract] = await Promise.all([
|
||||
contractsService.getClearance(id),
|
||||
contractsService.getById(id),
|
||||
]);
|
||||
return {
|
||||
kind: "contract",
|
||||
reference: contract.reference,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
clearance,
|
||||
};
|
||||
} catch {
|
||||
const [clearance, booking] = await Promise.all([
|
||||
bookingsService.getClearance(id),
|
||||
bookingsService.getById(id),
|
||||
]);
|
||||
return {
|
||||
kind: "booking",
|
||||
reference: booking.reference,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
clearance,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
|
||||
export default function GlClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
|
||||
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["gl-clearance-detail", id],
|
||||
queryFn: () => loadGlClearanceDetail(id!),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack align="center" py={80}>
|
||||
<Loader color="edr-green" />
|
||||
<Text c="dimmed">Loading clearance…</Text>
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
Could not load clearance for this item.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const backTo = "/dashboard/gl-djibouti/clearance";
|
||||
const workflowFiles = data.clearance.workflowFiles ?? [];
|
||||
const workflowFileCount = workflowFiles.filter((f) => f.file).length;
|
||||
const isImport = data.tradeDirection === "IMPORT";
|
||||
const hasDo = Boolean(findWorkflowFile(workflowFiles, "delivery_order"));
|
||||
const hasRo = Boolean(findWorkflowFile(workflowFiles, "release_order"));
|
||||
// DO upload is un-gated — Djibouti GL may attach it at any point, any file type.
|
||||
const canUploadDo = isImport;
|
||||
const vesselDepartureDate =
|
||||
"vesselDepartureDate" in data.clearance
|
||||
? (data.clearance.vesselDepartureDate ?? null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={data.reference}
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: "GL Djibouti Clearance", href: backTo },
|
||||
{ label: data.reference },
|
||||
]}
|
||||
meta={
|
||||
<Badge variant="light" color={isImport ? "edr-green" : "gray"} radius="sm">
|
||||
{data.tradeDirection}
|
||||
</Badge>
|
||||
}
|
||||
action={
|
||||
<Group gap="sm">
|
||||
{isImport ? (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
disabled={!canUploadDo}
|
||||
onClick={() => setUploadKind("do")}
|
||||
>
|
||||
{hasDo ? "Replace DO" : "Upload DO"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setUploadKind("ro")}
|
||||
>
|
||||
{hasRo ? "Replace RO" : "Upload RO"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="workflow" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="workflow" leftSection={<ClipboardList size={14} />}>
|
||||
Clearance workflow
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={14} />}
|
||||
rightSection={
|
||||
workflowFileCount > 0 ? (
|
||||
<Badge size="xs" variant="light" color="edr-green" circle>
|
||||
{workflowFileCount}
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Customs documents (all steps)
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="workflow">
|
||||
<Grid gutter="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{data.kind === "booking" ? (
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary readOnly />
|
||||
) : (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
phasedCustoms
|
||||
/>
|
||||
)}
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={data.kind === "contract" ? id : undefined}
|
||||
bookingId={data.kind === "booking" ? id : undefined}
|
||||
clearance={data.clearance}
|
||||
tradeDirection={data.tradeDirection}
|
||||
workflowFiles={workflowFiles}
|
||||
roleMode="DJ"
|
||||
useUploadModals
|
||||
onUploadDoRequest={() => setUploadKind("do")}
|
||||
onUploadRoRequest={() => setUploadKind("ro")}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="documents">
|
||||
{workflowFiles.length > 0 ? (
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
title="Customs documents (all steps)"
|
||||
onView={view}
|
||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
py={48}
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: "1px dashed var(--mantine-color-gray-4)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<Stack gap={8} align="center">
|
||||
<ThemeIcon variant="light" color="gray" radius="xl" size={48}>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" c="dimmed" maw={360}>
|
||||
No customs workflow documents uploaded yet. Files from Ethiopia-side
|
||||
clearance and your DO/RO uploads will appear here.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
<GlClearanceUploadModal
|
||||
opened={uploadKind != null}
|
||||
kind={uploadKind}
|
||||
onClose={() => setUploadKind(null)}
|
||||
entityId={id!}
|
||||
isBooking={data.kind === "booking"}
|
||||
workflowFiles={workflowFiles}
|
||||
vesselDepartureDate={vesselDepartureDate}
|
||||
onSuccess={() => void refetch()}
|
||||
onPreview={view}
|
||||
/>
|
||||
{viewer}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Tabs, Text } from "@mantine/core";
|
||||
import { ChevronRight, Container, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
import { useBookingDjClearanceQueue } from "@/hooks/bookings/useBookings";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||
const { data: bookingQueue, isLoading: bookingsLoading } = useBookingDjClearanceQueue();
|
||||
|
||||
const contractItems = contractQueue?.items ?? [];
|
||||
const bookingItems = bookingQueue ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="All customs contracts and bookings handed off to Djibouti GL — stays visible after DO/RO upload and booking creation."
|
||||
/>
|
||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="bookings">Bookings ({bookingItems.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. Items appear here once Ethiopia-side
|
||||
pre-clearance is finalized.
|
||||
</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="bookings">
|
||||
{bookingsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{bookingItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs bookings yet.
|
||||
</Text>
|
||||
) : (
|
||||
bookingItems.map((b) => (
|
||||
<Card
|
||||
key={b.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${b.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Container size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{b.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{b.tradeDirection} · {b.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="blue">
|
||||
Booking
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Grid,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
@@ -24,6 +25,12 @@ import type { Freight } from "@edr/types";
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import {
|
||||
RequestCustomerCard,
|
||||
RequestContractSummaryCard,
|
||||
RequestRouteCargoCard,
|
||||
RequestServiceTypeCard,
|
||||
} from "@/components/contracts/detail/RequestDetailCards";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const fmtDate = (iso?: string | null) =>
|
||||
@@ -167,49 +174,67 @@ export default function ShipmentRequestDetailPage() {
|
||||
}
|
||||
/>
|
||||
|
||||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Preferred date (informational)
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtDate(request.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={6}>
|
||||
Quantities
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
>
|
||||
{l}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{request.notes ? (
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
Customer note
|
||||
</Text>
|
||||
<Text size="sm">{request.notes}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{request.reviewNote ? (
|
||||
<Alert color="red" variant="light" radius="md" mt="sm">
|
||||
Rejected: {request.reviewNote}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — the request itself + route/cargo scope */}
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<Stack gap="lg">
|
||||
<SectionCard icon={CalendarDays} title="Requested shipment">
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Preferred date (informational)
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{fmtDate(request.scheduledDate)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={6}>
|
||||
Quantities
|
||||
</Text>
|
||||
<Stack gap={4}>
|
||||
{lineRows(request.requestedLines ?? {}).map((l, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="sm"
|
||||
size="lg"
|
||||
>
|
||||
{l}
|
||||
</Badge>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
{request.notes ? (
|
||||
<Box>
|
||||
<Text size="sm" c="dimmed" mb={4}>
|
||||
Customer note
|
||||
</Text>
|
||||
<Text size="sm">{request.notes}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{request.reviewNote ? (
|
||||
<Alert color="red" variant="light" radius="md" mt="sm">
|
||||
Rejected: {request.reviewNote}
|
||||
</Alert>
|
||||
) : null}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
<RequestRouteCargoCard contract={request.contract} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — customer, contract + service-type context */}
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<Stack gap="lg">
|
||||
<RequestCustomerCard contract={request.contract} />
|
||||
<RequestContractSummaryCard contract={request.contract} />
|
||||
<RequestServiceTypeCard contract={request.contract} />
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { ChevronRight, Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { Inbox, PackageSearch, RefreshCw, Search } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import {
|
||||
getShipmentRejectAction,
|
||||
getShipmentStaffRowAction,
|
||||
type ShipmentListRow,
|
||||
} from "@/features/contracts/mapShipmentListRow";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
|
||||
const cellMeta = {
|
||||
@@ -33,7 +41,6 @@ const fmtDate = (iso?: string | null) =>
|
||||
}).format(new Date(iso))
|
||||
: "—";
|
||||
|
||||
/** Summarize requested quantities for the list row. */
|
||||
function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
if (lines.containers?.length) {
|
||||
return lines.containers
|
||||
@@ -49,17 +56,13 @@ function summarizeLines(lines: Freight.RequestedShipmentLines): string {
|
||||
return "—";
|
||||
}
|
||||
|
||||
interface RequestRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export default function ShipmentRequestsPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [query, setQuery] = useState("");
|
||||
const [rejectTarget, setRejectTarget] = useState<ShipmentListRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const [acceptTarget, setAcceptTarget] = useState<ShipmentListRow | null>(null);
|
||||
|
||||
const { data, isLoading, isError, isFetching, refetch } = useQuery({
|
||||
queryKey: ["shipment-request-queue"],
|
||||
@@ -67,13 +70,26 @@ export default function ShipmentRequestsPage() {
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const rows = useMemo<RequestRow[]>(() => {
|
||||
const reject = useMutation({
|
||||
mutationFn: () =>
|
||||
contractsService.rejectBookingRequest(rejectTarget!.id, rejectNote),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["shipment-request-queue"] });
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
},
|
||||
});
|
||||
|
||||
const rows = useMemo<ShipmentListRow[]>(() => {
|
||||
const all = (data ?? []).map((r) => ({
|
||||
id: r.id,
|
||||
reference: r.reference || r.id.slice(0, 8),
|
||||
contractId: r.contractId,
|
||||
contractReference: r.contract?.reference ?? r.contractId,
|
||||
scheduledDate: r.scheduledDate,
|
||||
summary: summarizeLines(r.requestedLines ?? {}),
|
||||
status: r.status,
|
||||
createdBookingId: r.createdBookingId,
|
||||
}));
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return all;
|
||||
@@ -85,7 +101,7 @@ export default function ShipmentRequestsPage() {
|
||||
);
|
||||
}, [data, query]);
|
||||
|
||||
const columns = useMemo<ColumnDef<RequestRow>[]>(
|
||||
const columns = useMemo<ColumnDef<ShipmentListRow>[]>(
|
||||
() => [
|
||||
{
|
||||
id: "reference",
|
||||
@@ -126,16 +142,55 @@ export default function ShipmentRequestsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "go",
|
||||
size: 56,
|
||||
cell: () => (
|
||||
<Group justify="flex-end" pr="xs">
|
||||
<ChevronRight size={16} className="text-muted-foreground" />
|
||||
</Group>
|
||||
),
|
||||
id: "actions",
|
||||
header: () => <span className={ruleEngineTable.headerCell}>Action</span>,
|
||||
meta: cellMeta,
|
||||
cell: ({ row }) => {
|
||||
const primary = getShipmentStaffRowAction(row.original);
|
||||
const rejectAction = getShipmentRejectAction(row.original);
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{rejectAction ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRejectTarget(row.original);
|
||||
}}
|
||||
>
|
||||
{rejectAction.label}
|
||||
</Button>
|
||||
) : null}
|
||||
{primary.kind === "navigate" ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
radius="md"
|
||||
variant={primary.variant === "filled" ? "filled" : primary.variant}
|
||||
color="edr-green"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (
|
||||
primary.label === "Accept" &&
|
||||
row.original.status === "PENDING"
|
||||
) {
|
||||
setAcceptTarget(row.original);
|
||||
} else {
|
||||
navigate(primary.to(row.original));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{primary.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -203,6 +258,95 @@ export default function ShipmentRequestsPage() {
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Modal
|
||||
opened={rejectTarget !== null}
|
||||
onClose={() => {
|
||||
if (!reject.isPending) {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
}
|
||||
}}
|
||||
title="Reject shipment request"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Reject request{" "}
|
||||
<Text span fw={600}>
|
||||
{rejectTarget?.reference}
|
||||
</Text>
|
||||
? The customer will be notified.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Explain why this request cannot be accepted…"
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
minRows={3}
|
||||
radius="md"
|
||||
/>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
loading={reject.isPending}
|
||||
disabled={!rejectNote.trim()}
|
||||
onClick={() => reject.mutate()}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={acceptTarget !== null}
|
||||
onClose={() => setAcceptTarget(null)}
|
||||
title="Accept shipment request"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Proceed to create a booking for request{" "}
|
||||
<Text span fw={600}>
|
||||
{acceptTarget?.reference}
|
||||
</Text>
|
||||
? You will confirm the shipment price before submitting.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={() => setAcceptTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
if (!acceptTarget) return;
|
||||
const to = getShipmentStaffRowAction(acceptTarget);
|
||||
if (to.kind === "navigate") {
|
||||
navigate(to.to(acceptTarget));
|
||||
}
|
||||
setAcceptTarget(null);
|
||||
}}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { FormEvent, useMemo, useState } from "react";
|
||||
import { Ban, CircleCheck, Edit, Eye, Plus, Route as RouteIcon, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CircleCheck,
|
||||
Edit,
|
||||
Eye,
|
||||
Plus,
|
||||
Route as RouteIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import type { ColumnDef } from "@edr/ui-common";
|
||||
import {
|
||||
ActionIcon,
|
||||
@@ -7,13 +16,16 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
|
||||
@@ -26,22 +38,51 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { RouteRecord, YardRef } from "@/services/routes.service";
|
||||
import {
|
||||
formatRouteLabel,
|
||||
ROUTE_STATUS_OPTIONS,
|
||||
totalRouteDistanceKm,
|
||||
type RouteRecord,
|
||||
type RouteStatus,
|
||||
type YardRef,
|
||||
} from "@/services/routes.service";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
|
||||
type MilestoneFormRow = { yardId: string; distanceKm: string };
|
||||
|
||||
type RouteFormState = {
|
||||
name: string;
|
||||
milestones: string[];
|
||||
status: RouteStatus;
|
||||
milestones: MilestoneFormRow[];
|
||||
};
|
||||
|
||||
const emptyForm = (): RouteFormState => ({ name: "", milestones: ["", ""] });
|
||||
const emptyForm = (): RouteFormState => ({
|
||||
status: "AVAILABLE",
|
||||
milestones: [
|
||||
{ yardId: "", distanceKm: "0" },
|
||||
{ yardId: "", distanceKm: "" },
|
||||
],
|
||||
});
|
||||
|
||||
const yardLabel = (yard?: YardRef | null) => (yard ? `${yard.label} (${yard.code})` : "—");
|
||||
const yardLabel = (yard?: YardRef | null) =>
|
||||
yard ? `${yard.label} (${yard.code})` : "—";
|
||||
|
||||
const routeStops = (route: RouteRecord) =>
|
||||
(route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId);
|
||||
const statusColor = (status: RouteStatus) => {
|
||||
switch (status) {
|
||||
case "AVAILABLE":
|
||||
return "edr-green";
|
||||
case "MAINTENANCE":
|
||||
return "yellow";
|
||||
case "DAMAGED":
|
||||
return "red";
|
||||
case "STOP_WORKING":
|
||||
return "gray";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
};
|
||||
|
||||
const statusLabel = (status: RouteStatus) =>
|
||||
ROUTE_STATUS_OPTIONS.find((o) => o.value === status)?.label ?? status;
|
||||
|
||||
const normalizeRouteError = (error: unknown) => {
|
||||
const responseData = (error as { response?: { data?: unknown } })?.response?.data;
|
||||
@@ -57,6 +98,60 @@ const normalizeRouteError = (error: unknown) => {
|
||||
: "Save failed";
|
||||
};
|
||||
|
||||
function RouteTimeline({ route }: { route: RouteRecord }) {
|
||||
const stops = [...(route.milestones ?? [])].sort(
|
||||
(a, b) => a.sequenceNo - b.sequenceNo,
|
||||
);
|
||||
const total = totalRouteDistanceKm(route);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{stops.map((milestone, index) => {
|
||||
const label =
|
||||
milestone.yard?.label ?? milestone.yard?.code ?? milestone.yardId;
|
||||
const role =
|
||||
index === 0
|
||||
? "Origin"
|
||||
: index === stops.length - 1
|
||||
? "Destination"
|
||||
: `Milestone ${index}`;
|
||||
const km = Number(milestone.distanceKm ?? 0);
|
||||
return (
|
||||
<Box key={milestone.id ?? `${milestone.yardId}-${index}`}>
|
||||
{index > 0 && (
|
||||
<Group gap={8} pl={18} py={6}>
|
||||
<ThemeIcon size={22} radius="xl" variant="light" color="gray">
|
||||
<ArrowRight size={12} />
|
||||
</ThemeIcon>
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{km} km
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Badge size="sm" variant="light" color={index === 0 ? "teal" : "gray"}>
|
||||
{role}
|
||||
</Badge>
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Divider />
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Total distance
|
||||
</Text>
|
||||
<Text size="sm" fw={700}>
|
||||
{total} km
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutesPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
@@ -78,12 +173,14 @@ export default function RoutesPage() {
|
||||
if (!query) return routesQuery.data ?? [];
|
||||
return (routesQuery.data ?? []).filter((route) => {
|
||||
const searchable = [
|
||||
route.name,
|
||||
formatRouteLabel(route),
|
||||
route.originYard?.label,
|
||||
route.originYard?.code,
|
||||
route.destinationYard?.label,
|
||||
route.destinationYard?.code,
|
||||
...routeStops(route),
|
||||
...(route.milestones ?? []).map(
|
||||
(m) => m.yard?.label ?? m.yard?.code ?? m.yardId,
|
||||
),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
@@ -99,7 +196,7 @@ export default function RoutesPage() {
|
||||
}, [filteredRoutes, pagination.pageIndex, pagination.pageSize]);
|
||||
|
||||
const allRoutes = routesQuery.data ?? [];
|
||||
const activeCount = allRoutes.filter((route) => route.isActive).length;
|
||||
const availableCount = allRoutes.filter((r) => r.status === "AVAILABLE").length;
|
||||
|
||||
const yardOptions = useMemo(
|
||||
() =>
|
||||
@@ -110,6 +207,16 @@ export default function RoutesPage() {
|
||||
[yardsQuery.data],
|
||||
);
|
||||
|
||||
const formTotalKm = useMemo(
|
||||
() =>
|
||||
form.milestones.reduce(
|
||||
(sum, row, index) =>
|
||||
index === 0 ? sum : sum + Number(row.distanceKm || 0),
|
||||
0,
|
||||
),
|
||||
[form.milestones],
|
||||
);
|
||||
|
||||
const resetForm = () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
@@ -125,41 +232,52 @@ export default function RoutesPage() {
|
||||
const openEdit = (route: RouteRecord) => {
|
||||
setEditing(route);
|
||||
setForm({
|
||||
name: route.name,
|
||||
milestones: (route.milestones ?? [])
|
||||
.sort((left, right) => left.sequenceNo - right.sequenceNo)
|
||||
.map((milestone) => milestone.yardId),
|
||||
status: route.status,
|
||||
milestones: [...(route.milestones ?? [])]
|
||||
.sort((a, b) => a.sequenceNo - b.sequenceNo)
|
||||
.map((m, index) => ({
|
||||
yardId: m.yardId,
|
||||
distanceKm: String(index === 0 ? 0 : (m.distanceKm ?? "")),
|
||||
})),
|
||||
});
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const setMilestone = (index: number, yardId: string) => {
|
||||
const setMilestone = (index: number, patch: Partial<MilestoneFormRow>) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.map((value, currentIndex) =>
|
||||
currentIndex === index ? yardId : value,
|
||||
milestones: current.milestones.map((row, i) =>
|
||||
i === index ? { ...row, ...patch } : row,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addMilestone = () => {
|
||||
setForm((current) => ({ ...current, milestones: [...current.milestones, ""] }));
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: [...current.milestones, { yardId: "", distanceKm: "" }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeMilestone = (index: number) => {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
milestones: current.milestones.filter((_, currentIndex) => currentIndex !== index),
|
||||
milestones: current.milestones.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const buildPayload = () => ({
|
||||
status: form.status,
|
||||
milestones: form.milestones.map((row, index) => ({
|
||||
yardId: row.yardId,
|
||||
distanceKm:
|
||||
index === 0 ? 0 : row.distanceKm ? Number(row.distanceKm) : undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!form.name.trim()) {
|
||||
toast({ title: "Save failed", description: "Route name is required", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (form.milestones.length < 2 || form.milestones.some((yardId) => !yardId)) {
|
||||
if (form.milestones.length < 2 || form.milestones.some((row) => !row.yardId)) {
|
||||
toast({
|
||||
title: "Save failed",
|
||||
description: "Select at least an origin and destination yard",
|
||||
@@ -167,13 +285,20 @@ export default function RoutesPage() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
for (let i = 1; i < form.milestones.length; i++) {
|
||||
const km = Number(form.milestones[i].distanceKm);
|
||||
if (!form.milestones[i].distanceKm || Number.isNaN(km) || km < 0) {
|
||||
toast({
|
||||
title: "Save failed",
|
||||
description: `Enter segment KM for stop ${i + 1}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
milestones: form.milestones.map((yardId) => ({ yardId })),
|
||||
isActive: editing?.isActive ?? true,
|
||||
};
|
||||
const payload = buildPayload();
|
||||
if (editing) {
|
||||
await updateMutation.mutateAsync({ id: editing.id, data: payload });
|
||||
toast({ title: "Route updated" });
|
||||
@@ -190,9 +315,19 @@ export default function RoutesPage() {
|
||||
const handleDeactivate = async (route: RouteRecord) => {
|
||||
try {
|
||||
await deactivateMutation.mutateAsync(route.id);
|
||||
toast({ title: "Route deactivated" });
|
||||
toast({ title: "Route marked stop working" });
|
||||
} catch {
|
||||
toast({ title: "Deactivate failed", description: "Could not deactivate route", variant: "destructive" });
|
||||
toast({ title: "Update failed", description: "Could not update route status", variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (route: RouteRecord, status: RouteStatus) => {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: route.id, data: { status } });
|
||||
setViewing((current) => (current?.id === route.id ? { ...current, status } : current));
|
||||
toast({ title: "Status updated" });
|
||||
} catch (error) {
|
||||
toast({ title: "Update failed", description: normalizeRouteError(error), variant: "destructive" });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -200,10 +335,14 @@ export default function RoutesPage() {
|
||||
|
||||
const availableOptionsForIndex = (index: number) => {
|
||||
const selectedByOthers = new Set(
|
||||
form.milestones.filter((value, currentIndex) => currentIndex !== index && value),
|
||||
form.milestones
|
||||
.filter((row, i) => i !== index && row.yardId)
|
||||
.map((row) => row.yardId),
|
||||
);
|
||||
return yardOptions.filter(
|
||||
(option) => option.value === form.milestones[index] || !selectedByOthers.has(option.value),
|
||||
(option) =>
|
||||
option.value === form.milestones[index]?.yardId ||
|
||||
!selectedByOthers.has(option.value),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -217,7 +356,16 @@ export default function RoutesPage() {
|
||||
const headerClassName = ruleEngineTable.headerCell;
|
||||
const cellClassName = ruleEngineTable.bodyCell;
|
||||
return [
|
||||
{ id: "name", header: "Name", meta: { headerClassName, cellClassName }, cell: ({ row }) => row.original.name },
|
||||
{
|
||||
id: "corridor",
|
||||
header: "Corridor",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{formatRouteLabel(row.original)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "origin",
|
||||
header: "Origin",
|
||||
@@ -231,18 +379,24 @@ export default function RoutesPage() {
|
||||
cell: ({ row }) => yardLabel(row.original.destinationYard),
|
||||
},
|
||||
{
|
||||
id: "milestones",
|
||||
header: "Milestones",
|
||||
id: "distance",
|
||||
header: "Total KM",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => Math.max((row.original.milestones?.length ?? 0) - 2, 0),
|
||||
cell: ({ row }) => `${totalRouteDistanceKm(row.original)} km`,
|
||||
},
|
||||
{
|
||||
id: "milestones",
|
||||
header: "Stops",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => row.original.milestones?.length ?? 0,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<Badge color={row.original.isActive ? "edr-green" : "gray"} variant="light" size="sm">
|
||||
{row.original.isActive ? "Active" : "Inactive"}
|
||||
<Badge color={statusColor(row.original.status)} variant="light" size="sm">
|
||||
{statusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
@@ -262,11 +416,11 @@ export default function RoutesPage() {
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Deactivate">
|
||||
<Tooltip label="Mark stop working">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!row.original.isActive || deactivateMutation.isPending}
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -282,7 +436,7 @@ export default function RoutesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Routes"
|
||||
subtitle="Define rail corridors and their ordered yard stops used by train scheduling."
|
||||
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
||||
action={
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
Add route
|
||||
@@ -294,10 +448,10 @@ export default function RoutesPage() {
|
||||
loading={routesQuery.isLoading}
|
||||
items={[
|
||||
{ label: "Total routes", value: allRoutes.length, icon: RouteIcon },
|
||||
{ label: "Active", value: activeCount, icon: CircleCheck, color: "edr-green" },
|
||||
{ label: "Available", value: availableCount, icon: CircleCheck, color: "edr-green" },
|
||||
{
|
||||
label: "Inactive",
|
||||
value: allRoutes.length - activeCount,
|
||||
label: "Unavailable",
|
||||
value: allRoutes.length - availableCount,
|
||||
icon: Ban,
|
||||
color: "gray",
|
||||
},
|
||||
@@ -310,7 +464,7 @@ export default function RoutesPage() {
|
||||
<FleetToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
searchPlaceholder="Search routes…"
|
||||
searchPlaceholder="Search corridors…"
|
||||
viewMode={viewMode}
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
@@ -359,16 +513,13 @@ export default function RoutesPage() {
|
||||
<Card key={route.id} radius="lg" padding="lg" withBorder>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>{route.name}</Text>
|
||||
<Badge color={route.isActive ? "edr-green" : "gray"} variant="light" size="sm">
|
||||
{route.isActive ? "Active" : "Inactive"}
|
||||
<Text fw={600}>{formatRouteLabel(route)}</Text>
|
||||
<Badge color={statusColor(route.status)} variant="light" size="sm">
|
||||
{statusLabel(route.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{yardLabel(route.originYard)} → {yardLabel(route.destinationYard)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{Math.max((route.milestones?.length ?? 0) - 2, 0)} intermediate milestones
|
||||
{totalRouteDistanceKm(route)} km · {route.milestones?.length ?? 0} stops
|
||||
</Text>
|
||||
<Group gap={6} justify="flex-end">
|
||||
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
||||
@@ -405,26 +556,25 @@ export default function RoutesPage() {
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
// Capture the value before the state updater runs — React may
|
||||
// recycle the synthetic event, nulling currentTarget by the time
|
||||
// the updater executes ("Cannot read properties of null").
|
||||
const name = e.currentTarget.value;
|
||||
setForm((current) => ({ ...current, name }));
|
||||
}}
|
||||
/>
|
||||
{editing && (
|
||||
<Select
|
||||
label="Status"
|
||||
data={ROUTE_STATUS_OPTIONS}
|
||||
value={form.status}
|
||||
onChange={(value) =>
|
||||
value && setForm((current) => ({ ...current, status: value as RouteStatus }))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
Stops
|
||||
Stops & segment distances
|
||||
</Text>
|
||||
<Button type="button" variant="light" size="compact-sm" onClick={addMilestone}>
|
||||
Add milestone
|
||||
</Button>
|
||||
</Group>
|
||||
{form.milestones.map((yardId, index) => {
|
||||
{form.milestones.map((row, index) => {
|
||||
const role =
|
||||
index === 0
|
||||
? "Origin"
|
||||
@@ -432,18 +582,32 @@ export default function RoutesPage() {
|
||||
? "Destination"
|
||||
: "Milestone";
|
||||
return (
|
||||
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap">
|
||||
<Text w={100} size="sm" fw={500}>
|
||||
<Group key={`${role}-${index}`} align="flex-end" wrap="nowrap" gap="sm">
|
||||
<Text w={90} size="sm" fw={500}>
|
||||
{role}
|
||||
</Text>
|
||||
<Select
|
||||
style={{ flex: 1 }}
|
||||
data={availableOptionsForIndex(index)}
|
||||
value={yardId || null}
|
||||
onChange={(value) => value && setMilestone(index, value)}
|
||||
value={row.yardId || null}
|
||||
onChange={(value) => value && setMilestone(index, { yardId: value })}
|
||||
placeholder="Select yard"
|
||||
searchable
|
||||
/>
|
||||
{index > 0 ? (
|
||||
<NumberInput
|
||||
w={120}
|
||||
label="KM"
|
||||
min={0}
|
||||
decimalScale={2}
|
||||
value={row.distanceKm ? Number(row.distanceKm) : ""}
|
||||
onChange={(value) =>
|
||||
setMilestone(index, { distanceKm: String(value ?? "") })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Box w={120} />
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
@@ -455,6 +619,9 @@ export default function RoutesPage() {
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
<Text size="sm" c="dimmed">
|
||||
Total route distance: <strong>{formTotalKm} km</strong>
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" type="button" onClick={resetForm}>
|
||||
Cancel
|
||||
@@ -470,44 +637,37 @@ export default function RoutesPage() {
|
||||
<Modal
|
||||
opened={Boolean(viewing)}
|
||||
onClose={() => setViewing(null)}
|
||||
title={<Text fw={600}>Route details</Text>}
|
||||
title={<Text fw={600}>{viewing ? formatRouteLabel(viewing) : "Route details"}</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
size="md"
|
||||
>
|
||||
{viewing ? (
|
||||
<Stack gap="sm">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Status
|
||||
</Text>
|
||||
<Badge mt={4} color={statusColor(viewing.status)} variant="light">
|
||||
{statusLabel(viewing.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<Select
|
||||
w={200}
|
||||
label="Update status"
|
||||
data={ROUTE_STATUS_OPTIONS}
|
||||
value={viewing.status}
|
||||
onChange={(value) =>
|
||||
value && handleStatusChange(viewing, value as RouteStatus)
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Name
|
||||
<Text size="sm" fw={500} mb={8}>
|
||||
Road timeline
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{viewing.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Status
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{viewing.isActive ? "Active" : "Inactive"}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
Stops
|
||||
</Text>
|
||||
<Stack gap={6} mt={6}>
|
||||
{routeStops(viewing).map((stop, index, stops) => (
|
||||
<Text key={`${stop}-${index}`} size="sm" c="dimmed">
|
||||
{index === 0
|
||||
? "Origin"
|
||||
: index === stops.length - 1
|
||||
? "Destination"
|
||||
: `Milestone ${index}`}
|
||||
: {stop}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
<RouteTimeline route={viewing} />
|
||||
</div>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
PackageCheck,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
Ruler,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FirstMileContainerAllocationTable } from "@/components/FirstMileContainerAllocationTable";
|
||||
import { ReceiveInventoryModal } from "@/components/warehouses/ReceiveInventoryModal";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
@@ -342,6 +344,8 @@ const FirstMilePage = () => {
|
||||
const [distanceValue, setDistanceValue] = useState("");
|
||||
const [invoiceOpen, setInvoiceOpen] = useState(false);
|
||||
const [invoiceRecord, setInvoiceRecord] = useState<FirstMileRecord | null>(null);
|
||||
const [warehouseReceiveOpen, setWarehouseReceiveOpen] = useState(false);
|
||||
const [warehouseReceiveRecord, setWarehouseReceiveRecord] = useState<FirstMileRecord | null>(null);
|
||||
|
||||
const [containerAllocationOpen, setContainerAllocationOpen] = useState(false);
|
||||
const [containerAllocationFirstMileId, setContainerAllocationFirstMileId] = useState<string | null>(null);
|
||||
@@ -550,6 +554,16 @@ const FirstMilePage = () => {
|
||||
setInvoiceRecord(null);
|
||||
};
|
||||
|
||||
const openWarehouseReceive = (record: FirstMileRecord) => {
|
||||
setWarehouseReceiveRecord(record);
|
||||
setWarehouseReceiveOpen(true);
|
||||
};
|
||||
|
||||
const closeWarehouseReceive = () => {
|
||||
setWarehouseReceiveOpen(false);
|
||||
setWarehouseReceiveRecord(null);
|
||||
};
|
||||
|
||||
const openContainerAllocation = (firstMileId: string) => {
|
||||
setContainerAllocationFirstMileId(firstMileId);
|
||||
setContainerAllocationOpen(true);
|
||||
@@ -853,6 +867,7 @@ const FirstMilePage = () => {
|
||||
const nextStatus = NEXT_STATUS[row.original.status];
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const isPaid = (row.original as any).paid;
|
||||
const canReceiveToWarehouse = row.original.status === "RECEIVED_TO_PORT";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -891,6 +906,13 @@ const FirstMilePage = () => {
|
||||
>
|
||||
View detail
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<PackageCheck size={15} />}
|
||||
disabled={!canReceiveToWarehouse}
|
||||
onClick={() => openWarehouseReceive(row.original)}
|
||||
>
|
||||
Receive to warehouse
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Ruler size={15} />}
|
||||
onClick={() => openDistance(row.original.id)}
|
||||
@@ -1101,6 +1123,19 @@ const FirstMilePage = () => {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReceiveInventoryModal
|
||||
opened={warehouseReceiveOpen}
|
||||
onClose={closeWarehouseReceive}
|
||||
mode="bulk"
|
||||
direction="EXPORT"
|
||||
bookingId={warehouseReceiveRecord?.bookingId}
|
||||
bookingLabel={warehouseReceiveRecord ? bookingRef(warehouseReceiveRecord) : undefined}
|
||||
onReceived={() => {
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.FIRST_MILE.list() });
|
||||
closeWarehouseReceive();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Accept Booking modal — step 1: booking list, step 2: details + vehicle */}
|
||||
<Modal
|
||||
opened={acceptOpen}
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import type { ArrivalQueueItem } from "@/types/warehouse";
|
||||
import type { ArrivalQueueItem, ImportUnloadedItem, WarehouseInventoryItem } from "@/types/warehouse";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
@@ -45,8 +45,10 @@ import {
|
||||
lastMileService,
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
const formatPrice = (amount: number) =>
|
||||
@@ -111,6 +113,59 @@ const requestedDate = (r: LastMileRecord) => {
|
||||
const serviceTypeName = (r: LastMileRecord) =>
|
||||
r.booking?.serviceType?.label ?? r.booking?.serviceType?.name ?? "—";
|
||||
|
||||
const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem =>
|
||||
({
|
||||
id: row.id,
|
||||
bookingId: row.bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(row.weight) || 0,
|
||||
grnNumber: row.grnNumber,
|
||||
status: row.currentStatus,
|
||||
arrivedAt: row.arrivalTime,
|
||||
unloadedAt: row.arrivalTime,
|
||||
inspectionStatus: row.inspectionStatus,
|
||||
releaseDate: row.releaseDate,
|
||||
releaseOrderReference: row.releaseOrderReference,
|
||||
handoverDocumentReference: row.handoverDocumentReference,
|
||||
handoverDocumentDate: row.handoverDocumentDate,
|
||||
deliveredAt: row.deliveredAt,
|
||||
booking: row.bookingId
|
||||
? {
|
||||
id: row.bookingId,
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: "IMPORT",
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
const releasePrefillFromLastMile = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
driversById?: Map<string, Driver>,
|
||||
): ReleaseOrderTruckPrefill => {
|
||||
const vehicle = record.vehicle;
|
||||
const truckType = [vehicle?.manufacturer, vehicle?.model].filter(Boolean).join(" ").trim();
|
||||
const assignedDriver = vehicle?.assignedDriverId ? driversById?.get(vehicle.assignedDriverId) : undefined;
|
||||
const assignedDriverName = assignedDriver
|
||||
? `${assignedDriver.firstName ?? ""} ${assignedDriver.lastName ?? ""}`.trim()
|
||||
: "";
|
||||
return {
|
||||
truckPlateNumber: vehicle?.powerPlateNo || vehicle?.plateNumber || null,
|
||||
trailerPlateNumber: vehicle?.trailerPlateNo || null,
|
||||
driverName: vehicle?.assignedDriverName || assignedDriverName || null,
|
||||
driverLicense: assignedDriver?.licenseNumber || null,
|
||||
driverPhone: assignedDriver?.phoneNumber || null,
|
||||
truckType: vehicle?.vehicleType || truckType || null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
const InfoRow = ({ label, value }: { label: string; value: string }) => (
|
||||
<Stack gap={2}>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
@@ -329,6 +384,8 @@ const LastMilePage = () => {
|
||||
|
||||
const [allocationOpen, setAllocationOpen] = useState(false);
|
||||
const [allocationContainers, setAllocationContainers] = useState<LastMileContainerRow[]>([]);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseTruckPrefill, setReleaseTruckPrefill] = useState<ReleaseOrderTruckPrefill | null>(null);
|
||||
|
||||
const { data: listData, isLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.LAST_MILE.list(),
|
||||
@@ -360,6 +417,27 @@ const LastMilePage = () => {
|
||||
[records],
|
||||
);
|
||||
|
||||
const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId);
|
||||
|
||||
const { data: driversData } = useQuery({
|
||||
queryKey: ["drivers", "list", "ACTIVE"],
|
||||
queryFn: async () => {
|
||||
const res = await driversService.getAll({ status: "ACTIVE" });
|
||||
return res.data;
|
||||
},
|
||||
enabled: needsDriverLookup,
|
||||
});
|
||||
|
||||
const driversById = useMemo(
|
||||
() => new Map((driversData ?? []).map((driver) => [driver.id, driver])),
|
||||
[driversData],
|
||||
);
|
||||
|
||||
const { data: pickupReadyRows = [] } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "import-pickup-ready-queue"],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
});
|
||||
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
(Array.isArray(vehiclesData) ? vehiclesData : []).map((v) => {
|
||||
@@ -556,6 +634,15 @@ const LastMilePage = () => {
|
||||
[records, activeId],
|
||||
);
|
||||
|
||||
const pickupReadyByBooking = useMemo(() => {
|
||||
const map = new Map<string, ImportUnloadedItem>();
|
||||
for (const row of pickupReadyRows) {
|
||||
if (row.bookingId) map.set(row.bookingId, row);
|
||||
if (row.bookingReference) map.set(row.bookingReference, row);
|
||||
}
|
||||
return map;
|
||||
}, [pickupReadyRows]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||
[rowSelection],
|
||||
@@ -592,6 +679,7 @@ const LastMilePage = () => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return records.filter((r) => {
|
||||
if (!matchesFilter(r)) return false;
|
||||
if (filterPostPaymentPending && !(r.remainingPayment > 0)) return false;
|
||||
if (!term) return true;
|
||||
return [bookingRef(r), customerName(r), deliveryLocation(r), cargoDesc(r)]
|
||||
.join(" ")
|
||||
@@ -672,6 +760,37 @@ const LastMilePage = () => {
|
||||
setTripSlipOpen(true);
|
||||
};
|
||||
|
||||
const openTruckArrival = (record: LastMileRecord) => {
|
||||
if (!isAssigned(record)) {
|
||||
toast({
|
||||
title: "Assign a truck first",
|
||||
description: "Truck arrival opens after a last-mile vehicle is assigned.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const row = pickupReadyByBooking.get(record.bookingId) ?? pickupReadyByBooking.get(bookingRef(record));
|
||||
if (!row) {
|
||||
toast({
|
||||
title: "Import inventory is not pickup-ready",
|
||||
description: `${bookingRef(record)} must be unloaded and pass inspection before truck arrival.`,
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row, driversById));
|
||||
setReleaseItem(toReleaseInventoryItem(row));
|
||||
};
|
||||
|
||||
const closeTruckArrival = () => {
|
||||
setReleaseItem(null);
|
||||
setReleaseTruckPrefill(null);
|
||||
void qc.invalidateQueries({ queryKey: ["warehouse-inventory"] });
|
||||
void qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE.ROOT });
|
||||
};
|
||||
|
||||
const printTripSlip = () => {
|
||||
if (!tripSlipRecord) return;
|
||||
const win = window.open("", "_blank", "width=820,height=920");
|
||||
@@ -834,6 +953,9 @@ const LastMilePage = () => {
|
||||
const canPrint = row.original.status !== "PAYMENT_PENDING";
|
||||
const isPaid = (row.original as any).paid;
|
||||
const delivered = row.original.status === "DELIVERED";
|
||||
const releaseRow =
|
||||
pickupReadyByBooking.get(row.original.bookingId) ?? pickupReadyByBooking.get(bookingRef(row.original));
|
||||
const truckArrivalLabel = releaseRow?.releaseOrderReference ? "Truck Leaving" : "Truck Arrival";
|
||||
return (
|
||||
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||
<Menu position="bottom-end" width={200} withinPortal>
|
||||
@@ -865,6 +987,13 @@ const LastMilePage = () => {
|
||||
>
|
||||
Reassign
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<Truck size={15} />}
|
||||
disabled={!assigned}
|
||||
onClick={() => openTruckArrival(row.original)}
|
||||
>
|
||||
{truckArrivalLabel}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Eye size={15} />}
|
||||
@@ -912,7 +1041,7 @@ const LastMilePage = () => {
|
||||
},
|
||||
];
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [vehicleOptions]);
|
||||
}, [vehicleOptions, pickupReadyByBooking]);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -1413,6 +1542,13 @@ const LastMilePage = () => {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<ReleaseOrderModal
|
||||
opened={Boolean(releaseItem)}
|
||||
onClose={closeTruckArrival}
|
||||
item={releaseItem}
|
||||
truckPrefill={releaseTruckPrefill}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,7 +51,6 @@ interface CargoNode extends RuleEngineRecord {
|
||||
cargoTypeName?: string;
|
||||
code?: string;
|
||||
parentGroupId?: string | null;
|
||||
showFreeTextBox?: boolean;
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
@@ -80,7 +79,6 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||
],
|
||||
},
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
@@ -474,19 +472,6 @@ function CargoRow({
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.showFreeTextBox ? (
|
||||
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<FileText size={11} />}
|
||||
>
|
||||
Free text
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.unitOfMeasure ? (
|
||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ColumnFormat =
|
||||
| "rateStatus"
|
||||
| "date"
|
||||
| "number"
|
||||
| "currency"
|
||||
| "entityLabel"
|
||||
| "rateLabel";
|
||||
|
||||
@@ -36,6 +37,8 @@ export interface FormFieldDef {
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||
suffix?: string;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
hideWhen?: { field: string; equals: string[] };
|
||||
/**
|
||||
@@ -174,7 +177,6 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select parent cargo type (optional)",
|
||||
},
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
@@ -402,7 +404,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
columns: [
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
|
||||
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "currency" },
|
||||
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
|
||||
@@ -454,7 +456,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "INTERCITY"] },
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true },
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
|
||||
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
|
||||
{ name: "effectiveTo", label: "Effective to", type: "date" },
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -88,6 +90,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const autoPreviewedRef = useRef(false);
|
||||
|
||||
const detailQuery = useQuery(
|
||||
@@ -98,6 +104,42 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const schedule = detailQuery.data;
|
||||
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
|
||||
const isDjiboutiPort = (value?: string | null) =>
|
||||
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
|
||||
(value ?? "").toUpperCase().includes(token),
|
||||
);
|
||||
const gatepassApplies = Boolean(
|
||||
schedule &&
|
||||
((schedule.direction === "IMPORT" &&
|
||||
isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) ||
|
||||
(schedule.direction === "EXPORT" &&
|
||||
isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))),
|
||||
);
|
||||
const gatepassQuery = useQuery({
|
||||
queryKey: ["train-scheduling", "gatepass", scheduleId],
|
||||
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
|
||||
enabled: Boolean(scheduleId && gatepassApplies),
|
||||
});
|
||||
const secureGatepass = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
|
||||
securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined,
|
||||
reference: gatepassReference.trim() || undefined,
|
||||
fileUrl: gatepassFileUrl.trim() || undefined,
|
||||
notes: gatepassNotes.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
toast({ title: "Gate pass secured" });
|
||||
void gatepassQuery.refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Gate pass failed",
|
||||
description: parseError(error, "Could not secure gate pass"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
@@ -133,6 +175,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
if (!operation) return;
|
||||
const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt;
|
||||
setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : "");
|
||||
setGatepassReference(operation.documents?.GATE_PASS?.reference ?? "");
|
||||
setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? "");
|
||||
setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? "");
|
||||
}, [gatepassQuery.data]);
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
[schedule?.bookings],
|
||||
@@ -899,6 +951,83 @@ export default function TrainScheduleV2DetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
|
||||
>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<Title order={4} fw={700}>
|
||||
Djibouti Port gate pass
|
||||
</Title>
|
||||
<Badge
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.direction === "IMPORT"
|
||||
? "Secure before dispatch from Djibouti."
|
||||
: "Secure after dispatch before Djibouti Port entry / unloading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" grow>
|
||||
<TextInput
|
||||
label="Secured date"
|
||||
type="datetime-local"
|
||||
value={gatepassSecuredAt}
|
||||
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document reference"
|
||||
placeholder="Optional"
|
||||
value={gatepassReference}
|
||||
onChange={(event) => setGatepassReference(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document URL"
|
||||
placeholder="Optional upload/link"
|
||||
value={gatepassFileUrl}
|
||||
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={gatepassNotes}
|
||||
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Save as Secured
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Paper radius="xl" p="lg">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "@/components/trainScheduling/scheduleVisuals";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { formatRouteLabel } from "@/services/routes.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainScheduleListItem } from "@/types/trainScheduling";
|
||||
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
|
||||
@@ -88,7 +89,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
const schedulesQuery = useQuery(
|
||||
api.trainScheduling.scheduleList.queryOptions({ input: {} }),
|
||||
);
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||
const routesQuery = useQuery(
|
||||
api.routes.list.queryOptions({ input: { status: "AVAILABLE" } }),
|
||||
);
|
||||
const locomotivesQuery = useQuery(
|
||||
api.trainScheduling.availableLocomotives.queryOptions({
|
||||
input: { routeId: routeId || undefined },
|
||||
@@ -97,10 +100,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
[routesQuery.data],
|
||||
);
|
||||
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]);
|
||||
|
||||
const selectedRoute = activeRoutes.find((r) => r.id === routeId);
|
||||
|
||||
@@ -529,7 +529,7 @@ export default function TrainScheduleV2ListPage() {
|
||||
<Select
|
||||
label="Route"
|
||||
placeholder="Select route"
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: r.name }))}
|
||||
data={activeRoutes.map((r) => ({ value: r.id, label: formatRouteLabel(r) }))}
|
||||
value={routeId || null}
|
||||
onChange={(v) => setRouteId(v ?? "")}
|
||||
searchable
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -21,11 +22,17 @@ import {
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
@@ -43,8 +50,54 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const locationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
function isUnloadPending(item: ImportTrainItem) {
|
||||
return !item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, AssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -73,12 +126,26 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -95,6 +162,41 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -102,7 +204,8 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -112,11 +215,36 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
@@ -127,7 +255,9 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
@@ -169,10 +299,12 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train, assign each booking to a warehouse yard and zone, then unload it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -254,7 +386,7 @@ export default function ArrivalQueuePage() {
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
@@ -265,7 +397,27 @@ export default function ArrivalQueuePage() {
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[train.scheduleId]: {
|
||||
...(current[train.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId
|
||||
? draft
|
||||
: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -29,6 +29,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseGatewayPaymentMethod,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
import { openPdfBlob } from '@/components/warehouses/pdf';
|
||||
@@ -164,15 +165,23 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}),
|
||||
);
|
||||
const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
|
||||
const payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
|
||||
const [payerAccount, setPayerAccount] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
|
||||
useEffect(() => {
|
||||
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
|
||||
setPayerAccount('');
|
||||
}, [inv?.id, inv?.currency]);
|
||||
|
||||
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
@@ -302,6 +311,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnlinePay = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
const currentUrl = window.location.href;
|
||||
const result = await payOnline.mutateAsync({
|
||||
id: inv.id,
|
||||
payload: {
|
||||
method: gatewayMethod,
|
||||
platform: 'web',
|
||||
payerAccount: payerAccount.trim() || undefined,
|
||||
returnUrl: currentUrl,
|
||||
failureUrl: currentUrl,
|
||||
},
|
||||
});
|
||||
const url = result.clientAction?.url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: 'Payment initiated',
|
||||
description: 'No redirect URL was returned by the payment provider.',
|
||||
});
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
@@ -359,7 +396,31 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
|
||||
{canPay && (
|
||||
<>
|
||||
<Divider label="Record payment" labelPosition="left" />
|
||||
<Divider label="Online payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<Select
|
||||
label="Provider"
|
||||
value={gatewayMethod}
|
||||
onChange={(v) => setGatewayMethod((v as WarehouseGatewayPaymentMethod) ?? 'TELEBIRR')}
|
||||
data={[
|
||||
{ value: 'TELEBIRR', label: 'Telebirr' },
|
||||
{ value: 'WAAFI', label: 'Waafi' },
|
||||
]}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Wallet phone / account"
|
||||
value={payerAccount}
|
||||
onChange={(e) => setPayerAccount(e.currentTarget.value)}
|
||||
placeholder="Optional"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
|
||||
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label="Record manual payment" labelPosition="left" />
|
||||
<Group align="flex-end">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -38,6 +42,7 @@ const FREIGHT = [
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'BOTH', label: 'Import & Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
@@ -53,6 +58,23 @@ const numberValue = (value: string | number, fallback = 0) => {
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
const dash = '-';
|
||||
type CodeOptionSource = {
|
||||
id?: string;
|
||||
code?: string;
|
||||
cargoTypeName?: string;
|
||||
label?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const codeOptions = (rows: unknown[]) =>
|
||||
(rows as CodeOptionSource[])
|
||||
.filter((row) => row.code)
|
||||
.map((row) => ({
|
||||
value: row.code as string,
|
||||
label: `${row.cargoTypeName ?? row.label ?? row.name ?? row.code} (${row.code})`,
|
||||
}));
|
||||
|
||||
const isUnknownTiersError = (error: unknown) => extractErrorMessage(error).includes('property tiers should not exist');
|
||||
|
||||
export default function WarehouseRulesPage() {
|
||||
return (
|
||||
@@ -319,6 +341,12 @@ function AllocationRules() {
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
const { data, isLoading } = useFeeRules();
|
||||
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
|
||||
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
|
||||
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
|
||||
);
|
||||
const create = useCreateFeeRule();
|
||||
const remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -328,11 +356,56 @@ function FeeRules() {
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
currency: 'USD',
|
||||
});
|
||||
const rules = data ?? [];
|
||||
const cargoTypeOptions = codeOptions(cargoTypes);
|
||||
const containerTypeOptions = codeOptions(containerTypes);
|
||||
const isBulkRule = form.freightType === 'BULK';
|
||||
const isContainerRule = form.freightType === 'CONTAINER';
|
||||
|
||||
const resetForm = () =>
|
||||
setForm({
|
||||
name: '',
|
||||
ruleType: 'DEMURRAGE_FEE',
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [],
|
||||
currency: 'USD',
|
||||
});
|
||||
|
||||
const addTier = () =>
|
||||
setForm((f) => {
|
||||
const last = f.tiers[f.tiers.length - 1];
|
||||
const fromDay = last?.toDay ? last.toDay + 1 : f.tiers.length ? last.fromDay + 1 : f.freeDays + 1;
|
||||
return {
|
||||
...f,
|
||||
tiers: [...f.tiers, { fromDay, toDay: fromDay, ratePerDay: f.ratePerDay || 0 }],
|
||||
};
|
||||
});
|
||||
|
||||
const updateTier = (
|
||||
index: number,
|
||||
patch: Partial<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tier, i) => (i === index ? { ...tier, ...patch } : tier)),
|
||||
}));
|
||||
|
||||
const removeTier = (index: number) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.filter((_, i) => i !== index),
|
||||
}));
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.name.trim()) {
|
||||
@@ -340,18 +413,68 @@ function FeeRules() {
|
||||
return;
|
||||
}
|
||||
|
||||
await create.mutateAsync({
|
||||
const tiers = form.tiers.map((tier) => ({
|
||||
fromDay: tier.fromDay,
|
||||
toDay: tier.toDay || null,
|
||||
ratePerDay: tier.ratePerDay,
|
||||
}));
|
||||
for (const [index, tier] of tiers.entries()) {
|
||||
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: from day must be at least 1` });
|
||||
return;
|
||||
}
|
||||
if (tier.toDay != null && tier.toDay < tier.fromDay) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: to day must be after from day` });
|
||||
return;
|
||||
}
|
||||
if (tier.ratePerDay < 0) {
|
||||
toast({ variant: 'destructive', title: `Tier ${index + 1}: amount must be zero or greater` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? null,
|
||||
tradeDirection: clean(form.tradeDirection) ?? null,
|
||||
cargoTypeCode: clean(form.cargoTypeCode) ?? null,
|
||||
cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
|
||||
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
|
||||
freeDays: form.freeDays,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
await create.mutateAsync(payload as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
if (tiers.length && isUnknownTiersError(error)) {
|
||||
const legacyPayload: Omit<typeof payload, 'tiers'> = {
|
||||
name: payload.name,
|
||||
ruleType: payload.ruleType,
|
||||
freightType: payload.freightType,
|
||||
tradeDirection: payload.tradeDirection,
|
||||
cargoTypeCode: payload.cargoTypeCode,
|
||||
containerType: payload.containerType,
|
||||
freeDays: payload.freeDays,
|
||||
ratePerDay: payload.ratePerDay,
|
||||
currency: payload.currency,
|
||||
};
|
||||
await create.mutateAsync(legacyPayload as never);
|
||||
toast({
|
||||
title: 'Fee rule created without tiers',
|
||||
description: 'The connected API does not support progressive tiers yet. Deploy the warehouse fee tier migration/API to save tier rows.',
|
||||
});
|
||||
setOpen(false);
|
||||
resetForm();
|
||||
return;
|
||||
}
|
||||
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -370,7 +493,7 @@ function FeeRules() {
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -378,6 +501,9 @@ function FeeRules() {
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</Table.Th>
|
||||
<Table.Th>Trade</Table.Th>
|
||||
<Table.Th>Cargo</Table.Th>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Location scope</Table.Th>
|
||||
<Table.Th>Free days</Table.Th>
|
||||
<Table.Th>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
@@ -395,6 +521,20 @@ function FeeRules() {
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
|
||||
<Table.Td>{rule.containerType ?? dash}</Table.Td>
|
||||
<Table.Td>
|
||||
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
|
||||
<Stack gap={2}>
|
||||
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
|
||||
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
|
||||
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
|
||||
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
|
||||
</Stack>
|
||||
) : (
|
||||
dash
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{rule.freeDays}</Table.Td>
|
||||
<Table.Td>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
@@ -454,7 +594,14 @@ function FeeRules() {
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
value={form.freightType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))}
|
||||
onChange={(value) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
freightType: selectValue(value),
|
||||
cargoTypeCode: value === 'BULK' ? f.cargoTypeCode : '',
|
||||
containerType: value === 'CONTAINER' ? f.containerType : '',
|
||||
}))
|
||||
}
|
||||
clearable
|
||||
/>
|
||||
<Select
|
||||
@@ -464,15 +611,31 @@ function FeeRules() {
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder={cargoTypesLoading ? 'Loading cargo types...' : 'Any cargo'}
|
||||
data={cargoTypeOptions}
|
||||
value={form.cargoTypeCode || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, cargoTypeCode: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={cargoTypesLoading}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
{isContainerRule && (
|
||||
<Select
|
||||
label="Container type"
|
||||
placeholder={containerTypesLoading ? 'Loading container types...' : 'Any container'}
|
||||
data={containerTypeOptions}
|
||||
value={form.containerType || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, containerType: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={containerTypesLoading}
|
||||
/>
|
||||
)}
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
@@ -494,6 +657,51 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
Progressive tariff tiers
|
||||
</Text>
|
||||
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={addTier}>
|
||||
Add tier
|
||||
</Button>
|
||||
</Group>
|
||||
{form.tiers.map((tier, index) => (
|
||||
<Group key={index} grow align="end">
|
||||
<NumberInput
|
||||
label="From day"
|
||||
min={1}
|
||||
value={tier.fromDay}
|
||||
onChange={(value) => updateTier(index, { fromDay: numberValue(value, 1) || 1 })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="To day"
|
||||
min={tier.fromDay}
|
||||
value={tier.toDay ?? ''}
|
||||
placeholder="Open"
|
||||
onChange={(value) =>
|
||||
updateTier(index, {
|
||||
toDay: value === '' ? null : numberValue(value, tier.fromDay),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount / day"
|
||||
min={0}
|
||||
value={tier.ratePerDay}
|
||||
onChange={(value) => updateTier(index, { ratePerDay: numberValue(value) })}
|
||||
/>
|
||||
<ActionIcon variant="subtle" color="red" onClick={() => removeTier(index)} title="Remove tier">
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
))}
|
||||
{form.tiers.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
No stepped tiers. The flat rate per day is used after the free days.
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
Reference in New Issue
Block a user