From 94c64c2390b0338b5131578a641cb0bf63a8b3da Mon Sep 17 00:00:00 2001 From: Marshal Date: Sun, 2 Aug 2026 21:09:37 +0000 Subject: [PATCH] configer the stamp --- .../modules/backoffice/backoffice.service.ts | 24 + .../notification-recipients.service.ts | 19 +- apps/edr-freight-web/backoffice/package.json | 2 + .../trainScheduling/Train3DVisualization.tsx | 536 ++++++++++++++++++ .../TrainScheduleV2DetailPage.tsx | 17 + packages/types/src/freight/notifications.ts | 7 + pnpm-lock.yaml | 48 ++ 7 files changed, 650 insertions(+), 3 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx diff --git a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts index d49cc304e..49384922e 100644 --- a/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts +++ b/apps/edr-freight-api/src/modules/backoffice/backoffice.service.ts @@ -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 { + 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, diff --git a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts index be7964a3d..e3e711d19 100644 --- a/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts +++ b/apps/edr-freight-api/src/modules/notification-inbox/notification-recipients.service.ts @@ -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]; } } diff --git a/apps/edr-freight-web/backoffice/package.json b/apps/edr-freight-web/backoffice/package.json index 568b3a47a..675eced1a 100644 --- a/apps/edr-freight-web/backoffice/package.json +++ b/apps/edr-freight-web/backoffice/package.json @@ -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", diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx new file mode 100644 index 000000000..98e9d7d6a --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/Train3DVisualization.tsx @@ -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["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 = { + 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 ( + + + + Wagon {wagon.position ?? wagon.sequenceNo} + {wagon.physicalWagonNumber ? ` · ${wagon.physicalWagonNumber}` : ""} + + + + + + + {wagon.wagonType?.code ?? "UNKNOWN"} + {wagon.wagonType?.name} + + + Capacity {wagon.capacityTons} t · Loaded {wagon.assignedWeightTons} t + {wagon.tareWeightTons ? ` · Tare ${wagon.tareWeightTons} t` : ""} · {wagon.lengthMeters} m + + {wagon.allocations.length === 0 ? ( + Empty wagon — no allocations. + ) : ( + wagon.allocations.map((alloc) => { + const booking = bookings.find((b) => b.id === alloc.bookingId); + return ( + + + + {alloc.bookingReference ?? "—"} + {alloc.allocatedWeightTons} t + + {booking ? ( + <> + Customer: {booking.customer ?? "—"} + + {booking.origin ?? "?"} → {booking.destination ?? "?"} · {booking.status ?? "—"} + {booking.loadingStatus ? ` · ${booking.loadingStatus}` : ""} + + {booking.contractReference ? ( + Contract {booking.contractReference} + ) : null} + + ) : null} + {(alloc.containerItems ?? []).length > 0 ? ( + <> + + {(alloc.containerItems ?? []).map((c) => ( + + {c.containerNumber ?? "—"} + {c.grossWeightTons ?? "?"} t + + ))} + + ) : null} + {alloc.bulkLoad ? ( + + Bulk: {alloc.bulkLoad.cargoDescription ?? "cargo"} · {alloc.bulkLoad.weightTons} t + + ) : null} + + + ); + }) + )} + + + + ); +} + +export function Train3DVisualization({ schedule, onClose }: Train3DVisualizationProps) { + const containerRef = useRef(null); + const canvasHostRef = useRef(null); + const [selectedWagonId, setSelectedWagonId] = useState(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 ( + + + + + {moving ? "DISPATCHED — TRAIN IN MOTION" : `${schedule.status} — TRAIN STOPPED`} + + + {schedule.route?.name ?? schedule.reference ?? "Train"} · {wagons.length} wagons + + + + + Drag to orbit · scroll to zoom · click a wagon for details + + + + + + + {selectedWagon ? ( + setSelectedWagonId(null)} /> + ) : null} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx index e89977eef..968817985 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2DetailPage.tsx @@ -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(null); + const [visualization3DOpen, setVisualization3DOpen] = useState(false); const autoPreviewedRef = useRef(false); const detailQuery = useQuery( @@ -946,6 +948,18 @@ export default function TrainScheduleV2DetailPage() { + {(schedule.trainSet?.wagons?.length ?? 0) > 0 ? ( + + ) : null} {canPrintMarshalling ? (