mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 17:45:42 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -271,19 +271,19 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Routes",
|
||||
href: "/dashboard/routes",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Locomotives",
|
||||
href: "/dashboard/locomotives",
|
||||
icon: <Train />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Train Builder",
|
||||
href: "/dashboard/train-builder",
|
||||
icon: <Hammer />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
|
||||
// {
|
||||
@@ -295,7 +295,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
label: "Wagons",
|
||||
href: "/dashboard/wagons",
|
||||
icon: <Truck />,
|
||||
permission: FREIGHT_PERMS.fleet.view,
|
||||
permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view],
|
||||
},
|
||||
{
|
||||
label: "Vehicles",
|
||||
@@ -548,11 +548,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <Boxes />,
|
||||
children: [
|
||||
...getCategorySidebarChildren("configuration"),
|
||||
{
|
||||
label: "Contract validity",
|
||||
href: "/dashboard/configuration/contract-validity-periods",
|
||||
permission: FREIGHT_PERMS.config.contractValidity.view,
|
||||
},
|
||||
{
|
||||
label: "Train scheduling rules",
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
@@ -586,6 +581,16 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
const ET_CLEARANCE_HREF = "/dashboard/contracts/clearance";
|
||||
const DJ_CLEARANCE_HREF = "/dashboard/gl-djibouti/clearance";
|
||||
|
||||
// Routes a GL officer may reach beyond their clearance hub. Path B booking is
|
||||
// part of their job (create/rebook under a cleared contract, then view that
|
||||
// booking's clearance), but those routes live outside the clearance prefix —
|
||||
// without this allowlist the single-prefix lock bounces them out of their own
|
||||
// workflow. Matched against location.pathname (no query string).
|
||||
const GL_WORKFLOW_PATH_PATTERNS: RegExp[] = [
|
||||
/^\/dashboard\/contracts\/[^/]+\/create-booking(\/|$)/,
|
||||
/^\/dashboard\/bookings\/[^/]+\/clearance(\/|$)/,
|
||||
];
|
||||
|
||||
const isEtClearanceItem = (item: SidebarItem): boolean =>
|
||||
item.href === ET_CLEARANCE_HREF;
|
||||
const isDjClearanceItem = (item: SidebarItem): boolean =>
|
||||
@@ -721,7 +726,11 @@ const DashboardShell = () => {
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
if (
|
||||
glClearanceHome &&
|
||||
!location.pathname.startsWith(glClearanceHome) &&
|
||||
!GL_WORKFLOW_PATH_PATTERNS.some((re) => re.test(location.pathname))
|
||||
) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
@@ -1104,7 +1113,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1112,7 +1121,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1120,7 +1129,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1128,7 +1137,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1136,7 +1145,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1144,7 +1153,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1152,7 +1161,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1160,7 +1169,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1168,7 +1177,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1254,7 +1263,7 @@ const App = () => {
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.routes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1342,7 +1351,7 @@ const App = () => {
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.locomotives.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1350,7 +1359,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1358,7 +1367,7 @@ const App = () => {
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1366,7 +1375,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1374,7 +1383,7 @@ const App = () => {
|
||||
<Route
|
||||
path="train-builder/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.trains.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<TrainBuilderDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1382,7 +1391,7 @@ const App = () => {
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1390,7 +1399,7 @@ const App = () => {
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.containers.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1398,7 +1407,7 @@ const App = () => {
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RequirePermission permission={[FREIGHT_PERMS.cargoes.view, FREIGHT_PERMS.fleet.view]}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
@@ -1465,14 +1474,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<ContractValidityPeriodsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
/> */}
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route
|
||||
path="configuration/cargo-types/:id"
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { api } from "@/services/api";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
|
||||
onReviewClearance,
|
||||
}: ContractActionsToolbarProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { status } = contract;
|
||||
|
||||
// Intake permissions are split per freight type: an accept:bulk holder must
|
||||
// not see the accept button on a container contract (API enforces the same).
|
||||
const arm = contract.freightType === "BULK" ? "bulk" : "container";
|
||||
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
|
||||
const mayRequestChanges = hasPermission(
|
||||
user,
|
||||
FREIGHT_PERMS.contracts.requestChanges[arm],
|
||||
);
|
||||
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
|
||||
);
|
||||
}
|
||||
|
||||
const canAccept = status === "SUBMITTED";
|
||||
const canAccept =
|
||||
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
|
||||
// The document stays editable for the whole approval chain, but only by the
|
||||
// approver whose turn it is. The server resolves that against the caller's
|
||||
// position type; the client cannot derive it.
|
||||
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
|
||||
|
||||
{canAccept && (
|
||||
<>
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
{mayAccept && (
|
||||
<Button
|
||||
fullWidth
|
||||
color="edr-green"
|
||||
leftSection={<Check size={16} />}
|
||||
onClick={() => {
|
||||
setEditorMode("accept");
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
>
|
||||
Accept for approval
|
||||
</Button>
|
||||
)}
|
||||
{mayRequestChanges && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="orange"
|
||||
leftSection={<MessageSquareWarning size={16} />}
|
||||
onClick={() => setChangesOpen(true)}
|
||||
>
|
||||
Request changes
|
||||
</Button>
|
||||
)}
|
||||
{mayReject && (
|
||||
<Button
|
||||
fullWidth
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<XCircle size={16} />}
|
||||
onClick={() => setRejectOpen(true)}
|
||||
>
|
||||
Reject contract
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import type { Freight } from "@edr/types";
|
||||
import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress";
|
||||
import { SectionCard } from "@/components/bookings/detail/SectionCard";
|
||||
import type { useContractMutations } from "@/hooks/contracts/useContracts";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canApproveContractStep } from "@/lib/permissions";
|
||||
|
||||
type Mutations = ReturnType<typeof useContractMutations>;
|
||||
|
||||
@@ -29,6 +31,7 @@ export function ContractApprovalStepsCard({
|
||||
contract,
|
||||
mutations,
|
||||
}: ContractApprovalStepsCardProps) {
|
||||
const { user } = useAuth();
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [pendingStep, setPendingStep] =
|
||||
useState<Freight.IContractApprovalStep | null>(null);
|
||||
@@ -166,6 +169,10 @@ export function ContractApprovalStepsCard({
|
||||
key={step.id}
|
||||
step={step}
|
||||
isNext={actionable && nextPending?.id === step.id}
|
||||
// Buttons show only to the step's actual approver (matching
|
||||
// position type): a chief step never offers Approve/Reject to a
|
||||
// marketing officer. Everyone still sees the "next" highlight.
|
||||
canAct={canApproveContractStep(user, step.requiredRole)}
|
||||
isPending={
|
||||
mutations.approveStep.isPending ||
|
||||
mutations.rejectStep.isPending
|
||||
@@ -306,12 +313,14 @@ export function ContractApprovalStepsCard({
|
||||
function StepRow({
|
||||
step,
|
||||
isNext,
|
||||
canAct,
|
||||
isPending,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
step: Freight.IContractApprovalStep;
|
||||
isNext: boolean;
|
||||
canAct: boolean;
|
||||
isPending: boolean;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
@@ -372,8 +381,11 @@ function StepRow({
|
||||
)}
|
||||
</Box>
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" style={{ flexShrink: 0 }}>
|
||||
{isNext && step.status === "PENDING" && (
|
||||
{/* One element type per row: action buttons on the active step (they
|
||||
already imply "pending & actionable"), a status badge otherwise.
|
||||
Mixing compact buttons + a badge here made them read as misaligned. */}
|
||||
<Group gap="xs" wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
|
||||
{isNext && canAct && step.status === "PENDING" ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -395,16 +407,17 @@ function StepRow({
|
||||
Reject
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="light"
|
||||
color={statusColor}
|
||||
size="sm"
|
||||
radius="sm"
|
||||
tt="uppercase"
|
||||
>
|
||||
{step.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
|
||||
@@ -269,6 +269,8 @@ export default function GlCreateBookingForm() {
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
const [contractRouteId, setContractRouteId] = useState<string | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
// What the containers carry — captured per booking (moved off the contract).
|
||||
const [cargoDescription, setCargoDescription] = useState("");
|
||||
const [containerLines, setContainerLines] = useState<ContainerLineDraft[]>([]);
|
||||
const [bulk, setBulk] = useState<BulkDraft>({
|
||||
cargoWeightTons: "",
|
||||
@@ -459,6 +461,10 @@ export default function GlCreateBookingForm() {
|
||||
);
|
||||
};
|
||||
setPrefilled(true);
|
||||
// Rebook carries the expired booking's cargo description forward.
|
||||
if (copyFromBooking.cargoFreeText) {
|
||||
setCargoDescription(copyFromBooking.cargoFreeText);
|
||||
}
|
||||
setContainerLines(
|
||||
lines.map((c) => {
|
||||
const qty = Math.max(1, c.quantity);
|
||||
@@ -705,14 +711,23 @@ export default function GlCreateBookingForm() {
|
||||
|
||||
const lineErrors = useMemo<LineErrors[]>(() => {
|
||||
if (!isContainer || !contract) return [];
|
||||
return containerLines.map((line) => {
|
||||
// A line can be 0 (the contract covers both sizes; a booking may only need
|
||||
// one) but the booking as a whole needs at least one container — anchor
|
||||
// that error on the first line's quantity so it renders in the field.
|
||||
const totalQty = containerLines.reduce(
|
||||
(sum, l) => sum + Math.max(0, Number(l.quantity) || 0),
|
||||
0,
|
||||
);
|
||||
return containerLines.map((line, idx) => {
|
||||
const errs: LineErrors = {};
|
||||
const qty = Number(line.quantity || 0);
|
||||
if (line.quantity.trim() === "") {
|
||||
errs.quantity = "Quantity is required.";
|
||||
} else if (Number.isNaN(qty) || qty < 1) {
|
||||
errs.quantity = "At least 1.";
|
||||
} else if (line.units.length < qty) {
|
||||
} else if (Number.isNaN(qty) || qty < 0) {
|
||||
errs.quantity = "Enter 0 or more.";
|
||||
} else if (idx === 0 && totalQty < 1) {
|
||||
errs.quantity = "Book at least one container (either size).";
|
||||
} else if (qty >= 1 && line.units.length < qty) {
|
||||
errs.units = `Enter details for all ${qty} container(s).`;
|
||||
}
|
||||
if (contract.isHazardous) {
|
||||
@@ -789,6 +804,11 @@ export default function GlCreateBookingForm() {
|
||||
const routeError =
|
||||
multiRoute && !contractRouteId ? "Select a route." : undefined;
|
||||
|
||||
const cargoDescriptionError =
|
||||
isContainer && !cargoDescription.trim()
|
||||
? "Describe the cargo carried in the containers."
|
||||
: undefined;
|
||||
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
@@ -800,7 +820,8 @@ export default function GlCreateBookingForm() {
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
)
|
||||
) &&
|
||||
!cargoDescriptionError
|
||||
: !bulkErrors.quantity && !bulkErrors.hazardous && !bulkErrors.reefer;
|
||||
|
||||
const formValid = cargoValid && !hasOdd20ft && !dateError && !routeError;
|
||||
@@ -827,6 +848,8 @@ export default function GlCreateBookingForm() {
|
||||
};
|
||||
|
||||
if (isContainer) {
|
||||
// What the containers carry — captured per booking, not on the contract.
|
||||
if (cargoDescription.trim()) payload.cargoFreeText = cargoDescription.trim();
|
||||
payload.containers = containerLines
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -1245,6 +1268,19 @@ export default function GlCreateBookingForm() {
|
||||
)}
|
||||
{remainderNotice}
|
||||
<ContractCapacityNotice contractId={contract.id} isContainer />
|
||||
<Textarea
|
||||
label="Cargo description *"
|
||||
description="What do the containers carry on this shipment?"
|
||||
placeholder="e.g. Electronics, garments, machinery spare parts…"
|
||||
value={cargoDescription}
|
||||
onChange={(e) => setCargoDescription(e.currentTarget.value)}
|
||||
error={showErrors ? cargoDescriptionError : undefined}
|
||||
radius={10}
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
{containerLines.length === 0 ? (
|
||||
<Text fz="sm" c="dimmed">
|
||||
This contract has no container sizes in scope.
|
||||
@@ -1264,7 +1300,7 @@ export default function GlCreateBookingForm() {
|
||||
type="number"
|
||||
onKeyDown={blockNegative}
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
min={0}
|
||||
value={line.quantity}
|
||||
error={
|
||||
showErrors
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface FleetCardGridProps {
|
||||
pageCount: number;
|
||||
totalCount: number;
|
||||
onPaginationChange: OnChangeFn<PaginationState>;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
}
|
||||
|
||||
const FleetCardGrid = ({
|
||||
|
||||
@@ -8,8 +8,9 @@ import type { FleetRecord } from "@/services/fleet/fleet.service";
|
||||
export interface FleetRecordActionsProps {
|
||||
record: FleetRecord;
|
||||
config: FleetResourceConfig;
|
||||
onEdit: (record: FleetRecord) => void;
|
||||
onRemove: (record: FleetRecord) => void;
|
||||
/** Omit to hide the action (caller lacks the update/delete permission). */
|
||||
onEdit?: (record: FleetRecord) => void;
|
||||
onRemove?: (record: FleetRecord) => void;
|
||||
onAssignDriver?: (record: FleetRecord) => void;
|
||||
onHistory?: (record: FleetRecord) => void;
|
||||
onViewDetail?: (record: FleetRecord) => void;
|
||||
@@ -42,6 +43,17 @@ const FleetRecordActions = ({
|
||||
navigate(config.detailPath.replace(":id", String(record.id)));
|
||||
};
|
||||
|
||||
if (
|
||||
!onEdit &&
|
||||
!onRemove &&
|
||||
!showDetail &&
|
||||
!showViewDetail &&
|
||||
!showHistory &&
|
||||
!(isVehicle && onAssignDriver)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (layout === "compact") {
|
||||
return (
|
||||
<Menu position="bottom-end" withinPortal shadow="md">
|
||||
@@ -61,12 +73,14 @@ const FleetRecordActions = ({
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{onEdit ? (
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
@@ -91,13 +105,15 @@ const FleetRecordActions = ({
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
{onRemove ? (
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
@@ -121,12 +137,14 @@ const FleetRecordActions = ({
|
||||
Assign Driver
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
{onEdit ? (
|
||||
<MenuItem
|
||||
onClick={() => onEdit(record)}
|
||||
leftSection={<Edit2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
Edit
|
||||
</MenuItem>
|
||||
) : null}
|
||||
{showViewDetail ? (
|
||||
<MenuItem
|
||||
onClick={() => onViewDetail?.(record)}
|
||||
@@ -143,13 +161,15 @@ const FleetRecordActions = ({
|
||||
View details
|
||||
</MenuItem>
|
||||
) : null}
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
{onRemove ? (
|
||||
<MenuItem
|
||||
color="red"
|
||||
onClick={() => onRemove(record)}
|
||||
leftSection={<Trash2 size={14} strokeWidth={2} />}
|
||||
>
|
||||
{removeLabel}
|
||||
</MenuItem>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -287,6 +287,8 @@ export type SegmentStripBooking = {
|
||||
destinationYardId?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
wagonsRequired?: number | null;
|
||||
/** GROSS tons (cargo + tare of the booking's wagons), as the API sends it. */
|
||||
weightTons?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -300,10 +302,13 @@ export function SegmentOccupancyStrip({
|
||||
stops,
|
||||
bookings,
|
||||
maxWagons,
|
||||
maxGrossTons,
|
||||
}: {
|
||||
stops: Array<{ yardId: string; label: string }>;
|
||||
bookings: SegmentStripBooking[];
|
||||
maxWagons?: number | null;
|
||||
/** Loco pull ceiling incl. tolerance — per-leg gross is measured against it. */
|
||||
maxGrossTons?: number | null;
|
||||
}) {
|
||||
if (stops.length < 2) return null;
|
||||
const lastIdx = stops.length - 1;
|
||||
@@ -312,6 +317,7 @@ export function SegmentOccupancyStrip({
|
||||
const segments = stops.slice(0, -1).map((stop, edge) => {
|
||||
let cargo = 0;
|
||||
let intercity = 0;
|
||||
let grossTons = 0;
|
||||
for (const b of bookings) {
|
||||
const from = (b.originYardId ? indexOf.get(b.originYardId) : undefined) ?? 0;
|
||||
const to =
|
||||
@@ -322,8 +328,15 @@ export function SegmentOccupancyStrip({
|
||||
const wagons = Number(b.wagonsRequired) || 1;
|
||||
if (b.tradeDirection === "DOMESTIC") intercity += wagons;
|
||||
else cargo += wagons;
|
||||
grossTons += Number(b.weightTons) || 0;
|
||||
}
|
||||
return { from: stop, to: stops[edge + 1], cargo, intercity };
|
||||
return {
|
||||
from: stop,
|
||||
to: stops[edge + 1],
|
||||
cargo,
|
||||
intercity,
|
||||
grossTons: Math.round(grossTons * 10) / 10,
|
||||
};
|
||||
});
|
||||
|
||||
const cap = Number(maxWagons) || null;
|
||||
@@ -393,6 +406,22 @@ export function SegmentOccupancyStrip({
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
{seg.grossTons > 0 ? (
|
||||
<Text
|
||||
size="xs"
|
||||
ta="center"
|
||||
fw={600}
|
||||
c={
|
||||
maxGrossTons != null && seg.grossTons > maxGrossTons
|
||||
? "red.7"
|
||||
: "dimmed"
|
||||
}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{seg.grossTons}
|
||||
{maxGrossTons != null ? ` / ${maxGrossTons}` : ""} T gross
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{i === segments.length - 1 ? (
|
||||
<Stack gap={2} align="center" justify="flex-end" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -205,6 +205,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
|
||||
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
|
||||
driverName: truckPrefill.driverName ?? '',
|
||||
driverLicense: truckPrefill.driverLicense ?? '',
|
||||
driverPhone: truckPrefill.driverPhone ?? '',
|
||||
truckType: truckPrefill.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(truckPrefill.containerNumber),
|
||||
@@ -218,6 +219,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Customer · ${t.plateNumber} — ${t.driverName}`,
|
||||
trailerPlate: '',
|
||||
driverName: t.driverName,
|
||||
driverLicense: '',
|
||||
driverPhone: '',
|
||||
truckType: t.truckType,
|
||||
containerNumbers: (t.containers ?? []).map((c) => c.containerNumber).filter(Boolean),
|
||||
@@ -231,6 +233,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label: `Last-mile · ${t.truckPlateNumber ?? ''}${t.driverName ? ` — ${t.driverName}` : ''}`,
|
||||
trailerPlate: t.trailerPlateNumber ?? '',
|
||||
driverName: t.driverName ?? '',
|
||||
driverLicense: t.driverLicense ?? '',
|
||||
driverPhone: t.driverPhone ?? '',
|
||||
truckType: t.truckType ?? '',
|
||||
containerNumbers: splitContainerNumbers(t.containerNumber),
|
||||
@@ -281,6 +284,11 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
// way. A walk-in truck (typed plate, no assignment) stays editable at arrival.
|
||||
const isTruckIdentityLocked = isEntranceLocked || Boolean(selectedOption);
|
||||
const isDriverNameLocked = isEntranceLocked || Boolean(selectedOption?.driverName);
|
||||
// The freight order's truck details are the customer's / fleet's record — the
|
||||
// gate may FILL blanks (walk-in license, phone) but never edit shown values.
|
||||
const isTrailerLocked = isEntranceLocked || Boolean(selectedOption?.trailerPlate);
|
||||
const isDriverLicenseLocked = isEntranceLocked || Boolean(selectedOption?.driverLicense);
|
||||
const isDriverPhoneLocked = isEntranceLocked || Boolean(selectedOption?.driverPhone);
|
||||
const referenceLocked = Boolean(item?.releaseOrderReference) || savedBlocks.length > 0;
|
||||
|
||||
/** Load a truck into the form: its saved block if any, else its assignment. */
|
||||
@@ -293,7 +301,7 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
setTruckPlateNumber(plate);
|
||||
setTrailerPlateNumber(block?.trailerPlateNumber || option?.trailerPlate || '');
|
||||
setDriverName(block?.driverName || option?.driverName || '');
|
||||
setDriverLicense(block?.driverLicense || '');
|
||||
setDriverLicense(block?.driverLicense || option?.driverLicense || '');
|
||||
setDriverPhone(block?.driverPhone || option?.driverPhone || '');
|
||||
setTruckType(block?.truckType || option?.truckType || '');
|
||||
const loaded = block
|
||||
@@ -611,15 +619,15 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTrailerLocked}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isDriverNameLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isDriverLicenseLocked} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isDriverPhoneLocked} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
|
||||
@@ -199,6 +199,7 @@ export const QUERY_KEYS = {
|
||||
|
||||
MAINTENANCE: {
|
||||
ROOT: ["maintenance"] as const,
|
||||
dueBoard: () => ["maintenance", "due-board"] as const,
|
||||
schedules: (vehicleId?: string) =>
|
||||
["maintenance", "schedules", vehicleId ?? "all"] as const,
|
||||
upcoming: (vehicleId?: string) =>
|
||||
@@ -207,6 +208,8 @@ export const QUERY_KEYS = {
|
||||
["maintenance", "history", vehicleId ?? "all"] as const,
|
||||
stats: (vehicleId?: string) =>
|
||||
["maintenance", "stats", vehicleId ?? "all"] as const,
|
||||
intervals: (vehicleId?: string) =>
|
||||
["maintenance", "intervals", vehicleId ?? "all"] as const,
|
||||
},
|
||||
|
||||
FINANCIAL_REPORTS: {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { canApproveContractStep } from "./permissions";
|
||||
|
||||
const withPositionType = (typeKey: string): AuthUser => ({
|
||||
employee: [{ positions: [{ positionType: { key: typeKey } }] }],
|
||||
});
|
||||
|
||||
const withRole = (roleKey: string): AuthUser => ({ roles: [{ key: roleKey }] });
|
||||
|
||||
const withPermission = (permKey: string): AuthUser => ({
|
||||
permissionKeys: [permKey],
|
||||
});
|
||||
|
||||
describe("canApproveContractStep", () => {
|
||||
it("shows to the matching position type only", () => {
|
||||
const chief = withPositionType("-marketing-chief");
|
||||
expect(canApproveContractStep(chief, "-marketing-chief")).toBe(true);
|
||||
// a marketing officer must NOT see the chief step's buttons
|
||||
expect(canApproveContractStep(chief, "-marketing-director-")).toBe(false);
|
||||
});
|
||||
|
||||
it("lets super/org admins action any step", () => {
|
||||
expect(canApproveContractStep(withRole("super_admin"), "anything")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
canApproveContractStep(withRole("organization_admin"), "-marketing-chief"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves legacy chain roles via their position-type aliases", () => {
|
||||
const director = withPositionType("operation-director");
|
||||
expect(canApproveContractStep(director, "DIRECTOR")).toBe(true);
|
||||
expect(canApproveContractStep(director, "CEO")).toBe(false);
|
||||
});
|
||||
|
||||
it("honours the role's own legacy approve permission", () => {
|
||||
const staff = withPermission(
|
||||
"edr_freight_app:contracts:approve_director",
|
||||
);
|
||||
expect(canApproveContractStep(staff, "DIRECTOR")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT show to holders of an unrelated approve permission", () => {
|
||||
// the dropped blanket fallback: a line-staff approver is not a chief
|
||||
const lineStaff = withPermission(
|
||||
"edr_freight_app:contracts:approve_line_staff",
|
||||
);
|
||||
expect(canApproveContractStep(lineStaff, "-marketing-chief")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false without a user or role", () => {
|
||||
expect(canApproveContractStep(null, "-marketing-chief")).toBe(false);
|
||||
expect(canApproveContractStep(withPositionType("x"), null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
|
||||
},
|
||||
contracts: {
|
||||
view: "edr_freight_app:contracts:view",
|
||||
staffAccept: "edr_freight_app:contracts:staff_accept",
|
||||
requestChanges: "edr_freight_app:contracts:request_changes",
|
||||
reject: "edr_freight_app:contracts:reject",
|
||||
// Intake actions are split per freight type — mirror of the API registry.
|
||||
staffAccept: {
|
||||
bulk: "edr_freight_app:contracts:staff_accept:bulk",
|
||||
container: "edr_freight_app:contracts:staff_accept:container",
|
||||
},
|
||||
requestChanges: {
|
||||
bulk: "edr_freight_app:contracts:request_changes:bulk",
|
||||
container: "edr_freight_app:contracts:request_changes:container",
|
||||
},
|
||||
reject: {
|
||||
bulk: "edr_freight_app:contracts:reject:bulk",
|
||||
container: "edr_freight_app:contracts:reject:container",
|
||||
},
|
||||
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
|
||||
approveDirector: "edr_freight_app:contracts:approve_director",
|
||||
approveCeo: "edr_freight_app:contracts:approve_ceo",
|
||||
@@ -240,12 +250,6 @@ export const FREIGHT_PERMS = {
|
||||
cancel: "edr_freight_app:warehouse_fee_invoices:cancel",
|
||||
pay: "edr_freight_app:warehouse_fee_invoices:pay",
|
||||
},
|
||||
config: {
|
||||
contractValidity: {
|
||||
view: "edr_freight_app:config:contract_validity:view",
|
||||
manage: "edr_freight_app:config:contract_validity:manage",
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
fileUpload: {
|
||||
view: "edr_freight_app:settings:file_upload:view",
|
||||
@@ -420,6 +424,53 @@ export function hasPermission(
|
||||
return getPermissionKeys(user).includes(key);
|
||||
}
|
||||
|
||||
// Legacy chain roles predate position types; map each to the position types
|
||||
// that stand in for it. Mirror of the API's LEGACY_ROLE_POSITION_TYPES so the
|
||||
// button visibility matches what the approve/reject endpoint will accept.
|
||||
const LEGACY_ROLE_POSITION_TYPES: Record<string, string[]> = {
|
||||
LINE_STAFF: ["employee", "teamLeader", "officeHead", "recordOfficer"],
|
||||
DIRECTOR: ["director", "operation-director"],
|
||||
CEO: ["chief", "deputy"],
|
||||
};
|
||||
|
||||
const CONTRACT_APPROVE_ROLE_PERMISSION: Record<string, string> = {
|
||||
LINE_STAFF: FREIGHT_PERMS.contracts.approveLineStaff,
|
||||
DIRECTOR: FREIGHT_PERMS.contracts.approveDirector,
|
||||
CEO: FREIGHT_PERMS.contracts.approveCeo,
|
||||
};
|
||||
|
||||
/**
|
||||
* Can this user action a contract approval step requiring `requiredRole`?
|
||||
*
|
||||
* `requiredRole` is an `iam.position_types.key` (the role vocabulary approval
|
||||
* chains are configured in), or a legacy LINE_STAFF/DIRECTOR/CEO string. Used
|
||||
* to show Approve/Reject only to the step's actual approver — a chief step
|
||||
* shows only to a chief, a marketing-officer step only to that officer.
|
||||
*
|
||||
* Deliberately STRICTER than the API's `assertCanApproveContractStep`, which
|
||||
* also lets through anyone holding any contract-approve permission (a fallback
|
||||
* for delegates whose token omits the position type). That blanket is what made
|
||||
* every approver see the button, so it is dropped here: the visibility rule is
|
||||
* admin OR the matching position type (direct / legacy alias) OR the role's own
|
||||
* legacy approve permission. The server still guards the mutation.
|
||||
*/
|
||||
export function canApproveContractStep(
|
||||
user: AuthUser | null | undefined,
|
||||
requiredRole: string | null | undefined,
|
||||
): boolean {
|
||||
if (!user || !requiredRole) return false;
|
||||
if (isFreightApprovalAdmin(user)) return true;
|
||||
|
||||
const positionTypes = getPositionTypeKeys(user);
|
||||
if (positionTypes.includes(requiredRole)) return true;
|
||||
|
||||
const aliases = LEGACY_ROLE_POSITION_TYPES[requiredRole] ?? [];
|
||||
if (aliases.some((alias) => positionTypes.includes(alias))) return true;
|
||||
|
||||
const legacyPermission = CONTRACT_APPROVE_ROLE_PERMISSION[requiredRole];
|
||||
return Boolean(legacyPermission && hasPermission(user, legacyPermission));
|
||||
}
|
||||
|
||||
export function canAccessBookings(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.bookings.view);
|
||||
}
|
||||
@@ -466,6 +517,31 @@ export function canViewFleet(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.fleet.view);
|
||||
}
|
||||
|
||||
export type FleetCrudResource =
|
||||
| "locomotives"
|
||||
| "wagons"
|
||||
| "trains"
|
||||
| "routes"
|
||||
| "containers"
|
||||
| "cargoes"
|
||||
| "vehicles"
|
||||
| "drivers";
|
||||
|
||||
/**
|
||||
* Per-resource fleet CRUD check. The legacy coarse fleet:manage key still
|
||||
* grants every action (mirrors the API's one-of guard fallback).
|
||||
*/
|
||||
export function canFleetAction(
|
||||
user: AuthUser | null | undefined,
|
||||
resource: FleetCrudResource,
|
||||
action: "create" | "update" | "delete",
|
||||
): boolean {
|
||||
return (
|
||||
hasPermission(user, FREIGHT_PERMS[resource][action]) ||
|
||||
hasPermission(user, FREIGHT_PERMS.fleet.manage)
|
||||
);
|
||||
}
|
||||
|
||||
export function isFreightAdmin(user: AuthUser | null | undefined): boolean {
|
||||
return hasPermission(user, FREIGHT_PERMS.admin);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Container,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
BookingCompanyCard,
|
||||
BookingContractSummaryCard,
|
||||
BookingContainerUnitsCard,
|
||||
ClearanceReviewSection,
|
||||
BookingDocumentsPanel,
|
||||
ContractOrdersPanel,
|
||||
} from "@/components/bookings/detail";
|
||||
@@ -129,13 +127,9 @@ export default function BookingRequestDetailPage() {
|
||||
|
||||
const row = toBookingListRow(booking);
|
||||
const statusMeta = getStatusMeta(booking.status);
|
||||
// Non-customs clearance is reviewed here by Marketing in its own tab; customs
|
||||
// bookings are handled in the Global Logistics clearance queue instead.
|
||||
const showClearanceTab =
|
||||
!booking.customsClearingEnabled &&
|
||||
["AWAITING_DOCUMENTS", "DOCUMENTS_UNDER_REVIEW", "CLEARANCE_READY"].includes(
|
||||
booking.status,
|
||||
);
|
||||
// Clearance review + finalize now lives solely on the Operations "Clearance
|
||||
// Documents" hub (/dashboard/contracts/clearance-documents → detail page), so
|
||||
// no clearance tab is embedded here anymore.
|
||||
// A general contract drives an "Orders" tab: each drawdown order spawns a
|
||||
// child booking that staff manage (clearance/approval) independently.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
@@ -143,13 +137,11 @@ export default function BookingRequestDetailPage() {
|
||||
// customs-workflow, invoice or notice files — so the tab bar always renders.
|
||||
const requestedTab = searchParams.get("tab");
|
||||
const activeTab =
|
||||
requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
requestedTab === "orders" && isGeneralContract
|
||||
? "orders"
|
||||
: requestedTab === "documents"
|
||||
? "documents"
|
||||
: "overview";
|
||||
const setActiveTab = (tab: string | null) => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (tab && tab !== "overview") next.set("tab", tab);
|
||||
@@ -209,14 +201,6 @@ export default function BookingRequestDetailPage() {
|
||||
Orders
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Customer clearance
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FolderOpen size={16} />}
|
||||
@@ -236,14 +220,6 @@ export default function BookingRequestDetailPage() {
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{showClearanceTab && (
|
||||
<Tabs.Panel value="clearance">
|
||||
<ClearanceReviewSection
|
||||
bookingId={booking.id}
|
||||
onChanged={() => refetch()}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
<Tabs.Panel value="documents">
|
||||
<BookingDocumentsPanel bookingId={booking.id} />
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -138,9 +138,14 @@ export default function ClearanceDocumentsPage() {
|
||||
const generalQuery = useQuery({
|
||||
queryKey: ["clearance-documents", "general", bookingStatuses, page, search],
|
||||
queryFn: () =>
|
||||
// Per-booking self-clearance instances are drawdowns under GENERAL
|
||||
// non-customs contracts: they carry bookingType=ONE_TIME (each shipment
|
||||
// is one-time) with contractKind=GENERAL, so filtering on
|
||||
// bookingType=GENERAL_CONTRACT returned nothing. customsClearingEnabled
|
||||
// =false + the three per-booking clearance statuses already isolate
|
||||
// exactly this worklist — the same set the old booking-request tab showed.
|
||||
bookingsService.list({
|
||||
statuses: bookingStatuses,
|
||||
bookingType: "GENERAL_CONTRACT",
|
||||
customsClearingEnabled: "false",
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
|
||||
@@ -93,6 +93,11 @@ export default function ContractClearanceDetailPage() {
|
||||
}, [clearance]);
|
||||
|
||||
const reference = contract?.reference ?? "Clearance";
|
||||
// Path A (non-customs) → Operations reviews & finalizes; Path B (customs) → GL.
|
||||
// This page serves BOTH hubs (Ops "Clearance Documents" + GL "Document
|
||||
// Clearance"), so the reviewer is decided by the contract, not the hub — a
|
||||
// hardcoded value routes non-customs finalize to the GL endpoint and 409s.
|
||||
const selfClear = !contract?.customsClearingEnabled;
|
||||
const phasedCustoms =
|
||||
contract?.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
@@ -338,7 +343,7 @@ export default function ContractClearanceDetailPage() {
|
||||
<ContractClearanceReviewSection
|
||||
contractId={id!}
|
||||
hideSummary
|
||||
selfClear={false}
|
||||
selfClear={selfClear}
|
||||
readOnly={reviewReadOnly}
|
||||
approvalsLocked={phasedCustoms && docReviewLocked}
|
||||
queriesLocked={queriesLocked}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
Route as RouteIcon,
|
||||
ShieldCheck,
|
||||
Snowflake,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
@@ -51,7 +50,6 @@ import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"
|
||||
import { ContractWorkflowStepper } from "@/components/contracts/ContractWorkflowStepper";
|
||||
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 { ContractRevisionTimeline } from "@/components/contracts/ContractRevisionTimeline";
|
||||
import {
|
||||
@@ -74,15 +72,14 @@ import {
|
||||
import type { CustomerDocument } from "@/types/customer";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
// Clearance phase — staff can still ACT (approve / query / finalize).
|
||||
// Clearance phase — actionable (docs approve / query / finalize on the hub).
|
||||
const CLEARANCE_ACTIVE_STATUSES = [
|
||||
"AWAITING_CLEARANCE_DOCUMENTS",
|
||||
"CLEARANCE_UNDER_REVIEW",
|
||||
"CLEARANCE_READY_FOR_BOOKING",
|
||||
];
|
||||
|
||||
// Clearance is done — the tab stays visible but READ-ONLY so staff/customer can
|
||||
// see which documents were approved, by whom, and when.
|
||||
// Clearance is done — its documents are still worth loading (read-only record).
|
||||
const CLEARANCE_DONE_STATUSES = [
|
||||
"ACTIVE_SHIPMENT_IN_PROGRESS",
|
||||
"FULLY_EXECUTED",
|
||||
@@ -91,7 +88,9 @@ const CLEARANCE_DONE_STATUSES = [
|
||||
"EXPIRED",
|
||||
];
|
||||
|
||||
// Show the Clearance Review tab in either phase (active or done).
|
||||
// Contract is in (or past) its clearance phase — load the clearance view so the
|
||||
// Documents tab can show customs workflow files, and surface the "Review
|
||||
// clearance" deep-link to the Operations hub.
|
||||
const CLEARANCE_REVIEW_STATUSES = [
|
||||
...CLEARANCE_ACTIVE_STATUSES,
|
||||
...CLEARANCE_DONE_STATUSES,
|
||||
@@ -151,13 +150,13 @@ export default function ContractRequestDetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const showClearanceTabQuery = Boolean(
|
||||
const hasClearancePhase = 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,
|
||||
enabled: Boolean(id) && hasClearancePhase,
|
||||
});
|
||||
|
||||
// Customer profile documents (national ID, TIN, import/business license) for
|
||||
@@ -270,18 +269,10 @@ export default function ContractRequestDetailPage() {
|
||||
contract.status === "APPROVED_PENDING_SIGNATURE" ||
|
||||
contract.status === "REJECTED";
|
||||
|
||||
const showClearanceTab = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const phasedCustoms =
|
||||
contract.contractKind === "ONE_TIME" && Boolean(contract.customsClearingEnabled);
|
||||
const docsPhaseComplete =
|
||||
clearanceView?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const clearanceApprovalsLocked = phasedCustoms && docsPhaseComplete;
|
||||
// Once clearance is finalized the tab is informational only — no approve/query.
|
||||
const clearanceReadOnly = CLEARANCE_DONE_STATUSES.includes(contract.status);
|
||||
// Path A (no customs) → Operations reviews; Path B (customs) → GL reviews.
|
||||
const selfClear = !contract.customsClearingEnabled;
|
||||
// Clearance review + finalize now lives solely on the Operations "Clearance
|
||||
// Documents" hub. The Staff-actions "Review clearance" button deep-links there
|
||||
// while the contract is in a clearance-review status — no embedded tab here.
|
||||
const inClearanceReview = CLEARANCE_REVIEW_STATUSES.includes(contract.status);
|
||||
const files = contract.files ?? [];
|
||||
const contractPdf = files.find((f) => f.code === "contract");
|
||||
// Signature files (code `signature_<role>`) are baked into the contract PDF —
|
||||
@@ -303,9 +294,7 @@ export default function ContractRequestDetailPage() {
|
||||
? "documents"
|
||||
: requestedTab === "customer"
|
||||
? "customer"
|
||||
: requestedTab === "clearance" && showClearanceTab
|
||||
? "clearance"
|
||||
: "details";
|
||||
: "details";
|
||||
|
||||
const customerLabel = contract.isGovernment
|
||||
? (contract.governmentInstitution ?? "Government")
|
||||
@@ -485,39 +474,13 @@ export default function ContractRequestDetailPage() {
|
||||
<Tabs.Tab value="customer" leftSection={<Users size={16} />}>
|
||||
Customer
|
||||
</Tabs.Tab>
|
||||
{showClearanceTab && (
|
||||
<Tabs.Tab
|
||||
value="clearance"
|
||||
leftSection={<ShieldCheck size={16} />}
|
||||
>
|
||||
Clearance Review
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
<Grid gap="lg">
|
||||
{/* LEFT — primary content */}
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{currentTab === "clearance" ? (
|
||||
<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" ? (
|
||||
{currentTab === "documents" ? (
|
||||
<Stack gap="lg">
|
||||
<ContractDocumentsCard
|
||||
files={contractDocuments}
|
||||
@@ -727,7 +690,12 @@ export default function ContractRequestDetailPage() {
|
||||
contract={contract}
|
||||
mutations={mutations}
|
||||
onReviewClearance={
|
||||
showClearanceTab ? () => setTab("clearance") : undefined
|
||||
inClearanceReview
|
||||
? () =>
|
||||
navigate(
|
||||
`/dashboard/contracts/clearance-documents/${contract.id}`,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{showApprovalCard && (
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Box, Button, Card, Container, Group, Modal, Select, Stack, Text, Title
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { Inbox, Plus, Warehouse } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -37,6 +39,10 @@ const FleetResourcePage = () => {
|
||||
const slug = getFleetSlugFromPath(location.pathname) ?? DEFAULT_SLUG;
|
||||
const config = getFleetResource(slug);
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, slug, "create");
|
||||
const canUpdate = canFleetAction(user, slug, "update");
|
||||
const canDelete = canFleetAction(user, slug, "delete");
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -278,12 +284,16 @@ const FleetResourcePage = () => {
|
||||
record={row.original}
|
||||
config={config}
|
||||
layout="compact"
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onRemove={setRemoveTarget}
|
||||
onAssignDriver={setAssigningDriver}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
onAssignDriver={canUpdate ? setAssigningDriver : undefined}
|
||||
onHistory={setHistoryTarget}
|
||||
/>
|
||||
</div>
|
||||
@@ -291,7 +301,7 @@ const FleetResourcePage = () => {
|
||||
});
|
||||
|
||||
return base;
|
||||
}, [config, dynamicOptions.yards]);
|
||||
}, [config, dynamicOptions.yards, canUpdate, canDelete]);
|
||||
|
||||
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
|
||||
@@ -390,15 +400,17 @@ const FleetResourcePage = () => {
|
||||
<Group gap="sm">
|
||||
{slug === "wagons" ? (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Warehouse size={16} />}
|
||||
styles={{ label: { fontWeight: 500 } }}
|
||||
onClick={() => setWagonWorkspaceOpen(true)}
|
||||
>
|
||||
Yard Workspace
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="light"
|
||||
color="grape"
|
||||
@@ -410,12 +422,14 @@ const FleetResourcePage = () => {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
{canCreate ? (
|
||||
<Button leftSection={<Plus size={16} />} styles={{ label: { fontWeight: 500 } }} onClick={() => {
|
||||
setEditing(null);
|
||||
setFormOpen(true);
|
||||
}}>
|
||||
{config.addLabel}
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -529,11 +543,15 @@ const FleetResourcePage = () => {
|
||||
pageCount={pageCount}
|
||||
totalCount={filteredRows.length}
|
||||
onPaginationChange={setPagination}
|
||||
onEdit={(record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}}
|
||||
onRemove={setRemoveTarget}
|
||||
onEdit={
|
||||
canUpdate
|
||||
? (record) => {
|
||||
setEditing(record);
|
||||
setFormOpen(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onRemove={canDelete ? setRemoveTarget : undefined}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
Text,
|
||||
Title,
|
||||
Container,
|
||||
ActionIcon,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { CheckCircle2, Plus, Trash2 } from 'lucide-react';
|
||||
import Breadcrumbs from '@/components/ui/Breadcrumbs';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
@@ -26,6 +28,7 @@ interface MaintenanceSchedule {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem?: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
completedDate?: string;
|
||||
@@ -35,8 +38,36 @@ interface MaintenanceSchedule {
|
||||
serviceProvider?: string;
|
||||
}
|
||||
|
||||
interface DueBoardRow {
|
||||
scheduleId: string;
|
||||
vehicleId: string;
|
||||
plateNumber: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
description: string;
|
||||
scheduledDate: string;
|
||||
nextDueDate: string | null;
|
||||
nextDueKm: number | null;
|
||||
currentKm: number | null;
|
||||
kmRemaining: number | null;
|
||||
daysRemaining: number | null;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
interface MaintenanceInterval {
|
||||
id: string;
|
||||
vehicleId: string;
|
||||
maintenanceType: string;
|
||||
serviceItem: string | null;
|
||||
intervalKm: number | null;
|
||||
intervalDays: number | null;
|
||||
description: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
const emptyForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
description: '',
|
||||
scheduledDate: new Date().toISOString().split('T')[0],
|
||||
estimatedCost: 0,
|
||||
@@ -44,12 +75,34 @@ const emptyForm = {
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const emptyIntervalForm = {
|
||||
maintenanceType: 'PREVENTIVE',
|
||||
serviceItem: '',
|
||||
intervalKm: '' as number | '',
|
||||
intervalDays: '' as number | '',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export function MaintenancePage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedVehicle, setSelectedVehicle] = useState<string | null>(null);
|
||||
const [openScheduleModal, setOpenScheduleModal] = useState(false);
|
||||
const [formData, setFormData] = useState(emptyForm);
|
||||
const [intervalForm, setIntervalForm] = useState(emptyIntervalForm);
|
||||
const [completeTarget, setCompleteTarget] = useState<MaintenanceSchedule | null>(null);
|
||||
const [completeOdometer, setCompleteOdometer] = useState<number | ''>('');
|
||||
const [completeCost, setCompleteCost] = useState<number | ''>('');
|
||||
|
||||
// Maintenance is driven by time AND km, not a picked-then-scheduled action —
|
||||
// this is the fleet-wide board of what's actually due, by date or mileage.
|
||||
const { data: dueBoard, isLoading: dueLoading } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.dueBoard(),
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/maintenance/due-board');
|
||||
return (res.data || []) as DueBoardRow[];
|
||||
},
|
||||
});
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: QUERY_KEYS.VEHICLES.list(),
|
||||
@@ -69,7 +122,32 @@ export function MaintenancePage() {
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const { data: intervals } = useQuery({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.intervals(selectedVehicle || ''),
|
||||
queryFn: async () => {
|
||||
if (!selectedVehicle) return [];
|
||||
const res = await api.get(`/maintenance/intervals/${selectedVehicle}`);
|
||||
return (res.data || []) as MaintenanceInterval[];
|
||||
},
|
||||
enabled: !!selectedVehicle,
|
||||
});
|
||||
|
||||
const upcomingList: MaintenanceSchedule[] = Array.isArray(upcoming) ? upcoming : [];
|
||||
const intervalList: MaintenanceInterval[] = Array.isArray(intervals) ? intervals : [];
|
||||
|
||||
const invalidateVehicle = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.MAINTENANCE.ROOT });
|
||||
};
|
||||
|
||||
const onError = (err: unknown) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description:
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ??
|
||||
'Failed',
|
||||
variant: 'destructive',
|
||||
});
|
||||
};
|
||||
|
||||
const scheduleMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -77,24 +155,79 @@ export function MaintenancePage() {
|
||||
const res = await api.post('/maintenance/schedules', {
|
||||
vehicleId: selectedVehicle,
|
||||
...formData,
|
||||
serviceItem: formData.serviceItem.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Maintenance scheduled' });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: QUERY_KEYS.MAINTENANCE.upcoming(selectedVehicle || ''),
|
||||
});
|
||||
invalidateVehicle();
|
||||
setOpenScheduleModal(false);
|
||||
setFormData(emptyForm);
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: err?.response?.data?.message ?? 'Failed',
|
||||
variant: 'destructive',
|
||||
onError,
|
||||
});
|
||||
|
||||
// Interval upsert: "oil change every 10,000 km" — drives the auto-scheduling
|
||||
// of the next service when a maintenance completes with an odometer reading.
|
||||
const intervalMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedVehicle) return;
|
||||
const res = await api.post('/maintenance/intervals', {
|
||||
vehicleId: selectedVehicle,
|
||||
maintenanceType: intervalForm.maintenanceType,
|
||||
serviceItem: intervalForm.serviceItem.trim() || undefined,
|
||||
intervalKm: intervalForm.intervalKm === '' ? undefined : Number(intervalForm.intervalKm),
|
||||
intervalDays:
|
||||
intervalForm.intervalDays === '' ? undefined : Number(intervalForm.intervalDays),
|
||||
description: intervalForm.description.trim() || undefined,
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval saved' });
|
||||
invalidateVehicle();
|
||||
setIntervalForm(emptyIntervalForm);
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const deactivateIntervalMutation = useMutation({
|
||||
mutationFn: async (id: string) => api.delete(`/maintenance/intervals/${id}`),
|
||||
onSuccess: () => {
|
||||
toast({ title: 'Interval deactivated' });
|
||||
invalidateVehicle();
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
// Completion with odometer: the reading is what advances KM-based
|
||||
// scheduling — the API auto-creates the next SCHEDULED item from it.
|
||||
const completeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!completeTarget) return;
|
||||
const res = await api.patch(`/maintenance/schedules/${completeTarget.id}`, {
|
||||
status: 'COMPLETED',
|
||||
completedDate: new Date().toISOString(),
|
||||
odometerReading: completeOdometer === '' ? undefined : Number(completeOdometer),
|
||||
actualCost: completeCost === '' ? undefined : Number(completeCost),
|
||||
});
|
||||
return res.data;
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Maintenance completed',
|
||||
description:
|
||||
completeOdometer === ''
|
||||
? 'No odometer recorded — next service was NOT auto-scheduled.'
|
||||
: 'Next service auto-scheduled from the recorded odometer.',
|
||||
});
|
||||
invalidateVehicle();
|
||||
setCompleteTarget(null);
|
||||
setCompleteOdometer('');
|
||||
setCompleteCost('');
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const vehicleOptions =
|
||||
@@ -132,6 +265,64 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
|
||||
<Stack gap="md">
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Due Board — by date and driven km</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{dueLoading ? (
|
||||
<Text>Loading…</Text>
|
||||
) : dueBoard && dueBoard.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vehicle</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Next Due Date</Table.Th>
|
||||
<Table.Th>Next Due Km</Table.Th>
|
||||
<Table.Th>Current Km</Table.Th>
|
||||
<Table.Th>Remaining</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{dueBoard.map((row) => (
|
||||
<Table.Tr
|
||||
key={row.scheduleId}
|
||||
onClick={() => setSelectedVehicle(row.vehicleId)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td>{row.plateNumber}</Table.Td>
|
||||
<Table.Td>{row.maintenanceType}</Table.Td>
|
||||
<Table.Td>{row.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.nextDueDate ? new Date(row.nextDueDate).toLocaleDateString() : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>{row.nextDueKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{row.currentKm ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
{row.kmRemaining != null
|
||||
? `${row.kmRemaining} km`
|
||||
: row.daysRemaining != null
|
||||
? `${row.daysRemaining} d`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={row.overdue ? 'edr-red' : 'edr-blue'}>
|
||||
{row.overdue ? 'OVERDUE' : 'SCHEDULED'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">Nothing scheduled fleet-wide</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder padding="md">
|
||||
<Select
|
||||
label="Select Vehicle"
|
||||
@@ -150,50 +341,179 @@ export function MaintenancePage() {
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<>
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Service Intervals — drives auto-scheduling</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
e.g. oil change every 10,000 km. On completion with an odometer reading, the
|
||||
next service is scheduled automatically at reading + interval.
|
||||
</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Stack gap="sm">
|
||||
{intervalList.length > 0 && (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Every (km)</Table.Th>
|
||||
<Table.Th>Every (days)</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{intervalList.map((i) => (
|
||||
<Table.Tr key={i.id}>
|
||||
<Table.Td>{i.maintenanceType}</Table.Td>
|
||||
<Table.Td>{i.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalKm ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.intervalDays ?? '—'}</Table.Td>
|
||||
<Table.Td>{i.description ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip label="Deactivate — stops auto-scheduling">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={() => deactivateIntervalMutation.mutate(i.id)}
|
||||
>
|
||||
<Trash2 size={15} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<Group align="flex-end" gap="sm" wrap="wrap">
|
||||
<Select
|
||||
label="Type"
|
||||
w={150}
|
||||
data={['PREVENTIVE', 'CORRECTIVE', 'INSPECTION', 'REPAIR']}
|
||||
value={intervalForm.maintenanceType}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, maintenanceType: v || 'PREVENTIVE' })
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change"
|
||||
w={170}
|
||||
value={intervalForm.serviceItem}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, serviceItem: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (km)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalKm}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({ ...intervalForm, intervalKm: v === '' ? '' : Number(v) })
|
||||
}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Every (days)"
|
||||
min={0}
|
||||
w={130}
|
||||
value={intervalForm.intervalDays}
|
||||
onChange={(v) =>
|
||||
setIntervalForm({
|
||||
...intervalForm,
|
||||
intervalDays: v === '' ? '' : Number(v),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="Oil and filter change"
|
||||
style={{ flex: 1, minWidth: 160 }}
|
||||
value={intervalForm.description}
|
||||
onChange={(e) =>
|
||||
setIntervalForm({ ...intervalForm, description: e.currentTarget.value })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Plus size={14} />}
|
||||
loading={intervalMutation.isPending}
|
||||
disabled={
|
||||
intervalForm.intervalKm === '' && intervalForm.intervalDays === ''
|
||||
}
|
||||
onClick={() => intervalMutation.mutate()}
|
||||
>
|
||||
Save interval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
|
||||
<Card withBorder>
|
||||
<Card.Section p="md" withBorder>
|
||||
<Text fw={500}>Upcoming Maintenance</Text>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
{isLoading ? (
|
||||
<Text>Loading...</Text>
|
||||
) : upcomingList.length > 0 ? (
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Service Item</Table.Th>
|
||||
<Table.Th>Description</Table.Th>
|
||||
<Table.Th>Scheduled</Table.Th>
|
||||
<Table.Th>Est. Cost</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{upcomingList.map((m) => (
|
||||
<Table.Tr key={m.id}>
|
||||
<Table.Td>{m.maintenanceType}</Table.Td>
|
||||
<Table.Td>{m.serviceItem ?? '—'}</Table.Td>
|
||||
<Table.Td>{m.description}</Table.Td>
|
||||
<Table.Td>{new Date(m.scheduledDate).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>
|
||||
{m.estimatedCost != null
|
||||
? `ETB ${Number(m.estimatedCost).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`
|
||||
: '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={statusColor(m.status)}>{m.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{(m.status === 'SCHEDULED' || m.status === 'IN_PROGRESS') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={13} />}
|
||||
onClick={() => setCompleteTarget(m)}
|
||||
>
|
||||
Complete
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text c="dimmed">No upcoming maintenance</Text>
|
||||
)}
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -210,6 +530,12 @@ export function MaintenancePage() {
|
||||
value={formData.maintenanceType}
|
||||
onChange={(v) => setFormData({ ...formData, maintenanceType: v || 'PREVENTIVE' })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Service item"
|
||||
placeholder="e.g. oil change — links this schedule to its interval"
|
||||
value={formData.serviceItem}
|
||||
onChange={(e) => setFormData({ ...formData, serviceItem: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Description"
|
||||
placeholder="What needs to be done?"
|
||||
@@ -254,6 +580,49 @@ export function MaintenancePage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={completeTarget != null}
|
||||
onClose={() => setCompleteTarget(null)}
|
||||
title={`Complete maintenance${completeTarget?.serviceItem ? ` — ${completeTarget.serviceItem}` : ''}`}
|
||||
size="md"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Record the odometer at completion — the next service is auto-scheduled at reading +
|
||||
interval (e.g. completed at 50,000 km with a 10,000 km interval ⇒ next due at 60,000
|
||||
km).
|
||||
</Text>
|
||||
<NumberInput
|
||||
label="Odometer reading (km)"
|
||||
placeholder="e.g. 50000"
|
||||
min={0}
|
||||
required
|
||||
value={completeOdometer}
|
||||
onChange={(v) => setCompleteOdometer(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Actual cost (ETB)"
|
||||
min={0}
|
||||
value={completeCost}
|
||||
onChange={(v) => setCompleteCost(v === '' ? '' : Number(v))}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="light" onClick={() => setCompleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={15} />}
|
||||
loading={completeMutation.isPending}
|
||||
disabled={completeOdometer === ''}
|
||||
onClick={() => completeMutation.mutate()}
|
||||
>
|
||||
Complete & schedule next
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { api } from "@/services/api";
|
||||
import { ruleEngineService } from "@/services/ruleEngine/ruleEngine.service";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
formatRouteLabel,
|
||||
@@ -160,6 +162,10 @@ export default function RoutesPage() {
|
||||
const { viewMode, setViewMode } = useFleetViewMode("routes");
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const { toast } = useToast();
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, "routes", "create");
|
||||
const canUpdate = canFleetAction(user, "routes", "update");
|
||||
const canDelete = canFleetAction(user, "routes", "delete");
|
||||
|
||||
const routesQuery = useQuery(api.routes.list.queryOptions());
|
||||
const yardsQuery = useQuery(api.routes.yards.queryOptions());
|
||||
@@ -441,26 +447,30 @@ export default function RoutesPage() {
|
||||
<Eye size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Mark stop working">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
{canUpdate ? (
|
||||
<Tooltip label="Edit">
|
||||
<ActionIcon variant="subtle" color="gray" onClick={() => openEdit(row.original)}>
|
||||
<Edit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Tooltip label="Mark stop working">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={row.original.status === "STOP_WORKING" || deactivateMutation.isPending}
|
||||
onClick={() => handleDeactivate(row.original)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}, [deactivateMutation.isPending]);
|
||||
}, [deactivateMutation.isPending, canUpdate, canDelete]);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -468,9 +478,11 @@ export default function RoutesPage() {
|
||||
title="Routes"
|
||||
subtitle="Define rail corridors, segment distances, and operational status for train scheduling."
|
||||
action={
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
Add route
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Plus size={18} />} onClick={openCreate}>
|
||||
Add route
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -555,9 +567,11 @@ export default function RoutesPage() {
|
||||
<Button variant="light" size="compact-sm" onClick={() => setViewing(route)}>
|
||||
View
|
||||
</Button>
|
||||
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
||||
Edit
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button variant="light" size="compact-sm" onClick={() => openEdit(route)}>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
country = "Ethiopia";
|
||||
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
|
||||
} else if (
|
||||
appliesTo === "CONTAINER" ||
|
||||
appliesTo === "BULK" ||
|
||||
// Customs clearance + empty-container return are sold per direction +
|
||||
// route, so their yard dropdowns narrow exactly like base freight.
|
||||
(appliesTo === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
|
||||
) {
|
||||
const direction = String(values.tradeDirection ?? "");
|
||||
// Direction is what decides the countries, so offer nothing until it is set
|
||||
// rather than defaulting to one and letting it read as a real choice.
|
||||
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [search, setSearch] = useState("");
|
||||
// Category tabs (rates page): the active tab's filters go to the backend.
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
config?.listTabs?.[0]?.key ?? "",
|
||||
);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
|
||||
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
|
||||
sortOrder: "ASC" as const,
|
||||
}
|
||||
: {}),
|
||||
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
|
||||
}),
|
||||
[
|
||||
config?.orderConfig,
|
||||
config?.supportsSearch,
|
||||
config?.listTabs,
|
||||
activeTab,
|
||||
search,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
|
||||
useEffect(() => {
|
||||
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
|
||||
setSearch("");
|
||||
}, [config?.slug, setPagination]);
|
||||
setActiveTab(config?.listTabs?.[0]?.key ?? "");
|
||||
}, [config?.slug, config?.listTabs, setPagination]);
|
||||
|
||||
const { data, isLoading, isError, error } = useRuleEngineList(
|
||||
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
|
||||
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
<Card p={0}>
|
||||
<Stack gap={0}>
|
||||
{config.listTabs && (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onChange={(v) => {
|
||||
setActiveTab(v ?? config.listTabs![0].key);
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
}}
|
||||
px="md"
|
||||
pt="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{config.listTabs.map((tab) => (
|
||||
<Tabs.Tab key={tab.key} value={tab.key}>
|
||||
{tab.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
)}
|
||||
<Box px="md" pt="md" pb="sm" w="100%">
|
||||
<RuleEngineToolbar
|
||||
search={search}
|
||||
|
||||
@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A category tab above a resource list. The active tab's `filters` are sent to
|
||||
* the list endpoint verbatim, so filtering happens server-side (values may be
|
||||
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
|
||||
*/
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
slug: RuleEngineResourceSlug;
|
||||
label: string;
|
||||
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
|
||||
formFields: FormFieldDef[];
|
||||
supportsSearch?: boolean;
|
||||
orderConfig?: RuleEngineOrderConfig;
|
||||
/** Server-filtered category tabs rendered above the list (rates page). */
|
||||
listTabs?: RuleEngineListTab[];
|
||||
/** Primary line on card view (inferred from columns when omitted). */
|
||||
cardTitleKey?: string;
|
||||
/** Secondary line under title on card view (inferred when omitted). */
|
||||
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
|
||||
/** Surcharge triggers — only relevant when Applies to = Other. */
|
||||
const RATE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||
{
|
||||
label: "Overweight (export only — import derives from container price)",
|
||||
value: "OVERWEIGHT",
|
||||
},
|
||||
{ label: "Reefer cargo", value: "REEFER" },
|
||||
{ label: "Empty container return", value: "WITH_RETURN" },
|
||||
{
|
||||
label: "Empty container return (import, per route + container type)",
|
||||
value: "WITH_RETURN",
|
||||
},
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Lashing (flat, per booking)", value: "LASHING" },
|
||||
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
|
||||
/**
|
||||
* Rates priced per leg: base rail freight, plus the customs clearance fee and
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
|
||||
|
||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||
|
||||
/**
|
||||
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
subtitle: "Freight rates and approval workflow",
|
||||
searchPlaceholder: "Search rates by type or status...",
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
},
|
||||
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
// import-only for now so export is not offered) ────────────────────────
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
|
||||
optionsFromValues: (v: Record<string, unknown>) =>
|
||||
String(v.trigger ?? "") === "WITH_RETURN" &&
|
||||
String(v.appliesTo ?? "") === "OTHER"
|
||||
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
|
||||
},
|
||||
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
|
||||
{
|
||||
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
getInitialValue: (record) =>
|
||||
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
// ── Container type — Container freight, and container-kind intercity ──
|
||||
// ── Container type — Container freight, container-kind intercity, and
|
||||
// the empty-container return surcharge (20ft vs 40ft price differently) ─
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg starts",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{
|
||||
name: "destinationYardId",
|
||||
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Where the leg ends",
|
||||
showIf: isBaseFreightRate,
|
||||
showIf: isRouteScopedRate,
|
||||
},
|
||||
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
|
||||
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction, FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
@@ -76,6 +78,12 @@ export default function TrainBuilderDetailPage() {
|
||||
const [yardModalOpen, setYardModalOpen] = useState(false);
|
||||
const [disbandOpen, setDisbandOpen] = useState(false);
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false);
|
||||
const { user } = useAuth();
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
const canDelete = canFleetAction(user, "trains", "delete");
|
||||
const canAssign =
|
||||
hasPermission(user, FREIGHT_PERMS.trains.assignWagons) ||
|
||||
hasPermission(user, FREIGHT_PERMS.fleet.manage);
|
||||
|
||||
const compositionQuery = useQuery(
|
||||
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
|
||||
@@ -162,59 +170,67 @@ export default function TrainBuilderDetailPage() {
|
||||
</Group>
|
||||
}
|
||||
action={
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
Actions
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDisbandOpen(true)}
|
||||
>
|
||||
Disband train
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
canUpdate || canDelete ? (
|
||||
<Menu position="bottom-end" withinPortal shadow="md" width={220}>
|
||||
<Menu.Target>
|
||||
<Button variant="default" rightSection={<MoreHorizontal size={16} />}>
|
||||
Actions
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{canUpdate ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<Replace size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Change locomotives
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<MapPin size={15} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setYardModalOpen(true)}
|
||||
>
|
||||
Change yard
|
||||
</Menu.Item>
|
||||
{composition.status === "DEACTIVATED" ? (
|
||||
<Menu.Item
|
||||
leftSection={<Power size={15} />}
|
||||
disabled={blockingLocomotives.length > 0}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await activate.mutateAsync(composition.id);
|
||||
toast({ title: `Train ${composition.code} reactivated` });
|
||||
}, "Could not reactivate train")
|
||||
}
|
||||
>
|
||||
Reactivate train
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
leftSection={<PowerOff size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDeactivateOpen(true)}
|
||||
>
|
||||
Deactivate train
|
||||
</Menu.Item>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{canDelete ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<Trash2 size={15} />}
|
||||
disabled={composition.activeSchedules.length > 0}
|
||||
onClick={() => setDisbandOpen(true)}
|
||||
>
|
||||
Disband train
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -259,16 +275,18 @@ export default function TrainBuilderDetailPage() {
|
||||
.
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Replace size={14} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Detach & replace locomotives
|
||||
</Button>
|
||||
{canUpdate ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<Replace size={14} />}
|
||||
disabled={!composition.editable}
|
||||
onClick={() => setLocoModalOpen(true)}
|
||||
>
|
||||
Detach & replace locomotives
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="subtle"
|
||||
@@ -330,7 +348,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
|
||||
<Grid gap="lg" align="stretch">
|
||||
{composition.editable ? (
|
||||
{composition.editable && canAssign ? (
|
||||
<Grid.Col span={{ base: 12, md: 5 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
@@ -355,7 +373,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
) : null}
|
||||
<Grid.Col span={{ base: 12, md: composition.editable ? 7 : 12 }}>
|
||||
<Grid.Col span={{ base: 12, md: composition.editable && canAssign ? 7 : 12 }}>
|
||||
<Card h="100%">
|
||||
<Stack gap="sm">
|
||||
<Text fw={600}>Wagon order</Text>
|
||||
@@ -364,7 +382,7 @@ export default function TrainBuilderDetailPage() {
|
||||
</Text>
|
||||
<ConsistWagonList
|
||||
wagons={composition.wagons}
|
||||
editable={composition.editable}
|
||||
editable={composition.editable && canAssign}
|
||||
busy={busy}
|
||||
onReorder={(wagonIds) =>
|
||||
void withToast(
|
||||
|
||||
@@ -35,6 +35,8 @@ import {
|
||||
trainStatusLabel,
|
||||
} from "@/components/trainBuilder/trainStatus";
|
||||
import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canFleetAction } from "@/lib/permissions";
|
||||
import type {
|
||||
BuiltTrainListFilters,
|
||||
BuiltTrainStatus,
|
||||
@@ -54,6 +56,9 @@ export default function TrainBuilderListPage() {
|
||||
const [yardFilter, setYardFilter] = useState("ALL");
|
||||
const [buildOpen, setBuildOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<BuiltTrainSummary | null>(null);
|
||||
const { user } = useAuth();
|
||||
const canCreate = canFleetAction(user, "trains", "create");
|
||||
const canUpdate = canFleetAction(user, "trains", "update");
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setPagination((prev) =>
|
||||
@@ -240,24 +245,25 @@ export default function TrainBuilderListPage() {
|
||||
id: "actions",
|
||||
header: "",
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Edit train ${row.original.code}`}
|
||||
title="Edit name & train numbers"
|
||||
onClick={(e) => {
|
||||
// Row click navigates to the detail page — keep the edit local.
|
||||
e.stopPropagation();
|
||||
setEditTarget(row.original);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
canUpdate ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
aria-label={`Edit train ${row.original.code}`}
|
||||
title="Edit name & train numbers"
|
||||
onClick={(e) => {
|
||||
// Row click navigates to the detail page — keep the edit local.
|
||||
e.stopPropagation();
|
||||
setEditTarget(row.original);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}, []);
|
||||
}, [canUpdate]);
|
||||
|
||||
const tableStatus = trainsQuery.isLoading
|
||||
? "loading"
|
||||
@@ -271,9 +277,11 @@ export default function TrainBuilderListPage() {
|
||||
title="Train Builder"
|
||||
subtitle="Assemble coded trains from locomotives and wagons in a yard, ready to schedule as a unit."
|
||||
action={
|
||||
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
|
||||
Build train
|
||||
</Button>
|
||||
canCreate ? (
|
||||
<Button leftSection={<Hammer size={18} />} onClick={() => setBuildOpen(true)}>
|
||||
Build train
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -935,6 +935,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
stops={schedule.stops ?? []}
|
||||
bookings={schedule.bookings ?? []}
|
||||
maxWagons={schedule.maxWagons}
|
||||
maxGrossTons={schedule.maxGrossWeightTons}
|
||||
/>
|
||||
) : (
|
||||
<Box maw={340}>
|
||||
|
||||
@@ -49,7 +49,7 @@ export interface GetIframeUrlRequest {
|
||||
|
||||
// Create a dedicated axios instance for Metabase API
|
||||
const metabaseAxios = axios.create({
|
||||
baseURL: getEnvUrl("VITE_CHRONICLE_URL"),
|
||||
baseURL: getEnvUrl("VITE_CHRONICLE_URL", false),
|
||||
});
|
||||
|
||||
// Add auth token interceptor
|
||||
|
||||
@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -205,6 +208,8 @@ export const ruleEngineService = {
|
||||
sortBy: params?.sortBy,
|
||||
sortOrder: params?.sortOrder,
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
@@ -9,5 +9,3 @@ function normalizeBaseUrl(url: string) {
|
||||
export const AUDITLOG_API_URL: string = normalizeBaseUrl(
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false),
|
||||
);
|
||||
|
||||
if (!AUDITLOG_API_URL) console.warn("Missing VITE_AUDITLOG_API_URL");
|
||||
|
||||
@@ -9,18 +9,12 @@ import {
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_CHRONICLE_URL) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL — chronicle axios instance has no base URL");
|
||||
}
|
||||
|
||||
// Chronicle/audit-log backend is optional in this deployment — no warning
|
||||
// when unset; the module's screens are simply non-functional without it.
|
||||
const chronicleBaseUrl =
|
||||
getEnvUrl("VITE_CHRONICLE_URL", false) ||
|
||||
getEnvUrl("VITE_AUDITLOG_API_URL", false);
|
||||
|
||||
if (!chronicleBaseUrl) {
|
||||
console.warn("Missing VITE_CHRONICLE_URL and VITE_AUDITLOG_API_URL");
|
||||
}
|
||||
|
||||
const chronicleInstance = axios.create({
|
||||
baseURL: chronicleBaseUrl,
|
||||
});
|
||||
|
||||
@@ -8,14 +8,10 @@ import {
|
||||
} from "../utils/authPersistence";
|
||||
import { handleSessionExpiry } from "./sessionExpiry";
|
||||
|
||||
if (!import.meta.env.VITE_RECORD_API_URL) {
|
||||
console.warn(
|
||||
"Missing VITE_RECORD_API_URL — record axios instance has no base URL",
|
||||
);
|
||||
}
|
||||
|
||||
// Record backend is optional in this deployment — no warning when unset; the
|
||||
// module's screens are simply non-functional without it.
|
||||
const recordAxiosInstance = axios.create({
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL"),
|
||||
baseURL: getEnvUrl("VITE_RECORD_API_URL", false),
|
||||
});
|
||||
|
||||
// Attach auth token and CSRF defence header to every request
|
||||
|
||||
@@ -169,6 +169,8 @@ export interface BookingDetail {
|
||||
contractType: string;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
isHazardous: boolean;
|
||||
consolidationPartnerId?: string | null;
|
||||
|
||||
@@ -640,6 +640,8 @@ export interface TrainScheduleDetail {
|
||||
}>;
|
||||
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
|
||||
stops?: Array<{ yardId: string; label: string }>;
|
||||
/** Loco pull ceiling incl. overage tolerance — per-leg gross is held to it. */
|
||||
maxGrossWeightTons?: number | null;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
|
||||
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
|
||||
|
||||
const apiInstance = axios.create({
|
||||
baseURL: API_URL,
|
||||
|
||||
@@ -11,7 +11,7 @@ import Cookies from "js-cookie";
|
||||
import { getEnvUrl } from "@/shared/config/env";
|
||||
|
||||
const TENANT_ID = "adf98293-41ba-4bda-bdb4-70e30a70c1b7";
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL");
|
||||
const API_URL = getEnvUrl("VITE_RECORD_API_URL", false);
|
||||
|
||||
// Create axios instance for template API
|
||||
const apiInstance = axios.create({
|
||||
|
||||
Reference in New Issue
Block a user