mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: enhance locomotive creation and management with optional code generation and default max pull weight
This commit is contained in:
@@ -8,10 +8,13 @@ import {
|
||||
} from '../entities/locomotive.entity';
|
||||
|
||||
export class CreateLocomotiveDto {
|
||||
@ApiProperty({ example: 'LOCO-001' })
|
||||
// Optional on input — the service auto-generates a sequential LOCO-NNN code
|
||||
// when none is supplied.
|
||||
@ApiPropertyOptional({ example: 'LOCO-001' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
code!: string;
|
||||
code?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@@ -32,11 +35,13 @@ export class CreateLocomotiveDto {
|
||||
@IsUUID()
|
||||
currentYardId?: string;
|
||||
|
||||
@ApiProperty({ example: 3500 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
// Defaults to 2500 tons when omitted (see service).
|
||||
@ApiPropertyOptional({ example: 2500, default: 2500 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (value === '' || value == null ? undefined : Number(value)))
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxPullWeightTons!: number;
|
||||
maxPullWeightTons?: number;
|
||||
|
||||
@ApiProperty({ example: 760 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
|
||||
@@ -29,20 +29,40 @@ export class LocomotivesService {
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||
const [existing] = await this.locomotivesRepository.findAll({ where: { code: dto.code } });
|
||||
/** Default max pull weight (tons) applied when the caller omits it. */
|
||||
private static readonly DEFAULT_MAX_PULL_WEIGHT_TONS = 2500;
|
||||
|
||||
/**
|
||||
* Generate the next sequential locomotive code (LOCO-001, LOCO-002, …) by
|
||||
* scanning the highest existing LOCO-NNN number. Used when the caller does not
|
||||
* supply a code.
|
||||
*/
|
||||
private async generateCode(): Promise<string> {
|
||||
const all = await this.locomotivesRepository.findAll({});
|
||||
let max = 0;
|
||||
for (const loco of all) {
|
||||
const match = /^LOCO-(\d+)$/.exec(loco.code ?? '');
|
||||
if (match) max = Math.max(max, Number(match[1]));
|
||||
}
|
||||
return `LOCO-${String(max + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||
const code = dto.code?.trim() || (await this.generateCode());
|
||||
|
||||
const [existing] = await this.locomotivesRepository.findAll({ where: { code } });
|
||||
if (existing) {
|
||||
throw new ConflictException(`Locomotive code ${dto.code} already exists`);
|
||||
throw new ConflictException(`Locomotive code ${code} already exists`);
|
||||
}
|
||||
|
||||
return this.locomotivesRepository.create({
|
||||
code: dto.code,
|
||||
code,
|
||||
name: dto.name?.trim() || null,
|
||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||
status: dto.status as LocomotiveStatus,
|
||||
currentYardId: dto.currentYardId ?? null,
|
||||
maxPullWeightTons: dto.maxPullWeightTons,
|
||||
maxPullWeightTons:
|
||||
dto.maxPullWeightTons ?? LocomotivesService.DEFAULT_MAX_PULL_WEIGHT_TONS,
|
||||
maxTrainLengthMeters: dto.maxTrainLengthMeters,
|
||||
powerKw: dto.powerKw ?? null,
|
||||
tractionForceKn: dto.tractionForceKn ?? null,
|
||||
|
||||
@@ -77,7 +77,10 @@ export class WagonsService {
|
||||
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
|
||||
const wagon = await this.findById(id);
|
||||
Object.assign(wagon, dto);
|
||||
return this.wagonRepo.save(wagon);
|
||||
await this.wagonRepo.save(wagon);
|
||||
// Re-read with the relation so the response reflects the new yard label
|
||||
// instead of the stale relation object loaded before the assign.
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { Download, Zap, FileText, Clock } from "lucide-react";
|
||||
import { Stack, Text, Button } from "@mantine/core";
|
||||
|
||||
import { AllocateBookingWizard } from "@/components/trainScheduling/AllocateBookingWizard";
|
||||
import type { BookingDetail } from "@/types/booking";
|
||||
import { BookingActionsMenu } from "./BookingActionsMenu";
|
||||
import { SectionCard } from "./detail/SectionCard";
|
||||
import { toBookingListRow } from "@/features/bookings/mapBookingListRow";
|
||||
import { canAllocateBooking } from "@/features/bookings/booking-actions.config";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canManageScheduling } from "@/lib/permissions";
|
||||
import type { useBookingMutations } from "@/hooks/bookings/useBookings";
|
||||
|
||||
type Mutations = ReturnType<typeof useBookingMutations>;
|
||||
@@ -21,11 +16,8 @@ interface BookingActionsToolbarProps {
|
||||
|
||||
/** Detail-page actions: primary toolbar + downloads. */
|
||||
export function BookingActionsToolbar({ booking, mutations }: BookingActionsToolbarProps) {
|
||||
const { user } = useAuth();
|
||||
const row = toBookingListRow(booking);
|
||||
const { status } = booking;
|
||||
const [allocateOpen, setAllocateOpen] = useState(false);
|
||||
const canAllocate = canManageScheduling(user);
|
||||
|
||||
const downloadBlob = async (fn: () => Promise<Blob>, filename: string) => {
|
||||
const blob = await fn();
|
||||
@@ -106,11 +98,7 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
<Text size="xs" c="dimmed">
|
||||
Confirm each step before it is applied.
|
||||
</Text>
|
||||
<BookingActionsMenu
|
||||
row={row}
|
||||
variant="toolbar"
|
||||
onAllocateBooking={() => setAllocateOpen(true)}
|
||||
/>
|
||||
<BookingActionsMenu row={row} variant="toolbar" />
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
|
||||
@@ -130,14 +118,6 @@ export function BookingActionsToolbar({ booking, mutations }: BookingActionsTool
|
||||
</Button>
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{canAllocate && canAllocateBooking(booking) ? (
|
||||
<AllocateBookingWizard
|
||||
booking={booking}
|
||||
opened={allocateOpen}
|
||||
onClose={() => setAllocateOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,12 +66,20 @@ const FleetFormDialog = ({
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Seed the form ONLY when the dialog opens or the edited record changes — NOT
|
||||
// when `fields`/`emptyValues` get new object refs (they're rebuilt whenever the
|
||||
// dynamic select options finish loading). Re-seeding on those would wipe the
|
||||
// user's in-progress edits (e.g. a changed Current Yard / status) the moment
|
||||
// the yard or wagon-type options resolve.
|
||||
const recordId =
|
||||
initialRecord && "id" in initialRecord ? String(initialRecord.id) : null;
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues(buildInitialValues(fields, emptyValues, initialRecord));
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, fields, emptyValues, initialRecord]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, recordId]);
|
||||
|
||||
const shortFields = useMemo(
|
||||
() => fields.filter((f) => f.type !== "textarea"),
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
MessageSquareWarning,
|
||||
Play,
|
||||
ShieldCheck,
|
||||
TrainTrack,
|
||||
Truck,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -388,47 +386,10 @@ export function getBookingActions(
|
||||
actions = withCancel(OPERATION_REVIEW_ACTIONS);
|
||||
break;
|
||||
case "PAID":
|
||||
if (
|
||||
canAllocateBooking({ status, schedulingStatus: ctx.schedulingStatus })
|
||||
) {
|
||||
actions = [
|
||||
{
|
||||
id: "allocateBooking",
|
||||
label: "Allocate booking",
|
||||
shortLabel: "Allocate",
|
||||
description: "Assign to train, wagons, and finalize schedule",
|
||||
confirmTitle: "Allocate booking?",
|
||||
confirmDescription: "Opens the train allocation wizard.",
|
||||
variant: "default",
|
||||
icon: TrainTrack,
|
||||
primary: true,
|
||||
},
|
||||
{
|
||||
id: "startTransit",
|
||||
label: "Start transit",
|
||||
shortLabel: "Transit",
|
||||
description: "Begin rail movement",
|
||||
confirmTitle: "Start transit?",
|
||||
confirmDescription: "The booking will move to in transit status.",
|
||||
variant: "default",
|
||||
icon: Truck,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
actions = [
|
||||
{
|
||||
id: "startTransit",
|
||||
label: "Start transit",
|
||||
shortLabel: "Transit",
|
||||
description: "Begin rail movement",
|
||||
confirmTitle: "Start transit?",
|
||||
confirmDescription: "The booking will move to in transit status.",
|
||||
variant: "default",
|
||||
icon: Truck,
|
||||
primary: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
// Allocate is handled by the Operations "Ready to allocate" queue, not the
|
||||
// per-booking action menu. Start transit was removed entirely. No per-row
|
||||
// action remains in the PAID state.
|
||||
actions = [];
|
||||
break;
|
||||
case "IN_TRANSIT":
|
||||
actions = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FileSignature,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Milestone,
|
||||
Package,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
@@ -288,6 +289,16 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="default"
|
||||
leftSection={<Milestone size={16} />}
|
||||
onClick={() =>
|
||||
navigate(`/dashboard/bookings/${booking.id}/milestones`)
|
||||
}
|
||||
>
|
||||
View clearance milestones
|
||||
</Button>
|
||||
{showContractButton && (
|
||||
<Button
|
||||
fullWidth
|
||||
|
||||
@@ -246,7 +246,6 @@ const FleetResourcePage = () => {
|
||||
meta: { headerClassName, cellClassName },
|
||||
cell: ({ row }) => {
|
||||
const value = (row.original as unknown as Record<string, unknown>)[col.accessorKey];
|
||||
console.log(`${col.accessorKey}:`, value, 'format:', col.format);
|
||||
return formatFleetCell(value, col.format, col.accessorKey);
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -147,8 +147,8 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ id: "maxPullWeightTons", header: "Max pull (tons)", accessorKey: "maxPullWeightTons", format: "number" },
|
||||
{ id: "maxTrainLengthMeters", header: "Max length (m)", accessorKey: "maxTrainLengthMeters", format: "number" },
|
||||
],
|
||||
// Code is auto-generated server-side (LOCO-NNN) — omitted from the form.
|
||||
formFields: [
|
||||
{ name: "code", label: "Code", type: "text", required: true },
|
||||
{ name: "name", label: "Name", type: "text" },
|
||||
{ name: "locomotiveType", label: "Locomotive type", type: "select", required: true, options: LOCOMOTIVE_TYPE_OPTIONS },
|
||||
{ name: "status", label: "Status", type: "select", required: true, options: LOCOMOTIVE_STATUS_OPTIONS },
|
||||
@@ -160,12 +160,11 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
||||
{ name: "maxSpeedKmh", label: "Max speed (km/h)", type: "number" },
|
||||
],
|
||||
emptyValues: {
|
||||
code: "",
|
||||
name: "",
|
||||
locomotiveType: "DIESEL",
|
||||
status: "AVAILABLE",
|
||||
currentYardId: "",
|
||||
maxPullWeightTons: 0,
|
||||
maxPullWeightTons: 2500,
|
||||
maxTrainLengthMeters: 760,
|
||||
powerKw: "",
|
||||
tractionForceKn: "",
|
||||
|
||||
Reference in New Issue
Block a user