Merge pull request #1426 from Tria-plc/dev

dev
This commit is contained in:
marshal
2026-08-27 10:55:02 +03:00
committed by GitHub
17 changed files with 1439 additions and 805 deletions

View File

@@ -34,10 +34,6 @@ import {
directionScopeSql,
} from "../user-trade-access/trade-scope.util";
/** Bookings carry a contract_kind column; GENERAL = umbrella contract row, not a shipment. */
const EXCLUDE_GENERAL_CONTRACT_BOOKINGS =
"(booking.contract_kind IS NULL OR booking.contract_kind <> 'GENERAL')";
export type OverviewBookingKpisRow = {
total: number;
totalActive: number;
@@ -147,7 +143,6 @@ export class OverviewRepository {
"submittedToday",
)
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.setParameters({
closedStatuses: [...OVERVIEW_CLOSED_STATUSES],
@@ -337,7 +332,6 @@ export class OverviewRepository {
.select(`to_char(booking.created_at::date, 'YYYY-MM-DD')`, "date")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("booking.created_at::date")
@@ -357,7 +351,6 @@ export class OverviewRepository {
.select("booking.status", "status")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.groupBy("booking.status")
.getRawMany<{ status: string; count: string }>();
@@ -427,7 +420,6 @@ export class OverviewRepository {
.addSelect("booking.payment_currency", "paymentCurrency")
.addSelect("booking.created_at", "createdAt")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.orderBy("booking.created_at", "DESC")
.limit(limit)
@@ -463,7 +455,6 @@ export class OverviewRepository {
.select("booking.freight_type", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.freight_type")
@@ -485,7 +476,6 @@ export class OverviewRepository {
.select("booking.payment_currency", "label")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere("booking.status != 'DRAFT'")
.andWhere(scope.sql, scope.params)
.groupBy("booking.payment_currency")
@@ -602,7 +592,6 @@ export class OverviewRepository {
this.bookingRepository
.createQueryBuilder("booking")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(bookingScope.sql, bookingScope.params)
.andWhere(windowSql("booking.created_at"), { days, offsetDays })
.getCount(),
@@ -802,7 +791,6 @@ export class OverviewRepository {
.addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block")
.addSelect("COUNT(*)::int", "count")
.where("booking.deleted_at IS NULL")
.andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS)
.andWhere(scope.sql, scope.params)
.andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days })
.groupBy("EXTRACT(ISODOW FROM booking.created_at)::int")
@@ -1457,7 +1445,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1
ORDER BY count DESC
@@ -1475,7 +1462,6 @@ export class OverviewRepository {
ON y.id = CASE WHEN b.trade_direction = 'EXPORT'
THEN b.destination_yard_id ELSE b.origin_yard_id END
WHERE b.deleted_at IS NULL
AND (b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')
AND b.created_at >= NOW() - make_interval(days => $1::int)
GROUP BY 1, 2
ORDER BY 1, 2

View File

@@ -2,8 +2,10 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Company } from '../../companies/entities/company.entity';
import { Invoice } from '../../billing/entities/invoice.entity';
import { ShippingLineCompany } from '../../shipping-lines/entities/shipping-line-company.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, PAYER_EXPR, currencyOf } from '../revenue-classification';
const OPEN_STATUSES = ['ISSUED', 'PENDING', 'PARTIALLY_PAID', 'OVERDUE'];
@@ -13,13 +15,22 @@ function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
// to now() in SQL when the filter is unset (see the COALESCE below).
const asOf = (params.asOf as string | null) ?? null;
// Both payer joins are LEFT: an invoice billed to a shipping line carries no
// company, and an INNER join on `companies` silently drops its balance out of
// the arrears total.
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.innerJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(Company, 'c', 'c.id = i.company_id')
.leftJoin(ShippingLineCompany, 'slc', 'slc.id = i.shipping_line_company_id')
.where('i.deleted_at IS NULL')
.andWhere('i.status IN (:...openStatuses)', { openStatuses: OPEN_STATUSES })
.andWhere('i.balance_amount > 0')
// Stored casing has drifted ("usd" rows exist), and one arrears figure
// cannot span two currencies.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
})
.setParameter('asOf', asOf);
// ACL: invoices.source_id is a varchar pointer at the originating booking.
@@ -32,9 +43,15 @@ export const agingReceivablesReport: ReportDefinition = {
title: 'Aging Receivables',
description: 'Outstanding customer balances bucketed by days overdue',
group: 'Finance',
filters: [{ key: 'asOf', label: 'As of', type: 'date' }],
filters: [{ key: 'asOf', label: 'As of', type: 'date' }, CURRENCY_FILTER],
columns: [
{ key: 'customer', label: 'Customer', type: 'string', sortable: true, sortExpr: 'c.name' },
{
key: 'customer',
label: 'Customer',
type: 'string',
sortable: true,
sortExpr: PAYER_EXPR,
},
{ key: 'invoices', label: 'Invoices', type: 'number' },
{ key: 'outstanding', label: 'Outstanding', type: 'money', sortable: true },
{ key: 'current', label: 'Current', type: 'money' },
@@ -46,7 +63,7 @@ export const agingReceivablesReport: ReportDefinition = {
defaultSort: { key: 'outstanding', dir: 'DESC' },
query(ctx) {
return baseQuery(ctx)
.select('c.name', 'customer')
.select(PAYER_EXPR, 'customer')
.addSelect('COUNT(*)::int', 'invoices')
.addSelect('ROUND(SUM(i.balance_amount))::float8', 'outstanding')
.addSelect(
@@ -72,15 +89,19 @@ export const agingReceivablesReport: ReportDefinition = {
`ROUND(COALESCE(SUM(i.balance_amount) FILTER (WHERE i.due_at < COALESCE(:asOf::timestamptz, now()) - interval '90 days'), 0))::float8`,
'overdue90plus',
)
.groupBy('c.name');
.groupBy(PAYER_EXPR);
},
async summary(ctx) {
const row = await baseQuery(ctx)
.select('ROUND(COALESCE(SUM(i.balance_amount), 0))::float8', 'outstanding')
.addSelect('COUNT(DISTINCT c.id)::int', 'customers')
.addSelect(`COUNT(DISTINCT ${PAYER_EXPR})::int`, 'customers')
.getRawOne();
return [
{ label: 'Outstanding', value: Number(row?.outstanding ?? 0), unit: 'ETB' },
{
label: 'Outstanding',
value: Number(row?.outstanding ?? 0),
unit: currencyOf(ctx.params),
},
{ label: 'Customers with balance', value: Number(row?.customers ?? 0) },
];
},

View File

@@ -2,19 +2,34 @@ import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { Freight } from '@edr/types';
import { Invoice } from '../../billing/entities/invoice.entity';
import { applyBookingRefDirectionScope } from '../../user-trade-access/trade-scope.util';
import { ReportContext, ReportDefinition } from '../report.types';
import { CURRENCY_FILTER, currencyOf } from '../revenue-classification';
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({ value: v, label: v }));
const STATUS_OPTIONS = Object.values(Freight.InvoiceStatus).map((v) => ({
value: v,
label: v,
}));
function baseQuery(ctx: ReportContext): SelectQueryBuilder<ObjectLiteral> {
const { params } = ctx;
const qb = ctx.ds.createQueryBuilder().from(Invoice, 'i').where('i.deleted_at IS NULL');
const { params, directions } = ctx;
const qb = ctx.ds
.createQueryBuilder()
.from(Invoice, 'i')
.where('i.deleted_at IS NULL')
// Both currencies live in this table; one money column cannot hold both.
.andWhere('UPPER(i.currency) = :currency', {
currency: currencyOf(params).toUpperCase(),
});
if (params.dateFrom) qb.andWhere('i.created_at >= :dateFrom', { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere('i.created_at < :dateTo', { dateTo: params.dateTo });
const statuses = params.statuses as string[] | null;
if (statuses) qb.andWhere('i.status IN (:...statuses)', { statuses });
return qb;
// Every other Finance report scopes by the caller's trade directions; without
// it this one reports the value of invoices its reader may not see.
return applyBookingRefDirectionScope(qb, 'i.source_id', directions);
}
export const invoicingPipelineReport: ReportDefinition = {
@@ -24,7 +39,13 @@ export const invoicingPipelineReport: ReportDefinition = {
group: 'Finance',
filters: [
{ key: 'date', label: 'Created', type: 'daterange' },
{ key: 'statuses', label: 'Status', type: 'multiselect', options: STATUS_OPTIONS },
CURRENCY_FILTER,
{
key: 'statuses',
label: 'Status',
type: 'multiselect',
options: STATUS_OPTIONS,
},
],
columns: [
{ key: 'type', label: 'Type', type: 'string', sortable: true },
@@ -52,8 +73,16 @@ export const invoicingPipelineReport: ReportDefinition = {
.getRawOne();
return [
{ label: 'Invoices', value: Number(row?.invoices ?? 0) },
{ label: 'Total value', value: Number(row?.totalAmount ?? 0), unit: 'ETB' },
{ label: 'Outstanding', value: Number(row?.balance ?? 0), unit: 'ETB' },
{
label: 'Total value',
value: Number(row?.totalAmount ?? 0),
unit: currencyOf(ctx.params),
},
{
label: 'Outstanding',
value: Number(row?.balance ?? 0),
unit: currencyOf(ctx.params),
},
];
},
};

View File

@@ -28,8 +28,14 @@ import { ReportContext, ReportFilterDef, ReportFilterOption } from './report.typ
// ---------------------------------------------------------------------------
export const REVENUE_CATEGORIES: ReportFilterOption[] = [
{ value: 'CONTAINER_IMPORT_MULTIMODAL', label: 'Full Container Import — Multimodal' },
{ value: 'CONTAINER_IMPORT_UNIMODAL', label: 'Full Container Import — Unimodal' },
{
value: 'CONTAINER_IMPORT_MULTIMODAL',
label: 'Full Container Import — Multimodal',
},
{
value: 'CONTAINER_IMPORT_UNIMODAL',
label: 'Full Container Import — Unimodal',
},
{ value: 'CONTAINER_EXPORT', label: 'Full Container Export' },
{ value: 'EMPTY_CONTAINER_REEXPORT', label: 'Empty Container Re-export' },
{ value: 'FERTILIZER', label: 'Fertilizer Transportation' },
@@ -332,7 +338,10 @@ export const PERIOD_FILTER: ReportFilterDef = {
key: 'period',
label: 'Granularity',
type: 'select',
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({ value, label: u.label })),
options: Object.entries(PERIOD_UNITS).map(([value, u]) => ({
value,
label: u.label,
})),
};
/** The timestamp every revenue report buckets and filters on. */
@@ -478,7 +487,12 @@ export const REVENUE_FILTERS: ReportFilterDef[] = [
options: REVENUE_CATEGORIES,
},
{ key: 'origin', label: 'Origin', type: 'select', optionsQuery: yardOptions },
{ key: 'destination', label: 'Destination', type: 'select', optionsQuery: yardOptions },
{
key: 'destination',
label: 'Destination',
type: 'select',
optionsQuery: yardOptions,
},
{ key: 'customer', label: 'Customer / booking ref', type: 'text' },
{
key: 'methods',
@@ -537,9 +551,6 @@ export function revenueLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
})
.andWhere("i.source <> 'eims_self_test'")
// An umbrella general contract is paid once and drawn down by many orders;
// counting both double-counts its value.
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
// Mixing ETB and USD into one SUM produces a meaningless number.
.andWhere('il.currency = :currency', { currency: currencyOf(params) });
@@ -611,13 +622,13 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
deadInvoiceStatuses: DEAD_INVOICE_STATUSES,
})
.andWhere("i.source <> 'eims_self_test'")
.andWhere("(b.id IS NULL OR b.contract_kind IS NULL OR b.contract_kind <> 'GENERAL')")
.andWhere('i.currency = :currency', { currency: currencyOf(params) });
if (params.dateFrom) qb.andWhere(`${REVENUE_DATE} >= :dateFrom`, { dateFrom: params.dateFrom });
if (params.dateTo) qb.andWhere(`${REVENUE_DATE} < :dateTo`, { dateTo: params.dateTo });
if (params.origin) qb.andWhere('oy.code = :origin', { origin: params.origin });
if (params.destination) qb.andWhere('dy.code = :destination', { destination: params.destination });
if (params.destination)
qb.andWhere('dy.code = :destination', { destination: params.destination });
if (params.customer) {
qb.andWhere(
'(co.name ILIKE :customer OR slc.name ILIKE :customer OR b.reference ILIKE :customer)',
@@ -630,14 +641,37 @@ export function invoiceLedgerQb(ctx: ReportContext): SelectQueryBuilder<ObjectLi
}
/**
* What the payment gateway actually recorded against this invoice, summed.
* `invoices.payment_id` points at a payment-api intent id rather than a
* `freight.payments` row, so the reliable link is the booking id both sides
* carry.
* What the payment gateway actually recorded against this invoice.
*
* `freight.payments` is keyed by the booking, not the invoice — `ref_id` holds
* the booking id and there is no invoice column — while one booking routinely
* carries several invoices (25 booking ids here back 58 of them). Reading the
* booking's gateway total straight off each invoice therefore hands the same
* money to every sibling: 113M of gateway receipts claimed against 44M of
* recorded settlement, which surfaced as ~89M of variance that does not exist.
*
* So the booking's receipts are apportioned across its invoices by their share
* of what was recorded as settled — the same device as {@link PAID_SHARE}, and
* the only split that makes the report's gateway column sum to the payments
* table. A booking whose invoices record no settlement at all cannot be split
* that way; it falls back to the billed share, so gateway money nobody booked
* still shows up as variance instead of vanishing.
*
* `invoices.payment_id` does resolve to a `freight.payments` row, but only 72
* of 85 successful payments are pointed at by one, so keying on it drops real
* receipts.
*/
export const GATEWAY_PAID = `(
SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
WHERE p.ref_id = i.source_id AND p.status = 'success'
(SELECT COALESCE(SUM(p.amount), 0) FROM freight.payments p
WHERE p.ref_id = i.source_id AND p.status = 'success')
* COALESCE(
i.paid_amount / NULLIF((SELECT SUM(i2.paid_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
i.total_amount / NULLIF((SELECT SUM(i2.total_amount) FROM freight.invoices i2
WHERE i2.source_id = i.source_id AND i2.deleted_at IS NULL
AND i2.status NOT IN ('DRAFT', 'CANCELLED')), 0),
0)
)`;
/** The payer, whichever of the two mutually exclusive payer columns is set. */

View File

@@ -2902,27 +2902,17 @@ export class TrainSchedulingService {
schedule = reloaded;
}
}
// Loading is tracked per station: dispatching with cargo still to board at
// the origin marks it loaded (checklist + auto-load below), so the origin's
// loading time window must have been started first — same gate the
// per-booking load endpoint enforces.
const originBoarders = await this.unloadedOriginBoarderIds(
scheduleId,
schedule.originStationId,
);
const boardersToLoad = dto.loadedBookingIds
? originBoarders.filter((id) => new Set(dto.loadedBookingIds).has(id))
: originBoarders;
// Dispatch requires the origin's loading window to be COMPLETE: started
// and ended. Not started or still open both block — a train departs only
// after loading was formally opened and closed.
const originLoadingLog =
schedule.stationWorkLogs?.[schedule.originStationId]?.loading;
if (boardersToLoad.length && !originLoadingLog?.startedAt) {
if (!originLoadingLog?.startedAt) {
throw new BadRequestException(
'Start loading at the origin station before dispatching with cargo to load',
'Start (and end) the loading window at the origin station before dispatching',
);
}
// A train never departs mid-loading: once the origin's loading window was
// opened (or there is cargo to load), it must be ENDED before dispatch.
if ((boardersToLoad.length || originLoadingLog?.startedAt) && !originLoadingLog?.endedAt) {
if (!originLoadingLog?.endedAt) {
throw new BadRequestException(
'End the loading window at the origin station before dispatching',
);

View File

@@ -36,6 +36,13 @@ export function DoCollectionDateFields({
const outOfOrder =
Boolean(value.vesselArrival && value.doCollected) && !doDatesComplete(value);
const today = new Date();
today.setHours(0, 0, 0, 0);
const doMin =
value.vesselArrival && value.vesselArrival > today
? value.vesselArrival
: today;
return (
<Group grow align="flex-start" gap="sm" wrap="wrap">
<DateInput
@@ -45,7 +52,7 @@ export function DoCollectionDateFields({
onChange={(v) =>
onChange({ ...value, vesselArrival: v ? new Date(v) : null })
}
maxDate={new Date()}
minDate={today}
size="sm"
required
withAsterisk
@@ -57,8 +64,7 @@ export function DoCollectionDateFields({
onChange={(v) =>
onChange({ ...value, doCollected: v ? new Date(v) : null })
}
minDate={value.vesselArrival ?? undefined}
maxDate={new Date()}
minDate={doMin}
size="sm"
required
withAsterisk

View File

@@ -2055,9 +2055,10 @@ export default function GlCreateBookingForm() {
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, cargoWeightTons: e.currentTarget.value }))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, cargoWeightTons: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2074,9 +2075,10 @@ export default function GlCreateBookingForm() {
? bulkErrors.quantity
: undefined
}
onChange={(e) =>
setBulk((b) => ({ ...b, itemCount: e.currentTarget.value }))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, itemCount: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2091,12 +2093,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.requestedWagons}
error={showErrors ? bulkErrors.wagons : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
requestedWagons: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, requestedWagons: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2110,12 +2110,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.hazardousQuantity}
error={showErrors ? bulkErrors.hazardous : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
hazardousQuantity: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, hazardousQuantity: value }));
}}
radius={10}
styles={fieldStyles}
/>
@@ -2129,12 +2127,10 @@ export default function GlCreateBookingForm() {
step={1}
value={bulk.reeferQuantity}
error={showErrors ? bulkErrors.reefer : undefined}
onChange={(e) =>
setBulk((b) => ({
...b,
reeferQuantity: e.currentTarget.value,
}))
}
onChange={(e) => {
const value = e.currentTarget.value;
setBulk((b) => ({ ...b, reeferQuantity: value }));
}}
radius={10}
styles={fieldStyles}
/>

View File

@@ -0,0 +1,154 @@
import { Box, Button, Group, Stack, Table, Text } from "@mantine/core";
import { MapPin, Pencil } from "lucide-react";
import type { TrainCheckpoint } from "@/types/trainScheduling";
import { handlingHours } from "./JourneySpine";
import { Chip } from "./trackPrimitives";
import { KIND_TONE, track } from "./trackTheme";
const fmt = (iso: string) =>
new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const TH = {
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.7,
color: track.muted,
textTransform: "uppercase",
} as const;
/** Raw event trail under the spine — every logged pass, with its correction. */
export function CheckpointLogTable({
checkpoints,
onEdit,
}: {
checkpoints: TrainCheckpoint[];
onEdit?: (checkpoint: TrainCheckpoint) => void;
}) {
if (checkpoints.length === 0) {
return (
<Stack
align="center"
gap="xs"
py={42}
mx={24}
mb={24}
style={{
borderRadius: 14,
border: `1px solid ${track.borderSoft}`,
background: track.surface2,
}}
>
<Box
style={{
width: 52,
height: 52,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: track.brandDim,
color: track.brand,
}}
>
<MapPin size={22} />
</Box>
<Text size="13.5px" fw={700} c={track.text}>
No checkpoints yet
</Text>
<Text size="12px" c={track.muted} ta="center" maw={320}>
Each station the train passes will be logged here with its timestamp.
</Text>
</Stack>
);
}
return (
<Table.ScrollContainer minWidth={820}>
<Table verticalSpacing={13} horizontalSpacing={24} highlightOnHover>
<Table.Thead style={{ background: track.surface2 }}>
<Table.Tr>
<Table.Th style={{ ...TH, width: 220 }}>Station</Table.Th>
<Table.Th style={{ ...TH, width: 110 }}>Event</Table.Th>
<Table.Th style={{ ...TH, width: 160 }}>Time</Table.Th>
<Table.Th style={{ ...TH, width: 130 }}>Handling</Table.Th>
<Table.Th style={TH}>Note</Table.Th>
<Table.Th style={{ ...TH, width: 70 }} />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{checkpoints.map((cp) => {
const hours = handlingHours(cp);
const tone = KIND_TONE[cp.kind] ?? KIND_TONE.PASSED;
return (
<Table.Tr key={cp.id}>
<Table.Td>
<Group gap={9} wrap="nowrap">
<Box
style={{
width: 22,
height: 22,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: track.brandDim,
color: track.brand,
flexShrink: 0,
}}
>
<MapPin size={11} />
</Box>
<Text size="12.5px" fw={600} c={track.text}>
{cp.label ?? `Station ${cp.sequenceNo}`}
</Text>
</Group>
</Table.Td>
<Table.Td>
<Chip bg={tone.bg} fg={tone.fg}>
{cp.kind}
</Chip>
</Table.Td>
<Table.Td>
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
{fmt(cp.occurredAt)}
</Text>
</Table.Td>
<Table.Td>
<Text size="11.5px" c={hours === null ? track.text3 : track.text2}>
{hours === null ? "—" : `${hours} h`}
</Text>
</Table.Td>
<Table.Td>
<Text size="11.5px" c={cp.note ? track.muted : track.text3}>
{cp.note || "—"}
</Text>
</Table.Td>
<Table.Td>
{onEdit ? (
<Group justify="flex-end">
<Button
size="compact-xs"
radius={8}
variant="default"
leftSection={<Pencil size={11} />}
onClick={() => onEdit(cp)}
>
Edit
</Button>
</Group>
) : null}
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}

View File

@@ -1,4 +1,4 @@
import { Button, Divider, Group, Modal, SimpleGrid, Stack, Text, Textarea } from "@mantine/core";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMediaQuery } from "@mantine/hooks";
import { useEffect, useState } from "react";
@@ -67,6 +67,10 @@ export function CheckpointTimeModal({
const isSmallScreen = useMediaQuery("(max-width: 48em)");
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
// The four station-work stamps are no longer edited HERE — the track page's
// "Loading & unloading windows" section owns start/end with its own
// permissions. The modal still carries any existing stamps through
// unchanged on submit, so editing a checkpoint never wipes them.
const [handling, setHandling] = useState<HandlingState>(EMPTY_HANDLING);
useEffect(() => {
if (!opened) return;
@@ -94,7 +98,7 @@ export function CheckpointTimeModal({
onClose={onClose}
centered
fullScreen={isSmallScreen}
radius="lg"
radius={18}
title={
<Group gap={8}>
{icon}
@@ -121,34 +125,6 @@ export function CheckpointTimeModal({
radius="md"
/>
<Divider
label="Station work (optional)"
labelPosition="left"
styles={{ label: { fontWeight: 600 } }}
/>
<Text size="xs" c="dimmed" mt={-8}>
Loading and unloading times for this stop. Total handling is unloading start to
loading finish; the rest of the stay reports as other activity.
</Text>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
{HANDLING_FIELDS.map(([field, label]) => (
<DateTimePicker
key={field}
label={label}
value={handling[field]}
onChange={(v) =>
setHandling((prev) => ({ ...prev, [field]: v ? new Date(v) : null }))
}
maxDate={new Date()}
dropdownType={isSmallScreen ? "modal" : "popover"}
popoverProps={{ withinPortal: true }}
valueFormat="DD MMM YYYY HH:mm"
clearable
radius="md"
/>
))}
</SimpleGrid>
<Textarea
label="Note"
placeholder="Optional"
@@ -161,10 +137,11 @@ export function CheckpointTimeModal({
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
<Button variant="default" radius={9} onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
radius={9}
color={submitColor}
loading={loading}
disabled={!at}

View File

@@ -0,0 +1,266 @@
import { Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Pencil, Timer } from "lucide-react";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import type {
StationWorkLog,
TrackStation,
TrainCheckpoint,
} from "@/types/trainScheduling";
import { Chip } from "./trackPrimitives";
import { KIND_TONE, track } from "./trackTheme";
const NODE = 30;
/** Total handling at a stop: earliest start → latest finish. Null when unlogged. */
export function handlingHours(cp: TrainCheckpoint): number | null {
const starts = [cp.unloadingStartedAt, cp.loadingStartedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
if (!starts.length || !ends.length) return null;
return Math.round(((Math.max(...ends) - Math.min(...starts)) / 3_600_000) * 10) / 10;
}
function fmt(iso?: string | null) {
if (!iso) return "—";
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export interface JourneySpineProps {
scheduleId: string;
stations: TrackStation[];
currentSequenceNo: number;
checkpoints: TrainCheckpoint[];
stationWorkLogs?: Record<string, StationWorkLog>;
/** True when the train is DISPATCHED and staff may log progress. */
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
}
/**
* The journey: one vertical spine where every stop carries its pass time and
* its loading/unloading windows together, so an operator reads a station's
* whole story in one row instead of cross-referencing two lists.
*/
export function JourneySpine({
scheduleId,
stations,
currentSequenceNo,
checkpoints,
stationWorkLogs,
canLog,
loggingSeq,
onLogCheckpoint,
onEditCheckpoint,
}: JourneySpineProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
return (
<Stack gap={0} px={24} pt={6} pb={20}>
{stations.map((station, index) => {
const isLast = index === lastIndex;
const isFirst = index === 0;
const passed = station.sequenceNo <= currentSequenceNo;
const isCurrent = station.sequenceNo === currentSequenceNo;
const isNext = canLog && station.sequenceNo === currentSequenceNo + 1;
const checkpoint = bySeq.get(station.sequenceNo);
const workLog = stationWorkLogs?.[station.yardId];
const hours = checkpoint ? handlingHours(checkpoint) : null;
const kindTone = checkpoint ? KIND_TONE[checkpoint.kind] : null;
return (
<Group
key={station.yardId}
gap={16}
align="stretch"
wrap="nowrap"
style={{ width: "100%" }}
>
{/* gutter: node + the line running to the next stop */}
<Stack
gap={0}
align="center"
style={{ width: NODE, flexShrink: 0, alignSelf: "stretch" }}
>
<Box
style={{
width: NODE,
height: NODE,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
background: passed
? track.brand
: isNext
? track.surface
: track.surface2,
border: `2px solid ${
passed ? track.brand : isNext ? track.brand : track.border
}`,
color: passed ? "#FFFFFF" : isNext ? track.brand : track.text3,
}}
>
{passed ? (
<Check size={14} />
) : isLast ? (
<Flag size={14} />
) : (
<MapPin size={14} />
)}
</Box>
{!isLast ? (
<Box
style={{
width: 2,
flex: 1,
minHeight: 24,
background: passed ? track.brand : track.border,
}}
/>
) : null}
</Stack>
{/* body */}
<Stack gap={11} pt={2} pb={isLast ? 4 : 24} style={{ flex: 1, minWidth: 0 }}>
<Group gap={10} align="center" wrap="wrap">
<Text
size="14.5px"
fw={700}
c={passed || isNext ? track.text : track.text2}
>
{station.label}
</Text>
{isFirst ? (
<Chip bg={track.surface3} fg={track.muted}>
ORIGIN
</Chip>
) : null}
{isLast ? (
<Chip bg={track.surface3} fg={track.muted}>
DESTINATION
</Chip>
) : null}
{isCurrent ? (
<Chip bg={track.brand} fg="#FFFFFF">
TRAIN HERE
</Chip>
) : null}
{checkpoint && kindTone ? (
<Chip bg={kindTone.bg} fg={kindTone.fg}>
{checkpoint.kind}
</Chip>
) : null}
<Box style={{ flex: 1, minWidth: 0 }} />
{checkpoint ? (
<Group gap={10} wrap="nowrap">
<Text size="11.5px" c={track.text2} style={{ fontFamily: track.mono }}>
{fmt(checkpoint.occurredAt)}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius={8}
variant="default"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit
</Button>
) : null}
</Group>
) : isNext ? (
<Button
size="compact-sm"
radius={9}
color="edr-green"
leftSection={isLast ? <Flag size={13} /> : <MapPin size={13} />}
loading={loggingSeq === station.sequenceNo}
onClick={() => onLogCheckpoint?.(station.sequenceNo)}
>
{isLast ? "Mark arrived" : "Log pass"}
</Button>
) : (
<Button size="compact-sm" radius={9} variant="default" disabled>
{isLast ? "Mark arrived" : "Log pass"}
</Button>
)}
</Group>
{hours !== null || checkpoint?.note ? (
<Group gap={9} align="center" wrap="wrap">
{hours !== null ? (
<>
<Timer size={12} color={track.text3} />
<Text size="11.5px" c={track.muted}>
{hours} h handling
</Text>
</>
) : null}
{hours !== null && checkpoint?.note ? (
<Box
style={{
width: 3,
height: 3,
borderRadius: 999,
background: track.text3,
}}
/>
) : null}
{checkpoint?.note ? (
<Text size="11.5px" c={track.muted} style={{ flex: 1, minWidth: 0 }}>
{checkpoint.note}
</Text>
) : null}
</Group>
) : null}
{/* the station's work windows, inline */}
<Stack
gap={8}
p={14}
style={{
borderRadius: 12,
background: isCurrent ? "rgba(228,245,239,0.5)" : track.surface2,
border: `1px solid ${isCurrent ? "#B6E4D5" : track.borderSoft}`,
}}
>
{!isFirst ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
{!isLast ? (
<StationWorkControls
scheduleId={scheduleId}
yardId={station.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
</Stack>
</Stack>
</Group>
);
})}
</Stack>
);
}

View File

@@ -1,6 +1,5 @@
import {
Alert,
Badge,
Button,
Divider,
Group,
@@ -26,6 +25,8 @@ import { Freight } from "@edr/types";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import { Chip } from "./trackPrimitives";
import { DIRECTION_TONE, track as T } from "./trackTheme";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission as hasFreightPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
@@ -44,20 +45,15 @@ const fmtDate = (iso: string) => {
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const DIRECTION_COLORS: Record<string, string> = {
IMPORT: "blue",
EXPORT: "teal",
DOMESTIC: "violet",
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
const tone = DIRECTION_TONE[direction] ?? { bg: T.surface3, fg: T.muted };
return (
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
<Chip bg={tone.bg} fg={tone.fg}>
{DIRECTION_LABELS[direction] ?? direction}
</Badge>
</Chip>
);
}
@@ -72,15 +68,15 @@ function SectionLabel({
}) {
return (
<Group gap={8} align="center">
<ThemeIcon size={26} radius="md" variant="light" color="edr-green">
<ThemeIcon size={28} radius={8} variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Text fw={700} size="sm">
<Text fw={700} size="13.5px" c={T.text}>
{title}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{count}
</Badge>
<Chip bg={T.surface3} fg={T.text2}>
{String(count)}
</Chip>
</Group>
);
}
@@ -263,7 +259,7 @@ export function LogPassYardWorkModal({
opened={opened}
onClose={onClose}
size="xl"
radius="lg"
radius={18}
title={
<Group gap={8}>
{isFinal ? <Flag size={18} /> : <MapPin size={18} />}
@@ -271,9 +267,9 @@ export function LogPassYardWorkModal({
{isFinal ? "Arrival" : "Yard work"} {station?.label ?? ""}
</Text>
{logged ? (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
{isFinal ? "Arrived" : "Pass logged"}
</Badge>
<Chip bg={T.brandDim} fg={T.brand}>
{isFinal ? "ARRIVED" : "PASS LOGGED"}
</Chip>
) : null}
</Group>
}
@@ -316,7 +312,20 @@ export function LogPassYardWorkModal({
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
<Table
verticalSpacing={11}
highlightOnHover
styles={{
th: {
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
background: T.surface2,
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
@@ -331,12 +340,12 @@ export function LogPassYardWorkModal({
{arrivals.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
@@ -364,6 +373,7 @@ export function LogPassYardWorkModal({
>
<Button
size="compact-xs"
radius={8}
variant="light"
color="teal"
leftSection={<PackageCheck size={13} />}
@@ -417,7 +427,20 @@ export function LogPassYardWorkModal({
</Text>
) : null}
<Table.ScrollContainer minWidth={620}>
<Table verticalSpacing="xs" highlightOnHover>
<Table
verticalSpacing={11}
highlightOnHover
styles={{
th: {
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.7,
textTransform: "uppercase",
color: T.muted,
background: T.surface2,
},
}}
>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
@@ -433,18 +456,18 @@ export function LogPassYardWorkModal({
<Table.Tr key={row.id}>
<Table.Td>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
<Text size="12px" fw={600} style={{ fontFamily: T.mono }}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment ? (
<Badge size="xs" variant="light" color="grape">
<Chip bg={T.grapeDim} fg={T.grape}>
GOV
</Badge>
</Chip>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
<Text size="12.5px" c={T.text2}>{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
@@ -487,7 +510,8 @@ export function LogPassYardWorkModal({
>
<Button
size="compact-xs"
variant="light"
radius={8}
color="edr-green"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !loadingStarted || !row.canLoad}
loading={
@@ -509,6 +533,7 @@ export function LogPassYardWorkModal({
>
<Button
size="compact-xs"
radius={8}
variant="light"
color="red"
disabled={!canLeave || row.isGovernment}
@@ -554,26 +579,25 @@ export function LogPassYardWorkModal({
: ""}
</Text>
<Group gap="sm">
<Button variant="default" onClick={onClose}>
<Button variant="default" radius={9} onClick={onClose}>
Close
</Button>
{!logged ? (
<Tooltip
label="Start unloading first — arrival marks the remaining bookings arrived, so the unloading window must be open"
disabled={!(isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload))}
// Arrival comes BEFORE unloading: the train is marked arrived
// whenever it physically gets there, and the unloading window
// opens afterwards. Bookings then unload per booking inside the
// started window (the buttons above enforce that).
<Button
radius={9}
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
onClick={doLogPass}
>
<Button
color={isFinal ? "teal" : "edr-green"}
leftSection={isFinal ? <Flag size={15} /> : <MapPin size={15} />}
loading={recordCheckpoint.isPending}
disabled={isFinal && !unloadingStarted && arrivals.some((r) => r.canUnload)}
onClick={doLogPass}
>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
</Tooltip>
{isFinal
? `Mark arrived at ${station?.label ?? "destination"}`
: `Log pass at ${station?.label ?? "station"}`}
</Button>
) : null}
</Group>
</Group>

View File

@@ -1,13 +1,4 @@
import {
ActionIcon,
Badge,
Button,
Group,
Popover,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { ActionIcon, Box, Button, Group, Popover, Stack, Text, Tooltip } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation } from "@tanstack/react-query";
import { Pencil, PlayCircle, StopCircle } from "lucide-react";
@@ -18,6 +9,8 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
import { Chip, PhaseChip, phaseState } from "./trackPrimitives";
import { track } from "./trackTheme";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
@@ -28,7 +21,14 @@ const parseError = (error: unknown, fallback: string) => {
const fmtTime = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
return Number.isNaN(d.getTime())
? iso
: d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const fmtElapsed = (fromIso: string, toIso?: string | null) => {
@@ -71,9 +71,9 @@ function EditTimeButton({
<Popover.Target>
<Tooltip label={disabled ? disabledReason : `Correct the ${label} time`}>
<ActionIcon
size="xs"
variant="subtle"
color="gray"
size={26}
radius={7}
variant="default"
disabled={disabled}
onClick={() => setOpened((o) => !o)}
>
@@ -100,6 +100,7 @@ function EditTimeButton({
</Button>
<Button
size="compact-xs"
color="edr-green"
loading={saving}
disabled={!draft}
onClick={() => {
@@ -179,80 +180,84 @@ export function StationWorkControls({
);
};
const title = phase === "loading" ? "Loading" : "Unloading";
const started = Boolean(log?.startedAt);
const ended = Boolean(log?.endedAt);
const state = phaseState(log);
const started = state !== "idle";
const ended = state === "done";
const who = log?.endedByName ?? log?.startedByName;
return (
<Group gap="sm" wrap="wrap" align="center">
<Badge variant="light" color={ended ? "gray" : started ? "edr-green" : "yellow"} radius="sm">
{title}
{ended ? " done" : started ? " in progress" : " not started"}
</Badge>
<Group gap={10} wrap="wrap" align="center" style={{ width: "100%" }}>
<PhaseChip phase={phase} state={state} />
{!started ? (
<Tooltip
label={
canStart
? `Record the moment ${phase} work begins at this station`
: `You don't have permission to start ${phase}`
}
>
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<PlayCircle size={14} />}
disabled={!canStart}
loading={record.isPending}
onClick={() => doRecord("start")}
<>
<Box style={{ flex: 1, minWidth: 0 }} />
<Tooltip
label={
canStart
? `Record the moment ${phase} work begins at this station`
: `You don't have permission to start ${phase}`
}
>
Start {phase}
</Button>
</Tooltip>
<Button
size="compact-sm"
radius={8}
variant="default"
leftSection={<PlayCircle size={12} color={track.brand} />}
disabled={!canStart}
loading={record.isPending}
onClick={() => doRecord("start")}
styles={{ label: { color: track.brand, fontSize: 11.5 } }}
>
Start {phase}
</Button>
</Tooltip>
</>
) : (
<>
<Group gap={4} wrap="nowrap">
<Text size="xs" c="dimmed">
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"} (
{fmtElapsed(log!.startedAt!, log?.endedAt)})
</Text>
{log?.startedByName || log?.endedByName ? (
<Tooltip
label={[
log?.startedByName ? `Started by ${log.startedByName}` : null,
log?.endedByName ? `Ended by ${log.endedByName}` : null,
]
.filter(Boolean)
.join(" · ")}
>
<Badge size="xs" variant="light" color="gray" radius="sm">
{log?.endedByName ?? log?.startedByName}
</Badge>
</Tooltip>
) : null}
<Text size="11px" c={track.text2} style={{ fontFamily: track.mono }}>
{fmtTime(log!.startedAt!)} {ended ? fmtTime(log!.endedAt!) : "…"}
</Text>
<Chip bg={track.surface} fg={track.text2} border={track.border}>
{fmtElapsed(log!.startedAt!, log?.endedAt)}
</Chip>
{who ? (
<Tooltip
label={[
log?.startedByName ? `Started by ${log.startedByName}` : null,
log?.endedByName ? `Ended by ${log.endedByName}` : null,
]
.filter(Boolean)
.join(" · ")}
>
<Text size="11px" c={track.muted}>
{who}
</Text>
</Tooltip>
) : null}
<Box style={{ flex: 1, minWidth: 0 }} />
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}
disabled={!canStart}
disabledReason={`You don't have permission to edit the ${phase} start`}
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
onSave={(at) => doRecord("start", at)}
saving={record.isPending}
/>
{ended ? (
<EditTimeButton
label={`${phase} start`}
value={log!.startedAt!}
disabled={!canStart}
disabledReason={`You don't have permission to edit the ${phase} start`}
maxDate={log?.endedAt ? new Date(log.endedAt) : new Date()}
onSave={(at) => doRecord("start", at)}
label={`${phase} end`}
value={log!.endedAt!}
disabled={!canEnd}
disabledReason={`You don't have permission to edit the ${phase} end`}
minDate={new Date(log!.startedAt!)}
onSave={(at) => doRecord("end", at)}
saving={record.isPending}
/>
{ended ? (
<EditTimeButton
label={`${phase} end`}
value={log!.endedAt!}
disabled={!canEnd}
disabledReason={`You don't have permission to edit the ${phase} end`}
minDate={new Date(log!.startedAt!)}
onSave={(at) => doRecord("end", at)}
saving={record.isPending}
/>
) : null}
</Group>
{!ended ? (
) : (
<Tooltip
label={
canEnd
@@ -262,17 +267,21 @@ export function StationWorkControls({
>
<Button
size="compact-sm"
variant="light"
color="orange"
leftSection={<StopCircle size={14} />}
radius={8}
variant="default"
leftSection={<StopCircle size={12} color={track.amber} />}
disabled={!canEnd}
loading={record.isPending}
onClick={() => doRecord("end")}
styles={{
root: { background: track.amberDim, borderColor: track.amberBorder },
label: { color: track.amber, fontSize: 11.5 },
}}
>
End {phase}
</Button>
</Tooltip>
) : null}
)}
</>
)}
</Group>

View File

@@ -0,0 +1,205 @@
import { Box, Group, RingProgress, Stack, Text } from "@mantine/core";
import { ArrowRight, CircleDot, Flag, Navigation } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { statusMeta } from "@/components/trainScheduling/scheduleVisuals";
import { Chip } from "./trackPrimitives";
import { track } from "./trackTheme";
export interface TrackStatValue {
icon: LucideIcon;
label: string;
value: string;
}
/**
* Left-rail identity card: gradient cap (train number, progress ring, status,
* current station) over the route strip and the stat list.
*/
export function TrackStatusCard({
trainNumber,
direction,
status,
progressPct,
reached,
totalStations,
currentStation,
stateLine,
origin,
destination,
stats,
}: {
trainNumber?: string | null;
direction?: string | null;
status: string;
progressPct: number;
reached: number;
totalStations: number;
currentStation: string;
stateLine: string;
origin: string | null;
destination: string | null;
stats: TrackStatValue[];
}) {
return (
<Box
style={{
background: track.surface,
border: `1px solid ${track.border}`,
borderRadius: 16,
overflow: "hidden",
}}
>
<Stack gap={18} p="22px 22px 20px" style={{ background: track.capGradient }}>
<Group gap={12} align="center" wrap="nowrap">
<Box
style={{
width: 44,
height: 44,
borderRadius: 13,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.18)",
border: "1px solid rgba(255,255,255,0.36)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={21} />
</Box>
<Stack gap={4} style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} fz={19} c="white" lh={1.2} truncate>
{trainNumber ?? "Train tracking"}
</Text>
<Text
fz={9.5}
fw={700}
tt="uppercase"
style={{ letterSpacing: 1, color: "rgba(255,255,255,0.72)" }}
>
Train tracking
</Text>
</Stack>
{direction ? (
<Chip
bg="rgba(255,255,255,0.16)"
fg="#FFFFFF"
border="rgba(255,255,255,0.36)"
>
{direction}
</Chip>
) : null}
</Group>
<Group gap={18} align="center" wrap="nowrap">
<RingProgress
size={104}
thickness={9}
roundCaps
sections={[{ value: progressPct, color: "white" }]}
rootColor="rgba(255,255,255,0.24)"
label={
<Stack gap={1} align="center">
<Text fw={700} fz={23} lh={1} c="white">
{Math.round(progressPct)}%
</Text>
<Text
fz={8.5}
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.7, color: "rgba(255,255,255,0.78)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/>
<Stack gap={9} style={{ minWidth: 0, flex: 1 }}>
<Box
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "6px 12px",
borderRadius: 999,
background: "white",
width: "fit-content",
}}
>
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: statusMeta(status).dot,
}}
/>
<Text fz={10.5} fw={700} c={track.brandDark} style={{ letterSpacing: 0.6 }}>
{status}
</Text>
</Box>
<Text
fz={11.5}
fw={600}
style={{ letterSpacing: 0.4, color: "rgba(255,255,255,0.72)" }}
>
{stateLine}
</Text>
<Text fz={16} fw={700} c="white" truncate>
{currentStation}
</Text>
</Stack>
</Group>
</Stack>
<Group
gap={10}
px={20}
py={14}
wrap="nowrap"
align="center"
style={{
background: track.surface2,
borderBottom: `1px solid ${track.borderSoft}`,
}}
>
<CircleDot size={14} color={track.brand} style={{ flexShrink: 0 }} />
<Text size="12.5px" fw={600} c={track.text} truncate>
{origin ?? "—"}
</Text>
<Box style={{ flex: 1 }} />
<ArrowRight size={14} color={track.text3} style={{ flexShrink: 0 }} />
<Box style={{ flex: 1 }} />
<Text size="12.5px" fw={600} c={track.text} truncate>
{destination ?? "—"}
</Text>
<Flag size={13} color={track.muted} style={{ flexShrink: 0 }} />
</Group>
<Stack gap={0} px={20} pt={6} pb={14}>
{stats.map((s, i) => {
const Icon = s.icon;
return (
<Group
key={s.label}
gap={10}
py={11}
wrap="nowrap"
align="center"
style={i ? { borderTop: `1px solid ${track.borderSoft}` } : undefined}
>
<Icon size={15} color={track.muted} style={{ flexShrink: 0 }} />
<Text size="12.5px" c={track.text2} style={{ flex: 1, minWidth: 0 }}>
{s.label}
</Text>
<Text size="12.5px" fw={700} c={track.text} style={{ flexShrink: 0 }}>
{s.value}
</Text>
</Group>
);
})}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,149 @@
import { Box, Group, Stack, Text } from "@mantine/core";
import type { ReactNode } from "react";
import type { StationWorkPhaseLog } from "@/types/trainScheduling";
import { PHASE_TONE, track, type PhaseState } from "./trackTheme";
/** Small uppercase tag — the design's one chip shape, tinted per use. */
export function Chip({
children,
bg,
fg,
border,
}: {
children: ReactNode;
bg: string;
fg: string;
border?: string;
}) {
return (
<Box
component="span"
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "4px 9px",
borderRadius: 6,
background: bg,
border: border ? `1px solid ${border}` : undefined,
color: fg,
fontSize: 9.5,
fontWeight: 700,
letterSpacing: 0.6,
lineHeight: 1.4,
whiteSpace: "nowrap",
flexShrink: 0,
}}
>
{children}
</Box>
);
}
/** Card header: tinted icon chip + title + one-line hint, optional right slot. */
export function SectionHead({
icon,
title,
hint,
right,
}: {
icon: ReactNode;
title: string;
hint: string;
right?: ReactNode;
}) {
return (
<Group
gap={13}
align="center"
wrap="nowrap"
px={24}
py={18}
style={{ borderBottom: `1px solid ${track.borderSoft}` }}
>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: track.brandDim,
color: track.brand,
flexShrink: 0,
}}
>
{icon}
</Box>
<Stack gap={3} style={{ minWidth: 0, flex: 1 }}>
<Text fw={700} size="15px" c={track.text}>
{title}
</Text>
<Text size="12px" c={track.muted}>
{hint}
</Text>
</Stack>
{right}
</Group>
);
}
/** Which of the three window states a phase log is in. */
export function phaseState(log?: StationWorkPhaseLog | null): PhaseState {
if (log?.endedAt) return "done";
if (log?.startedAt) return "active";
return "idle";
}
export function phaseChipLabel(
phase: "loading" | "unloading",
state: PhaseState,
) {
const title = phase === "loading" ? "Loading" : "Unloading";
const suffix =
state === "done"
? "done"
: state === "active"
? "in progress"
: "not started";
return `${title} ${suffix}`;
}
export function PhaseChip({
phase,
state,
}: {
phase: "loading" | "unloading";
state: PhaseState;
}) {
const tone = PHASE_TONE[state];
return (
<Box
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "5px 10px",
borderRadius: 7,
background: tone.bg,
width: 150,
flexShrink: 0,
}}
>
<Box
style={{
width: 6,
height: 6,
borderRadius: 999,
background: tone.fg,
flexShrink: 0,
}}
/>
<Text size="10.5px" fw={700} c={tone.fg} style={{ lineHeight: 1.4 }}>
{phaseChipLabel(phase, state)}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,69 @@
/**
* Design tokens for the train-tracking surface, mirroring ui/scheule/track.pen.
*
* The rest of the scheduling pages key off `scheduleVisuals`/`freightBrand`;
* tracking is its own light "work surface" palette, so the tokens live here
* rather than widening the shared brand file. Brand green is darkened from the
* shared #1B9E7A to #0E8C68 so label text clears AA contrast on white.
*/
export const track = {
bg: "#F6F8FA",
surface: "#FFFFFF",
surface2: "#F4F7F9",
surface3: "#E9EEF3",
border: "#DCE4EC",
borderSoft: "#E8EDF2",
brand: "#0E8C68",
brandDark: "#0A6B50",
brandLight: "#12A87D",
brandDim: "#E4F5EF",
text: "#0F1D2B",
text2: "#48606F",
text3: "#9BAEBE",
muted: "#6A8296",
teal: "#0E8C82",
tealDim: "#DFF3F1",
blue: "#2563C9",
blueDim: "#E4EDFB",
amber: "#A66A08",
amberDim: "#FDF2DC",
amberBorder: "#E8C88C",
amberText: "#8A6420",
red: "#C43D3D",
redDim: "#FBE9E9",
grape: "#7C4BC4",
grapeDim: "#F0E7FB",
/** Status-cap wash on the left rail's identity card. */
capGradient:
"linear-gradient(115deg, #0A6B50 0%, #0E8C68 55%, #12A87D 100%)",
mono: "'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
} as const;
/** Checkpoint-kind chip colors, keyed by TrainCheckpointKind. */
export const KIND_TONE: Record<string, { bg: string; fg: string }> = {
DEPARTED: { bg: track.blueDim, fg: track.blue },
PASSED: { bg: track.brandDim, fg: track.brand },
ARRIVED: { bg: track.tealDim, fg: track.teal },
};
/** Loading/unloading window state chips. */
export const PHASE_TONE = {
done: { bg: track.surface3, fg: track.muted },
active: { bg: track.amberDim, fg: track.amber },
idle: { bg: track.surface2, fg: track.text3 },
} as const;
export type PhaseState = keyof typeof PHASE_TONE;
/** Trade-direction chips in the yard-work tables. */
export const DIRECTION_TONE: Record<string, { bg: string; fg: string }> = {
IMPORT: { bg: track.blueDim, fg: track.blue },
EXPORT: { bg: track.tealDim, fg: track.teal },
DOMESTIC: { bg: track.grapeDim, fg: track.grape },
};
export const cardStyle = {
background: track.surface,
border: `1px solid ${track.border}`,
borderRadius: 16,
} as const;

View File

@@ -4,48 +4,31 @@ import { useState } from "react";
import {
ArrowLeft,
CalendarClock,
CheckCircle2,
Clock,
ChevronRight,
FileText,
Flag,
ListChecks,
MapPin,
Navigation,
Package,
PackageCheck,
Pencil,
Train,
Route,
TrainFront,
} from "lucide-react";
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
import {
Alert,
Badge,
Box,
Button,
Group,
Loader,
Paper,
RingProgress,
Stack,
Text,
ThemeIcon,
Timeline,
Title,
} from "@mantine/core";
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
import { JourneySpine } from "@/components/trainScheduling/JourneySpine";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import { TrackStatusCard } from "@/components/trainScheduling/TrackStatusCard";
import { Chip, SectionHead } from "@/components/trainScheduling/trackPrimitives";
import { track as T } from "@/components/trainScheduling/trackTheme";
import type {
CheckpointHandlingTimes,
TrackStation,
TrainCheckpoint,
} from "@/types/trainScheduling";
import {
RouteCorridor,
StatusPill,
scheduleBrand,
} from "@/components/trainScheduling/scheduleVisuals";
import { freightBrand } from "@/theme/freight-brand";
import { useMutation, useQuery } from "@tanstack/react-query";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
@@ -80,23 +63,6 @@ const pickHandling = (
.filter(([, value]) => keepNulls || value !== null),
);
/**
* Total loading and unloading at a stop, the way the reports measure it:
* earliest start to latest finish, so a stop that only loaded or only unloaded
* still reads. Null when nothing was logged.
*/
const handlingHours = (cp: TrainCheckpoint): number | null => {
const times = [cp.unloadingStartedAt, cp.loadingStartedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
const ends = [cp.loadingCompletedAt, cp.unloadingCompletedAt]
.filter((v): v is string => Boolean(v))
.map((v) => new Date(v).getTime());
if (!times.length || !ends.length) return null;
const hours = (Math.max(...ends) - Math.min(...times)) / 3_600_000;
return Math.round(hours * 10) / 10;
};
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
@@ -117,85 +83,12 @@ function formatDateTime(iso?: string | null) {
});
}
/**
* A single fact in the hero's glass meta strip — icon chip + uppercase label +
* value, laid on the translucent panel over the gradient.
*/
function HeroStat({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<Group gap={10} wrap="nowrap" style={{ minWidth: 0 }}>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
color: "white",
flexShrink: 0,
}}
>
{icon}
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.72)" }}
>
{label}
</Text>
<Text size="sm" fw={700} c="white" truncate>
{value}
</Text>
</Stack>
</Group>
);
}
/** Section header — icon chip + title + one-line hint. Shared by the cards. */
function SectionHead({
icon,
title,
hint,
}: {
icon: React.ReactNode;
title: string;
hint: string;
}) {
return (
<Group gap="sm" align="center" wrap="nowrap">
<ThemeIcon size={36} radius="md" variant="light" color="edr-green">
{icon}
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text fw={800} size="sm">
{title}
</Text>
<Text size="xs" c="dimmed">
{hint}
</Text>
</Stack>
</Group>
);
}
const CARD_STYLE = {
borderColor: scheduleBrand.mutedBorder,
boxShadow: scheduleBrand.shadowSm,
} as const;
const CARD = {
background: T.surface,
border: `1px solid ${T.border}`,
borderRadius: 16,
overflow: "hidden" as const,
};
export default function TrainScheduleTrackPage() {
const { scheduleId } = useParams<{ scheduleId: string }>();
@@ -259,22 +152,18 @@ export default function TrainScheduleTrackPage() {
if (trackQuery.isLoading) {
return (
<PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
</PageContainer>
<Group justify="center" py="xl">
<Loader size="sm" color="edr-green" />
</Group>
);
}
const track = trackQuery.data;
if (!track || !scheduleId) {
return (
<PageContainer>
<Text c="dimmed" py="xl">
Tracking data not found.
</Text>
</PageContainer>
<Text c="dimmed" py="xl" px="lg">
Tracking data not found.
</Text>
);
}
@@ -386,27 +275,50 @@ export default function TrainScheduleTrackPage() {
const forgottenBoarders =
currentYard?.toLoad.filter((r) => !r.loadedAt) ?? [];
// The stop the operator acts on next — drives the left rail's action card.
const nextStation = canLog
? track.stations.find((s) => s.sequenceNo === track.currentSequenceNo + 1)
: undefined;
const nextIsFinal =
nextStation?.sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
return (
<PageContainer>
<Group justify="space-between" w="100%">
<Box style={{ background: T.bg, minHeight: "100%" }}>
{/* ── Top bar ── */}
<Group
gap={14}
px={36}
py={16}
wrap="nowrap"
align="center"
style={{ background: T.surface, borderBottom: `1px solid ${T.border}` }}
>
<Button
component={Link}
to={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
variant="subtle"
color="gray"
variant="default"
radius={9}
size="compact-sm"
leftSection={<ArrowLeft size={16} />}
w="fit-content"
leftSection={<ArrowLeft size={15} />}
>
Back to schedule
</Button>
<Group gap={8} align="center" wrap="nowrap" visibleFrom="sm">
<Text size="12.5px" c={T.muted}>
Train scheduling
</Text>
<ChevronRight size={13} color={T.text3} />
<Text size="12.5px" fw={600} c={T.text}>
{track.trainNumber ?? "Schedule"} · Tracking
</Text>
</Group>
<Box style={{ flex: 1 }} />
{inTransit || arrived ? (
<Button
variant="light"
color="edr-green"
radius="lg"
variant="default"
radius={9}
size="compact-sm"
leftSection={<FileText size={16} />}
leftSection={<FileText size={15} color={T.brand} />}
loading={intercityMarshalling.isPending}
onClick={() => void openIntercityMarshalling()}
>
@@ -415,423 +327,239 @@ export default function TrainScheduleTrackPage() {
) : null}
</Group>
{/* ── Hero: gradient wash, route + a bold progress ring woven together ── */}
<Paper
radius="lg"
p={0}
style={{ overflow: "hidden", boxShadow: scheduleBrand.shadow }}
{/* ── Two-column work surface ── */}
<Group
align="flex-start"
gap={28}
px={36}
pt={28}
pb={56}
wrap="wrap"
style={{ width: "100%" }}
>
<Box
style={{
background: scheduleBrand.heroGradient,
padding: "26px 28px",
position: "relative",
}}
>
{/* soft decorative glow, purely artistic */}
<Box
style={{
position: "absolute",
top: -80,
right: -60,
width: 260,
height: 260,
borderRadius: "50%",
background: "rgba(255,255,255,0.10)",
pointerEvents: "none",
}}
/>
<Group
justify="space-between"
align="flex-start"
wrap="wrap"
gap="xl"
style={{ position: "relative" }}
>
{/* left — identity + route */}
<Stack gap={14} style={{ minWidth: 260, flex: 1 }}>
<Group gap="md" align="center" wrap="nowrap">
<Box
style={{
width: 52,
height: 52,
borderRadius: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.26)",
color: "white",
flexShrink: 0,
}}
>
<Navigation size={26} />
</Box>
<Stack gap={6} style={{ minWidth: 0 }}>
<Group gap="sm" align="center" wrap="wrap">
<Title order={3} fw={800} c="white">
Train tracking
</Title>
{track.trainNumber ? (
<Badge
variant="white"
color="dark"
radius="sm"
styles={{ root: { color: freightBrand.primaryDark } }}
>
{track.trainNumber}
</Badge>
) : null}
{track.direction ? (
<Badge
variant="outline"
radius="sm"
styles={{
root: {
color: "white",
borderColor: "rgba(255,255,255,0.5)",
},
}}
>
{track.direction}
</Badge>
) : null}
</Group>
<Box maw={380}>
<RouteCorridor
origin={track.origin}
destination={track.destination}
variant="compact"
onDark
/>
</Box>
</Stack>
</Group>
<Group gap="sm">
<StatusPill status={track.status} size="md" />
<Box
px={12}
py={5}
style={{
borderRadius: 999,
background: "rgba(255,255,255,0.16)",
border: "1px solid rgba(255,255,255,0.24)",
}}
>
<Text size="xs" fw={700} c="white" style={{ letterSpacing: 0.2 }}>
{arrived
? "Journey complete"
: inTransit
? `En route · ${currentStation}`
: "Awaiting dispatch"}
</Text>
</Box>
</Group>
</Stack>
{/* right — progress ring, the artistic focal point */}
<RingProgress
size={132}
thickness={11}
roundCaps
sections={[{ value: clampedPct, color: "white" }]}
rootColor="rgba(255,255,255,0.22)"
label={
<Stack gap={0} align="center">
<Text fw={800} fz={26} lh={1} c="white">
{Math.round(clampedPct)}%
</Text>
<Text
size="10px"
fw={700}
tt="uppercase"
style={{ letterSpacing: 0.6, color: "rgba(255,255,255,0.8)" }}
>
{reached}/{totalStations} stops
</Text>
</Stack>
}
/>
</Group>
</Box>
{/* glass meta strip below the wash */}
<Group
justify="space-between"
wrap="wrap"
gap="lg"
px={28}
py="md"
style={{
background: freightBrand.primaryDark,
borderTop: "1px solid rgba(255,255,255,0.12)",
}}
>
<HeroStat icon={<MapPin size={16} />} label="Current" value={currentStation} />
<HeroStat
icon={<CalendarClock size={16} />}
label="Departed"
value={formatDateTime(track.actualDepartureAt)}
/>
<HeroStat
icon={<Flag size={16} />}
label="Arrived"
value={formatDateTime(track.actualArrivalAt)}
/>
<HeroStat
icon={<Train size={16} />}
label="Stations"
value={`${reached} of ${totalStations}`}
/>
</Group>
</Paper>
{/* ── Route corridor ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Stack gap="md">
<SectionHead
icon={<Navigation size={17} />}
title="Route corridor"
hint={
canLog
? "Log the train passing each station; the final station marks arrival."
: arrived
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."
{/* left rail */}
<Stack gap={16} style={{ width: 352, flexShrink: 0, flexGrow: 1, maxWidth: "100%" }}>
<TrackStatusCard
trainNumber={track.trainNumber}
direction={track.direction}
status={track.status}
progressPct={clampedPct}
reached={reached}
totalStations={totalStations}
currentStation={currentStation}
stateLine={
arrived
? "Journey complete"
: inTransit
? "En route"
: "Awaiting dispatch"
}
/>
<RouteCorridorTrack
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
origin={track.origin}
destination={track.destination}
stats={[
{
icon: CalendarClock,
label: "Departed",
value: formatDateTime(track.actualDepartureAt),
},
{
icon: Flag,
label: "Arrived",
value: formatDateTime(track.actualArrivalAt),
},
{ icon: MapPin, label: "Current station", value: currentStation },
{
icon: TrainFront,
label: "Stations reached",
value: `${reached} of ${totalStations}`,
},
]}
/>
{/* Cargo the operator forgot: boarders at the CURRENT station stay
loadable until the next pass is logged. */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<PackageCheck size={16} />}
title={`${forgottenBoarders.length} booking${
forgottenBoarders.length === 1 ? "" : "s"
} at ${currentStationObj.label} not loaded yet`}
>
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
<Text size="sm">
The train is at {currentStationObj.label} cargo boarding here can
still be loaded before the next station is logged.
{/* next action */}
{nextStation ? (
<Stack gap={14} p={18} style={CARD}>
<Group gap={9} align="center" wrap="nowrap">
<Text
size="9.5px"
fw={700}
tt="uppercase"
c={T.muted}
style={{ letterSpacing: 1 }}
>
Next action
</Text>
<Box style={{ flex: 1 }} />
<Chip bg={T.surface3} fg={T.text2}>
{`STOP ${reached + 1} OF ${totalStations}`}
</Chip>
</Group>
<Text size="15px" fw={700} c={T.text} lh={1.3}>
{nextIsFinal
? `Mark arrived at ${nextStation.label}`
: `Log pass at ${nextStation.label}`}
</Text>
<Text size="12px" c={T.text2} lh={1.45}>
{nextIsFinal
? "Marks the train arrived: remaining bookings arrive, assets are freed."
: "Logging the pass marks arriving bookings and unlocks loading for cargo boarding here."}
</Text>
<Group gap={8} wrap="nowrap">
<Button
color="edr-green"
radius={9}
size="compact-sm"
variant="light"
color="yellow"
style={{ flex: 1 }}
leftSection={nextIsFinal ? <Flag size={14} /> : <MapPin size={14} />}
loading={
recordCheckpoint.isPending &&
recordCheckpoint.variables?.payload.sequenceNo ===
nextStation.sequenceNo
}
onClick={() => handleLog(nextStation.sequenceNo)}
>
{nextIsFinal ? "Mark arrived" : "Log pass"}
</Button>
<Button
variant="default"
radius={9}
size="compact-sm"
leftSection={<Package size={14} />}
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
station: nextStation,
isFinal: Boolean(nextIsFinal),
alreadyLogged: false,
})
}
>
Open yard work
Yard work
</Button>
</Group>
</Alert>
</Stack>
) : null}
{/* forgotten boarders */}
{currentStationObj && forgottenBoarders.length > 0 ? (
<Stack
gap={11}
p={16}
style={{
background: T.amberDim,
border: `1px solid ${T.amberBorder}`,
borderRadius: 14,
}}
>
<Group gap={9} align="center" wrap="nowrap">
<PackageCheck size={16} color={T.amber} style={{ flexShrink: 0 }} />
<Text size="13px" fw={700} c={T.amber}>
{forgottenBoarders.length} booking
{forgottenBoarders.length === 1 ? "" : "s"} not loaded
</Text>
</Group>
<Text size="11.5px" c={T.amberText} lh={1.45}>
The train is at {currentStationObj.label} cargo boarding here can still
be loaded before the next station is logged.
</Text>
<Button
size="compact-sm"
radius={9}
variant="white"
w="fit-content"
styles={{
root: { borderColor: T.amberBorder, border: `1px solid ${T.amberBorder}` },
label: { color: T.amber, fontWeight: 700, fontSize: 12.5 },
}}
onClick={() =>
setYardModal({
station: currentStationObj,
isFinal:
currentStationObj.sequenceNo ===
track.stations[totalStations - 1]?.sequenceNo,
alreadyLogged: true,
})
}
>
Open yard work
</Button>
</Stack>
) : null}
</Stack>
</Paper>
{/* ── Loading / unloading windows per station ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<SectionHead
icon={<Clock size={17} />}
title="Loading & unloading windows"
hint="Start and end each station's work window — times, duration and who recorded them"
/>
<Stack gap="sm" mt="md">
{track.stations.map((s, i) => {
const isFirst = i === 0;
const isLast = i === track.stations.length - 1;
const workLog = track.stationWorkLogs?.[s.yardId];
return (
<Paper
key={s.yardId}
withBorder
radius="md"
p="sm"
style={{
background:
track.currentSequenceNo === s.sequenceNo
? "var(--mantine-color-green-0)"
: undefined,
}}
>
<Group gap={10} mb={6} wrap="nowrap">
<ThemeIcon size={30} radius="xl" variant="light" color="edr-green">
{isLast ? <Flag size={15} /> : <MapPin size={15} />}
</ThemeIcon>
<Text fw={700} size="sm">
{s.label}
</Text>
{isFirst ? (
<Badge size="xs" variant="light" color="edr-green">
origin
</Badge>
) : null}
{isLast ? (
<Badge size="xs" variant="light" color="gray">
destination
</Badge>
) : null}
{track.currentSequenceNo === s.sequenceNo ? (
<Badge size="xs" variant="filled" color="edr-green">
train here
</Badge>
) : null}
</Group>
<Stack gap={6} pl={40}>
{!isLast ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="loading"
log={workLog?.loading}
/>
) : null}
{!isFirst ? (
<StationWorkControls
scheduleId={scheduleId ?? ""}
yardId={s.yardId}
phase="unloading"
log={workLog?.unloading}
/>
) : null}
</Stack>
</Paper>
);
})}
</Stack>
</Paper>
{/* ── Checkpoint log ── */}
<Paper radius="lg" p="lg" withBorder style={CARD_STYLE}>
<Group justify="space-between" wrap="nowrap" mb="md">
<SectionHead
icon={<CheckCircle2 size={17} />}
title="Checkpoint log"
hint={`${track.checkpoints.length} event${
track.checkpoints.length === 1 ? "" : "s"
} recorded`}
/>
</Group>
{track.checkpoints.length === 0 ? (
<Stack
align="center"
gap="xs"
py={40}
style={{
borderRadius: 14,
border: `1px dashed ${scheduleBrand.mutedBorder}`,
background: scheduleBrand.softSurface,
}}
>
<ThemeIcon size={48} radius="xl" variant="light" color="edr-green">
<MapPin size={22} />
</ThemeIcon>
<Text size="sm" fw={700} c="gray.7">
No checkpoints yet
</Text>
<Text size="xs" c="dimmed" ta="center" maw={320}>
Each station the train passes will be logged here with its
timestamp.
</Text>
</Stack>
) : (
<Timeline
active={track.checkpoints.length}
bulletSize={24}
lineWidth={2}
color="edr-green"
>
{track.checkpoints.map((cp) => (
<Timeline.Item
key={cp.id}
bullet={
cp.kind === "ARRIVED" ? (
<CheckCircle2 size={13} />
) : (
<MapPin size={12} />
)
}
title={
<Group gap="sm" justify="space-between" wrap="nowrap">
<Group gap="sm">
<Text fw={700} size="sm">
{cp.label ?? `Station ${cp.sequenceNo}`}
{/* main column */}
<Stack gap={20} style={{ flex: 1, minWidth: 520 }}>
<Box style={CARD}>
<SectionHead
icon={<Route size={17} />}
title="Journey & station work"
hint={
canLog
? "Every stop with its pass time and loading windows — the final station marks arrival."
: arrived
? "This train has arrived at its destination."
: "Tracking becomes available once the train is dispatched."
}
right={
<Group gap={12} wrap="nowrap" visibleFrom="md">
{[
[T.brand, "Passed"],
[T.amber, "Active"],
[T.text3, "Upcoming"],
].map(([color, label]) => (
<Group key={label} gap={5} wrap="nowrap">
<Box
style={{
width: 7,
height: 7,
borderRadius: 999,
background: color,
}}
/>
<Text size="11px" fw={600} c={T.muted}>
{label}
</Text>
<Badge
size="xs"
radius="sm"
variant="light"
color={
cp.kind === "ARRIVED"
? "teal"
: cp.kind === "DEPARTED"
? "blue"
: "edr-green"
}
>
{cp.kind}
</Badge>
</Group>
{canEdit ? (
<Button
size="compact-xs"
radius="md"
variant="light"
color="gray"
leftSection={<Pencil size={12} />}
onClick={() => setEditModal(cp)}
>
Edit
</Button>
) : null}
</Group>
}
>
<Text size="xs" c="dimmed">
{formatDateTime(cp.occurredAt)}
</Text>
{handlingHours(cp) !== null ? (
<Text size="xs" c="dimmed" mt={2}>
Loading + unloading {handlingHours(cp)} h
</Text>
) : null}
{cp.note ? (
<Text size="xs" mt={2}>
{cp.note}
</Text>
) : null}
</Timeline.Item>
))}
</Timeline>
)}
</Paper>
))}
</Group>
}
/>
<JourneySpine
scheduleId={scheduleId}
stations={track.stations}
currentSequenceNo={track.currentSequenceNo}
checkpoints={track.checkpoints}
stationWorkLogs={track.stationWorkLogs}
canLog={canLog}
loggingSeq={
recordCheckpoint.isPending
? recordCheckpoint.variables?.payload.sequenceNo
: null
}
onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/>
</Box>
<Box style={CARD}>
<SectionHead
icon={<ListChecks size={16} />}
title="Checkpoint log"
hint="Raw event trail — every logged pass with its correction history"
right={
<Chip bg={T.surface3} fg={T.text2}>
{`${track.checkpoints.length} EVENT${
track.checkpoints.length === 1 ? "" : "S"
}`}
</Chip>
}
/>
<CheckpointLogTable
checkpoints={track.checkpoints}
onEdit={canEdit ? setEditModal : undefined}
/>
</Box>
</Stack>
</Group>
<CheckpointTimeModal
opened={logModal !== null}
@@ -875,6 +603,6 @@ export default function TrainScheduleTrackPage() {
isFinal={yardModal?.isFinal ?? false}
alreadyLogged={yardModal?.alreadyLogged ?? false}
/>
</PageContainer>
</Box>
);
}

View File

@@ -500,15 +500,10 @@ export default function TrainScheduleV2DetailPage() {
: undefined;
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
const originLoadingEnded = Boolean(originLoadingLog?.endedAt);
const dispatchBoardersKept = pendingOriginBoarders.some(
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
);
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
// A train never departs mid-loading: once the window opened (or cargo is to
// board), it must be ENDED before dispatch — same gate the server enforces.
const dispatchNeedsLoadingEnd =
(dispatchBoardersKept || originLoadingStarted) && !originLoadingEnded;
const dispatchBlockedByLoading = dispatchNeedsLoadingStart || dispatchNeedsLoadingEnd;
// Dispatch requires the origin's loading window to be COMPLETE (started AND
// ended): not started → disabled, in progress → disabled, ended → active.
// Same gate the server enforces.
const dispatchBlockedByLoading = !originLoadingEnded;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -967,15 +962,11 @@ export default function TrainScheduleV2DetailPage() {
phase="loading"
log={originLoadingLog}
/>
{dispatchNeedsLoadingStart ? (
{dispatchBlockedByLoading ? (
<Text size="xs" c="dimmed">
Start loading before dispatching the ticked bookings are marked
loaded at dispatch, which needs an open loading window.
</Text>
) : dispatchNeedsLoadingEnd ? (
<Text size="xs" c="dimmed">
End the loading window before dispatching a train never departs
mid-loading.
{originLoadingStarted
? "End the loading window before dispatching — a train never departs mid-loading."
: "Start and end the loading window before dispatching — dispatch needs a completed loading window."}
</Text>
) : null}
</Stack>
@@ -1711,9 +1702,9 @@ export default function TrainScheduleV2DetailPage() {
</Button>
<Tooltip
label={
dispatchNeedsLoadingStart
? "Start loading at the origin station first — dispatch marks the ticked bookings loaded"
: "End the loading window at the origin station first — a train never departs mid-loading"
originLoadingStarted
? "End the loading window at the origin station first — a train never departs mid-loading"
: "Start and end the loading window at the origin station first — dispatch needs a completed loading window"
}
disabled={!dispatchBlockedByLoading}
>