mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
configer the stamp
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
48
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user