mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge pull request #723 from Tria-plc/freight/feature/user_management_UI
Freight/feature/user management UI
This commit is contained in:
@@ -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, ER0001–ER1100, 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)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user