Merge pull request #1280 from Tria-plc/freight/nati-2

Freight/nati 2
This commit is contained in:
Nathnael Wondisha
2026-08-13 22:16:04 +03:00
committed by GitHub
25 changed files with 1837 additions and 121 deletions

View File

@@ -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;
}

View File

@@ -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' })

View File

@@ -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
`);
}
}

View File

@@ -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,19 +351,26 @@ export class OverviewService {
): Promise<OverviewCustomersTabDto> {
const days = OVERVIEW_RANGE_DAYS[range];
const [kpis, customerGrowthTrend, customersByType, topCustomersByBookings] =
await Promise.all([
this.overviewRepository.getCustomerKpis(),
this.overviewRepository.getCustomerGrowthTrend(days),
this.overviewRepository.getCustomersByType(),
this.overviewRepository.getTopCustomersByBookings(8, dirs),
]);
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 {
kpis,
customerGrowthTrend,
customersByType,
topCustomersByBookings,
profilesByTypeStatus,
generatedAt: new Date().toISOString(),
};
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 }));
}

View File

@@ -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";
}

View File

@@ -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"

View File

@@ -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",

View File

@@ -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>

View File

@@ -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) =>

View File

@@ -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",
},

View File

@@ -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),

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -22,6 +22,11 @@ export type {
IOverviewLabelCount,
IOverviewPaymentMethodBreakdown,
IOverviewCurrencyAmount,
IOverviewMatrixCell,
IOverviewTrainLoad,
IOverviewTurnaround,
IOverviewFleetTab,
IOverviewClearanceTab,
IOverviewBookingsTab,
IOverviewContractsTab,
IOverviewBillingTab,

View File

@@ -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">
<Box
maw={920}
mx="auto"
w="100%"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<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">

View File

@@ -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;
}
}