mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -116,6 +116,28 @@ export class OverviewTonnagePointDto {
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
/** One bucket × series cell of a two-dimensional breakdown. */
|
||||
export class OverviewMatrixCellDto {
|
||||
@ApiProperty({ example: 'Flat wagon' }) group!: string;
|
||||
@ApiProperty({ example: 'AVAILABLE' }) series!: string;
|
||||
@ApiProperty() count!: number;
|
||||
}
|
||||
|
||||
export class OverviewTrainLoadDto {
|
||||
@ApiProperty() scheduleId!: string;
|
||||
@ApiProperty({ example: '8001' }) trainNumber!: string;
|
||||
@ApiProperty({ example: '2026-08-13' }) date!: string;
|
||||
@ApiProperty({ example: 'EXPORT' }) direction!: string;
|
||||
@ApiProperty() wagonsAllocated!: number;
|
||||
@ApiProperty() wagonsTotal!: number;
|
||||
@ApiProperty() tons!: number;
|
||||
}
|
||||
|
||||
export class OverviewTurnaroundDto {
|
||||
@ApiProperty({ example: '8001' }) trainSet!: string;
|
||||
@ApiProperty({ example: 26.5 }) hours!: number;
|
||||
}
|
||||
|
||||
export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: OverviewOperationsKpisDto })
|
||||
kpis!: OverviewOperationsKpisDto;
|
||||
@@ -150,6 +172,64 @@ export class OverviewOperationsTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
cargoStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
bookingsByPort!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
bookingStatusByPort!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewTrainLoadDto] })
|
||||
trainLoads!: OverviewTrainLoadDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewFleetTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
wagonStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
wagonStatusByType!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
wagonStatusByYard!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
locomotiveStatusBreakdown!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
locomotivesByYard!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
locomotivesByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ nullable: true, example: 26.5 })
|
||||
avgTurnaroundHours!: number | null;
|
||||
|
||||
@ApiProperty({ type: [OverviewTurnaroundDto] })
|
||||
turnaroundByTrain!: OverviewTurnaroundDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
export class OverviewClearanceTabDto {
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
bookingDocumentsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
contractDocumentsByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewStatusCountDto] })
|
||||
invoicesByStatus!: OverviewStatusCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
invoicesByType!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
handoversByMile!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
@@ -167,6 +247,9 @@ export class OverviewCustomersTabDto {
|
||||
@ApiProperty({ type: [OverviewLabelCountDto] })
|
||||
topCustomersByBookings!: OverviewLabelCountDto[];
|
||||
|
||||
@ApiProperty({ type: [OverviewMatrixCellDto] })
|
||||
profilesByTypeStatus!: OverviewMatrixCellDto[];
|
||||
|
||||
@ApiProperty()
|
||||
generatedAt!: string;
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewClearanceTabDto,
|
||||
OverviewContractsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewFleetTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
@@ -106,6 +108,22 @@ export class OverviewController {
|
||||
return this.overviewService.getOperationsTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('fleet')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Wagon and locomotive fleet detail, plus turnaround' })
|
||||
@ApiOkResponse({ type: OverviewFleetTabDto })
|
||||
getFleetTab(@Query() query: OverviewQueryDto): Promise<OverviewFleetTabDto> {
|
||||
return this.overviewService.getFleetTab(query.range ?? '30d');
|
||||
}
|
||||
|
||||
@Get('clearance')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Document review and invoice queues' })
|
||||
@ApiOkResponse({ type: OverviewClearanceTabDto })
|
||||
getClearanceTab(): Promise<OverviewClearanceTabDto> {
|
||||
return this.overviewService.getClearanceTab();
|
||||
}
|
||||
|
||||
@Get('customers')
|
||||
@BookingStaff(FREIGHT_PERMS.overview.view)
|
||||
@ApiOperation({ summary: 'Customers tab metrics and charts' })
|
||||
|
||||
@@ -58,6 +58,21 @@ export type OverviewRecentBookingRow = {
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
/** One cell of a two-dimensional breakdown (bucket × stacked series). */
|
||||
export type MatrixCell = { group: string; series: string; count: number };
|
||||
|
||||
export type TurnaroundRow = { trainSet: string; hours: number };
|
||||
|
||||
export type TrainLoadRow = {
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
date: string;
|
||||
direction: string;
|
||||
wagonsAllocated: number;
|
||||
wagonsTotal: number;
|
||||
tons: number;
|
||||
};
|
||||
|
||||
export type OverviewContractKpisRow = {
|
||||
total: number;
|
||||
totalActive: number;
|
||||
@@ -1261,4 +1276,334 @@ export class OverviewRepository {
|
||||
createdAt: row.createdAt,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fleet / operations detail. These read tables that have no repository
|
||||
// injected here (locomotives, train_set_wagons, document reviews, …), so they
|
||||
// go through the shared entity manager with plain SQL instead of adding six
|
||||
// more constructor arguments for one query each.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private get sql() {
|
||||
return this.wagonRepository.manager;
|
||||
}
|
||||
|
||||
private async matrix(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<MatrixCell[]> {
|
||||
const rows = await this.sql.query<
|
||||
{ group: string; series: string; count: string }[]
|
||||
>(statement, params);
|
||||
return rows.map((row) => ({
|
||||
group: row.group,
|
||||
series: row.series,
|
||||
count: Number(row.count),
|
||||
}));
|
||||
}
|
||||
|
||||
private async labelCounts(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ label: string; count: number }[]> {
|
||||
const rows = await this.sql.query<{ label: string; count: string }[]>(
|
||||
statement,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({ label: row.label, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
private async statusCounts(
|
||||
statement: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<{ status: string; count: number }[]> {
|
||||
const rows = await this.sql.query<{ status: string; count: string }[]>(
|
||||
statement,
|
||||
params,
|
||||
);
|
||||
return rows.map((row) => ({ status: row.status, count: Number(row.count) }));
|
||||
}
|
||||
|
||||
/** Wagon lifecycle state crossed with wagon type — "how many flat wagons are detained". */
|
||||
getWagonStatusByType(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(wt.name, 'Unknown') AS "group",
|
||||
w.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.wagons w
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
/** Same lifecycle state, per yard the wagon currently sits in. */
|
||||
getWagonStatusByYard(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT y.label AS "group",
|
||||
w.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.wagons w
|
||||
INNER JOIN freight.yards y ON y.id = w.current_yard_id
|
||||
WHERE w.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
getLocomotiveStatusBreakdown(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.locomotives
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/** Locomotives per station, split by status — the OCC's first question. */
|
||||
getLocomotivesByYard(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(y.label, 'Unassigned') AS "group",
|
||||
l.status AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.locomotives l
|
||||
LEFT JOIN freight.yards y ON y.id = l.current_yard_id
|
||||
WHERE l.deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
getLocomotivesByType(): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(`
|
||||
SELECT COALESCE(locomotive_type, 'Unknown') AS label,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.locomotives
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Departure-to-departure gap per train set over the range. Only consecutive
|
||||
* *actual* departures count — a schedule that never left says nothing about
|
||||
* how fast the set turned around.
|
||||
*/
|
||||
async getTrainTurnaround(
|
||||
days: number,
|
||||
limit: number,
|
||||
): Promise<{ avgHours: number | null; rows: TurnaroundRow[] }> {
|
||||
const rows = await this.sql.query<
|
||||
{ trainSet: string; hours: string }[]
|
||||
>(
|
||||
`
|
||||
WITH departures AS (
|
||||
SELECT s.train_set_id,
|
||||
s.actual_departure_at,
|
||||
LAG(s.actual_departure_at) OVER (
|
||||
PARTITION BY s.train_set_id ORDER BY s.actual_departure_at
|
||||
) AS previous_departure
|
||||
FROM freight.train_schedules s
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.actual_departure_at IS NOT NULL
|
||||
AND s.actual_departure_at >= NOW() - make_interval(days => $1::int)
|
||||
)
|
||||
SELECT COALESCE(t.train_number, t.code, 'Train set') AS "trainSet",
|
||||
ROUND(
|
||||
AVG(
|
||||
EXTRACT(EPOCH FROM (d.actual_departure_at - d.previous_departure)) / 3600
|
||||
)::numeric,
|
||||
1
|
||||
) AS hours
|
||||
FROM departures d
|
||||
LEFT JOIN freight.train_sets ts ON ts.id = d.train_set_id
|
||||
LEFT JOIN freight.trains t ON t.id = ts.train_id
|
||||
WHERE d.previous_departure IS NOT NULL
|
||||
GROUP BY 1
|
||||
ORDER BY hours ASC
|
||||
LIMIT $2::int
|
||||
`,
|
||||
[days, limit],
|
||||
);
|
||||
|
||||
const mapped = rows.map((row) => ({
|
||||
trainSet: row.trainSet,
|
||||
hours: Number(row.hours),
|
||||
}));
|
||||
const avgHours = mapped.length
|
||||
? Number(
|
||||
(
|
||||
mapped.reduce((sum, row) => sum + row.hours, 0) / mapped.length
|
||||
).toFixed(1),
|
||||
)
|
||||
: null;
|
||||
|
||||
return { avgHours, rows: mapped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings per port yard. Import cargo enters at its origin yard, export
|
||||
* cargo leaves from its destination yard — anything else is counted at origin.
|
||||
*/
|
||||
getBookingsByPort(days: number): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(
|
||||
`
|
||||
SELECT y.label AS label, COUNT(*)::int AS count
|
||||
FROM freight.bookings b
|
||||
INNER JOIN freight.yards y
|
||||
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
|
||||
`,
|
||||
[days],
|
||||
);
|
||||
}
|
||||
|
||||
getBookingStatusByPort(days: number): Promise<MatrixCell[]> {
|
||||
return this.matrix(
|
||||
`
|
||||
SELECT y.label AS "group", b.status AS series, COUNT(*)::int AS count
|
||||
FROM freight.bookings b
|
||||
INNER JOIN freight.yards y
|
||||
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
|
||||
`,
|
||||
[days],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon fill and tonnage per scheduled train. Slots come from the train set's
|
||||
* wagon list, the load from confirmed booking allocations against those slots.
|
||||
*/
|
||||
async getTrainLoads(limit: number): Promise<TrainLoadRow[]> {
|
||||
const rows = await this.sql.query<
|
||||
{
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
date: string;
|
||||
direction: string;
|
||||
wagonsTotal: string;
|
||||
wagonsAllocated: string;
|
||||
tons: string;
|
||||
}[]
|
||||
>(
|
||||
`
|
||||
SELECT s.id AS "scheduleId",
|
||||
COALESCE(s.train_number, s.reference, '—') AS "trainNumber",
|
||||
to_char(s.scheduled_departure_date, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(s.direction, 'DOMESTIC') AS direction,
|
||||
COALESCE(slots.total, 0)::int AS "wagonsTotal",
|
||||
COALESCE(load.wagons, 0)::int AS "wagonsAllocated",
|
||||
COALESCE(load.tons, 0)::float AS tons
|
||||
FROM freight.train_schedules s
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(*)::int AS total
|
||||
FROM freight.train_set_wagons tsw
|
||||
WHERE tsw.train_set_id = s.train_set_id
|
||||
AND tsw.deleted_at IS NULL
|
||||
) slots ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT COUNT(DISTINCT wba.train_set_wagon_id)::int AS wagons,
|
||||
COALESCE(SUM(wba.allocated_weight_tons), 0) AS tons
|
||||
FROM freight.wagon_booking_allocations wba
|
||||
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
|
||||
WHERE tsw.train_set_id = s.train_set_id
|
||||
AND wba.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
) load ON TRUE
|
||||
WHERE s.deleted_at IS NULL
|
||||
AND s.status <> 'DRAFT'
|
||||
ORDER BY s.scheduled_departure_date DESC
|
||||
LIMIT $1::int
|
||||
`,
|
||||
[limit],
|
||||
);
|
||||
|
||||
return rows.map((row) => ({
|
||||
scheduleId: row.scheduleId,
|
||||
trainNumber: row.trainNumber,
|
||||
date: row.date,
|
||||
direction: row.direction,
|
||||
wagonsTotal: Number(row.wagonsTotal),
|
||||
wagonsAllocated: Number(row.wagonsAllocated),
|
||||
tons: Number(row.tons),
|
||||
}));
|
||||
}
|
||||
|
||||
getBookingDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.booking_document_review
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getContractDocumentsByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.contract_document_review
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getInvoicesByStatus(): Promise<{ status: string; count: number }[]> {
|
||||
return this.statusCounts(`
|
||||
SELECT status, COUNT(*)::int AS count
|
||||
FROM freight.invoices
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY status
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
getInvoicesByType(): Promise<{ label: string; count: number }[]> {
|
||||
return this.labelCounts(`
|
||||
SELECT COALESCE(type, 'Other') AS label, COUNT(*)::int AS count
|
||||
FROM freight.invoices
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1
|
||||
ORDER BY count DESC
|
||||
`);
|
||||
}
|
||||
|
||||
/** Handover papers per mile, split into signed and awaiting signature. */
|
||||
getHandoversByMile(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT COALESCE(mile_type, 'Unknown') AS "group",
|
||||
CASE WHEN signed_at IS NULL THEN 'PENDING' ELSE 'SIGNED' END AS series,
|
||||
COUNT(*)::int AS count
|
||||
FROM freight.booking_handovers
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
|
||||
/** Company profiles per type × status — active, pending and suspended per trade role. */
|
||||
getProfilesByTypeStatus(): Promise<MatrixCell[]> {
|
||||
return this.matrix(`
|
||||
SELECT type AS "group", status AS series, COUNT(*)::int AS count
|
||||
FROM freight.company_profiles
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import type { OverviewResponseDto } from './dto/overview-response.dto';
|
||||
import type {
|
||||
OverviewBillingTabDto,
|
||||
OverviewBookingsTabDto,
|
||||
OverviewClearanceTabDto,
|
||||
OverviewContractsTabDto,
|
||||
OverviewCustomersTabDto,
|
||||
OverviewFleetTabDto,
|
||||
OverviewOperationsTabDto,
|
||||
OverviewStaffTabDto,
|
||||
} from './dto/overview-tab-response.dto';
|
||||
@@ -237,6 +239,9 @@ export class OverviewService {
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
bookingsByPort,
|
||||
bookingStatusByPort,
|
||||
trainLoads,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getOperationsKpis(),
|
||||
this.overviewRepository.getDepartureTrend(days),
|
||||
@@ -249,6 +254,9 @@ export class OverviewService {
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getContainerStatusBreakdown(),
|
||||
this.overviewRepository.getCargoStatusBreakdown(),
|
||||
this.overviewRepository.getBookingsByPort(days),
|
||||
this.overviewRepository.getBookingStatusByPort(days),
|
||||
this.overviewRepository.getTrainLoads(8),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -263,6 +271,76 @@ export class OverviewService {
|
||||
wagonStatusBreakdown,
|
||||
containerStatusBreakdown,
|
||||
cargoStatusBreakdown,
|
||||
bookingsByPort,
|
||||
bookingStatusByPort,
|
||||
trainLoads,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rolling stock in depth: wagons and locomotives crossed with type and yard,
|
||||
* plus how fast each train set turns around. Feeds the operations and control
|
||||
* centre dashboards.
|
||||
*/
|
||||
async getFleetTab(
|
||||
range: OverviewRangeQuery = '30d',
|
||||
): Promise<OverviewFleetTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [
|
||||
wagonStatusBreakdown,
|
||||
wagonStatusByType,
|
||||
wagonStatusByYard,
|
||||
locomotiveStatusBreakdown,
|
||||
locomotivesByYard,
|
||||
locomotivesByType,
|
||||
turnaround,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getWagonStatusBreakdown(),
|
||||
this.overviewRepository.getWagonStatusByType(),
|
||||
this.overviewRepository.getWagonStatusByYard(),
|
||||
this.overviewRepository.getLocomotiveStatusBreakdown(),
|
||||
this.overviewRepository.getLocomotivesByYard(),
|
||||
this.overviewRepository.getLocomotivesByType(),
|
||||
this.overviewRepository.getTrainTurnaround(days, 8),
|
||||
]);
|
||||
|
||||
return {
|
||||
wagonStatusBreakdown,
|
||||
wagonStatusByType,
|
||||
wagonStatusByYard,
|
||||
locomotiveStatusBreakdown,
|
||||
locomotivesByYard,
|
||||
locomotivesByType,
|
||||
avgTurnaroundHours: turnaround.avgHours,
|
||||
turnaroundByTrain: turnaround.rows,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Document and invoice queues — the GL desks' and marketing's work in progress. */
|
||||
async getClearanceTab(): Promise<OverviewClearanceTabDto> {
|
||||
const [
|
||||
bookingDocumentsByStatus,
|
||||
contractDocumentsByStatus,
|
||||
invoicesByStatus,
|
||||
invoicesByType,
|
||||
handoversByMile,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getBookingDocumentsByStatus(),
|
||||
this.overviewRepository.getContractDocumentsByStatus(),
|
||||
this.overviewRepository.getInvoicesByStatus(),
|
||||
this.overviewRepository.getInvoicesByType(),
|
||||
this.overviewRepository.getHandoversByMile(),
|
||||
]);
|
||||
|
||||
return {
|
||||
bookingDocumentsByStatus,
|
||||
contractDocumentsByStatus,
|
||||
invoicesByStatus,
|
||||
invoicesByType,
|
||||
handoversByMile,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -273,12 +351,18 @@ export class OverviewService {
|
||||
): Promise<OverviewCustomersTabDto> {
|
||||
const days = OVERVIEW_RANGE_DAYS[range];
|
||||
|
||||
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
|
||||
await Promise.all([
|
||||
const [
|
||||
kpis,
|
||||
customerGrowthTrend,
|
||||
customersByType,
|
||||
topCustomersByBookings,
|
||||
profilesByTypeStatus,
|
||||
] = await Promise.all([
|
||||
this.overviewRepository.getCustomerKpis(),
|
||||
this.overviewRepository.getCustomerGrowthTrend(days),
|
||||
this.overviewRepository.getCustomersByType(),
|
||||
this.overviewRepository.getTopCustomersByBookings(8, dirs),
|
||||
this.overviewRepository.getProfilesByTypeStatus(),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -286,6 +370,7 @@ export class OverviewService {
|
||||
customerGrowthTrend,
|
||||
customersByType,
|
||||
topCustomersByBookings,
|
||||
profilesByTypeStatus,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* The GL desks (Ethiopia / Djibouti) work cargo through clearance: containers
|
||||
* and cargo state first, the trains carrying them second, and the bookings
|
||||
* waiting on a human third. Clearance-document counts are not aggregated by
|
||||
* the overview API yet, so this stops at cargo state.
|
||||
*/
|
||||
export function ClearanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const handovers = pivotMatrix(clearance.data?.handoversByMile);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Work queue first: it is what a GL desk acts on, and it is the band
|
||||
that always has rows even when no cargo is in the yard. */}
|
||||
<Band index={1} title="Waiting on someone">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Documents & invoices" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
{/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Booking documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contract documents"
|
||||
data={toDonut(tab.contractDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewStackedBarChart
|
||||
title="Handover papers"
|
||||
data={handovers.rows}
|
||||
series={handovers.series}
|
||||
xKey="group"
|
||||
emptyMessage="No handovers generated"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Cargo & containers" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Cargo status"
|
||||
data={toDonut(tab.cargoStatusBreakdown)}
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/**
|
||||
* The default layout — money, attention, network, pipeline. Kept for the CEO,
|
||||
* director and org-manager roles, and for anyone whose role has no dedicated
|
||||
* dashboard (superadmin, IAM admins).
|
||||
*/
|
||||
export function ExecutiveOverview({ data, range }: RoleOverviewProps) {
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { useOverviewBillingTab } from "@/hooks/useOverview";
|
||||
import { AsyncBand, Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/** Money only: what was earned, how it was collected, and what is still owed. */
|
||||
export function FinanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const billing = useOverviewBillingTab(range, true);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Payments" query={billing}>
|
||||
{(tab) => <OverviewBillingTabPanel data={tab} />}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={3} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import { humanize } from "@/lib/format";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
enumLabelsToBars,
|
||||
enumLabelsToDonut,
|
||||
formatDayLabel,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Customer-facing view: who is buying, what they booked, what they signed and
|
||||
* what it earned. Customs-declaration document counts and the Djibouti invoice
|
||||
* queue are part of the marketing brief but have no overview aggregation yet.
|
||||
*/
|
||||
export function MarketingOverview({ data, range }: RoleOverviewProps) {
|
||||
const customers = useOverviewCustomersTab(range, true);
|
||||
const contracts = useOverviewContractsTab(range, true);
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const profilesByType = pivotMatrix(customers.data?.profilesByTypeStatus);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Customers" query={customers}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewCustomersTabPanel data={tab} />
|
||||
{/* Statuses live on the profile, not the company — so active vs
|
||||
pending vs suspended is only meaningful per trade role. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Profiles by trade role and status"
|
||||
data={profilesByType.rows}
|
||||
series={profilesByType.series}
|
||||
xKey="group"
|
||||
formatXLabel={humanize}
|
||||
emptyMessage="No customer profiles yet"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Contracts" query={contracts}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by status"
|
||||
data={toDonut(tab.contractsByStatus)}
|
||||
emptyMessage="No contracts yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by kind"
|
||||
data={enumLabelsToDonut(tab.contractsByKind)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Contracts by freight type"
|
||||
data={enumLabelsToBars(tab.contractsByFreightType)}
|
||||
valueLabel="Contracts"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Documents & invoicing" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Customs documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Invoices by type"
|
||||
data={enumLabelsToBars(tab.invoicesByType)}
|
||||
valueLabel="Invoices"
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={5} title="Revenue & movement">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{ops.data ? (
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={ops.data.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CalendarClock, Send, Timer, Train, Truck } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Operation control centre view: what rolling stock sits where, and what is
|
||||
* moving today. Locomotive availability per station and train turn-around are
|
||||
* part of the OCC brief but have no overview aggregation yet — they are absent
|
||||
* rather than approximated.
|
||||
*/
|
||||
export function OccOverview({ range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const wagonsByYard = pivotMatrix(fleet.data?.wagonStatusByYard);
|
||||
const locomotivesByYard = pivotMatrix(fleet.data?.locomotivesByYard);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Network right now" query={ops}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active trains",
|
||||
value: tab.kpis?.trainsActive ?? 0,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Departures due",
|
||||
value: tab.kpis?.schedulesUpcoming ?? 0,
|
||||
icon: CalendarClock,
|
||||
accent: "sky",
|
||||
},
|
||||
{
|
||||
label: "Dispatched today",
|
||||
value: tab.kpis?.dispatchedToday ?? 0,
|
||||
icon: Send,
|
||||
accent: "amber",
|
||||
},
|
||||
// Labels stay short: five cells share ~1100px at 1440 wide,
|
||||
// and a hint pushes the last two into truncation.
|
||||
{
|
||||
label: "Wagons free",
|
||||
value: tab.kpis?.wagonsAvailable ?? 0,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Turnaround",
|
||||
value:
|
||||
fleet.data?.avgTurnaroundHours != null
|
||||
? `${fleet.data.avgTurnaroundHours}h`
|
||||
: "—",
|
||||
icon: Timer,
|
||||
accent: "violet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Yard and type are long-tailed lists — bars read better than
|
||||
donuts, and the donuts stay at lg 4 so their legends fit. */}
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{/* Per-yard status, not a plain per-yard count: the OCC needs to
|
||||
know which of the wagons standing at a yard can actually run. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by yard and status"
|
||||
data={wagonsByYard.rows}
|
||||
series={wagonsByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons positioned"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by type"
|
||||
data={labelsToBars(tab.wagonsByType)}
|
||||
valueLabel="Wagons"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Wagon status (${sumCounts(tab.wagonStatusBreakdown)})`}
|
||||
data={toDonut(tab.wagonStatusBreakdown)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={2} title="Locomotives" query={fleet}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Locomotives by station and status"
|
||||
data={locomotivesByYard.rows}
|
||||
series={locomotivesByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Locomotive status (${sumCounts(tab.locomotiveStatusBreakdown)})`}
|
||||
data={toDonut(tab.locomotiveStatusBreakdown)}
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Turnaround per train set (departure to departure)"
|
||||
data={(tab.turnaroundByTrain ?? []).map((row) => ({
|
||||
label: row.trainSet,
|
||||
value: row.hours,
|
||||
}))}
|
||||
valueLabel="Hours"
|
||||
emptyMessage="No train set has two recorded departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CircleCheck, Link2, OctagonAlert, Truck, Wrench } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import { TrainLoadCard } from "./TrainLoadCard";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
countByStatus,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Wagon fleet, booking demand and freight moved — the operations officer's
|
||||
* three questions. Wagon counts come from the status breakdown rather than the
|
||||
* headline KPI so every lifecycle state (including detained / out of service)
|
||||
* is accounted for against the same total.
|
||||
*/
|
||||
export function OperationsOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const statusByType = pivotMatrix(fleet.data?.wagonStatusByType);
|
||||
const bookingsByPort = pivotMatrix(ops.data?.bookingStatusByPort);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Wagon fleet" query={ops}>
|
||||
{(tab) => {
|
||||
const wagons = tab.wagonStatusBreakdown ?? [];
|
||||
const total = sumCounts(wagons);
|
||||
const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY");
|
||||
const assigned = countByStatus(wagons, "ASSIGNED");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Five cells fit a 1440px screen only without hints — the
|
||||
status donut below carries the same detail anyway. */}
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{ label: "Total wagons", value: total, icon: Truck },
|
||||
{
|
||||
label: "Available",
|
||||
value: available,
|
||||
icon: CircleCheck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{ label: "Assigned", value: assigned, icon: Link2, accent: "sky" },
|
||||
{
|
||||
label: "Maintenance",
|
||||
value: countByStatus(wagons, "MAINTENANCE"),
|
||||
icon: Wrench,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
label: "Detained / OOS",
|
||||
value: countByStatus(wagons, "DETAINED", "OUT_OF_SERVICE"),
|
||||
icon: OctagonAlert,
|
||||
accent: "rose",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagons by status"
|
||||
data={toDonut(wagons)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{/* Status per type answers "how many flat wagons can I use
|
||||
today", which the plain per-type count could not. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by type and status"
|
||||
data={statusByType.rows}
|
||||
series={statusByType.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by yard"
|
||||
data={labelsToBars(tab.wagonsByYard)}
|
||||
valueLabel="Wagons"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Freight moved" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Trains & ports" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<TrainLoadCard loads={tab.trainLoads ?? []} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Bookings by port and status"
|
||||
data={bookingsByPort.rows}
|
||||
series={bookingsByPort.series}
|
||||
xKey="group"
|
||||
emptyMessage="No bookings in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TrainFront } from "lucide-react";
|
||||
import { Badge, Group, Progress, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import type { IOverviewTrainLoad } from "@/types/overview";
|
||||
import { SummaryCard } from "@/components/overview/summary/SummaryCard";
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "yellow",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/**
|
||||
* Wagon fill per scheduled train: the bar is allocated slots against the train
|
||||
* set's own wagon count, so a short train at 100% reads as full rather than as
|
||||
* a small number next to a big one.
|
||||
*/
|
||||
export function TrainLoadCard({ loads = [] }: { loads: IOverviewTrainLoad[] }) {
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={TrainFront}
|
||||
accent="blue"
|
||||
title="Wagons allocated per train"
|
||||
subtitle="Slots filled and tonnage loaded"
|
||||
>
|
||||
{loads.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No scheduled trains
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{loads.map((load) => {
|
||||
const percent = load.wagonsTotal
|
||||
? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<Stack key={load.scheduleId} gap={4}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{load.trainNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={DIRECTION_COLOR[load.direction] ?? "gray"}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
{humanize(load.direction)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{load.date}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "}
|
||||
{Math.round(load.tons).toLocaleString()} t
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
color={percent >= 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Skeleton, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||
import type {
|
||||
IOverviewDashboard,
|
||||
IOverviewLabelCount,
|
||||
IOverviewMatrixCell,
|
||||
IOverviewStatusCount,
|
||||
OverviewRange,
|
||||
} from "@/types/overview";
|
||||
|
||||
/** Every role layout takes the same summary payload + the selected range. */
|
||||
export interface RoleOverviewProps {
|
||||
data: IOverviewDashboard;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
export function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
export function Band({
|
||||
index,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal shape of the react-query result a band consumes. */
|
||||
interface BandQuery<T> {
|
||||
data?: T;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A band fed by one of the per-domain overview endpoints. Loading shows a
|
||||
* skeleton, a failure degrades to an inline notice — a role dashboard stitches
|
||||
* several endpoints together and one 403 (a role without that domain's
|
||||
* permission) must not blank the whole page.
|
||||
*/
|
||||
export function AsyncBand<T>({
|
||||
index,
|
||||
title,
|
||||
query,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
query: BandQuery<T>;
|
||||
children: (data: T) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Band index={index} title={title}>
|
||||
{query.isLoading ? (
|
||||
<Skeleton height={300} radius="lg" />
|
||||
) : query.isError || !query.data ? (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
title={`${title} unavailable`}
|
||||
>
|
||||
This section could not be loaded for your account.
|
||||
</Alert>
|
||||
) : (
|
||||
children(query.data)
|
||||
)}
|
||||
</Band>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
||||
export const DIRECTION_SERIES = [
|
||||
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
||||
{ key: "importCount", label: "Import", color: "#0369a1" },
|
||||
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
||||
];
|
||||
|
||||
export function formatDayLabel(date: string) {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function sumCounts(items: IOverviewStatusCount[] = []) {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) {
|
||||
return items
|
||||
.filter((item) => statuses.includes(item.status))
|
||||
.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */
|
||||
export function toDonut(items: IOverviewStatusCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.status), value: item.count }));
|
||||
}
|
||||
|
||||
/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */
|
||||
export function labelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
export function labelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix cells → the row/series shape OverviewStackedBarChart wants: one row
|
||||
* per `group`, one series per distinct `series` value, colors fixed by the
|
||||
* series' position so a status keeps its color across charts.
|
||||
*/
|
||||
export function pivotMatrix(cells: IOverviewMatrixCell[] = []) {
|
||||
const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort();
|
||||
const groups = [...new Set(cells.map((cell) => cell.group))];
|
||||
|
||||
const rows = groups.map((group) => {
|
||||
const row: Record<string, string | number> = { group };
|
||||
for (const key of seriesKeys) row[key] = 0;
|
||||
for (const cell of cells) {
|
||||
if (cell.group === group) row[cell.series] = cell.count;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const series = seriesKeys.map((key, index) => ({
|
||||
key,
|
||||
label: humanize(key),
|
||||
color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length],
|
||||
}));
|
||||
|
||||
return { rows, series };
|
||||
}
|
||||
|
||||
/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */
|
||||
export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.label), value: item.count }));
|
||||
}
|
||||
|
||||
export function enumLabelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: humanize(item.label), value: item.count }));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { getPositionKeys } from "@/lib/permissions";
|
||||
|
||||
/** One overview composition. Every backoffice user lands on exactly one of these. */
|
||||
export type OverviewLayoutKey =
|
||||
| "executive"
|
||||
| "operations"
|
||||
| "occ"
|
||||
| "marketing"
|
||||
| "finance"
|
||||
| "clearance";
|
||||
|
||||
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
|
||||
executive: "Executive dashboard",
|
||||
operations: "Operations dashboard",
|
||||
occ: "Control centre dashboard",
|
||||
marketing: "Marketing dashboard",
|
||||
finance: "Finance dashboard",
|
||||
clearance: "Clearance & logistics dashboard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Role/position key → layout, in match priority order: a user holding several
|
||||
* of these keys gets the first match, so the specific operational view wins
|
||||
* over the broad executive one. Position keys are matched too because the IAM
|
||||
* payload models the GL desks as positions (`ethiopian_gl`) on some accounts
|
||||
* and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
|
||||
*/
|
||||
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
|
||||
["edr_operations_officer", "operations"],
|
||||
["truck_machinery_chief", "operations"],
|
||||
["edr_line_staff", "occ"],
|
||||
["edr_gl_ethiopia", "clearance"],
|
||||
["edr_gl_djibouti", "clearance"],
|
||||
["ethiopian_gl", "clearance"],
|
||||
["djibouti_gl", "clearance"],
|
||||
["edr_marketing", "marketing"],
|
||||
["edr_finance", "finance"],
|
||||
["edr_director", "executive"],
|
||||
["edr_ceo", "executive"],
|
||||
["edr_org_manager", "executive"],
|
||||
];
|
||||
|
||||
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */
|
||||
export function resolveOverviewLayout(
|
||||
user: AuthUser | null | undefined,
|
||||
): OverviewLayoutKey {
|
||||
const held = new Set(getPositionKeys(user));
|
||||
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
|
||||
}
|
||||
@@ -36,6 +36,8 @@ interface OverviewHeroProps {
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
/** Name of the role-specific layout rendered below, e.g. "Operations dashboard". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,7 @@ export function OverviewHero({
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
label,
|
||||
}: OverviewHeroProps) {
|
||||
const { user } = useAuth();
|
||||
const fullName = (user?.name?.en ?? user?.name?.am)?.trim();
|
||||
@@ -97,6 +100,19 @@ export function OverviewHero({
|
||||
<Text c="rgba(255,255,255,0.75)" size="sm">
|
||||
{dateLabel}
|
||||
</Text>
|
||||
{label ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
|
||||
@@ -12,6 +12,16 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */
|
||||
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */
|
||||
function pctDelta(current: number, previous: number): number | undefined {
|
||||
if (previous === 0) return undefined;
|
||||
@@ -40,7 +50,7 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi
|
||||
? [
|
||||
{
|
||||
label: `Revenue (${rangeLabel})`,
|
||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
|
||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCompactCurrency(n, "ETB")} />,
|
||||
hint: formatCurrency(current.revenueUsd, "USD"),
|
||||
icon: Banknote,
|
||||
color: "yellow",
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface KpiItem {
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
|
||||
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
|
||||
* Optional percent change vs a prior period, rendered as a tinted ▲/▼ pill
|
||||
* next to the value (green up, red down; zero and null hidden).
|
||||
*/
|
||||
delta?: number;
|
||||
/**
|
||||
@@ -64,7 +64,9 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
key={item.label}
|
||||
{...(linkProps as Record<string, unknown>)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 px-5 py-4",
|
||||
// min-w-0 lets a crowded strip (five cells, long labels)
|
||||
// truncate its labels instead of overflowing the card.
|
||||
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
|
||||
index > 0 &&
|
||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||
item.href &&
|
||||
@@ -115,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
}}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}
|
||||
{Math.abs(item.delta)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -242,6 +242,8 @@ export const QUERY_KEYS = {
|
||||
["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: (range?: string) =>
|
||||
["overview", "operations", range ?? "30d"] as const,
|
||||
fleetTab: (range?: string) => ["overview", "fleet", range ?? "30d"] as const,
|
||||
clearanceTab: () => ["overview", "clearance"] as const,
|
||||
customersTab: (range?: string) =>
|
||||
["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) =>
|
||||
|
||||
@@ -166,6 +166,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACTS: "/overview/contracts",
|
||||
BILLING: "/overview/billing",
|
||||
OPERATIONS: "/overview/operations",
|
||||
FLEET: "/overview/fleet",
|
||||
CLEARANCE: "/overview/clearance",
|
||||
CUSTOMERS: "/overview/customers",
|
||||
STAFF: "/overview/staff",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,22 @@ export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean)
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewFleetTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.fleetTab(range),
|
||||
queryFn: () => overviewService.getFleetTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewClearanceTab(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.clearanceTab(),
|
||||
queryFn: () => overviewService.getClearanceTab(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewCustomersTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.customersTab(range),
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactElement } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
|
||||
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
|
||||
import { FinanceOverview } from "@/components/overview/layouts/FinanceOverview";
|
||||
import { MarketingOverview } from "@/components/overview/layouts/MarketingOverview";
|
||||
import { OccOverview } from "@/components/overview/layouts/OccOverview";
|
||||
import { OperationsOverview } from "@/components/overview/layouts/OperationsOverview";
|
||||
import type { RoleOverviewProps } from "@/components/overview/layouts/layout-kit";
|
||||
import {
|
||||
OVERVIEW_LAYOUT_LABEL,
|
||||
resolveOverviewLayout,
|
||||
type OverviewLayoutKey,
|
||||
} from "@/components/overview/role-dashboards.config";
|
||||
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "@/components/overview/summary/overview-summary.css";
|
||||
|
||||
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
|
||||
const RANGE_DAYS: Record<OverviewRange, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
/** Which composition each role sees below the hero. */
|
||||
const LAYOUTS: Record<OverviewLayoutKey, (props: RoleOverviewProps) => ReactElement> = {
|
||||
executive: ExecutiveOverview,
|
||||
operations: OperationsOverview,
|
||||
occ: OccOverview,
|
||||
marketing: MarketingOverview,
|
||||
finance: FinanceOverview,
|
||||
clearance: ClearanceOverview,
|
||||
};
|
||||
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
@@ -54,8 +50,14 @@ function OverviewSkeleton() {
|
||||
const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||
|
||||
// Hero, range control and headline KPIs are role-neutral; everything below
|
||||
// them is chosen by role key.
|
||||
const layoutKey = resolveOverviewLayout(user);
|
||||
const RoleLayout = LAYOUTS[layoutKey];
|
||||
|
||||
const accessDenied =
|
||||
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
||||
|
||||
@@ -74,6 +76,7 @@ const OverviewPage = () => {
|
||||
generatedAt={data?.generatedAt}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching && !isLoading}
|
||||
label={OVERVIEW_LAYOUT_LABEL[layoutKey]}
|
||||
/>
|
||||
{data ? (
|
||||
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
|
||||
@@ -115,60 +118,7 @@ const OverviewPage = () => {
|
||||
<OverviewSkeleton />
|
||||
</Stack>
|
||||
) : data ? (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range]}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
<RoleLayout data={data} range={range} />
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
IOverviewBillingTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewClearanceTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewCustomersTab,
|
||||
IOverviewDashboard,
|
||||
IOverviewFleetTab,
|
||||
IOverviewOperationsTab,
|
||||
IOverviewStaffTab,
|
||||
OverviewRange,
|
||||
@@ -52,6 +54,19 @@ export const overviewService = {
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getFleetTab: async (range?: OverviewRange): Promise<IOverviewFleetTab> => {
|
||||
const response = await client.get<IOverviewFleetTab>(O.FLEET, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
/** Document + invoice queues; not range-scoped — these are open work items. */
|
||||
getClearanceTab: async (): Promise<IOverviewClearanceTab> => {
|
||||
const response = await client.get<IOverviewClearanceTab>(O.CLEARANCE);
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getCustomersTab: async (range?: OverviewRange): Promise<IOverviewCustomersTab> => {
|
||||
const response = await client.get<IOverviewCustomersTab>(O.CUSTOMERS, {
|
||||
params: range ? { range } : undefined,
|
||||
|
||||
@@ -22,6 +22,11 @@ export type {
|
||||
IOverviewLabelCount,
|
||||
IOverviewPaymentMethodBreakdown,
|
||||
IOverviewCurrencyAmount,
|
||||
IOverviewMatrixCell,
|
||||
IOverviewTrainLoad,
|
||||
IOverviewTurnaround,
|
||||
IOverviewFleetTab,
|
||||
IOverviewClearanceTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewBillingTab,
|
||||
|
||||
@@ -33,8 +33,6 @@ import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
import "./contract-sign-bar.css";
|
||||
|
||||
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
|
||||
|
||||
/**
|
||||
@@ -227,9 +225,29 @@ export default function ContractViewPage() {
|
||||
return (
|
||||
<Box
|
||||
p={{ base: "md", md: "xl" }}
|
||||
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
|
||||
style={{
|
||||
// Lock the page to the viewport so the contract iframe is the ONLY
|
||||
// scroll container — page scroll + iframe scroll together made the
|
||||
// sign flow slippery. The negative margin cancels AppShell.Main's
|
||||
// global 112px bottom clearance (see AppLayout) for this page only;
|
||||
// the sign bar below already keeps content clear of the chat FAB.
|
||||
height: "calc(100dvh - var(--app-shell-header-offset, 72px))",
|
||||
marginBottom: -112,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
maw={920}
|
||||
mx="auto"
|
||||
w="100%"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -272,15 +290,24 @@ export default function ContractViewPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
|
||||
<Paper
|
||||
withBorder
|
||||
radius="lg"
|
||||
p={0}
|
||||
// minHeight keeps the document usable on short/landscape viewports;
|
||||
// the page then overflows and window-scrolls a little, which beats
|
||||
// an unreadably squashed iframe.
|
||||
style={{ overflow: "hidden", flex: 1, minHeight: 220 }}
|
||||
>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
title="Contract document"
|
||||
onLoad={handleIframeLoad}
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
minHeight: "80vh",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
background: "white",
|
||||
}}
|
||||
@@ -293,19 +320,18 @@ export default function ContractViewPage() {
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="md"
|
||||
mt="md"
|
||||
w="100%"
|
||||
maw={920}
|
||||
mx="auto"
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 0,
|
||||
left: "var(--sign-bar-left, 0px)",
|
||||
right: 0,
|
||||
zIndex: 100,
|
||||
borderTop: "1px solid var(--mantine-color-gray-3)",
|
||||
// Above SupportWidget's Affix (zIndex 300) — the fixed chat FAB
|
||||
// shares this bottom-right corner and would otherwise render on
|
||||
// top of the required agree-and-sign bar.
|
||||
position: "relative",
|
||||
zIndex: 301,
|
||||
background: "var(--mantine-color-body)",
|
||||
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
|
||||
maxHeight: "80vh",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
className="contract-sign-bar"
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/* Keeps the fixed sign bar confined to the content area (right of the
|
||||
navbar) instead of spanning the full viewport and drifting off-center. */
|
||||
.contract-sign-bar {
|
||||
--sign-bar-left: 0px;
|
||||
}
|
||||
|
||||
@media (min-width: 48em) {
|
||||
.contract-sign-bar {
|
||||
--sign-bar-left: 260px;
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,34 @@ export interface IOverviewTonnagePoint {
|
||||
tons: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* One cell of a two-dimensional breakdown — `group` is the bucket on the
|
||||
* category axis (wagon type, yard, port), `series` the stacked slice (status).
|
||||
*/
|
||||
export interface IOverviewMatrixCell {
|
||||
group: string;
|
||||
series: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** One scheduled train's wagon allocation and load against its own capacity. */
|
||||
export interface IOverviewTrainLoad {
|
||||
scheduleId: string;
|
||||
trainNumber: string;
|
||||
/** Scheduled departure date, YYYY-MM-DD. */
|
||||
date: string;
|
||||
direction: string;
|
||||
wagonsAllocated: number;
|
||||
wagonsTotal: number;
|
||||
tons: number;
|
||||
}
|
||||
|
||||
/** Departure-to-departure gap for one train set, in hours. */
|
||||
export interface IOverviewTurnaround {
|
||||
trainSet: string;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
export interface IOverviewOperationsTab {
|
||||
kpis: IOverviewOperationsKpis;
|
||||
departureTrend: IOverviewDirectionTrendPoint[];
|
||||
@@ -230,6 +258,36 @@ export interface IOverviewOperationsTab {
|
||||
wagonStatusBreakdown: IOverviewStatusCount[];
|
||||
containerStatusBreakdown: IOverviewStatusCount[];
|
||||
cargoStatusBreakdown: IOverviewStatusCount[];
|
||||
/** Bookings per port yard (import → origin, export → destination). */
|
||||
bookingsByPort: IOverviewLabelCount[];
|
||||
/** Same ports, split by booking status. */
|
||||
bookingStatusByPort: IOverviewMatrixCell[];
|
||||
/** Recent/upcoming trains with their wagon fill and tonnage. */
|
||||
trainLoads: IOverviewTrainLoad[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export interface IOverviewFleetTab {
|
||||
wagonStatusBreakdown: IOverviewStatusCount[];
|
||||
wagonStatusByType: IOverviewMatrixCell[];
|
||||
wagonStatusByYard: IOverviewMatrixCell[];
|
||||
locomotiveStatusBreakdown: IOverviewStatusCount[];
|
||||
locomotivesByYard: IOverviewMatrixCell[];
|
||||
locomotivesByType: IOverviewLabelCount[];
|
||||
/** Mean departure-to-departure gap across all train sets, null when < 2 departures. */
|
||||
avgTurnaroundHours: number | null;
|
||||
turnaroundByTrain: IOverviewTurnaround[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
/** Document and invoice queues the GL desks and marketing work through. */
|
||||
export interface IOverviewClearanceTab {
|
||||
bookingDocumentsByStatus: IOverviewStatusCount[];
|
||||
contractDocumentsByStatus: IOverviewStatusCount[];
|
||||
invoicesByStatus: IOverviewStatusCount[];
|
||||
invoicesByType: IOverviewLabelCount[];
|
||||
/** Signed vs pending handover papers, per mile. */
|
||||
handoversByMile: IOverviewMatrixCell[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
@@ -238,6 +296,8 @@ export interface IOverviewCustomersTab {
|
||||
customerGrowthTrend: IOverviewTrendPoint[];
|
||||
customersByType: IOverviewLabelCount[];
|
||||
topCustomersByBookings: IOverviewLabelCount[];
|
||||
/** Company profiles per type × status — active vs pending vs suspended. */
|
||||
profilesByTypeStatus: IOverviewMatrixCell[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user