Merge pull request #723 from Tria-plc/freight/feature/user_management_UI

Freight/feature/user management UI
This commit is contained in:
yaschalew10
2026-07-16 10:50:34 +03:00
committed by GitHub
4 changed files with 237 additions and 21 deletions

View File

@@ -0,0 +1,104 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Re-seed the EDR wagon fleet onto the official ER numbering.
*
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
* silently returned nothing. This seed uses the enum value.
*
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
* the types already exist and only the wagon↔type link is (re)established here.
*/
type FleetRow = {
code: string;
start: number;
end: number;
count: number;
};
/** Official fleet: 1100 wagons, ER0001ER1100, contiguous across 10 types. */
const FLEET: FleetRow[] = [
{ code: 'PW2', start: 1, end: 220, count: 220 },
{ code: 'CW4', start: 221, end: 330, count: 110 },
{ code: 'CW3', start: 331, end: 350, count: 20 },
{ code: 'KW2', start: 351, end: 370, count: 20 },
{ code: 'KW3', start: 371, end: 390, count: 20 },
{ code: 'NW5', start: 391, end: 940, count: 550 },
{ code: 'BW1', start: 941, end: 950, count: 10 },
{ code: 'GW2', start: 951, end: 1060, count: 110 },
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
];
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
name = 'SeedEdrWagonFleetErNumbering2260000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// Full replacement: the ER range is the fleet of record, so any wagon
// outside it is stale seed data. Safe to hard-delete — containers and
// train_set_wagons null their link, wagon_movements cascade.
await queryRunner.query(`DELETE FROM freight.wagons;`);
for (const row of FLEET) {
if (row.end - row.start + 1 !== row.count) {
throw new Error(`wagon_range_mismatch:${row.code}`);
}
const [typeRecord] = await queryRunner.query(
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
[row.code],
);
if (!typeRecord?.id) {
throw new Error(`wagon_type_missing:${row.code}`);
}
// generate_series builds the range server-side — one round trip per type
// instead of 1100 individual INSERTs.
await queryRunner.query(
`
INSERT INTO freight.wagons (
wagon_number,
wagon_type_id,
status,
current_yard_id,
train_id,
sequence_number,
notes,
train_set_wagon_id,
current_train_schedule_id
)
SELECT
'ER' || LPAD(seq::text, 4, '0'),
$1::uuid,
'AVAILABLE',
NULL,
NULL,
NULL,
NULL,
NULL,
NULL
FROM generate_series($2::int, $3::int) AS seq
ON CONFLICT (wagon_number) DO UPDATE SET
wagon_type_id = EXCLUDED.wagon_type_id,
status = EXCLUDED.status,
current_yard_id = EXCLUDED.current_yard_id,
updated_at = now();
`,
[typeRecord.id, row.start, row.end],
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
);
}
}

View File

