configer the stamp

This commit is contained in:
Marshal
2026-08-02 21:09:37 +00:00
parent 950bcc0912
commit 94c64c2390
7 changed files with 650 additions and 3 deletions

View File

@@ -59,6 +59,30 @@ export class BackofficeService {
];
}
/**
* IAM user ids of current employees (any org) holding ANY of the given
* permission keys — used by the notification recipients resolver's
* `permissionKeys` selector for department/role-scoped targeting.
*/
async getEmployeeUserIdsByPermission(
permissionKeys: string[],
): Promise<string[]> {
if (!permissionKeys.length) return [];
const rows: { userId: string | null }[] = await this.employeeRepository
.createQueryBuilder("employee")
.innerJoin("employee.employeePositions", "employeePosition")
.innerJoin("employeePosition.position", "position")
.innerJoin("position.positionPermission", "positionPermission")
.innerJoin("positionPermission.permission", "permission")
.where("employee.isCurrent = :isCurrent", { isCurrent: true })
.andWhere("permission.key IN (:...permissionKeys)", { permissionKeys })
.select("DISTINCT employee.user_id", "userId")
.getRawMany();
return rows
.map((r) => r.userId)
.filter((id): id is string => Boolean(id));
}
async createOrganizationUser(
organizationId: string,
dto: CreateOrganizationUserDto,

View File

@@ -13,9 +13,8 @@ import { ExternalProfileRepository } from "../companies/external-profile.reposit
* - `companyId` → all portal users linked to the company (external_profiles).
* - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff).
*
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
* - `permissionKeys` → current employees (any org) holding any of these
* permission keys (e.g. department/role-scoped targeting).
*/
@Injectable()
export class NotificationRecipientsService {
@@ -82,6 +81,20 @@ export class NotificationRecipientsService {
}
}
if (recipients.permissionKeys?.length) {
try {
for (const uid of await this.backoffice.getEmployeeUserIdsByPermission(
recipients.permissionKeys,
)) {
ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve permissionKeys recipients: ${(err as Error).message}`,
);
}
}
return [...ids];
}
}

View File

@@ -55,6 +55,7 @@
"@tanstack/react-table": "^8.21.3",
"@tinymce/tinymce-react": "^6.3.0",
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.1.1.tgz",
"@types/three": "^0.185.3",
"@vis.gl/react-google-maps": "^1.8.3",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
@@ -103,6 +104,7 @@
"sonner": "^2.0.7",
"stream-browserify": "^3.0.0",
"tailwind-merge": "^3.6.0",
"three": "^0.185.1",
"tinymce": "^8.6.0",
"xlsx": "^0.18.5",
"zod": "^3.25.76",

View File

@@ -0,0 +1,536 @@
import { Badge, Box, Button, CloseButton, Divider, Group, Paper, ScrollArea, Stack, Text } from "@mantine/core";
import { Maximize2, Minimize2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Wagon = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
interface Train3DVisualizationProps {
schedule: TrainScheduleDetail;
onClose: () => void;
}
const CONTAINER_PALETTE = [0xc0392b, 0x2471a3, 0x1e8449, 0xd4ac0d, 0x884ea0, 0xca6f1e, 0x117a65, 0x6e2c00];
const hashColor = (key: string) => {
let h = 0;
for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) | 0;
return CONTAINER_PALETTE[Math.abs(h) % CONTAINER_PALETTE.length];
};
type WagonKind = "container" | "bulk" | "tank" | "flat";
const wagonKind = (w: Wagon): WagonKind => {
const code = `${w.wagonType?.code ?? ""} ${w.wagonType?.name ?? ""}`.toUpperCase();
if (/TANK|LIQUID|FUEL/.test(code)) return "tank";
if (/HOPPER|BULK|OPEN|GONDOLA/.test(code) || w.allocations.some((a) => a.bulkLoad)) return "bulk";
if (/FLAT|CONT/.test(code) || w.allocations.some((a) => a.containerItems?.length)) return "container";
return "flat";
};
const WAGON_BODY_COLOR: Record<WagonKind, number> = {
container: 0x4a5568,
bulk: 0x7b4a2d,
tank: 0x8a8f98,
flat: 0x3d4852,
};
const M = 1; // 1 unit = 1 meter
const GAUGE = 1.435 * M;
const WHEEL_R = 0.46 * M;
const DECK_H = 1.2 * M;
function buildStars(scene: THREE.Scene) {
const geo = new THREE.BufferGeometry();
const n = 2500;
const pos = new Float32Array(n * 3);
for (let i = 0; i < n; i++) {
// random point on upper hemisphere, far away
const r = 900;
const theta = Math.random() * Math.PI * 2;
const phi = Math.acos(Math.random() * 0.95); // bias above horizon
pos[i * 3] = r * Math.sin(phi) * Math.cos(theta);
pos[i * 3 + 1] = Math.abs(r * Math.cos(phi)) + 20;
pos[i * 3 + 2] = r * Math.sin(phi) * Math.sin(theta);
}
geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
const mat = new THREE.PointsMaterial({ color: 0xffffff, size: 1.6, sizeAttenuation: false, transparent: true, opacity: 0.85 });
scene.add(new THREE.Points(geo, mat));
}
function buildTrack(scene: THREE.Scene, length: number) {
const railMat = new THREE.MeshStandardMaterial({ color: 0x9aa0a8, metalness: 0.9, roughness: 0.35 });
const railGeo = new THREE.BoxGeometry(length, 0.18 * M, 0.08 * M);
for (const z of [-GAUGE / 2, GAUGE / 2]) {
const rail = new THREE.Mesh(railGeo, railMat);
rail.position.set(0, 0.28 * M, z);
scene.add(rail);
}
const sleeperGeo = new THREE.BoxGeometry(0.24 * M, 0.14 * M, 2.4 * M);
const sleeperMat = new THREE.MeshStandardMaterial({ color: 0x3b2f26, roughness: 1 });
const count = Math.floor(length / 0.65);
const sleepers = new THREE.InstancedMesh(sleeperGeo, sleeperMat, count);
const m4 = new THREE.Matrix4();
for (let i = 0; i < count; i++) {
m4.setPosition(-length / 2 + i * 0.65, 0.12 * M, 0);
sleepers.setMatrixAt(i, m4);
}
scene.add(sleepers);
// ballast bed
const ballast = new THREE.Mesh(
new THREE.BoxGeometry(length, 0.1 * M, 4.2 * M),
new THREE.MeshStandardMaterial({ color: 0x565a5f, roughness: 1 }),
);
ballast.position.y = 0.03;
scene.add(ballast);
// ground
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(2200, 800),
new THREE.MeshStandardMaterial({ color: 0x101418, roughness: 1 }),
);
ground.rotation.x = -Math.PI / 2;
ground.position.y = -0.02;
scene.add(ground);
}
function addWheels(group: THREE.Group, length: number, wheels: THREE.Mesh[]) {
const geo = new THREE.CylinderGeometry(WHEEL_R, WHEEL_R, 0.25, 20);
const mat = new THREE.MeshStandardMaterial({ color: 0x1c1f24, metalness: 0.7, roughness: 0.4 });
const bogieOffsets = [-length / 2 + 1.8, length / 2 - 1.8];
for (const x of bogieOffsets) {
for (const dx of [-0.9, 0.9]) {
for (const z of [-GAUGE / 2, GAUGE / 2]) {
const wheel = new THREE.Mesh(geo, mat);
wheel.rotation.x = Math.PI / 2;
wheel.position.set(x + dx, WHEEL_R + 0.05, z);
group.add(wheel);
wheels.push(wheel);
}
}
const bogie = new THREE.Mesh(
new THREE.BoxGeometry(2.6, 0.4, GAUGE + 0.4),
new THREE.MeshStandardMaterial({ color: 0x22262c, roughness: 0.8 }),
);
bogie.position.set(x, WHEEL_R + 0.35, 0);
group.add(bogie);
}
}
function buildLocomotive(loco: { code: string } | undefined, wheels: THREE.Mesh[]): THREE.Group {
const g = new THREE.Group();
const len = 20 * M;
const bodyMat = new THREE.MeshStandardMaterial({ color: 0x1e6f42, metalness: 0.4, roughness: 0.5 });
const body = new THREE.Mesh(new THREE.BoxGeometry(len, 3.2, 3), bodyMat);
body.position.y = DECK_H + 1.6;
g.add(body);
const cab = new THREE.Mesh(new THREE.BoxGeometry(4.4, 1.1, 3.05), new THREE.MeshStandardMaterial({ color: 0xf2a516, metalness: 0.3, roughness: 0.5 }));
cab.position.set(len / 2 - 2.6, DECK_H + 3.75, 0);
g.add(cab);
const nose = new THREE.Mesh(new THREE.BoxGeometry(1.6, 2.2, 2.6), bodyMat);
nose.position.set(len / 2 + 0.6, DECK_H + 1.1, 0);
g.add(nose);
// headlight
const light = new THREE.Mesh(
new THREE.SphereGeometry(0.22, 12, 12),
new THREE.MeshStandardMaterial({ color: 0xfff6c9, emissive: 0xfff2a8, emissiveIntensity: 2 }),
);
light.position.set(len / 2 + 1.4, DECK_H + 1.6, 0);
g.add(light);
const spot = new THREE.SpotLight(0xfff2c0, 60, 90, 0.4, 0.6);
spot.position.copy(light.position);
spot.target.position.set(len / 2 + 40, 0.5, 0);
g.add(spot, spot.target);
addWheels(g, len, wheels);
g.userData.length = len;
g.userData.locoCode = loco?.code;
return g;
}
function buildWagon(wagon: Wagon, wheels: THREE.Mesh[], pickables: THREE.Object3D[]): THREE.Group {
const g = new THREE.Group();
const len = Math.max(8, wagon.lengthMeters || 14) * M;
const kind = wagonKind(wagon);
const bodyMat = new THREE.MeshStandardMaterial({ color: WAGON_BODY_COLOR[kind], metalness: 0.35, roughness: 0.6 });
// frame/deck common to all
const deck = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 3), bodyMat);
deck.position.y = DECK_H;
g.add(deck);
if (kind === "container") {
const items = wagon.allocations.flatMap((a) =>
(a.containerItems ?? []).map((c) => ({ ...c, bookingRef: a.bookingReference ?? a.bookingId })),
);
const n = Math.max(items.length, 0);
const slotLen = n > 1 ? len / n - 0.3 : Math.min(12.2, len - 1.5);
items.forEach((c, i) => {
const cx = n > 1 ? -len / 2 + (i + 0.5) * (len / n) : 0;
const box = new THREE.Mesh(
new THREE.BoxGeometry(slotLen, 2.6, 2.44),
new THREE.MeshStandardMaterial({ color: hashColor(c.bookingRef), metalness: 0.2, roughness: 0.55 }),
);
box.position.set(cx, DECK_H + 0.35 / 2 + 1.3, 0);
// corrugation hint: thin ribs
const rib = new THREE.Mesh(
new THREE.BoxGeometry(slotLen * 0.98, 2.4, 2.5),
new THREE.MeshStandardMaterial({ color: 0x000000, transparent: true, opacity: 0.12 }),
);
rib.position.copy(box.position);
g.add(box, rib);
});
} else if (kind === "bulk") {
// open hopper walls
const wallMat = new THREE.MeshStandardMaterial({ color: 0x8a5a34, metalness: 0.25, roughness: 0.7 });
const wallH = 2.2;
const side = new THREE.BoxGeometry(len, wallH, 0.12);
for (const z of [-1.45, 1.45]) {
const wall = new THREE.Mesh(side, wallMat);
wall.position.set(0, DECK_H + wallH / 2 + 0.17, z);
g.add(wall);
}
const end = new THREE.BoxGeometry(0.12, wallH, 3);
for (const x of [-len / 2 + 0.06, len / 2 - 0.06]) {
const wall = new THREE.Mesh(end, wallMat);
wall.position.set(x, DECK_H + wallH / 2 + 0.17, 0);
g.add(wall);
}
const loaded = wagon.allocations.some((a) => a.bulkLoad) || wagon.assignedWeightTons > 0;
if (loaded) {
// cargo mound: squashed bumpy cylinder rows
const cargoMat = new THREE.MeshStandardMaterial({ color: 0x5c4a33, roughness: 1 });
const mounds = Math.max(2, Math.floor(len / 4));
for (let i = 0; i < mounds; i++) {
const mound = new THREE.Mesh(new THREE.SphereGeometry(1.5, 12, 8), cargoMat);
mound.scale.set((len / mounds) * 0.42, 0.55, 0.9);
mound.position.set(-len / 2 + (i + 0.5) * (len / mounds), DECK_H + wallH * 0.85, 0);
g.add(mound);
}
}
} else if (kind === "tank") {
const tank = new THREE.Mesh(
new THREE.CylinderGeometry(1.4, 1.4, len - 1.6, 24),
new THREE.MeshStandardMaterial({ color: 0xb9bec7, metalness: 0.6, roughness: 0.3 }),
);
tank.rotation.z = Math.PI / 2;
tank.position.y = DECK_H + 1.6;
g.add(tank);
const dome = new THREE.Mesh(new THREE.CylinderGeometry(0.45, 0.45, 0.5, 16), bodyMat);
dome.position.y = DECK_H + 3.1;
g.add(dome);
} else {
// empty flat: low side rails
const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.35, 0.1), bodyMat);
for (const z of [-1.45, 1.45]) {
const r = rail.clone();
r.position.set(0, DECK_H + 0.35, z);
g.add(r);
}
}
addWheels(g, len, wheels);
g.userData.length = len;
g.userData.wagonId = wagon.id;
g.traverse((o) => {
o.userData.wagonId = wagon.id;
});
pickables.push(g);
return g;
}
function WagonDetailPanel({ wagon, schedule, onClose }: { wagon: Wagon; schedule: TrainScheduleDetail; onClose: () => void }) {
const bookings = schedule.bookings ?? [];
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}>
Wagon {wagon.position ?? wagon.sequenceNo}
{wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""}
</Text>
<CloseButton variant="transparent" c="gray.4" onClick={onClose} />
</Group>
<ScrollArea.Autosize mah="70vh">
<Stack gap="xs">
<Group gap="xs">
<Badge color="yellow" variant="light">{wagon.wagonType?.code ?? "UNKNOWN"}</Badge>
<Text size="xs" c="gray.4">{wagon.wagonType?.name}</Text>
</Group>
<Text size="xs" c="gray.4">
Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t
{wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m
</Text>
{wagon.allocations.length === 0 ? (
<Text size="sm" c="gray.5">Empty wagon no allocations.</Text>
) : (
wagon.allocations.map((alloc) => {
const booking = bookings.find((b) => b.id === alloc.bookingId);
return (
<Paper key={alloc.id} p="sm" radius="md" style={{ background: "rgba(255,255,255,0.06)" }}>
<Stack gap={6}>
<Group justify="space-between">
<Text size="sm" fw={600} ff="monospace">{alloc.bookingReference ?? "—"}</Text>
<Badge size="xs" variant="light" color="teal">{alloc.allocatedWeightTons} t</Badge>
</Group>
{booking ? (
<>
<Text size="xs" c="gray.3">Customer: {booking.customer ?? "—"}</Text>
<Text size="xs" c="gray.4">
{booking.origin ?? "?"} {booking.destination ?? "?"} · {booking.status ?? "—"}
{booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""}
</Text>
{booking.contractReference ? (
<Text size="xs" c="gray.5" ff="monospace">Contract {booking.contractReference}</Text>
) : null}
</>
) : null}
{(alloc.containerItems ?? []).length > 0 ? (
<>
<Divider color="rgba(255,255,255,0.1)" label="Containers" labelPosition="left" />
{(alloc.containerItems ?? []).map((c) => (
<Group key={c.id} justify="space-between">
<Text size="xs" ff="monospace" c="gray.2">{c.containerNumber ?? "—"}</Text>
<Text size="xs" c="gray.5">{c.grossWeightTons ?? "?"} t</Text>
</Group>
))}
</>
) : null}
{alloc.bulkLoad ? (
<Text size="xs" c="gray.3">
Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t
</Text>
) : null}
</Stack>
</Paper>
);
})
)}
</Stack>
</ScrollArea.Autosize>
</Paper>
);
}
export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) {
const containerRef = useRef<HTMLDivElement>(null);
const canvasHostRef = useRef<HTMLDivElement>(null);
const [selectedWagonId, setSelectedWagonId] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const wagons = schedule.trainSet?.wagons ?? [];
const moving = String(schedule.status).toUpperCase() === "DISPATCHED";
const selectedWagon = wagons.find((w) => w.id === selectedWagonId) ?? null;
useEffect(() => {
const host = canvasHostRef.current;
if (!host) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x02040a);
scene.fog = new THREE.Fog(0x02040a, 250, 900);
const camera = new THREE.PerspectiveCamera(55, host.clientWidth / host.clientHeight, 0.1, 2000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(host.clientWidth, host.clientHeight);
host.appendChild(renderer.domElement);
// lights: moonlit night
scene.add(new THREE.AmbientLight(0x8899bb, 0.35));
const moon = new THREE.DirectionalLight(0xbfd0ff, 1.1);
moon.position.set(-80, 120, 60);
scene.add(moon);
const warm = new THREE.PointLight(0xffc873, 0.6, 200);
warm.position.set(0, 25, 30);
scene.add(warm);
buildStars(scene);
// train assembly
const wheels: THREE.Mesh[] = [];
const pickables: THREE.Object3D[] = [];
const train = new THREE.Group();
const gap = 1.0 * M;
let cursor = 0;
const locos = schedule.trainSet?.locomotives?.length
? schedule.trainSet.locomotives
: schedule.trainSet?.locomotive
? [schedule.trainSet.locomotive]
: [{ code: "LOCO" }];
for (const loco of locos) {
const lg = buildLocomotive(loco, wheels);
lg.position.x = cursor - lg.userData.length / 2;
cursor -= lg.userData.length + gap;
train.add(lg);
}
const ordered = [...wagons].sort((a, b) => (a.position ?? a.sequenceNo) - (b.position ?? b.sequenceNo));
for (const wagon of ordered) {
const wg = buildWagon(wagon, wheels, pickables);
wg.position.x = cursor - wg.userData.length / 2;
cursor -= wg.userData.length + gap;
train.add(wg);
}
const trainLen = -cursor;
train.position.x = trainLen / 2; // center train on origin
scene.add(train);
const trackLen = Math.max(trainLen * 2.5, 400);
buildTrack(scene, moving ? Math.max(trackLen, 1600) : trackLen);
camera.position.set(trainLen * 0.12, 14, 42);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 2, 0);
controls.maxPolarAngle = Math.PI / 2 - 0.03;
controls.maxDistance = 500;
controls.enableDamping = true;
// picking
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
let highlighted: THREE.Object3D | null = null;
const setEmissive = (root: THREE.Object3D, on: boolean) => {
root.traverse((o) => {
const mesh = o as THREE.Mesh;
const mat = mesh.material as THREE.MeshStandardMaterial | undefined;
if (mat?.emissive) {
mat.emissive.setHex(on ? 0x335588 : 0x000000);
mat.emissiveIntensity = on ? 0.9 : 1;
}
});
};
const onClick = (e: MouseEvent) => {
const rect = renderer.domElement.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const hits = raycaster.intersectObjects(pickables, true);
const wagonId = hits[0]?.object.userData.wagonId as string | undefined;
if (highlighted) setEmissive(highlighted, false);
highlighted = wagonId ? (pickables.find((p) => p.userData.wagonId === wagonId) ?? null) : null;
if (highlighted) setEmissive(highlighted, true);
setSelectedWagonId(wagonId ?? null);
};
renderer.domElement.addEventListener("click", onClick);
const onResize = () => {
camera.aspect = host.clientWidth / host.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(host.clientWidth, host.clientHeight);
};
const resizeObserver = new ResizeObserver(onResize);
resizeObserver.observe(host);
let raf = 0;
let last = performance.now();
const speed = 8; // 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) {
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;
}
controls.update();
renderer.render(scene, camera);
};
animate();
return () => {
cancelAnimationFrame(raf);
resizeObserver.disconnect();
renderer.domElement.removeEventListener("click", onClick);
controls.dispose();
renderer.dispose();
host.removeChild(renderer.domElement);
scene.traverse((o) => {
const mesh = o as THREE.Mesh;
mesh.geometry?.dispose?.();
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
mats.forEach((m) => m?.dispose?.());
});
};
// rebuild scene only when schedule identity/status changes
}, [schedule.id, schedule.status, moving, 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();
};
document.addEventListener("fullscreenchange", onFsChange);
window.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("fullscreenchange", onFsChange);
window.removeEventListener("keydown", onKey);
};
}, [onClose]);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void containerRef.current?.requestFullscreen();
};
return (
<Box
ref={containerRef}
style={{
position: "fixed",
inset: 0,
zIndex: 400,
background: "#02040a",
}}
>
<Box ref={canvasHostRef} style={{ position: "absolute", inset: 0 }} />
<Group
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>
<Badge size="lg" variant="light" color="yellow">
{schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons
</Badge>
</Group>
<Group gap="xs" style={{ position: "absolute", bottom: 16, left: 16, zIndex: 10 }}>
<Text size="xs" c="gray.5">
Drag to orbit · scroll to zoom · click a wagon for details
</Text>
</Group>
<Group gap="xs" style={{ position: "absolute", top: 16, right: 16, zIndex: 11 }}>
<Button
size="compact-sm"
variant="default"
leftSection={isFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
onClick={toggleFullscreen}
>
{isFullscreen ? "Exit fullscreen" : "Fullscreen"}
</Button>
<Button size="compact-sm" color="red" variant="light" leftSection={<X size={14} />} onClick={onClose}>
Close
</Button>
</Group>
{selectedWagon ? (
<WagonDetailPanel wagon={selectedWagon} schedule={schedule} onClose={() => setSelectedWagonId(null)} />
) : null}
</Box>
);
}

View File

@@ -71,6 +71,7 @@ import {
PreviewSummary,
ScheduleWarningsAlert,
} from "@/components/trainScheduling/ScheduleWarningsAlert";
import { Train3DVisualization } from "@/components/trainScheduling/Train3DVisualization";
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
import { TrainConsistView } from "@/components/trainScheduling/compositionEditor";
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
@@ -116,6 +117,7 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
const [visualization3DOpen, setVisualization3DOpen] = useState(false);
const autoPreviewedRef = useRef(false);
const detailQuery = useQuery(
@@ -946,6 +948,18 @@ export default function TrainScheduleV2DetailPage() {
</Stack>
</Group>
<Group gap="sm">
{(schedule.trainSet?.wagons?.length ?? 0) > 0 ? (
<Button
variant="gradient"
gradient={{ from: "#0f172a", to: "#334155" }}
radius="lg"
size="sm"
leftSection={<Eye size={16} />}
onClick={() => setVisualization3DOpen(true)}
>
3D Visualization
</Button>
) : null}
{canPrintMarshalling ? (
<Button
variant="light"
@@ -1397,6 +1411,9 @@ export default function TrainScheduleV2DetailPage() {
</Group>
</Stack>
</Modal>
{visualization3DOpen ? (
<Train3DVisualization schedule={schedule} onClose={() => setVisualization3DOpen(false)} />
) : null}
</PageContainer>
);
}

View File

@@ -92,6 +92,13 @@ export interface NotificationRecipients {
organizationId?: string;
/** Backoffice: every current employee across all organizations. */
allBackoffice?: boolean;
/**
* Backoffice: current employees (any org) who hold ANY of these permission
* keys — e.g. notify only marketing, not every employee. Super/org admins
* are not implicitly included; add `allBackoffice`/explicit userIds too if
* admins should also see it.
*/
permissionKeys?: string[];
}
/** Input any subsystem passes to `NotificationInboxService.notify(...)`. */

48
pnpm-lock.yaml generated
View File

@@ -352,6 +352,9 @@ importers:
'@tria-plc/iamui':
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
'@types/three':
specifier: ^0.185.3
version: 0.185.3
'@vis.gl/react-google-maps':
specifier: ^1.8.3
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -496,6 +499,9 @@ importers:
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
three:
specifier: ^0.185.1
version: 0.185.1
tinymce:
specifier: ^8.6.0
version: 8.6.0
@@ -1826,6 +1832,9 @@ packages:
'@date-fns/tz@1.5.0':
resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==}
'@dimforge/rapier3d-compat@0.12.0':
resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==}
'@dotenvx/dotenvx@1.71.0':
resolution: {integrity: sha512-KEUw/mGu+EDRhYWRTNGHIimVCs9NvMFaIXOGrHSXoCteKLE5EsJnmPjOPpYorjXVg/0xG0fbdVw720azw1z4ag==}
hasBin: true
@@ -4728,6 +4737,9 @@ packages:
cpu: [arm64]
os: [win32]
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
'@tybys/wasm-util@0.10.2':
resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==}
@@ -4954,6 +4966,9 @@ packages:
'@types/stack-utils@2.0.3':
resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==}
'@types/stats.js@0.17.4':
resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==}
'@types/statuses@2.0.6':
resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==}
@@ -4963,6 +4978,9 @@ packages:
'@types/supertest@6.0.3':
resolution: {integrity: sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==}
'@types/three@0.185.3':
resolution: {integrity: sha512-8TqTn1+fjPWuJ4mR6Igtg56DCf9b5EeAlwhb5xaa6WlBrsg7SvG0NQbyctGRkHCKwg0uftfCspOEsTXelgktMA==}
'@types/tinymce@4.6.9':
resolution: {integrity: sha512-pDxBUlV4v1jgJ97SlnVOSyf3KUy3OQ3s5Ddpfh1L9M5lXlBmX7TJ2OLSozx1WBxp91acHvYPWDwz2U/kMM1oxQ==}
@@ -4987,6 +5005,9 @@ packages:
'@types/vorpal@1.12.8':
resolution: {integrity: sha512-Qt+Yxa1q6QCaYMxZFXlyPOF3ktIscTelNr1AFYuKM7/Dhlki4gvc476uFyA/hYvskSA6V8W+55x9FjlbAPcYdQ==}
'@types/webxr@0.5.24':
resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
@@ -9158,6 +9179,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
meshoptimizer@1.1.1:
resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==}
methods@1.1.2:
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
@@ -11305,6 +11329,9 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
three@0.185.1:
resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==}
throttleit@1.0.1:
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
@@ -12785,6 +12812,8 @@ snapshots:
'@date-fns/tz@1.5.0': {}
'@dimforge/rapier3d-compat@0.12.0': {}
'@dotenvx/dotenvx@1.71.0':
dependencies:
commander: 11.1.0
@@ -16831,6 +16860,8 @@ snapshots:
'@turbo/windows-arm64@2.9.16':
optional: true
'@tweenjs/tween.js@23.1.3': {}
'@tybys/wasm-util@0.10.2':
dependencies:
tslib: 2.8.1
@@ -17092,6 +17123,8 @@ snapshots:
'@types/stack-utils@2.0.3': {}
'@types/stats.js@0.17.4': {}
'@types/statuses@2.0.6': {}
'@types/superagent@8.1.10':
@@ -17106,6 +17139,15 @@ snapshots:
'@types/methods': 1.1.4
'@types/superagent': 8.1.10
'@types/three@0.185.3':
dependencies:
'@dimforge/rapier3d-compat': 0.12.0
'@tweenjs/tween.js': 23.1.3
'@types/stats.js': 0.17.4
'@types/webxr': 0.5.24
fflate: 0.8.3
meshoptimizer: 1.1.1
'@types/tinymce@4.6.9':
dependencies:
'@types/jquery': 4.0.1
@@ -17125,6 +17167,8 @@ snapshots:
'@types/vorpal@1.12.8': {}
'@types/webxr@0.5.24': {}
'@types/ws@8.18.1':
dependencies:
'@types/node': 20.19.42
@@ -21897,6 +21941,8 @@ snapshots:
merge2@1.4.1: {}
meshoptimizer@1.1.1: {}
methods@1.1.2: {}
micromatch@3.1.10:
@@ -24474,6 +24520,8 @@ snapshots:
dependencies:
any-promise: 1.3.0
three@0.185.1: {}
throttleit@1.0.1: {}
through2@2.0.5: