mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
Merge pull request #847 from Tria-plc/freight_feature/usermanagement
chnages
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locomotive names must be unique so staff can identify a unit by name alone
|
||||||
|
* (the card view leads with `name`, falling back to `code`). Uniqueness is:
|
||||||
|
*
|
||||||
|
* - case/whitespace-insensitive — "MTL1", "mtl1" and " MTL1 " are one name;
|
||||||
|
* - scoped to live rows — a decommissioned (soft-deleted) locomotive must not
|
||||||
|
* hold its name hostage, matching how the fleet reuses yard codes;
|
||||||
|
* - skipped for blank names — `name` stays optional, and NULL/'' rows are
|
||||||
|
* excluded rather than colliding with each other.
|
||||||
|
*
|
||||||
|
* A partial expression index gives all three; a plain UNIQUE column cannot.
|
||||||
|
*/
|
||||||
|
export class UniqueLocomotiveName2430000000000 implements MigrationInterface {
|
||||||
|
name = 'UniqueLocomotiveName2430000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
// Pre-existing duplicates would abort CREATE UNIQUE INDEX. Suffix every
|
||||||
|
// copy after the oldest (…-2, …-3) so the index can build; the oldest row
|
||||||
|
// keeps the original name. Deterministic on created_at, then id.
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH ranked AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
row_number() OVER (
|
||||||
|
PARTITION BY lower(btrim(name))
|
||||||
|
ORDER BY created_at, id
|
||||||
|
) AS rn
|
||||||
|
FROM "freight"."locomotives"
|
||||||
|
WHERE deleted_at IS NULL
|
||||||
|
AND name IS NOT NULL
|
||||||
|
AND btrim(name) <> ''
|
||||||
|
)
|
||||||
|
UPDATE "freight"."locomotives" AS l
|
||||||
|
SET name = btrim(ranked.name) || '-' || ranked.rn
|
||||||
|
FROM ranked
|
||||||
|
WHERE l.id = ranked.id
|
||||||
|
AND ranked.rn > 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_locomotives_name_active"
|
||||||
|
ON "freight"."locomotives" (lower(btrim("name")))
|
||||||
|
WHERE "deleted_at" IS NULL
|
||||||
|
AND "name" IS NOT NULL
|
||||||
|
AND btrim("name") <> ''
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`DROP INDEX IF EXISTS "freight"."UQ_locomotives_name_active"`,
|
||||||
|
);
|
||||||
|
// The de-duplicating renames are not reversed: the original names are no
|
||||||
|
// longer recoverable, and restoring them would re-introduce the conflict.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,13 @@ export class Locomotive extends BaseEntity {
|
|||||||
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
|
||||||
code!: string;
|
code!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional, but unique when set. Enforced in the DB by the partial expression
|
||||||
|
* index `UQ_locomotives_name_active` (see UniqueLocomotiveName2430000000000):
|
||||||
|
* case- and whitespace-insensitive, live rows only, blanks exempt. Not a
|
||||||
|
* `unique: true` column — that would be case-sensitive and would let a
|
||||||
|
* soft-deleted locomotive keep holding its name.
|
||||||
|
*/
|
||||||
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,23 @@ export class LocomotivesRepository extends BaseRepository<Locomotive> {
|
|||||||
) {
|
) {
|
||||||
super(repository);
|
super(repository);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A live locomotive already holding this name, compared the same way the
|
||||||
|
* `UQ_locomotives_name_active` index compares: case- and whitespace-
|
||||||
|
* insensitive, soft-deleted rows excluded. `excludeId` skips the row being
|
||||||
|
* updated so it can keep its own name.
|
||||||
|
*/
|
||||||
|
findByName(name: string, excludeId?: string): Promise<Locomotive | null> {
|
||||||
|
const qb = this.repository
|
||||||
|
.createQueryBuilder('locomotive')
|
||||||
|
.where('lower(btrim(locomotive.name)) = lower(btrim(:name))', { name });
|
||||||
|
|
||||||
|
if (excludeId) {
|
||||||
|
qb.andWhere('locomotive.id != :excludeId', { excludeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// createQueryBuilder already filters soft-deleted rows (no withDeleted()).
|
||||||
|
return qb.getOne();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,23 @@ export class LocomotivesService {
|
|||||||
return `LOCO-${String(max + 1).padStart(3, '0')}`;
|
return `LOCO-${String(max + 1).padStart(3, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a name already worn by another live locomotive. Compared
|
||||||
|
* case-insensitively on the trimmed value so this matches the DB index
|
||||||
|
* `UQ_locomotives_name_active` — otherwise a clash the guard waved through
|
||||||
|
* would surface as a raw 500 from the index instead of a 409. `excludeId`
|
||||||
|
* lets an update keep its own name.
|
||||||
|
*/
|
||||||
|
private async assertNameAvailable(name: string, excludeId?: string): Promise<void> {
|
||||||
|
const clash = await this.locomotivesRepository.findByName(name, excludeId);
|
||||||
|
|
||||||
|
if (clash) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`Locomotive name "${name.trim()}" is already used by ${clash.code}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
async create(dto: CreateLocomotiveDto): Promise<Locomotive> {
|
||||||
const code = dto.code?.trim() || (await this.generateCode());
|
const code = dto.code?.trim() || (await this.generateCode());
|
||||||
|
|
||||||
@@ -68,9 +85,15 @@ export class LocomotivesService {
|
|||||||
throw new ConflictException(`Locomotive code ${code} already exists`);
|
throw new ConflictException(`Locomotive code ${code} already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Name stays optional; only a non-blank one has to be unique.
|
||||||
|
const name = dto.name?.trim() || null;
|
||||||
|
if (name) {
|
||||||
|
await this.assertNameAvailable(name);
|
||||||
|
}
|
||||||
|
|
||||||
return this.locomotivesRepository.create({
|
return this.locomotivesRepository.create({
|
||||||
code,
|
code,
|
||||||
name: dto.name?.trim() || null,
|
name,
|
||||||
locomotiveType: dto.locomotiveType as LocomotiveType,
|
locomotiveType: dto.locomotiveType as LocomotiveType,
|
||||||
status: dto.status as LocomotiveStatus,
|
status: dto.status as LocomotiveStatus,
|
||||||
currentYardId: dto.currentYardId ?? null,
|
currentYardId: dto.currentYardId ?? null,
|
||||||
@@ -107,6 +130,15 @@ export class LocomotivesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only when the caller actually sends a name — an omitted field keeps the
|
||||||
|
// current one, and clearing it to blank is allowed.
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
const nextName = dto.name?.trim() || null;
|
||||||
|
if (nextName) {
|
||||||
|
await this.assertNameAvailable(nextName, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A locomotive coupled to a built train follows the train: its yard and
|
// A locomotive coupled to a built train follows the train: its yard and
|
||||||
// status are owned by the train-builder flow, not this generic PATCH.
|
// status are owned by the train-builder flow, not this generic PATCH.
|
||||||
const link = await this.findTrainLink(id);
|
const link = await this.findTrainLink(id);
|
||||||
|
|||||||
@@ -205,6 +205,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
const availableCount = availableWagons.length;
|
const availableCount = availableWagons.length;
|
||||||
const assignedCount = assignedWagons.length;
|
const assignedCount = assignedWagons.length;
|
||||||
const otherCount = otherWagons.length;
|
const otherCount = otherWagons.length;
|
||||||
|
// Split "Other" so a coupled wagon is visible as such. The Available/Assigned
|
||||||
|
// buckets deliberately count only UNCOUPLED wagons (see above), so a yard
|
||||||
|
// holding 54 assigned wagons of which 53 are on a train shows "Assigned 1" —
|
||||||
|
// accurate for shunting, but unreadable unless the other 53 are named.
|
||||||
|
const onTrainCount = useMemo(
|
||||||
|
() => matching.filter((w) => w.trainId != null).length,
|
||||||
|
[matching],
|
||||||
|
);
|
||||||
|
|
||||||
const destinationYardOptions = useMemo(
|
const destinationYardOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -397,9 +405,14 @@ const WagonYardWorkspaceModal = ({ opened, onClose }: WagonYardWorkspaceModalPro
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="lg" wrap="wrap">
|
<Group gap="lg" wrap="wrap">
|
||||||
<LegendDot color="teal" label="Available" value={availableCount} />
|
<LegendDot color="teal" label="Available in yard" value={availableCount} />
|
||||||
<LegendDot color="blue" label="Assigned" value={assignedCount} />
|
<LegendDot color="blue" label="Assigned in yard" value={assignedCount} />
|
||||||
{otherCount > 0 ? <LegendDot color="gray" label="Other" value={otherCount} /> : null}
|
{onTrainCount > 0 ? (
|
||||||
|
<LegendDot color="gray" label="On train" value={onTrainCount} />
|
||||||
|
) : null}
|
||||||
|
{otherCount - onTrainCount > 0 ? (
|
||||||
|
<LegendDot color="gray" label="Other" value={otherCount - onTrainCount} />
|
||||||
|
) : null}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const LOCOMOTIVE_STATUS_OPTIONS = [
|
|||||||
{ label: "Out of service", value: "OUT_OF_SERVICE" },
|
{ label: "Out of service", value: "OUT_OF_SERVICE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Every status a wagon can hold — for FILTERING the list. ASSIGNED belongs here:
|
||||||
|
// staff still need to search for assigned wagons.
|
||||||
const WAGON_STATUS_OPTIONS = [
|
const WAGON_STATUS_OPTIONS = [
|
||||||
{ label: "Available", value: Freight.WagonStatus.Available },
|
{ label: "Available", value: Freight.WagonStatus.Available },
|
||||||
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
{ label: "Assigned", value: Freight.WagonStatus.Assigned },
|
||||||
@@ -135,6 +137,15 @@ const WAGON_STATUS_OPTIONS = [
|
|||||||
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
{ label: "Detained", value: Freight.WagonStatus.Detained },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Statuses staff may set BY HAND on the create/edit form. ASSIGNED is omitted
|
||||||
|
// on purpose: a wagon becomes ASSIGNED as a side effect of being built into a
|
||||||
|
// train, never by editing it directly. Setting it by hand produced wagons that
|
||||||
|
// claim to be assigned while coupled to nothing, which the yard workspace then
|
||||||
|
// counts as in-yard stock.
|
||||||
|
const WAGON_EDITABLE_STATUS_OPTIONS = WAGON_STATUS_OPTIONS.filter(
|
||||||
|
(o) => o.value !== Freight.WagonStatus.Assigned,
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -333,7 +344,7 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
|
|||||||
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
{ name: "wagonNumber", label: "Wagon number", type: "text", required: true },
|
||||||
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
{ name: "wagonTypeId", label: "Wagon type", type: "select", required: true, dynamicOptions: "wagonTypes" },
|
||||||
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
{ name: "currentYardId", label: "Current Yard", type: "select", dynamicOptions: "yards" },
|
||||||
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_STATUS_OPTIONS },
|
{ name: "status", label: "Status", type: "select", required: true, options: WAGON_EDITABLE_STATUS_OPTIONS },
|
||||||
{ name: "notes", label: "Notes", type: "textarea" },
|
{ name: "notes", label: "Notes", type: "textarea" },
|
||||||
],
|
],
|
||||||
emptyValues: {
|
emptyValues: {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useDebouncedValue } from "@mantine/hooks";
|
|||||||
import { isAxiosError } from "axios";
|
import { isAxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Ban,
|
// Ban, — used only by the commented-out "Cancel schedule" row action
|
||||||
CalendarClock,
|
CalendarClock,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
@@ -196,7 +196,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
|
||||||
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
// const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
|
||||||
|
|
||||||
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
// Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
|
||||||
// API rejects them, so keep them out of the picker entirely.
|
// API rejects them, so keep them out of the picker entirely.
|
||||||
@@ -464,6 +464,9 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
Booking window settings
|
Booking window settings
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
{/* Cancel schedule — hidden for now (frontend only; the
|
||||||
|
cancelSchedule mutation is untouched). Restore by
|
||||||
|
uncommenting.
|
||||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
color="red"
|
color="red"
|
||||||
@@ -487,6 +490,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
Cancel schedule
|
Cancel schedule
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
) : null}
|
) : null}
|
||||||
|
*/}
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -494,7 +498,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}, [navigate, cancel.isPending, cancel, toast]);
|
}, [navigate, toast]);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!routeId || !scheduleDate || !trainId) {
|
if (!routeId || !scheduleDate || !trainId) {
|
||||||
|
|||||||
@@ -344,6 +344,11 @@ export function Step2ServiceType({
|
|||||||
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
|
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
|
||||||
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
|
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
|
||||||
}
|
}
|
||||||
|
// The customs clearing agent field is hidden for intercity — drop any value
|
||||||
|
// carried over from a draft or an operation-type switch.
|
||||||
|
if (isIntercity && form.getValues("customsClearingAgent")) {
|
||||||
|
form.setValue("customsClearingAgent", "", { shouldDirty: true });
|
||||||
|
}
|
||||||
}, [isIntercity, form]);
|
}, [isIntercity, form]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -538,7 +543,9 @@ export function Step2ServiceType({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{includesCustoms ? (
|
{/* Intercity (domestic) moves never cross a border, so no customs
|
||||||
|
clearing agent is collected. */}
|
||||||
|
{isIntercity ? null : includesCustoms ? (
|
||||||
<Box
|
<Box
|
||||||
px={16}
|
px={16}
|
||||||
py={14}
|
py={14}
|
||||||
|
|||||||
Reference in New Issue
Block a user