mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
per-user trade-direction access scope
This commit is contained in:
@@ -111,6 +111,7 @@ import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetail
|
||||
import TrainScheduleV2DetailPage from "./pages/trainScheduling/TrainScheduleV2DetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage";
|
||||
import TradeAccessPage from "./pages/configuration/TradeAccessPage";
|
||||
import ContractValidityPeriodsPage from "./pages/configuration/ContractValidityPeriodsPage";
|
||||
import FirstMilePage from "./pages/operations/FirstMilePage";
|
||||
import LastMilePage from "./pages/operations/LastMilePage";
|
||||
@@ -585,6 +586,11 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
href: "/dashboard/configuration/train-scheduling-rules",
|
||||
permission: FREIGHT_PERMS.trainScheduling.rulesManage,
|
||||
},
|
||||
{
|
||||
label: "Trade access",
|
||||
href: "/dashboard/configuration/trade-access",
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -1549,6 +1555,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/trade-access"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<TradeAccessPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
{/* <Route
|
||||
path="configuration/contract-validity-periods"
|
||||
element={
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { ChevronLeft, ChevronRight, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Gauge, Maximize2, Minimize2, Train, TrainFront, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
@@ -319,7 +319,11 @@ function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[]): THREE.Group {
|
||||
function buildLocomotive(
|
||||
loco: { id?: string; code: string } | undefined,
|
||||
wheels: THREE.Mesh[],
|
||||
pickables: THREE.Object3D[],
|
||||
): THREE.Group {
|
||||
const g = new THREE.Group();
|
||||
const len = 20 * M;
|
||||
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1e6f42, metalness: 0.4, roughness: 0.5 });
|
||||
@@ -345,7 +349,12 @@ function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[
|
||||
g.add(spot, spot.target);
|
||||
addWheels(g, len, wheels);
|
||||
g.userData.length = len;
|
||||
g.userData.locoCode = loco?.code;
|
||||
const locoId = loco?.id ?? loco?.code ?? "loco";
|
||||
g.userData.locoId = locoId;
|
||||
g.traverse((o) => {
|
||||
o.userData.locoId = locoId;
|
||||
});
|
||||
pickables.push(g);
|
||||
return g;
|
||||
}
|
||||
|
||||
@@ -457,6 +466,119 @@ function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3
|
||||
return g;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number | null | undefined }) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="gray.5">{label}</Text>
|
||||
<Text size="xs" c="gray.1" fw={600} ta="right">{value}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function LocoDetailPanel({
|
||||
locoId,
|
||||
schedule,
|
||||
onClose,
|
||||
onDrive,
|
||||
}: {
|
||||
locoId: string;
|
||||
schedule: TrainScheduleDetail;
|
||||
onClose: () => void;
|
||||
onDrive: () => void;
|
||||
}) {
|
||||
const locos = schedule.trainSet?.locomotives?.length
|
||||
? schedule.trainSet.locomotives
|
||||
: schedule.trainSet?.locomotive
|
||||
? [schedule.trainSet.locomotive]
|
||||
: [];
|
||||
const loco = locos.find((l) => l.id === locoId || l.code === locoId);
|
||||
const ts = schedule.trainSet;
|
||||
return (
|
||||
<Paper
|
||||
radius="lg"
|
||||
p="md"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
right: 16,
|
||||
width: 340,
|
||||
maxHeight: "calc(100% - 76px)",
|
||||
background: "rgba(13, 17, 23, 0.92)",
|
||||
border: "1px solid rgba(255,255,255,0.15)",
|
||||
color: "#e6edf3",
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={700}>Locomotive {loco?.code ?? ""}</Text>
|
||||
<CloseButton variant="transparent" c="gray.4" onClick={onClose} />
|
||||
</Group>
|
||||
<Button fullWidth color="orange" size="xs" mb="sm" leftSection={<Gauge size={14} />} onClick={onDrive}>
|
||||
Drive this locomotive
|
||||
</Button>
|
||||
<ScrollArea.Autosize mah="70vh">
|
||||
<Stack gap="xs">
|
||||
{loco ? (
|
||||
<>
|
||||
{loco.name ? <Text size="sm" c="gray.3">{loco.name}</Text> : null}
|
||||
<Group gap="xs">
|
||||
<Badge color="yellow" variant="light">{loco.status}</Badge>
|
||||
</Group>
|
||||
<InfoRow label="Max pull weight" value={`${loco.maxPullWeightTons} t`} />
|
||||
{loco.maxTrainLengthMeters ? (
|
||||
<InfoRow label="Max train length" value={`${loco.maxTrainLengthMeters} m`} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Text size="sm" c="gray.5">No locomotive assigned yet.</Text>
|
||||
)}
|
||||
<Divider color="rgba(255,255,255,0.1)" label="Train" labelPosition="left" />
|
||||
<InfoRow label="Voyage / reference" value={schedule.reference} />
|
||||
<InfoRow label="Train number" value={schedule.trainNumber} />
|
||||
<InfoRow
|
||||
label="Train"
|
||||
value={schedule.train ? `${schedule.train.code}${schedule.train.trainName ? ` · ${schedule.train.trainName}` : ""}` : null}
|
||||
/>
|
||||
<InfoRow label="Status" value={String(schedule.status)} />
|
||||
<InfoRow label="Direction" value={schedule.direction} />
|
||||
<InfoRow label="Route" value={schedule.route?.name} />
|
||||
<InfoRow
|
||||
label="From → To"
|
||||
value={
|
||||
schedule.originStation || schedule.destinationStation
|
||||
? `${schedule.originStation?.label ?? schedule.originStation?.code ?? "?"} → ${schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? "?"}`
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Departure"
|
||||
value={schedule.scheduledDepartureDate ? new Date(schedule.scheduledDepartureDate).toLocaleString() : null}
|
||||
/>
|
||||
<InfoRow
|
||||
label="Arrival"
|
||||
value={schedule.scheduledArrivalDate ? new Date(schedule.scheduledArrivalDate).toLocaleString() : null}
|
||||
/>
|
||||
{ts ? (
|
||||
<>
|
||||
<Divider color="rgba(255,255,255,0.1)" label="Consist" labelPosition="left" />
|
||||
<InfoRow label="Wagons" value={ts.wagonCount} />
|
||||
<InfoRow label="Total weight" value={`${ts.totalWeightTons} t`} />
|
||||
<InfoRow label="Total length" value={`${ts.totalLengthMeters} m`} />
|
||||
{ts.heaviestLeg ? (
|
||||
<InfoRow
|
||||
label="Heaviest leg"
|
||||
value={`${ts.heaviestLeg.grossWeightTons} t · ${ts.heaviestLeg.lengthMeters} m`}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) {
|
||||
const bookings = schedule.bookings ?? [];
|
||||
return (
|
||||
@@ -547,6 +669,8 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasHostRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
|
||||
const [selectedLocoId, setSelectedLocoId] = useState<string | null>(null);
|
||||
const [driveMode, setDriveMode] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const cameraApiRef = useRef<{
|
||||
overview: () => void;
|
||||
@@ -556,7 +680,9 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
} | null>(null);
|
||||
|
||||
const wagons = schedule.trainSet?.wagons ?? [];
|
||||
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
|
||||
const dispatched = String(schedule.status).toUpperCase() === "DISPATCHED";
|
||||
// drive mode always simulates motion, even before dispatch
|
||||
const moving = dispatched || driveMode;
|
||||
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
|
||||
const orderedWagons = [...wagons].sort(
|
||||
(a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo),
|
||||
@@ -572,10 +698,16 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
: orderedWagons.length - 1
|
||||
: (selectedIndex + dir + orderedWagons.length) % orderedWagons.length;
|
||||
const wagon = orderedWagons[next];
|
||||
setSelectedLocoId(null);
|
||||
setSelectedWagonId(wagon.id);
|
||||
cameraApiRef.current?.focusWagon(wagon.id);
|
||||
};
|
||||
|
||||
const firstLocoId =
|
||||
schedule.trainSet?.locomotives?.[0]?.id ??
|
||||
schedule.trainSet?.locomotive?.id ??
|
||||
"LOCO";
|
||||
|
||||
useEffect(() => {
|
||||
const host = canvasHostRef.current;
|
||||
if (!host) return;
|
||||
@@ -619,11 +751,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
: schedule.trainSet?.locomotive
|
||||
? [schedule.trainSet.locomotive]
|
||||
: [{ code: "LOCO" }];
|
||||
const locoGroups: Array<{ id: string; group: THREE.Group }> = [];
|
||||
for (const loco of locos) {
|
||||
const lg = buildLocomotive(loco, wheels);
|
||||
const lg = buildLocomotive(loco, wheels, pickables);
|
||||
lg.position.x = cursor - lg.userData.length / 2;
|
||||
cursor -= lg.userData.length + gap;
|
||||
train.add(lg);
|
||||
locoGroups.push({ id: lg.userData.locoId as string, group: lg });
|
||||
}
|
||||
const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo));
|
||||
const wagonGroups: Array<{ id: string; group: THREE.Group }> = [];
|
||||
@@ -650,6 +784,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
controls.maxDistance = Math.max(600, trainLen * 1.6);
|
||||
controls.enableDamping = true;
|
||||
controls.zoomToCursor = true; // wheel zooms toward the point under the pointer
|
||||
controls.enabled = !driveMode; // cab ride owns the camera
|
||||
|
||||
// camera fly-to: goal recomputed each frame so it tracks a moving train
|
||||
let cameraGoal: (() => { pos: THREE.Vector3; target: THREE.Vector3 }) | null = null;
|
||||
@@ -692,6 +827,20 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
controls.addEventListener("start", () => {
|
||||
cameraGoal = null;
|
||||
});
|
||||
if (driveMode) {
|
||||
// cab ride: camera on the driver's seat of the lead loco, looking down the line
|
||||
const locoLen = frontGroup.userData.length as number;
|
||||
cameraGoal = () => {
|
||||
frontGroup.getWorldPosition(worldPos);
|
||||
return {
|
||||
pos: new THREE.Vector3(worldPos.x + locoLen / 2 - 3.2, DECK_H + 3.9, 1.05),
|
||||
target: new THREE.Vector3(worldPos.x + locoLen / 2 + 140, 2.2, 0),
|
||||
};
|
||||
};
|
||||
} else if (moving) {
|
||||
// dispatched → open following the rolling train so motion is obvious
|
||||
cameraApiRef.current.overview();
|
||||
}
|
||||
|
||||
// picking
|
||||
const raycaster = new THREE.Raycaster();
|
||||
@@ -714,11 +863,21 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
raycaster.setFromCamera(pointer, camera);
|
||||
const hits = raycaster.intersectObjects(pickables, true);
|
||||
const wagonId = hits[0]?.object.userData.wagonId as string | undefined;
|
||||
const locoId = hits[0]?.object.userData.locoId as string | undefined;
|
||||
if (highlighted) setEmissive(highlighted, false);
|
||||
highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null;
|
||||
highlighted = wagonId
|
||||
? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null)
|
||||
: locoId
|
||||
? (locoGroups.find((l) => l.id === locoId)?.group ?? null)
|
||||
: null;
|
||||
if (highlighted) setEmissive(highlighted, true);
|
||||
setSelectedWagonId(wagonId ?? null);
|
||||
setSelectedLocoId(locoId && !wagonId ? locoId : null);
|
||||
if (wagonId) cameraApiRef.current?.focusWagon(wagonId);
|
||||
else if (locoId) {
|
||||
const lg = locoGroups.find((l) => l.id === locoId);
|
||||
if (lg) flyToObject(lg.group, 30, 9);
|
||||
}
|
||||
};
|
||||
renderer.domElement.addEventListener("click", onClick);
|
||||
|
||||
@@ -732,20 +891,27 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
|
||||
let raf = 0;
|
||||
let last = performance.now();
|
||||
const speed = 8; // m/s visual speed when dispatched
|
||||
let elapsed = 0;
|
||||
const speed = 14; // m/s visual speed when dispatched
|
||||
const animate = () => {
|
||||
raf = requestAnimationFrame(animate);
|
||||
const now = performance.now();
|
||||
const dt = Math.min((now - last) / 1000, 0.1);
|
||||
last = now;
|
||||
if (moving) {
|
||||
elapsed += dt;
|
||||
train.position.x += speed * dt;
|
||||
for (const w of wheels) w.rotation.y += (speed * dt) / WHEEL_R;
|
||||
if (train.position.x > trainLen / 2 + 120) train.position.x = trainLen / 2 - 120;
|
||||
// subtle rail-joint sway so motion reads even up close
|
||||
train.position.y = Math.sin(elapsed * 9) * 0.03;
|
||||
train.rotation.z = Math.sin(elapsed * 4.5) * 0.0025;
|
||||
// loop over the long track stretch; jump happens far off-screen
|
||||
const half = finalTrackLen / 2 - trainLen - 40;
|
||||
if (train.position.x > half + trainLen) train.position.x = -half;
|
||||
}
|
||||
if (cameraGoal) {
|
||||
const goal = cameraGoal();
|
||||
const k = Math.min(1, dt * 3.2);
|
||||
const k = Math.min(1, dt * (driveMode ? 7 : 3.2));
|
||||
camera.position.lerp(goal.pos, k);
|
||||
controls.target.lerp(goal.target, k);
|
||||
// static scene: release goal once settled so orbiting feels free again
|
||||
@@ -772,12 +938,14 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
});
|
||||
};
|
||||
// rebuild scene only when schedule identity/status changes
|
||||
}, [schedule.id, schedule.status, moving, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [schedule.id, schedule.status, moving, driveMode, wagons.length]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setIsFullscreen(Boolean(document.fullscreenElement));
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !document.fullscreenElement) onClose();
|
||||
if (e.key !== "Escape" || document.fullscreenElement) return;
|
||||
if (driveMode) setDriveMode(false);
|
||||
else onClose();
|
||||
};
|
||||
document.addEventListener("fullscreenchange", onFsChange);
|
||||
window.addEventListener("keydown", onKey);
|
||||
@@ -785,7 +953,7 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
document.removeEventListener("fullscreenchange", onFsChange);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [onClose]);
|
||||
}, [onClose, driveMode]);
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (document.fullscreenElement) void document.exitFullscreen();
|
||||
@@ -807,8 +975,12 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
gap="xs"
|
||||
style={{ position: "absolute", top: 16, left: 16, zIndex: 10 }}
|
||||
>
|
||||
<Badge size="lg" variant="filled" color={moving ? "green" : "gray"}>
|
||||
{moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`}
|
||||
<Badge size="lg" variant="filled" color={driveMode ? "orange" : dispatched ? "green" : "gray"}>
|
||||
{driveMode
|
||||
? "DRIVER VIEW — SIMULATION"
|
||||
: dispatched
|
||||
? "DISPATCHED — TRAIN IN MOTION"
|
||||
: `${schedule.status} — TRAIN STOPPED`}
|
||||
</Badge>
|
||||
<Badge size="lg" variant="light" color="yellow">
|
||||
{schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons
|
||||
@@ -839,13 +1011,26 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
size="compact-sm"
|
||||
variant="default"
|
||||
leftSection={<TrainFront size={14} />}
|
||||
onClick={() => cameraApiRef.current?.front()}
|
||||
onClick={() => {
|
||||
setSelectedWagonId(null);
|
||||
setSelectedLocoId(firstLocoId);
|
||||
cameraApiRef.current?.front();
|
||||
}}
|
||||
>
|
||||
Locomotive
|
||||
</Button>
|
||||
<Button size="compact-sm" variant="default" onClick={() => cameraApiRef.current?.rear()}>
|
||||
Last wagon
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={driveMode ? "filled" : "default"}
|
||||
color={driveMode ? "orange" : undefined}
|
||||
leftSection={<Gauge size={14} />}
|
||||
onClick={() => setDriveMode((d) => !d)}
|
||||
>
|
||||
{driveMode ? "Exit drive" : "Drive"}
|
||||
</Button>
|
||||
<Divider orientation="vertical" color="rgba(255,255,255,0.2)" />
|
||||
<Button size="compact-sm" variant="default" onClick={() => stepWagon(-1)} px={8}>
|
||||
<ChevronLeft size={16} />
|
||||
@@ -885,6 +1070,13 @@ export function Train3DVisualization({ schedule, onClose }: Train3DVisualization
|
||||
</Group>
|
||||
{selectedWagon ? (
|
||||
<WagonDetailPanel wagon={selectedWagon} schedule={schedule} onClose={() => setSelectedWagonId(null)} />
|
||||
) : selectedLocoId ? (
|
||||
<LocoDetailPanel
|
||||
locoId={selectedLocoId}
|
||||
schedule={schedule}
|
||||
onClose={() => setSelectedLocoId(null)}
|
||||
onDrive={() => setDriveMode(true)}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
ALL_TRADE_DIRECTIONS,
|
||||
userTradeAccessService,
|
||||
type TradeDirection,
|
||||
} from "@/services/userTradeAccess.service";
|
||||
|
||||
/**
|
||||
* Current user's trade-direction scope. While loading (or on error) it
|
||||
* reports full access — the API enforces the real scope regardless; this
|
||||
* hook only trims filter dropdowns to the directions the user can see.
|
||||
*/
|
||||
export function useMyTradeAccess() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["user-trade-access", "me"],
|
||||
queryFn: userTradeAccessService.me,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const directions: TradeDirection[] = data?.directions ?? [
|
||||
...ALL_TRADE_DIRECTIONS,
|
||||
];
|
||||
|
||||
return {
|
||||
restricted: data?.restricted ?? false,
|
||||
directions,
|
||||
/** Trim `{ value }`-shaped dropdown options to the allowed directions. */
|
||||
filterOptions: <T extends { value: string }>(options: T[]): T[] =>
|
||||
options.filter((o) => directions.includes(o.value as TradeDirection)),
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -139,6 +140,7 @@ export default function BookingRequestsPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>(() =>
|
||||
paramStatuses.split(",").filter(Boolean),
|
||||
);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(
|
||||
paramDirection,
|
||||
);
|
||||
@@ -602,7 +604,7 @@ export default function BookingRequestsPage() {
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { useEmployees } from "@/user-management/hooks/useEmployees";
|
||||
import {
|
||||
ALL_TRADE_DIRECTIONS,
|
||||
TRADE_DIRECTION_LABELS,
|
||||
userTradeAccessService,
|
||||
type TradeDirection,
|
||||
} from "@/services/userTradeAccess.service";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const QUERY_KEY = ["user-trade-access", "list"] as const;
|
||||
|
||||
type EmployeeRow = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-user trade-direction access (Import / Export / Intercity checkboxes).
|
||||
* All three checked (or never configured) = unrestricted; unchecking limits
|
||||
* the user's contracts, bookings, schedules, batch board, payments, invoices
|
||||
* and overview to the checked directions. Admins always bypass the scope.
|
||||
*/
|
||||
export default function TradeAccessPage() {
|
||||
const { user } = useAuth();
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const organizationId =
|
||||
user?.employee && user.employee.length > 0
|
||||
? user.employee[0].organizationId
|
||||
: undefined;
|
||||
|
||||
const { employeesResponseByOrg, isLoadingEmployeesByOrg } = useEmployees({
|
||||
organizationId,
|
||||
});
|
||||
|
||||
const { data: configs, isLoading: configsLoading } = useQuery({
|
||||
queryKey: QUERY_KEY,
|
||||
queryFn: userTradeAccessService.list,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: ({
|
||||
userId,
|
||||
directions,
|
||||
}: {
|
||||
userId: string;
|
||||
directions: TradeDirection[];
|
||||
}) => userTradeAccessService.set(userId, directions),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: QUERY_KEY });
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["user-trade-access", "me"],
|
||||
});
|
||||
toast.success("Trade access updated");
|
||||
},
|
||||
});
|
||||
|
||||
const configByUser = useMemo(() => {
|
||||
const map = new Map<string, TradeDirection[]>();
|
||||
for (const row of configs ?? []) map.set(row.userId, row.directions);
|
||||
return map;
|
||||
}, [configs]);
|
||||
|
||||
const rows: EmployeeRow[] = useMemo(() => {
|
||||
const items = employeesResponseByOrg?.items ?? [];
|
||||
const mapped = items
|
||||
.map((item: { user?: { id?: string; name?: { en?: string }; email?: string; username?: string } }) => ({
|
||||
userId: item.user?.id ?? "",
|
||||
name: item.user?.name?.en ?? item.user?.username ?? "—",
|
||||
email: item.user?.email ?? "",
|
||||
}))
|
||||
.filter((r: EmployeeRow) => r.userId);
|
||||
const term = search.trim().toLowerCase();
|
||||
if (!term) return mapped;
|
||||
return mapped.filter(
|
||||
(r: EmployeeRow) =>
|
||||
r.name.toLowerCase().includes(term) ||
|
||||
r.email.toLowerCase().includes(term),
|
||||
);
|
||||
}, [employeesResponseByOrg, search]);
|
||||
|
||||
// No row yet = unrestricted, so render as all three checked.
|
||||
const directionsFor = (userId: string): TradeDirection[] =>
|
||||
configByUser.get(userId) ?? [...ALL_TRADE_DIRECTIONS];
|
||||
|
||||
const toggle = (userId: string, direction: TradeDirection) => {
|
||||
const current = directionsFor(userId);
|
||||
const next = current.includes(direction)
|
||||
? current.filter((d) => d !== direction)
|
||||
: [...current, direction];
|
||||
saveMutation.mutate({ userId, directions: next });
|
||||
};
|
||||
|
||||
const loading = isLoadingEmployeesByOrg || configsLoading;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Trade direction access</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose which trade directions each backoffice user can see. This
|
||||
filters their contracts, bookings, schedules, batch board, payments,
|
||||
invoices and overview. All three checked means full access; super and
|
||||
organization admins are never restricted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name or email…"
|
||||
className="w-full max-w-sm rounded-md border px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading users…</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
{ALL_TRADE_DIRECTIONS.map((d) => (
|
||||
<TableHead key={d} className="text-center">
|
||||
{TRADE_DIRECTION_LABELS[d]}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead>Access</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const dirs = directionsFor(row.userId);
|
||||
const unrestricted = dirs.length === ALL_TRADE_DIRECTIONS.length;
|
||||
return (
|
||||
<TableRow key={row.userId}>
|
||||
<TableCell className="font-medium">{row.name}</TableCell>
|
||||
<TableCell>{row.email}</TableCell>
|
||||
{ALL_TRADE_DIRECTIONS.map((d) => (
|
||||
<TableCell key={d} className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-primary"
|
||||
checked={dirs.includes(d)}
|
||||
disabled={saveMutation.isPending}
|
||||
onChange={() => toggle(row.userId, d)}
|
||||
aria-label={`${row.name} — ${TRADE_DIRECTION_LABELS[d]}`}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell>
|
||||
{unrestricted ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Full access
|
||||
</span>
|
||||
) : dirs.length === 0 ? (
|
||||
<span className="text-xs font-medium text-red-600">
|
||||
No data
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-medium text-amber-600">
|
||||
{dirs.map((d) => TRADE_DIRECTION_LABELS[d]).join(" + ")}{" "}
|
||||
only
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3 + ALL_TRADE_DIRECTIONS.length}
|
||||
className="text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No users found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -92,6 +93,7 @@ export default function ClearanceDocumentsPage() {
|
||||
const [bookingStatuses, setBookingStatuses] = useState(
|
||||
BOOKING_STATUS_OPTIONS[0].value,
|
||||
);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(null);
|
||||
const [ownershipFilter, setOwnershipFilter] = useState<string | null>(null);
|
||||
@@ -309,7 +311,7 @@ export default function ClearanceDocumentsPage() {
|
||||
<Group gap="sm" mt="sm" wrap="wrap">
|
||||
<Select
|
||||
placeholder="Direction"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { directionLabel } from "@/lib/utils";
|
||||
import { useMyTradeAccess } from "@/hooks/useMyTradeAccess";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
@@ -156,6 +157,7 @@ export default function ContractRequestsPage() {
|
||||
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
|
||||
// Filter controls (empty/null = "all").
|
||||
const [statusFilter, setStatusFilter] = useState<string[]>([]);
|
||||
const { filterOptions } = useMyTradeAccess();
|
||||
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
|
||||
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
|
||||
null,
|
||||
@@ -561,7 +563,7 @@ export default function ContractRequestsPage() {
|
||||
/>
|
||||
<Select
|
||||
placeholder="All directions"
|
||||
data={TRADE_DIRECTION_OPTIONS}
|
||||
data={filterOptions(TRADE_DIRECTION_OPTIONS)}
|
||||
value={directionFilter}
|
||||
onChange={(v) => {
|
||||
setDirectionFilter(v);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { api as client } from "../auth/http";
|
||||
|
||||
export type TradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
|
||||
|
||||
export const ALL_TRADE_DIRECTIONS: TradeDirection[] = [
|
||||
"IMPORT",
|
||||
"EXPORT",
|
||||
"DOMESTIC",
|
||||
];
|
||||
|
||||
export const TRADE_DIRECTION_LABELS: Record<TradeDirection, string> = {
|
||||
IMPORT: "Import",
|
||||
EXPORT: "Export",
|
||||
DOMESTIC: "Intercity",
|
||||
};
|
||||
|
||||
export interface UserTradeAccessRow {
|
||||
userId: string;
|
||||
directions: TradeDirection[];
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MyTradeAccess {
|
||||
restricted: boolean;
|
||||
directions: TradeDirection[];
|
||||
}
|
||||
|
||||
export const userTradeAccessService = {
|
||||
/** All configured per-user scopes (admin only). */
|
||||
list: async (): Promise<UserTradeAccessRow[]> =>
|
||||
(await client.get("/user-trade-access")).data,
|
||||
|
||||
/** Current user's effective scope. */
|
||||
me: async (): Promise<MyTradeAccess> =>
|
||||
(await client.get("/user-trade-access/me")).data,
|
||||
|
||||
/** Set the directions a user may see (admin only). */
|
||||
set: async (
|
||||
userId: string,
|
||||
directions: TradeDirection[],
|
||||
): Promise<UserTradeAccessRow> =>
|
||||
(await client.put(`/user-trade-access/${userId}`, { directions })).data,
|
||||
};
|
||||
Reference in New Issue
Block a user