mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
add reference field to train schedules and implement unique sequence generation
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds train_schedules.reference: a human-facing unique schedule number
|
||||||
|
* S-YYYY-NNNNN (per-year sequence, like bookings' BK-YYYY-NNNNNN).
|
||||||
|
*
|
||||||
|
* - Adds the nullable column.
|
||||||
|
* - Backfills existing rows: within each created-at year, numbers rows by
|
||||||
|
* created_at ascending (oldest → S-<year>-00001). Deterministic order.
|
||||||
|
* - Adds a partial unique index (NULLs allowed so a future insert can stage
|
||||||
|
* the row before the app stamps its reference).
|
||||||
|
*/
|
||||||
|
export class AddTrainScheduleReference2030000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules
|
||||||
|
ADD COLUMN IF NOT EXISTS reference VARCHAR(20);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Backfill per-year, ordered by created_at (oldest = 00001). Uses the row's
|
||||||
|
// own created-at year as the reference year so historical rows keep a
|
||||||
|
// sensible number.
|
||||||
|
await queryRunner.query(`
|
||||||
|
WITH numbered AS (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
EXTRACT(YEAR FROM created_at)::int AS yr,
|
||||||
|
ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY EXTRACT(YEAR FROM created_at)
|
||||||
|
ORDER BY created_at ASC, id ASC
|
||||||
|
) AS seq
|
||||||
|
FROM freight.train_schedules
|
||||||
|
WHERE reference IS NULL
|
||||||
|
)
|
||||||
|
UPDATE freight.train_schedules ts
|
||||||
|
SET reference = 'S-' || numbered.yr || '-' || LPAD(numbered.seq::text, 5, '0')
|
||||||
|
FROM numbered
|
||||||
|
WHERE ts.id = numbered.id;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS ux_train_schedules_reference
|
||||||
|
ON freight.train_schedules (reference)
|
||||||
|
WHERE reference IS NOT NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
DROP INDEX IF EXISTS freight.ux_train_schedules_reference;
|
||||||
|
`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.train_schedules DROP COLUMN IF EXISTS reference;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -217,6 +217,20 @@ export class ContractBookingService {
|
|||||||
await this.applyWeightResults(loaded);
|
await this.applyWeightResults(loaded);
|
||||||
}
|
}
|
||||||
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
const computed = await this.bookingPricingService.computePriceForBooking(loaded);
|
||||||
|
// Reject a zero-price booking outright. A total of 0 means no contract rate
|
||||||
|
// matched the route/container (or the rate is unset), so the booking is not
|
||||||
|
// valid to ship or invoice. Roll back the just-inserted row + its lines so it
|
||||||
|
// does NOT occupy the one-time contract's single active-booking slot — else
|
||||||
|
// the customer's retry hits "already has an active booking" against a broken
|
||||||
|
// draft. The customer must fix the contract's rates, then rebook.
|
||||||
|
if (!(computed.totalAmount > 0)) {
|
||||||
|
await this.bookingsRepository.deleteContainers(booking.id);
|
||||||
|
await this.bookingsRepository.hardDelete(booking.id);
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Booking price came out as 0 — no contract rate matches this ' +
|
||||||
|
'route/cargo. Set the contract rate and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
totalAmount: computed.totalAmount,
|
totalAmount: computed.totalAmount,
|
||||||
priorityScore: computed.priorityScore,
|
priorityScore: computed.priorityScore,
|
||||||
|
|||||||
@@ -61,6 +61,12 @@ export class TrainSchedule extends BaseEntity {
|
|||||||
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
@Column({ name: 'train_number', type: 'varchar', length: 20, nullable: true })
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
|
|
||||||
|
// Human-facing unique schedule reference (S-YYYY-NNNNN). Shown on the schedule
|
||||||
|
// list, booking windows, and load lists. Assigned at creation from the highest
|
||||||
|
// sequence issued this year (see TrainSchedulesRepository.maxReferenceSequence).
|
||||||
|
@Column({ name: 'reference', type: 'varchar', length: 20, nullable: true, unique: true })
|
||||||
|
reference?: string | null;
|
||||||
|
|
||||||
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
@Column({ name: 'direction', type: 'varchar', length: 10, nullable: true })
|
||||||
direction?: string | null;
|
direction?: string | null;
|
||||||
|
|
||||||
|
|||||||
@@ -58,4 +58,22 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.repo(manager).update(id, { status, ...extra } as never);
|
await this.repo(manager).update(id, { status, ...extra } as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Highest NNNNN sequence already issued for `S-<year>-…` references. Includes
|
||||||
|
* soft-deleted rows so the next number never reuses one still occupying the
|
||||||
|
* unique index (see the same pattern on BookingsRepository).
|
||||||
|
*/
|
||||||
|
async maxReferenceSequence(year: number): Promise<number> {
|
||||||
|
const row = await this.repository
|
||||||
|
.createQueryBuilder('schedule')
|
||||||
|
.withDeleted()
|
||||||
|
.select(
|
||||||
|
"COALESCE(MAX(CAST(SUBSTRING(schedule.reference FROM '[0-9]+$') AS int)), 0)",
|
||||||
|
'max',
|
||||||
|
)
|
||||||
|
.where('schedule.reference LIKE :prefix', { prefix: `S-${year}-%` })
|
||||||
|
.getRawOne<{ max: string | number | null }>();
|
||||||
|
return Number(row?.max ?? 0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
import { DataSource, EntityManager, In, IsNull, Not, QueryFailedError } from 'typeorm';
|
||||||
|
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
@@ -242,6 +242,7 @@ const DEFAULT_TRAIN_LIMITS: Required<TrainLimitConfig> = {
|
|||||||
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
/** Raw row shape for the booking-window queries (company- and contract-scoped). */
|
||||||
interface BookingWindowRow {
|
interface BookingWindowRow {
|
||||||
schedule_id: string;
|
schedule_id: string;
|
||||||
|
reference: string | null;
|
||||||
contract_id: string | null;
|
contract_id: string | null;
|
||||||
contract_kind: string | null;
|
contract_kind: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
@@ -821,20 +822,24 @@ export class TrainSchedulingService {
|
|||||||
...ruleSnapshot,
|
...ruleSnapshot,
|
||||||
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
...computeImportWindowTimes(departure, windowCfg, new Date()),
|
||||||
};
|
};
|
||||||
const schedule = manager.getRepository(TrainSchedule).create({
|
const maxWagons = (await this.resolveTrainLimitConfig(dto, limitLoco))
|
||||||
trainSetId: trainSet.id,
|
.maxWagonsPerTrain;
|
||||||
routeId: route.id,
|
// Retry past a concurrent insert that grabbed the same S-<year> sequence
|
||||||
originStationId: route.originYardId,
|
// (the unique index rejects the loser; it re-reads the max and tries again).
|
||||||
destinationStationId: route.destinationYardId,
|
const saved = await this.insertScheduleWithReference(manager, (reference) =>
|
||||||
scheduledDepartureDate: departure,
|
manager.getRepository(TrainSchedule).create({
|
||||||
status: TrainScheduleStatusEnum.Draft,
|
reference,
|
||||||
direction,
|
trainSetId: trainSet.id,
|
||||||
maxWagons: (
|
routeId: route.id,
|
||||||
await this.resolveTrainLimitConfig(dto, limitLoco)
|
originStationId: route.originYardId,
|
||||||
).maxWagonsPerTrain,
|
destinationStationId: route.destinationYardId,
|
||||||
...windowFields,
|
scheduledDepartureDate: departure,
|
||||||
});
|
status: TrainScheduleStatusEnum.Draft,
|
||||||
const saved = await manager.getRepository(TrainSchedule).save(schedule);
|
direction,
|
||||||
|
maxWagons,
|
||||||
|
...windowFields,
|
||||||
|
}),
|
||||||
|
);
|
||||||
// Locomotives stay in their current status until dispatch — advance scheduling
|
// Locomotives stay in their current status until dispatch — advance scheduling
|
||||||
// must not block the locomotive from serving earlier trains.
|
// must not block the locomotive from serving earlier trains.
|
||||||
return saved.id;
|
return saved.id;
|
||||||
@@ -2560,7 +2565,8 @@ export class TrainSchedulingService {
|
|||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
},
|
},
|
||||||
order: { scheduledDepartureDate: 'DESC', createdAt: 'DESC' },
|
// Newest-created first (the client can re-sort; this is the default order).
|
||||||
|
order: { createdAt: 'DESC', scheduledDepartureDate: 'DESC' },
|
||||||
});
|
});
|
||||||
return schedules.map((s) => this.mapScheduleListItem(s));
|
return schedules.map((s) => this.mapScheduleListItem(s));
|
||||||
}
|
}
|
||||||
@@ -3785,9 +3791,41 @@ export class TrainSchedulingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||||||
|
* past a concurrent insert that grabbed the same sequence (the unique index
|
||||||
|
* rejects the loser). Mirrors insertWithGeneratedReference for bookings, but
|
||||||
|
* runs inside the caller's transaction manager so the row joins the same commit.
|
||||||
|
*/
|
||||||
|
private async insertScheduleWithReference(
|
||||||
|
manager: EntityManager,
|
||||||
|
build: (reference: string) => TrainSchedule,
|
||||||
|
): Promise<TrainSchedule> {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
const repo = manager.getRepository(TrainSchedule);
|
||||||
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||||
|
const seq = await this.trainSchedulesRepository.maxReferenceSequence(year);
|
||||||
|
const reference = `S-${year}-${String(seq + 1).padStart(5, '0')}`;
|
||||||
|
try {
|
||||||
|
return await repo.save(build(reference));
|
||||||
|
} catch (err) {
|
||||||
|
// 23505 = unique_violation on ux_train_schedules_reference; re-read + retry.
|
||||||
|
const code = (err as { driverError?: { code?: string } })?.driverError?.code;
|
||||||
|
if (err instanceof QueryFailedError && code === '23505' && attempt < 4) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Unreachable — the loop either returns or throws — but satisfies the compiler.
|
||||||
|
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||||
|
}
|
||||||
|
|
||||||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
|
reference: schedule.reference ?? null,
|
||||||
|
createdAt: schedule.createdAt ?? null,
|
||||||
scheduleDate: schedule.scheduledDepartureDate,
|
scheduleDate: schedule.scheduledDepartureDate,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||||
@@ -3882,6 +3920,7 @@ export class TrainSchedulingService {
|
|||||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||||
`SELECT DISTINCT ON (ts.id)
|
`SELECT DISTINCT ON (ts.id)
|
||||||
ts.id AS schedule_id,
|
ts.id AS schedule_id,
|
||||||
|
ts.reference AS reference,
|
||||||
cr.contract_id AS contract_id,
|
cr.contract_id AS contract_id,
|
||||||
c.contract_kind AS contract_kind,
|
c.contract_kind AS contract_kind,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
@@ -3934,6 +3973,7 @@ export class TrainSchedulingService {
|
|||||||
async getBookingWindowsForContract(contractId: string) {
|
async getBookingWindowsForContract(contractId: string) {
|
||||||
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
const rows: Array<BookingWindowRow> = await this.dataSource.query(
|
||||||
`SELECT DISTINCT ts.id AS schedule_id,
|
`SELECT DISTINCT ts.id AS schedule_id,
|
||||||
|
ts.reference AS reference,
|
||||||
cr.contract_id AS contract_id,
|
cr.contract_id AS contract_id,
|
||||||
c.contract_kind AS contract_kind,
|
c.contract_kind AS contract_kind,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
@@ -3981,6 +4021,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
> = await this.dataSource.query(
|
> = await this.dataSource.query(
|
||||||
`SELECT ts.id AS schedule_id,
|
`SELECT ts.id AS schedule_id,
|
||||||
|
ts.reference AS reference,
|
||||||
ts.train_number,
|
ts.train_number,
|
||||||
ts.direction,
|
ts.direction,
|
||||||
ts.window_phase,
|
ts.window_phase,
|
||||||
@@ -4016,6 +4057,7 @@ export class TrainSchedulingService {
|
|||||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||||
return {
|
return {
|
||||||
scheduleId: r.schedule_id,
|
scheduleId: r.schedule_id,
|
||||||
|
reference: r.reference ?? null,
|
||||||
contractId: r.contract_id,
|
contractId: r.contract_id,
|
||||||
contractKind: r.contract_kind,
|
contractKind: r.contract_kind,
|
||||||
direction: r.direction,
|
direction: r.direction,
|
||||||
@@ -4377,6 +4419,7 @@ export class TrainSchedulingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
|
reference: schedule.reference ?? null,
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
freightType: this.resolveScheduleFreightType(schedule),
|
freightType: this.resolveScheduleFreightType(schedule),
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { api } from "@/services/api";
|
|||||||
*/
|
*/
|
||||||
interface WindowRow {
|
interface WindowRow {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
|
reference?: string | null;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
direction: string | null;
|
direction: string | null;
|
||||||
windowPhase: string | null;
|
windowPhase: string | null;
|
||||||
@@ -195,6 +196,11 @@ function WindowCard({ w }: { w: WindowRow }) {
|
|||||||
{w.destination ?? "—"}
|
{w.destination ?? "—"}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
|
{w.reference ? (
|
||||||
|
<Text fz={12} fw={600} ff="monospace" c="edr-green.7" truncate>
|
||||||
|
{w.reference}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
{w.trainNumber ? (
|
{w.trainNumber ? (
|
||||||
<Text fz={12} c="dimmed" truncate>
|
<Text fz={12} c="dimmed" truncate>
|
||||||
Train {w.trainNumber}
|
Train {w.trainNumber}
|
||||||
|
|||||||
@@ -864,6 +864,16 @@ export default function TrainScheduleV2DetailPage() {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
<Group gap="sm" align="center" wrap="wrap">
|
<Group gap="sm" align="center" wrap="wrap">
|
||||||
|
{schedule.reference ? (
|
||||||
|
<Badge
|
||||||
|
variant="filled"
|
||||||
|
color="edr-green"
|
||||||
|
radius="sm"
|
||||||
|
style={{ fontWeight: 700, fontFamily: "monospace" }}
|
||||||
|
>
|
||||||
|
{schedule.reference}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
<Title order={2} fw={700} style={{ color: "#0f172a" }}>
|
||||||
{schedule.route?.name ?? "Train schedule"}
|
{schedule.route?.name ?? "Train schedule"}
|
||||||
</Title>
|
</Title>
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||||
const [freightFilter, setFreightFilter] = useState("ALL");
|
const [freightFilter, setFreightFilter] = useState("ALL");
|
||||||
|
const [originFilter, setOriginFilter] = useState("ALL");
|
||||||
|
const [destinationFilter, setDestinationFilter] = useState("ALL");
|
||||||
|
// Default: newest-created first, matching the API's default order.
|
||||||
|
const [sortBy, setSortBy] = useState<"createdAt" | "scheduleDate" | "reference">(
|
||||||
|
"createdAt",
|
||||||
|
);
|
||||||
|
const [sortDir, setSortDir] = useState<"desc" | "asc">("desc");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||||
const [editDateSchedule, setEditDateSchedule] =
|
const [editDateSchedule, setEditDateSchedule] =
|
||||||
@@ -152,13 +159,32 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
return base;
|
return base;
|
||||||
}, [allSchedules]);
|
}, [allSchedules]);
|
||||||
|
|
||||||
|
// Distinct origins/destinations present in the loaded schedules, for the
|
||||||
|
// corridor filters. Sorted A→Z; "ALL" prepended by the Select data below.
|
||||||
|
const originOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
[...new Set(allSchedules.map((s) => s.origin).filter(Boolean))].sort() as string[],
|
||||||
|
[allSchedules],
|
||||||
|
);
|
||||||
|
const destinationOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
[
|
||||||
|
...new Set(allSchedules.map((s) => s.destination).filter(Boolean)),
|
||||||
|
].sort() as string[],
|
||||||
|
[allSchedules],
|
||||||
|
);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const query = search.trim().toLowerCase();
|
const query = search.trim().toLowerCase();
|
||||||
return allSchedules.filter((s) => {
|
const matched = allSchedules.filter((s) => {
|
||||||
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
if (statusFilter !== "ALL" && s.status !== statusFilter) return false;
|
||||||
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
if (freightFilter !== "ALL" && s.freightType !== freightFilter) return false;
|
||||||
|
if (originFilter !== "ALL" && s.origin !== originFilter) return false;
|
||||||
|
if (destinationFilter !== "ALL" && s.destination !== destinationFilter)
|
||||||
|
return false;
|
||||||
if (!query) return true;
|
if (!query) return true;
|
||||||
const haystack = [
|
const haystack = [
|
||||||
|
s.reference,
|
||||||
s.trainNumber,
|
s.trainNumber,
|
||||||
s.routeName,
|
s.routeName,
|
||||||
s.origin,
|
s.origin,
|
||||||
@@ -173,7 +199,31 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return haystack.includes(query);
|
return haystack.includes(query);
|
||||||
});
|
});
|
||||||
}, [allSchedules, search, statusFilter, freightFilter]);
|
|
||||||
|
const dir = sortDir === "asc" ? 1 : -1;
|
||||||
|
const sorted = [...matched].sort((a, b) => {
|
||||||
|
let cmp = 0;
|
||||||
|
if (sortBy === "reference") {
|
||||||
|
cmp = (a.reference ?? "").localeCompare(b.reference ?? "");
|
||||||
|
} else {
|
||||||
|
// createdAt or scheduleDate — compare as timestamps (missing sorts last).
|
||||||
|
const av = new Date(a[sortBy] ?? 0).getTime();
|
||||||
|
const bv = new Date(b[sortBy] ?? 0).getTime();
|
||||||
|
cmp = av - bv;
|
||||||
|
}
|
||||||
|
return cmp * dir;
|
||||||
|
});
|
||||||
|
return sorted;
|
||||||
|
}, [
|
||||||
|
allSchedules,
|
||||||
|
search,
|
||||||
|
statusFilter,
|
||||||
|
freightFilter,
|
||||||
|
originFilter,
|
||||||
|
destinationFilter,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
]);
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
const pageCount = Math.max(1, Math.ceil(filtered.length / pagination.pageSize));
|
||||||
const paged = useMemo(() => {
|
const paged = useMemo(() => {
|
||||||
@@ -185,6 +235,16 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
const headerClassName = ruleEngineTable.headerCell;
|
const headerClassName = ruleEngineTable.headerCell;
|
||||||
const cellClassName = ruleEngineTable.bodyCell;
|
const cellClassName = ruleEngineTable.bodyCell;
|
||||||
return [
|
return [
|
||||||
|
{
|
||||||
|
id: "reference",
|
||||||
|
header: "Ref",
|
||||||
|
meta: { headerClassName, cellClassName },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} ff="monospace" c="edr-green.8">
|
||||||
|
{row.original.reference ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "date",
|
id: "date",
|
||||||
header: "Departure",
|
header: "Departure",
|
||||||
@@ -471,6 +531,58 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
w={140}
|
w={140}
|
||||||
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
/>
|
/>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
placeholder="Origin"
|
||||||
|
searchable
|
||||||
|
value={originFilter}
|
||||||
|
onChange={(v) => setOriginFilter(v ?? "ALL")}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: "All origins" },
|
||||||
|
...originOptions.map((o) => ({ value: o, label: o })),
|
||||||
|
]}
|
||||||
|
w={160}
|
||||||
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
placeholder="Destination"
|
||||||
|
searchable
|
||||||
|
value={destinationFilter}
|
||||||
|
onChange={(v) => setDestinationFilter(v ?? "ALL")}
|
||||||
|
data={[
|
||||||
|
{ value: "ALL", label: "All destinations" },
|
||||||
|
...destinationOptions.map((d) => ({ value: d, label: d })),
|
||||||
|
]}
|
||||||
|
w={170}
|
||||||
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
radius="lg"
|
||||||
|
value={`${sortBy}:${sortDir}`}
|
||||||
|
onChange={(v) => {
|
||||||
|
if (!v) return;
|
||||||
|
const [by, dir] = v.split(":") as [
|
||||||
|
typeof sortBy,
|
||||||
|
typeof sortDir,
|
||||||
|
];
|
||||||
|
setSortBy(by);
|
||||||
|
setSortDir(dir);
|
||||||
|
}}
|
||||||
|
data={[
|
||||||
|
{ value: "createdAt:desc", label: "Newest created" },
|
||||||
|
{ value: "createdAt:asc", label: "Oldest created" },
|
||||||
|
{ value: "scheduleDate:desc", label: "Departure ↓" },
|
||||||
|
{ value: "scheduleDate:asc", label: "Departure ↑" },
|
||||||
|
{ value: "reference:asc", label: "Reference ↑" },
|
||||||
|
{ value: "reference:desc", label: "Reference ↓" },
|
||||||
|
]}
|
||||||
|
w={170}
|
||||||
|
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -693,9 +805,16 @@ function ScheduleCard({
|
|||||||
<Train size={18} />
|
<Train size={18} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Stack gap={0} style={{ minWidth: 0 }}>
|
<Stack gap={0} style={{ minWidth: 0 }}>
|
||||||
<Text fw={600} size="sm" lineClamp={1}>
|
<Group gap={6} wrap="nowrap">
|
||||||
{schedule.routeName ?? "Train schedule"}
|
{schedule.reference ? (
|
||||||
</Text>
|
<Text size="xs" fw={700} ff="monospace" c="edr-green.8">
|
||||||
|
{schedule.reference}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text fw={600} size="sm" lineClamp={1}>
|
||||||
|
{schedule.routeName ?? "Train schedule"}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{day} · {time}
|
{day} · {time}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ export interface LocomotiveRecord {
|
|||||||
|
|
||||||
export interface TrainScheduleListItem {
|
export interface TrainScheduleListItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
reference?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
scheduleDate: string;
|
scheduleDate: string;
|
||||||
trainNumber?: string | null;
|
trainNumber?: string | null;
|
||||||
routeName?: string | null;
|
routeName?: string | null;
|
||||||
@@ -237,6 +239,7 @@ export interface BatchBoardBooking {
|
|||||||
*/
|
*/
|
||||||
export interface StaffBookingWindow {
|
export interface StaffBookingWindow {
|
||||||
scheduleId: string;
|
scheduleId: string;
|
||||||
|
reference: string | null;
|
||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
direction: "IMPORT" | "EXPORT" | null;
|
direction: "IMPORT" | "EXPORT" | null;
|
||||||
windowPhase: BookingWindowPhase | string | null;
|
windowPhase: BookingWindowPhase | string | null;
|
||||||
@@ -432,6 +435,7 @@ export interface UpdateScheduleWindowRulePayload {
|
|||||||
|
|
||||||
export interface TrainScheduleDetail {
|
export interface TrainScheduleDetail {
|
||||||
id: string;
|
id: string;
|
||||||
|
reference?: string | null;
|
||||||
status: TrainScheduleStatus | string;
|
status: TrainScheduleStatus | string;
|
||||||
deferredBookings?: DeferredBookingRow[];
|
deferredBookings?: DeferredBookingRow[];
|
||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user