@@ -1,5 +1,5 @@
import { AppDataSource } from '../data-source';
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
async function seedEdRWagons() {
await AppDataSource.initialize();
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
await queryRunner.connect();
await queryRunner.startTransaction();
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
const [summary] = await queryRunner.query(`
const summary = await queryRunner.query(`
SELECT
COUNT(*)::int AS total,
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
wt.code,
wt.name,
COUNT(*)::int AS wagons,
MIN(w.wagon_number) AS first_wagon,
MAX(w.wagon_number) AS last_wagon,
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
FROM freight.wagons w
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
GROUP BY wt.code, wt.name
ORDER BY MIN(w.wagon_number);
`);
const [totals] = await queryRunner.query(`
SELECT COUNT(*)::int AS total FROM freight.wagons;
`);
await queryRunner.commitTransaction();
console.log('Seeded EDR wagon fleet:', summary);
console.table(summary);
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;

View File

@@ -208,6 +208,20 @@ const billingIssues = (r: FirstMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
/**
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
* vehicle missing either field cannot produce an invoice line. Returns the
* human-readable gap, or null when the vehicle is billable.
*/
const pricingGap = (
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
): string | null => {
if (!v) return null;
const missing: string[] = [];
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
if (!String(v.currency ?? "").trim()) missing.push("Currency");
return missing.length ? missing.join(" and ") : null;
};
const customerName = (r: FirstMileRecord) => r.booking?.company?.name ?? "—";
const pickupLocation = (r: FirstMileRecord) => r.booking?.firstMilePickupAddress ?? "—";
const cargoDesc = (r: FirstMileRecord) => {
@@ -681,6 +695,22 @@ const FirstMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
// below rather than silently billing 0 once distances are entered.
const pricingGapById = useMemo(() => {
const map = new Map<string, string | null>();
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
if (v?.id) map.set(v.id, pricingGap(v));
};
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
add(activeRecord?.vehicle);
return map;
}, [vehiclesData, activeRecord]);
const vehicleLabelFor = (id: string) =>
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -932,6 +962,21 @@ const FirstMilePage = () => {
if (!targetIds.length) return;
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
if (unpriced.length) {
toast({
title: "Vehicle is not priced",
description: `${unpriced
.map((v) => `${v.label} (${v.gap} not set)`)
.join("; ")} — set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1365,9 +1410,18 @@ const FirstMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
onChange={(v) => {
const gap = v ? pricingGapById.get(v) : null;
if (v && gap) {
toast({
title: "Vehicle is not priced",
description: `${vehicleLabelFor(v)}${gap} not set. Set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0}

View File

@@ -251,6 +251,20 @@ const billingIssues = (r: LastMileRecord) => {
];
return { zeroPrice, mixedCurrency: currencies.length > 1, currencies };
};
/**
* The mile bills as distance × pricePerKm in the vehicle's own currency, so a
* vehicle missing either field cannot produce an invoice line. Returns the
* human-readable gap, or null when the vehicle is billable.
*/
const pricingGap = (
v?: { pricePerKm?: number | string | null; currency?: string | null } | null,
): string | null => {
if (!v) return null;
const missing: string[] = [];
if (!(Number(v.pricePerKm) > 0)) missing.push("Price per KM");
if (!String(v.currency ?? "").trim()) missing.push("Currency");
return missing.length ? missing.join(" and ") : null;
};
const customerName = (r: LastMileRecord) => r.booking?.company?.name ?? "—";
const deliveryLocation = (r: LastMileRecord) => r.booking?.lastMileDeliveryAddress ?? "—";
const cargoDesc = (r: LastMileRecord) => {
@@ -870,6 +884,22 @@ const LastMilePage = () => {
return opts;
}, [vehicleOptions, activeRecord]);
// Pricing gap per vehicle id — an unpriced vehicle is blocked from assignment
// below rather than silently billing 0 once distances are entered.
const pricingGapById = useMemo(() => {
const map = new Map<string, string | null>();
const add = (v?: { id: string; pricePerKm?: number | string | null; currency?: string | null } | null) => {
if (v?.id) map.set(v.id, pricingGap(v));
};
for (const v of Array.isArray(vehiclesData) ? vehiclesData : []) add(v);
for (const a of activeRecord?.vehicleAssignments ?? []) add(a.vehicle);
add(activeRecord?.vehicle);
return map;
}, [vehiclesData, activeRecord]);
const vehicleLabelFor = (id: string) =>
assignVehicleOptions.find((o) => o.value === id)?.label ?? id;
// Full booking (with container units) for the assign modal's container dropdown.
// Fetched on open so container numbers show regardless of what the list embeds.
const { data: assignBooking } = useQuery({
@@ -1018,6 +1048,21 @@ const LastMilePage = () => {
if (!targetIds.length) return;
// Backstop for rows the Select guard never saw (pre-filled reassignments).
const unpriced = vehicles
.map((v) => ({ label: vehicleLabelFor(v.vehicleId), gap: pricingGapById.get(v.vehicleId) }))
.filter((v): v is { label: string; gap: string } => Boolean(v.gap));
if (unpriced.length) {
toast({
title: "Vehicle is not priced",
description: `${unpriced
.map((v) => `${v.label} (${v.gap} not set)`)
.join("; ")} — set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
// Empty set = unassign all (setVehicles releases the removed vehicles).
Promise.all(targetIds.map((id) => setVehiclesMutation.mutateAsync({ id, vehicles })))
.then(() => {
@@ -1738,9 +1783,18 @@ const LastMilePage = () => {
(o) => o.value === row.vehicleId || !vehicleRows.some((r) => r.vehicleId === o.value),
)}
value={row.vehicleId}
onChange={(v) =>
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)))
}
onChange={(v) => {
const gap = v ? pricingGapById.get(v) : null;
if (v && gap) {
toast({
title: "Vehicle is not priced",
description: `${vehicleLabelFor(v)}${gap} not set. Set it on the vehicle before assigning.`,
variant: "destructive",
});
return;
}
setVehicleRows((prev) => prev.map((x, idx) => (idx === i ? { ...x, vehicleId: v } : x)));
}}
searchable
clearable
disabled={assignVehicleOptions.length === 0}