mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
changes
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
Container,
|
||||
FileSignature,
|
||||
FileText,
|
||||
Flag,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
Network,
|
||||
@@ -15,14 +14,21 @@ import {
|
||||
Send,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
SlidersHorizontal,
|
||||
Train,
|
||||
Truck,
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { Navigate, Outlet, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { FreightDashboardLayout, type SidebarItem, type SidebarSection } from "@/components/layout";
|
||||
import { useAuth } from "./auth/useAuth";
|
||||
@@ -37,11 +43,11 @@ import ContractRequestDetailPage from "./pages/contracts/ContractRequestDetailPa
|
||||
import ContractViewPage from "./pages/contracts/ContractViewPage";
|
||||
import ContractClearanceListPage from "./pages/contracts/ContractClearanceListPage";
|
||||
import ContractClearanceDetailPage from "./pages/contracts/ContractClearanceDetailPage";
|
||||
import GlEthiopiaClearanceListPage from "./pages/contracts/GlEthiopiaClearanceListPage";
|
||||
import GlDjiboutiClearanceListPage from "./pages/contracts/GlDjiboutiClearanceListPage";
|
||||
import GlClearanceDetailPage from "./pages/contracts/GlClearanceDetailPage";
|
||||
import ShipmentRequestsPage from "./pages/contracts/ShipmentRequestsPage";
|
||||
import ShipmentRequestDetailPage from "./pages/contracts/ShipmentRequestDetailPage";
|
||||
import GlCreateBookingForm from "./components/contracts/GlCreateBookingForm";
|
||||
import BookingMilestonesPage from "./pages/contracts/BookingMilestonesPage";
|
||||
import DocumentClearanceDetailPage from "./pages/bookings/DocumentClearanceDetailPage";
|
||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import DemoUser1Page from "./pages/dashboard/demo/DemoUser1Page";
|
||||
@@ -139,19 +145,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Document Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceReview,
|
||||
permission: [
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "GL Ethiopia Clearance",
|
||||
href: "/dashboard/gl-ethiopia/clearance",
|
||||
icon: <Flag />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
},
|
||||
{
|
||||
label: "GL Djibouti Clearance",
|
||||
href: "/dashboard/gl-djibouti/clearance",
|
||||
icon: <Ship />,
|
||||
permission: FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
label: "Shipment Requests",
|
||||
href: "/dashboard/shipment-requests",
|
||||
icon: <Send />,
|
||||
permission: FREIGHT_PERMS.contracts.createBooking,
|
||||
},
|
||||
{
|
||||
label: "Train Schedules",
|
||||
@@ -481,7 +485,11 @@ const App = () => {
|
||||
/>
|
||||
<Route
|
||||
path="clearance/:id"
|
||||
element={<Navigate to="/dashboard/contracts/clearance" replace />}
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Contracts (Path A/B) */}
|
||||
@@ -509,11 +517,41 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="bookings/:bookingId/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.clearanceView}>
|
||||
<DocumentClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipment-requests"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<ShipmentRequestsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="shipment-requests/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.createBooking}>
|
||||
<ShipmentRequestDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* GL (Path B) contract clearance review hub */}
|
||||
<Route
|
||||
path="contracts/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -521,51 +559,21 @@ const App = () => {
|
||||
<Route
|
||||
path="contracts/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceReview}>
|
||||
<RequirePermission
|
||||
permission={[
|
||||
FREIGHT_PERMS.contracts.clearanceReview,
|
||||
FREIGHT_PERMS.contracts.clearanceEtActions,
|
||||
FREIGHT_PERMS.contracts.clearanceDjActions,
|
||||
]}
|
||||
>
|
||||
<ContractClearanceDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-ethiopia/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||
<GlEthiopiaClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-ethiopia/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceEtActions}>
|
||||
<GlClearanceDetailPage
|
||||
backTo="/dashboard/gl-ethiopia/clearance"
|
||||
breadcrumbsLabel="GL Ethiopia Clearance"
|
||||
roleMode="ET"
|
||||
/>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-djibouti/clearance"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<GlDjiboutiClearanceListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="gl-djibouti/clearance/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.contracts.clearanceDjActions}>
|
||||
<GlClearanceDetailPage
|
||||
backTo="/dashboard/gl-djibouti/clearance"
|
||||
breadcrumbsLabel="GL Djibouti Clearance"
|
||||
roleMode="DJ"
|
||||
/>
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="gl-ethiopia/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-ethiopia/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-djibouti/clearance" element={<LegacyGlClearanceRedirect />} />
|
||||
<Route path="gl-djibouti/clearance/:id" element={<LegacyGlClearanceRedirect />} />
|
||||
{/* Path A ops queue out of scope for now → fold into the GL hub. */}
|
||||
<Route
|
||||
path="contracts/ops-clearance"
|
||||
@@ -909,4 +917,13 @@ const App = () => {
|
||||
);
|
||||
};
|
||||
|
||||
/** Redirect legacy GL Ethiopia/Djibouti clearance URLs to the unified hub. */
|
||||
function LegacyGlClearanceRedirect() {
|
||||
const { id } = useParams();
|
||||
if (id) {
|
||||
return <Navigate to={`/dashboard/contracts/clearance/${id}`} replace />;
|
||||
}
|
||||
return <Navigate to="/dashboard/contracts/clearance" replace />;
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -4,17 +4,6 @@ import type { Freight } from "@edr/types";
|
||||
|
||||
const BRAND_GREEN = "var(--freight-brand, #0A6F4D)";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "Customer docs",
|
||||
GL_ET_REVIEW: "GL ET review",
|
||||
GL_DJ_COLLECTION: "GL Djibouti",
|
||||
GL_ET_OUTPUT: "Declaration",
|
||||
CUSTOMER_DUTY: "Duty / tax",
|
||||
GL_ET_POST_CLEARANCE: "ET clearance",
|
||||
GL_DJ_LOADING: "Loading",
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
const IMPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -24,6 +13,17 @@ const IMPORT_PHASES = [
|
||||
"GL_DJ_COLLECTION",
|
||||
] as const;
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
CUSTOMER_INTAKE: "Customer docs",
|
||||
GL_ET_REVIEW: "GL ET review",
|
||||
GL_DJ_COLLECTION: "GL Djibouti DO",
|
||||
GL_ET_OUTPUT: "Declaration",
|
||||
CUSTOMER_DUTY: "Duty / customer pays",
|
||||
GL_ET_POST_CLEARANCE: "Transit & finalize",
|
||||
GL_DJ_LOADING: "Loading",
|
||||
POST_TRANSIT: "Transit",
|
||||
};
|
||||
|
||||
const EXPORT_PHASES = [
|
||||
"CUSTOMER_INTAKE",
|
||||
"GL_ET_REVIEW",
|
||||
@@ -43,7 +43,7 @@ export function ClearancePhaseStepper({
|
||||
tradeDirection,
|
||||
compact = false,
|
||||
}: {
|
||||
clearance?: Freight.ContractClearanceView | null;
|
||||
clearance?: Freight.ContractClearanceView | Freight.ClearanceView | null;
|
||||
tradeDirection?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { Download, Eye, FileText } from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { isViewable } from "@edr/ui-common";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { fileViewUrl } from "@/constants/apiConfig";
|
||||
|
||||
const CATEGORY_LABELS: Record<
|
||||
Freight.ClearanceWorkflowFileCategory,
|
||||
string
|
||||
> = {
|
||||
declaration: "Declaration",
|
||||
duty: "Duty & taxes",
|
||||
transit: "Transit",
|
||||
djibouti: "Djibouti",
|
||||
};
|
||||
|
||||
const CATEGORY_ORDER: Freight.ClearanceWorkflowFileCategory[] = [
|
||||
"declaration",
|
||||
"duty",
|
||||
"transit",
|
||||
"djibouti",
|
||||
];
|
||||
|
||||
const OWNER_LABELS: Record<Freight.ClearanceWorkflowFileOwner, string> = {
|
||||
customer: "Customer",
|
||||
gl_et: "GL Ethiopia",
|
||||
gl_dj: "GL Djibouti",
|
||||
};
|
||||
|
||||
export interface ClearanceWorkflowFilesPanelProps {
|
||||
files: Freight.ClearanceWorkflowFile[];
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function ClearanceWorkflowFilesPanel({
|
||||
files,
|
||||
onView,
|
||||
onDownload,
|
||||
title = "Customs workflow documents",
|
||||
}: ClearanceWorkflowFilesPanelProps) {
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const grouped = CATEGORY_ORDER.map((category) => ({
|
||||
category,
|
||||
label: CATEGORY_LABELS[category],
|
||||
items: files.filter((f) => f.category === category),
|
||||
})).filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<SectionCard icon={FileText} title={title} accent="edr-green">
|
||||
<Stack gap="md">
|
||||
{grouped.map((group) => (
|
||||
<Box key={group.category}>
|
||||
<Text size="xs" fw={700} c="dimmed" tt="uppercase" mb={8}>
|
||||
{group.label}
|
||||
</Text>
|
||||
<Stack gap={8}>
|
||||
{group.items.map((item) => (
|
||||
<WorkflowFileRow
|
||||
key={item.code}
|
||||
item={item}
|
||||
onView={onView}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkflowFileRow({
|
||||
item,
|
||||
onView,
|
||||
onDownload,
|
||||
}: {
|
||||
item: Freight.ClearanceWorkflowFile;
|
||||
onView: (file: { name: string; url: string }) => void;
|
||||
onDownload?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const file = item.file;
|
||||
if (!file) return null;
|
||||
|
||||
const viewUrl = fileViewUrl(file.id);
|
||||
const canPreview = isViewable({ name: file.name, url: viewUrl });
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="md" p="sm">
|
||||
<Group justify="space-between" wrap="nowrap" align="center">
|
||||
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={36}>
|
||||
<FileText size={17} />
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{item.label}
|
||||
</Text>
|
||||
<Group gap={6} wrap="nowrap" mt={2}>
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm" tt="none">
|
||||
{OWNER_LABELS[item.uploadedBy]}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{canPreview ? (
|
||||
<Tooltip label="Preview">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="default"
|
||||
radius="md"
|
||||
leftSection={<Eye size={13} />}
|
||||
onClick={() => onView({ name: file.name, url: viewUrl })}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{onDownload ? (
|
||||
<Tooltip label="Download">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
radius="md"
|
||||
leftSection={<Download size={13} />}
|
||||
onClick={() => onDownload({ id: file.id, name: file.name })}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,14 @@
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, ShieldCheck } from "lucide-react";
|
||||
import { Stack, Group, Text, Badge, Button, Box } from "@mantine/core";
|
||||
import {
|
||||
Stack,
|
||||
Group,
|
||||
Text,
|
||||
Badge,
|
||||
Button,
|
||||
Box,
|
||||
Modal,
|
||||
} from "@mantine/core";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
@@ -19,6 +27,10 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
|
||||
const steps = useMemo(
|
||||
() =>
|
||||
[...(contract.approvalSteps ?? [])].sort(
|
||||
@@ -30,6 +42,24 @@ export function ContractApprovalStepsCard({
|
||||
const nextPending = steps.find((s) => s.status === "PENDING");
|
||||
const summary = formatContractApprovalProgress(contract.status, steps);
|
||||
|
||||
const openApprove = (step: Freight.IContractApprovalStep) => {
|
||||
setPendingStep(step);
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const closeApprove = () => {
|
||||
setConfirmOpen(false);
|
||||
setPendingStep(null);
|
||||
};
|
||||
|
||||
const runApprove = () => {
|
||||
if (!pendingStep) return;
|
||||
mutations.approveStep.mutate(
|
||||
{ stepId: pendingStep.id, requiredRole: pendingStep.requiredRole },
|
||||
{ onSuccess: () => closeApprove() },
|
||||
);
|
||||
};
|
||||
|
||||
const subtitle =
|
||||
summary.detail ||
|
||||
(nextPending
|
||||
@@ -39,54 +69,87 @@ export function ContractApprovalStepsCard({
|
||||
: "Accept submission to begin");
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
<>
|
||||
<SectionCard
|
||||
icon={ShieldCheck}
|
||||
title="Approval chain"
|
||||
extra={
|
||||
<Badge color="edr-green" variant="light" radius="sm">
|
||||
{steps.filter((s) => s.status === "APPROVED").length}/{steps.length}
|
||||
</Badge>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed" mb="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() =>
|
||||
mutations.approveStep.mutate({
|
||||
stepId: step.id,
|
||||
requiredRole: step.requiredRole,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{steps.length === 0 ? (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
ta="center"
|
||||
py="lg"
|
||||
px="md"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: "1px dashed var(--mantine-color-gray-3)",
|
||||
background: "var(--mantine-color-gray-0)",
|
||||
}}
|
||||
>
|
||||
Use <strong>Accept for approval</strong> in staff actions to
|
||||
instantiate steps.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{steps.map((step) => (
|
||||
<StepRow
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={nextPending?.id === step.id}
|
||||
isPending={mutations.approveStep.isPending}
|
||||
onApprove={() => openApprove(step)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
<Modal
|
||||
opened={confirmOpen}
|
||||
onClose={closeApprove}
|
||||
title="Approve this step?"
|
||||
radius="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
You are about to approve the{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{pendingStep?.requiredRole}
|
||||
</Text>{" "}
|
||||
step for contract{" "}
|
||||
<Text span fw={600} c="dark">
|
||||
{contract.reference}
|
||||
</Text>
|
||||
. This action cannot be undone from this screen.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={closeApprove}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Check size={16} />}
|
||||
loading={mutations.approveStep.isPending}
|
||||
onClick={runApprove}
|
||||
>
|
||||
Confirm approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,17 @@ export interface ContractClearanceReviewSectionProps {
|
||||
* by whom, when) but hide all approve / query / finalize actions.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/**
|
||||
* Document approvals are locked (e.g. after all docs approved in phased flow)
|
||||
* but queries remain available until {@link readOnly}.
|
||||
*/
|
||||
approvalsLocked?: boolean;
|
||||
/**
|
||||
* ONE_TIME customs contracts use the phased milestone workflow. Hides the
|
||||
* legacy "Finalize clearance" shortcut; booking readiness follows delivery
|
||||
* order (import) or export release.
|
||||
*/
|
||||
phasedCustoms?: boolean;
|
||||
}
|
||||
|
||||
const STATUS_META: Record<
|
||||
@@ -89,6 +100,8 @@ export function ContractClearanceReviewSection({
|
||||
hideSummary,
|
||||
selfClear = false,
|
||||
readOnly = false,
|
||||
phasedCustoms = false,
|
||||
approvalsLocked = false,
|
||||
}: ContractClearanceReviewSectionProps) {
|
||||
const [queryNotes, setQueryNotes] = useState<Record<string, string>>({});
|
||||
const [openQuery, setOpenQuery] = useState<Record<string, boolean>>({});
|
||||
@@ -170,14 +183,16 @@ export function ContractClearanceReviewSection({
|
||||
subtitle={
|
||||
readOnly
|
||||
? `Reviewed by the ${reviewerTeam} team.`
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
: approvalsLocked
|
||||
? "Documents are approved — you can still open a query if something needs fixing."
|
||||
: "Approve each document, or open a query to tell the customer what to fix."
|
||||
}
|
||||
extra={
|
||||
<Group gap={10} wrap="nowrap" align="center">
|
||||
<Text size="xs" c="dimmed" fw={600}>
|
||||
{stats.approved}/{stats.total} approved
|
||||
</Text>
|
||||
{!readOnly && approvableKeys.length > 0 && (
|
||||
{!readOnly && !approvalsLocked && approvableKeys.length > 0 && (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
@@ -221,6 +236,7 @@ export function ContractClearanceReviewSection({
|
||||
doc={doc}
|
||||
reviewerTeam={reviewerTeam}
|
||||
readOnly={readOnly}
|
||||
approvalsLocked={approvalsLocked}
|
||||
note={queryNotes[doc.fileKey] ?? ""}
|
||||
queryOpen={openQuery[doc.fileKey] ?? false}
|
||||
onToggleQuery={(open) =>
|
||||
@@ -241,7 +257,7 @@ export function ContractClearanceReviewSection({
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
{glDocs.length > 0 && (
|
||||
{glDocs.length > 0 && !phasedCustoms && (
|
||||
<SectionCard
|
||||
icon={Upload}
|
||||
title="GL output documents"
|
||||
@@ -372,8 +388,39 @@ export function ContractClearanceReviewSection({
|
||||
<CheckCircle2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Clearance was finalized by the {reviewerTeam} team. This is a
|
||||
read-only record of the approved documents.
|
||||
{phasedCustoms
|
||||
? "Document review is complete. Continue customs milestones in the action panel."
|
||||
: `Clearance was finalized by the ${reviewerTeam} team. This is a read-only record of the approved documents.`}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : phasedCustoms && approvalsLocked ? (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={28}>
|
||||
<CheckCircle2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
Document review is complete. Use the action panel for declaration, duty, and
|
||||
transit steps — or open a query above if a customer document needs correction.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : phasedCustoms ? (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon
|
||||
variant="light"
|
||||
color={clearance.allApproved ? "edr-green" : "gray"}
|
||||
radius="md"
|
||||
size={28}
|
||||
>
|
||||
<FileCheck2 size={15} />
|
||||
</ThemeIcon>
|
||||
<Text fz="12.5px" c="dimmed">
|
||||
{clearance.allApproved
|
||||
? "All required documents are approved. Upload declaration, duty, transit permit, and delivery order in the action panel."
|
||||
: "Approve every required document to unlock the customs milestone steps."}
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
@@ -450,6 +497,7 @@ function DocReviewCard({
|
||||
doc,
|
||||
reviewerTeam,
|
||||
readOnly,
|
||||
approvalsLocked,
|
||||
note,
|
||||
queryOpen,
|
||||
onToggleQuery,
|
||||
@@ -462,6 +510,7 @@ function DocReviewCard({
|
||||
doc: Freight.ContractClearanceDocument;
|
||||
reviewerTeam: string;
|
||||
readOnly: boolean;
|
||||
approvalsLocked: boolean;
|
||||
note: string;
|
||||
queryOpen: boolean;
|
||||
onToggleQuery: (open: boolean) => void;
|
||||
@@ -598,7 +647,7 @@ function DocReviewCard({
|
||||
>
|
||||
Open query
|
||||
</Button>
|
||||
{!isApproved && (
|
||||
{!isApproved && !approvalsLocked && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="edr-green"
|
||||
|
||||
@@ -382,7 +382,7 @@ export default function GlCreateBookingForm() {
|
||||
} catch {
|
||||
// Non-fatal — the booking exists; the request link can be retried.
|
||||
}
|
||||
navigate(`/dashboard/clearance/${booking.id}`);
|
||||
navigate(`/dashboard/bookings/${booking.id}/clearance`);
|
||||
} else {
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,6 +26,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { clearanceWorkflowFileLabel } from "@edr/types";
|
||||
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import { detailStyles } from "@/components/bookings/detail/booking-detail.styles";
|
||||
@@ -199,9 +200,10 @@ function formatBytes(bytes?: number | null): string {
|
||||
/** Human label for a file's `code` (e.g. "business_license" → "Business license"). */
|
||||
function codeLabel(code?: string | null): string | null {
|
||||
if (!code) return null;
|
||||
return code
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
return (
|
||||
clearanceWorkflowFileLabel(code) ??
|
||||
code.replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
export interface ContractDocumentsCardProps {
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
|
||||
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 { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
@@ -138,7 +139,9 @@ export function AllocateBookingWizard({
|
||||
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: {
|
||||
@@ -244,7 +247,7 @@ export function AllocateBookingWizard({
|
||||
}, [containerUnits, containerSlots, savedPlacementsFromSchedule]);
|
||||
|
||||
const activeRoutes = useMemo(
|
||||
() => (routesQuery.data ?? []).filter((r) => r.isActive),
|
||||
() => routesQuery.data ?? [],
|
||||
[routesQuery.data],
|
||||
);
|
||||
|
||||
@@ -522,7 +525,7 @@ export function AllocateBookingWizard({
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<Select
|
||||
label="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
|
||||
|
||||
@@ -44,6 +44,8 @@ export const QUERY_KEYS = {
|
||||
listSummary: (filter?: BookingListFilter) =>
|
||||
["bookings", "list-summary", filter ?? {}] as const,
|
||||
byId: (id: string) => ["bookings", "detail", id] as const,
|
||||
clearanceQueue: (region?: string) =>
|
||||
["bookings", "clearance-queue", region ?? "ET"] as const,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
|
||||
@@ -123,6 +123,18 @@ export const URL_CONSTANTS = {
|
||||
COMPLETE: (id: string) => `/bookings/${id}/operations/complete`,
|
||||
CANCEL: (id: string) => `/bookings/${id}/cancel`,
|
||||
CONSOLIDATION: (id: string) => `/bookings/${id}/consolidation`,
|
||||
CLEARANCE: (id: string) => `/bookings/${id}/clearance`,
|
||||
CLEARANCE_DECLARATION: (id: string) => `/bookings/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/bookings/${id}/clearance/duty`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/bookings/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) => `/bookings/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) => `/bookings/${id}/clearance/delivery-order`,
|
||||
CLEARANCE_RELEASE_ORDER: (id: string) => `/bookings/${id}/clearance/release-order`,
|
||||
CLEARANCE_RO_AMENDMENT: (id: string) => `/bookings/${id}/clearance/ro-amendment`,
|
||||
CLEARANCE_EXPORT_RELEASE: (id: string) => `/bookings/${id}/clearance/export-release`,
|
||||
CLEARANCE_ET_QUEUE: "/bookings/clearance/et-queue",
|
||||
CLEARANCE_DJ_QUEUE: "/bookings/clearance/dj-queue",
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
@@ -148,6 +160,8 @@ export const URL_CONSTANTS = {
|
||||
CLEARANCE_DECLARATION: (id: string) => `/contracts/${id}/clearance/declaration`,
|
||||
CLEARANCE_DUTY: (id: string) => `/contracts/${id}/clearance/duty`,
|
||||
CLEARANCE_DUTY_SLIP: (id: string) => `/contracts/${id}/clearance/duty-slip`,
|
||||
CLEARANCE_FINALIZE_PRE: (id: string) =>
|
||||
`/contracts/${id}/clearance/finalize-pre-clearance`,
|
||||
CLEARANCE_TRANSIT_PERMIT: (id: string) =>
|
||||
`/contracts/${id}/clearance/transit-permit`,
|
||||
CLEARANCE_DELIVERY_ORDER: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
export interface ShipmentListRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
contractId: string;
|
||||
contractReference: string;
|
||||
scheduledDate?: string | null;
|
||||
summary: string;
|
||||
status: Freight.BookingRequestStatus;
|
||||
createdBookingId?: string | null;
|
||||
}
|
||||
|
||||
export type ShipmentRowAction =
|
||||
| {
|
||||
kind: "navigate";
|
||||
label: string;
|
||||
to: (row: ShipmentListRow) => string;
|
||||
variant: "filled" | "light" | "default";
|
||||
}
|
||||
| {
|
||||
kind: "reject";
|
||||
label: string;
|
||||
variant: "light";
|
||||
};
|
||||
|
||||
/** Primary staff action for a shipment request list row. */
|
||||
export function getShipmentStaffRowAction(
|
||||
row: Pick<
|
||||
ShipmentListRow,
|
||||
"status" | "contractId" | "id" | "createdBookingId"
|
||||
>,
|
||||
): ShipmentRowAction {
|
||||
if (row.status === "PENDING") {
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "Accept",
|
||||
to: (r) =>
|
||||
`/dashboard/contracts/${r.contractId}/create-booking?requestId=${r.id}`,
|
||||
variant: "filled",
|
||||
};
|
||||
}
|
||||
if (row.status === "ACCEPTED" && row.createdBookingId) {
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "View clearance",
|
||||
to: (r) => `/dashboard/clearance/${r.createdBookingId}`,
|
||||
variant: "light",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "navigate",
|
||||
label: "Review",
|
||||
to: (r) => `/dashboard/shipment-requests/${r.id}`,
|
||||
variant: "default",
|
||||
};
|
||||
}
|
||||
|
||||
export function getShipmentRejectAction(
|
||||
row: Pick<ShipmentListRow, "status">,
|
||||
): ShipmentRowAction | null {
|
||||
if (row.status !== "PENDING") return null;
|
||||
return { kind: "reject", label: "Reject", variant: "light" };
|
||||
}
|
||||
@@ -188,3 +188,19 @@ export function useBookingMutations(bookingId: string) {
|
||||
downloadContract: () => bookingsService.downloadContract(bookingId),
|
||||
};
|
||||
}
|
||||
|
||||
export function useBookingEtClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.clearanceQueue("ET"),
|
||||
queryFn: () => bookingsService.getEtClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useBookingDjClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.BOOKINGS.clearanceQueue("DJ"),
|
||||
queryFn: () => bookingsService.getDjClearanceQueue(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -264,7 +264,10 @@ export function useContractClearanceMutations(
|
||||
);
|
||||
refresh();
|
||||
},
|
||||
onError: () => toast.error("Could not update document"),
|
||||
onError: (e) =>
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : "Could not update document",
|
||||
),
|
||||
});
|
||||
|
||||
// Approve every still-pending customer document in one click. There is no
|
||||
|
||||
@@ -29,17 +29,22 @@ 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 { bookingsService } from "@/services/bookings.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 { data: booking } = useBookingDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["clearance", id],
|
||||
queryFn: () => bookingsService.getClearance(id!),
|
||||
@@ -59,6 +64,10 @@ export default function DocumentClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = booking?.reference ?? "Clearance";
|
||||
const isPhasedGeneral =
|
||||
Boolean(booking?.customsClearingEnabled) &&
|
||||
booking?.contractKind === "GENERAL" &&
|
||||
Boolean(clearance?.phase);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -124,15 +133,30 @@ export default function DocumentClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero booking={booking} clearance={clearance} stats={stats} />
|
||||
|
||||
{isPhasedGeneral ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance as Freight.ContractClearanceView}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — document review (shared with the Marketing booking detail) */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 7 : 8 }}>
|
||||
<ClearanceReviewSection bookingId={id!} hideSummary />
|
||||
</Grid.Col>
|
||||
|
||||
{/* RIGHT — sticky progress gauge */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<Grid.Col span={{ base: 12, lg: isPhasedGeneral ? 5 : 4 }}>
|
||||
{isPhasedGeneral ? (
|
||||
<PhasedClearanceActionPanel
|
||||
bookingId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={booking?.tradeDirection ?? "IMPORT"}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
<SectionCard
|
||||
icon={PackageCheck}
|
||||
title="Review progress"
|
||||
@@ -174,9 +198,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{isPhasedGeneral && clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={
|
||||
clearance.milestones as Parameters<
|
||||
typeof ClearanceMilestoneTimeline
|
||||
>[0]["milestones"]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</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) => ({
|
||||
|
||||
@@ -19,24 +19,50 @@ import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
PackageCheck,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
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 { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { ClearanceWorkflowFilesPanel } from "@/components/contracts/ClearanceWorkflowFilesPanel";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { downloadBookingFile } from "@/services/files.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type RoleMode = "ET" | "DJ" | "ALL";
|
||||
|
||||
function resolveRoleMode(
|
||||
canReview: boolean,
|
||||
canEt: boolean,
|
||||
canDj: boolean,
|
||||
): RoleMode {
|
||||
if (canReview || (canEt && canDj)) return "ALL";
|
||||
if (canEt) return "ET";
|
||||
if (canDj) return "DJ";
|
||||
return "ALL";
|
||||
}
|
||||
|
||||
export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { user } = useAuth();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.contracts.clearanceReview);
|
||||
const canEt =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceEtActions) || canReview;
|
||||
const canDj =
|
||||
hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions) || canReview;
|
||||
const roleMode = resolveRoleMode(canReview, canEt, canDj);
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
@@ -63,10 +89,17 @@ 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?.bookingReady ?? clearance?.clearanceStatus === "CLEARANCE_READY_FOR_BOOKING";
|
||||
const clearanceReadOnly = Boolean(
|
||||
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",
|
||||
@@ -76,6 +109,14 @@ export default function ContractClearanceDetailPage() {
|
||||
"EXPIRED",
|
||||
].includes(contract.status),
|
||||
);
|
||||
const reviewReadOnly =
|
||||
roleMode === "DJ" ? true : shipmentLocked;
|
||||
const reviewColSpan = roleMode === "DJ" ? 12 : 7;
|
||||
const actionColSpan = roleMode === "DJ" ? 12 : 5;
|
||||
const bookingHref =
|
||||
roleMode === "ET" || roleMode === "ALL"
|
||||
? `/dashboard/contracts/${id}/create-booking`
|
||||
: undefined;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -109,6 +150,8 @@ export default function ContractClearanceDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const workflowFiles = clearance.workflowFiles ?? [];
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
@@ -156,15 +199,6 @@ export default function ContractClearanceDetailPage() {
|
||||
|
||||
<ClearanceHero contract={contract} stats={stats} />
|
||||
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{ready ? (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
@@ -176,29 +210,57 @@ export default function ContractClearanceDetailPage() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Grid gap="lg">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={clearanceReadOnly}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: reviewColSpan }}>
|
||||
{roleMode === "DJ" ? (
|
||||
<SectionCard
|
||||
icon={ClipboardList}
|
||||
title="Contract context"
|
||||
accent="edr-green"
|
||||
>
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
phasedCustoms={phasedCustoms}
|
||||
/>
|
||||
</SectionCard>
|
||||
) : (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
)}
|
||||
{workflowFiles.length > 0 ? (
|
||||
<Box mt="lg">
|
||||
<ClearanceWorkflowFilesPanel
|
||||
files={workflowFiles}
|
||||
onView={view}
|
||||
onDownload={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
/>
|
||||
</Box>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<Grid.Col span={{ base: 12, lg: actionColSpan }}>
|
||||
<Stack gap="md">
|
||||
{contract?.contractKind === "ONE_TIME" && contract.customsClearingEnabled ? (
|
||||
{phasedCustoms ? (
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode="ALL"
|
||||
tradeDirection={contract?.tradeDirection ?? "IMPORT"}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={
|
||||
ready ? `/dashboard/contracts/${id}/create-booking` : undefined
|
||||
}
|
||||
bookingCreateHref={ready ? bookingHref : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Box style={{ position: "sticky", top: 24 }}>
|
||||
@@ -248,17 +310,8 @@ export default function ContractClearanceDetailPage() {
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={
|
||||
clearance.milestones as Parameters<
|
||||
typeof ClearanceMilestoneTimeline
|
||||
>[0]["milestones"]
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</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,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Ship,
|
||||
ShipWheel,
|
||||
Table as TableIcon,
|
||||
Truck,
|
||||
@@ -43,9 +45,16 @@ 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,
|
||||
useDjClearanceQueue,
|
||||
useEtClearanceQueue,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
|
||||
type ViewMode = "table" | "cards";
|
||||
type QueueTab = "all" | "et" | "dj";
|
||||
|
||||
interface ClearanceRow {
|
||||
id: string;
|
||||
@@ -167,12 +176,81 @@ function StatusBadge({ row }: { row: ClearanceRow }) {
|
||||
*/
|
||||
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 canDj = hasPermission(user, FREIGHT_PERMS.contracts.clearanceDjActions);
|
||||
|
||||
const defaultQueue: QueueTab = canReview
|
||||
? "all"
|
||||
: canEt
|
||||
? "et"
|
||||
: canDj
|
||||
? "dj"
|
||||
: "all";
|
||||
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: djData, isLoading: djLoading, isError: djError, isFetching: djFetching, refetch: refetchDj } =
|
||||
useDjClearanceQueue(queueTab === "dj");
|
||||
|
||||
const data =
|
||||
queueTab === "et" ? etData : queueTab === "dj" ? djData : allData;
|
||||
const isLoading =
|
||||
queueTab === "et" ? etLoading : queueTab === "dj" ? djLoading : allLoading;
|
||||
const isError =
|
||||
queueTab === "et" ? etError : queueTab === "dj" ? djError : allError;
|
||||
const isFetching =
|
||||
queueTab === "et" ? etFetching : queueTab === "dj" ? djFetching : allFetching;
|
||||
const refetch = () => {
|
||||
if (queueTab === "et") void refetchEt();
|
||||
else if (queueTab === "dj") void refetchDj();
|
||||
else void refetchAll();
|
||||
};
|
||||
|
||||
const queueTabOptions = useMemo(() => {
|
||||
const opts: { value: QueueTab; label: ReactNode }[] = [];
|
||||
if (canReview || (canEt && canDj)) {
|
||||
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>
|
||||
),
|
||||
});
|
||||
}
|
||||
if (canDj) {
|
||||
opts.push({
|
||||
value: "dj",
|
||||
label: (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Ship size={15} />
|
||||
<Box visibleFrom="sm">DJ queue</Box>
|
||||
</Group>
|
||||
),
|
||||
});
|
||||
}
|
||||
return opts;
|
||||
}, [canReview, canEt, canDj]);
|
||||
|
||||
const allRows = useMemo(
|
||||
() => (data?.items ?? []).map(toClearanceRow),
|
||||
@@ -380,6 +458,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,
|
||||
@@ -48,6 +49,7 @@ 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,
|
||||
@@ -59,6 +61,7 @@ import {
|
||||
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";
|
||||
@@ -140,6 +143,15 @@ export default function ContractRequestDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
@@ -222,6 +234,13 @@ 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.
|
||||
@@ -414,18 +433,39 @@ export default function ContractRequestDetailPage() {
|
||||
{/* 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" ? (
|
||||
<ContractDocumentsCard
|
||||
files={files}
|
||||
onView={handleViewFile}
|
||||
onDownload={handleDownloadFile}
|
||||
/>
|
||||
<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} />
|
||||
) : (
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Grid, Loader, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
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 { ClearancePhaseStepper } from "@/components/contracts/ClearancePhaseStepper";
|
||||
import { PhasedClearanceActionPanel } from "@/components/contracts/PhasedClearanceActionPanel";
|
||||
import { ClearanceMilestoneTimeline } from "@/components/contracts/ClearanceMilestoneTimeline";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useContractDetail } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlClearanceDetailPage({
|
||||
backTo,
|
||||
breadcrumbsLabel,
|
||||
roleMode,
|
||||
}: {
|
||||
backTo: string;
|
||||
breadcrumbsLabel: string;
|
||||
roleMode: "ET" | "DJ";
|
||||
}) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.clearance(id ?? ""),
|
||||
queryFn: () => contractsService.getClearance(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 || !clearance || !contract) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
Could not load clearance for this contract.
|
||||
</Alert>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const bookingHref =
|
||||
roleMode === "ET" ? `/dashboard/contracts/${id}/create-booking` : undefined;
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<Stack gap="lg">
|
||||
<PageHeader
|
||||
title={contract.reference}
|
||||
backTo={backTo}
|
||||
breadcrumbs={[
|
||||
{ label: breadcrumbsLabel, href: backTo },
|
||||
{ label: contract.reference },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Paper withBorder radius="md" p="lg">
|
||||
<ClearancePhaseStepper
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Grid>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 7 }}>
|
||||
{roleMode === "ET" ? (
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly={false}
|
||||
onChanged={() => void refetch()}
|
||||
/>
|
||||
) : (
|
||||
<SectionCard title="Contract context" accent="edr-green">
|
||||
<Text size="sm" c="dimmed" mb="sm">
|
||||
Review upstream status before uploading Djibouti documents.
|
||||
</Text>
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
readOnly
|
||||
/>
|
||||
</SectionCard>
|
||||
)}
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: roleMode === "DJ" ? 12 : 5 }}>
|
||||
<PhasedClearanceActionPanel
|
||||
contractId={id!}
|
||||
clearance={clearance}
|
||||
tradeDirection={contract.tradeDirection}
|
||||
roleMode={roleMode}
|
||||
onChanged={() => void refetch()}
|
||||
bookingCreateHref={bookingHref}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
|
||||
{clearance.milestones && clearance.milestones.length > 0 ? (
|
||||
<ClearanceMilestoneTimeline
|
||||
milestones={clearance.milestones as Parameters<typeof ClearanceMilestoneTimeline>[0]["milestones"]}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useDjClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Contracts awaiting Djibouti GL action (DO / RO)."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need Djibouti GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).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="cyan">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Flag } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import { useEtClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlEthiopiaClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useEtClearanceQueue();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Ethiopia — Clearance"
|
||||
subtitle="Contracts awaiting Ethiopia GL action in the phased clearance workflow."
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{(data?.items ?? []).length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No contracts need ET GL action right now.
|
||||
</Text>
|
||||
) : (
|
||||
(data?.items ?? []).map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-ethiopia/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Flag 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">
|
||||
{c.tradeDirection}
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -177,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" },
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1122,8 +1122,14 @@ export const api = {
|
||||
},
|
||||
|
||||
routes: {
|
||||
list: endpoint<void, RouteRecord[]>("routes", "list", () =>
|
||||
routesService.getAll().then((r) => r.data),
|
||||
list: endpoint<{ status?: import("./routes.service").RouteStatus } | void, RouteRecord[]>(
|
||||
"routes",
|
||||
"list",
|
||||
(input) =>
|
||||
routesService
|
||||
.getAll(input?.status ? { status: input.status } : undefined)
|
||||
.then((r) => r.data),
|
||||
(input) => ["routes", input?.status ?? "all"],
|
||||
),
|
||||
|
||||
yards: endpoint<void, YardRef[]>(
|
||||
|
||||
@@ -301,6 +301,100 @@ export const bookingsService = {
|
||||
|
||||
governmentExpedite: (id: string) =>
|
||||
postBooking<BookingDetail>(B.GOVERNMENT_EXPEDITE(id)),
|
||||
|
||||
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||
const response = await client.get(B.CLEARANCE(id));
|
||||
return unwrap(response.data) as Freight.ClearanceView;
|
||||
},
|
||||
|
||||
uploadDeclaration: async (
|
||||
id: string,
|
||||
files: Record<string, File | null>,
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
if (file) form.append(key, file);
|
||||
}
|
||||
const response = await client.post(B.CLEARANCE_DECLARATION(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
adviseDuty: async (
|
||||
id: string,
|
||||
payload: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
attachment?: File | null;
|
||||
},
|
||||
): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("dutyRequired", String(payload.dutyRequired));
|
||||
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||
if (payload.currency) form.append("currency", payload.currency);
|
||||
if (payload.declarationSerial) {
|
||||
form.append("declarationSerial", payload.declarationSerial);
|
||||
}
|
||||
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||
const response = await client.post(B.CLEARANCE_DUTY(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
finalizePreClearance: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_FINALIZE_PRE(id)),
|
||||
|
||||
uploadTransitPermit: async (id: string, file: File): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(B.CLEARANCE_TRANSIT_PERMIT(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
uploadDeliveryOrder: async (id: string, file: File): Promise<BookingDetail> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const response = await client.post(B.CLEARANCE_DELIVERY_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as BookingDetail;
|
||||
},
|
||||
|
||||
uploadReleaseOrder: async (
|
||||
id: string,
|
||||
file: File,
|
||||
vesselDepartureDate: string,
|
||||
): Promise<{ hold?: boolean; holdReason?: string }> => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
form.append("vesselDepartureDate", vesselDepartureDate);
|
||||
const response = await client.post(B.CLEARANCE_RELEASE_ORDER(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as { hold?: boolean; holdReason?: string };
|
||||
},
|
||||
|
||||
requestRoAmendment: (id: string, note?: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_RO_AMENDMENT(id), { note }),
|
||||
|
||||
confirmExportRelease: (id: string) =>
|
||||
postBooking<BookingDetail>(B.CLEARANCE_EXPORT_RELEASE(id), {}),
|
||||
|
||||
getEtClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||
const response = await client.get(B.CLEARANCE_ET_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
},
|
||||
|
||||
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
|
||||
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
|
||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||
},
|
||||
};
|
||||
|
||||
async function ensurePdfBlob(blob: Blob): Promise<Blob> {
|
||||
|
||||
@@ -239,15 +239,32 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
adviseContractDuty: (
|
||||
adviseContractDuty: async (
|
||||
id: string,
|
||||
payload: {
|
||||
dutyRequired: boolean;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
declarationSerial?: string;
|
||||
attachment?: File | null;
|
||||
},
|
||||
) => postContract<Freight.IContract>(C.CLEARANCE_DUTY(id), payload),
|
||||
): Promise<Freight.IContract> => {
|
||||
const form = new FormData();
|
||||
form.append("dutyRequired", String(payload.dutyRequired));
|
||||
if (payload.amount != null) form.append("amount", String(payload.amount));
|
||||
if (payload.currency) form.append("currency", payload.currency);
|
||||
if (payload.declarationSerial) {
|
||||
form.append("declarationSerial", payload.declarationSerial);
|
||||
}
|
||||
if (payload.attachment) form.append("attachment", payload.attachment);
|
||||
const response = await client.post(C.CLEARANCE_DUTY(id), form, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return unwrap(response.data) as Freight.IContract;
|
||||
},
|
||||
|
||||
finalizePreClearance: (id: string) =>
|
||||
postContract<Freight.IContract>(C.CLEARANCE_FINALIZE_PRE(id)),
|
||||
|
||||
uploadContractTransitPermit: async (
|
||||
id: string,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { api as apiClient } from '../auth/http';
|
||||
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
|
||||
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
|
||||
|
||||
export interface YardRef {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -14,32 +16,54 @@ export interface RouteMilestone {
|
||||
routeId: string;
|
||||
yardId: string;
|
||||
sequenceNo: number;
|
||||
distanceKm?: number | null;
|
||||
yard?: YardRef | null;
|
||||
}
|
||||
|
||||
export interface RouteRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
status: RouteStatus;
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
isActive: boolean;
|
||||
originYard?: YardRef | null;
|
||||
destinationYard?: YardRef | null;
|
||||
milestones?: RouteMilestone[];
|
||||
}
|
||||
|
||||
export interface SaveRoutePayload {
|
||||
name: string;
|
||||
milestones: Array<{ yardId: string }>;
|
||||
isActive?: boolean;
|
||||
milestones: Array<{ yardId: string; distanceKm?: number }>;
|
||||
status?: RouteStatus;
|
||||
}
|
||||
|
||||
export function formatRouteLabel(route: RouteRecord): string {
|
||||
const origin =
|
||||
route.originYard?.code ?? route.originYard?.label ?? 'Origin';
|
||||
const dest =
|
||||
route.destinationYard?.code ?? route.destinationYard?.label ?? 'Destination';
|
||||
return `${origin} → ${dest}`;
|
||||
}
|
||||
|
||||
export function totalRouteDistanceKm(route: RouteRecord): number {
|
||||
return (route.milestones ?? []).reduce(
|
||||
(sum, m) => sum + Number(m.distanceKm ?? 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
export const ROUTE_STATUS_OPTIONS: Array<{ value: RouteStatus; label: string }> = [
|
||||
{ value: 'AVAILABLE', label: 'Available' },
|
||||
{ value: 'MAINTENANCE', label: 'Maintenance' },
|
||||
{ value: 'DAMAGED', label: 'Damaged' },
|
||||
{ value: 'STOP_WORKING', label: 'Stop working' },
|
||||
];
|
||||
|
||||
interface YardListResponse {
|
||||
data: YardRef[];
|
||||
}
|
||||
|
||||
export const routesService = {
|
||||
getAll: () => apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE),
|
||||
getAll: (params?: { status?: RouteStatus; search?: string }) =>
|
||||
apiClient.get<RouteRecord[]>(URL_CONSTANTS.ROUTES.BASE, { params }),
|
||||
getById: (id: string) => apiClient.get<RouteRecord>(URL_CONSTANTS.ROUTES.BY_ID(id)),
|
||||
create: (data: SaveRoutePayload) => apiClient.post(URL_CONSTANTS.ROUTES.BASE, data),
|
||||
update: (id: string, data: Partial<SaveRoutePayload>) =>
|
||||
|
||||
Reference in New Issue
Block a user