From 147887b7c586f9222280778abef20547bc2d5e48 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 18:13:42 +0000 Subject: [PATCH 1/2] feat: dashbaord overview --- .../overview/dto/overview-tab-response.dto.ts | 83 +++++ .../modules/overview/overview.controller.ts | 18 + .../modules/overview/overview.repository.ts | 345 ++++++++++++++++++ .../src/modules/overview/overview.service.ts | 99 ++++- .../overview/layouts/ClearanceOverview.tsx | 154 ++++++++ .../overview/layouts/ExecutiveOverview.tsx | 76 ++++ .../overview/layouts/FinanceOverview.tsx | 58 +++ .../overview/layouts/MarketingOverview.tsx | 154 ++++++++ .../overview/layouts/OccOverview.tsx | 181 +++++++++ .../overview/layouts/OperationsOverview.tsx | 181 +++++++++ .../overview/layouts/TrainLoadCard.tsx | 74 ++++ .../overview/layouts/layout-kit.tsx | 164 +++++++++ .../overview/role-dashboards.config.ts | 50 +++ .../overview/summary/OverviewHero.tsx | 16 + .../overview/summary/OverviewHeroKpis.tsx | 12 +- .../src/components/page/KpiStrip.tsx | 10 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 2 + .../backoffice/src/hooks/useOverview.ts | 16 + .../src/pages/dashboard/OverviewPage.tsx | 114 ++---- .../src/services/overview.service.ts | 15 + .../backoffice/src/types/overview.ts | 5 + packages/types/src/freight/overview.ts | 60 +++ 23 files changed, 1795 insertions(+), 94 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts index 7c60a91a6..fff9c138d 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-tab-response.dto.ts @@ -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; } diff --git a/apps/edr-freight-api/src/modules/overview/overview.controller.ts b/apps/edr-freight-api/src/modules/overview/overview.controller.ts index 45c5cbfed..fcec82d4d 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.controller.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.controller.ts @@ -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 { + 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 { + return this.overviewService.getClearanceTab(); + } + @Get('customers') @BookingStaff(FREIGHT_PERMS.overview.view) @ApiOperation({ summary: 'Customers tab metrics and charts' }) diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index 18b6bb54d..ec258b274 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 + `); + } } diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index a10bb8816..71ab07bf2 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -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 { + 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 { + 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 { 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(), }; } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx new file mode 100644 index 000000000..1484cb5e9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ClearanceOverview.tsx @@ -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 ( + + {/* 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. */} + + + + + + + + + + + + + {(tab) => ( + + {/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */} + + + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx new file mode 100644 index 000000000..6b6e12fa2 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/ExecutiveOverview.tsx @@ -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 = { "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 ( + + {/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */} + + + + + + + + + + + + {/* Band 2 — where the money runs, and what's waiting on someone. */} + + + + + + + + + + + + {/* Band 3 — the network now, and when demand arrives. */} + + + + + + + + + + + + {/* Band 4 — the booking pipeline, full width so every stage bar has room. */} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx new file mode 100644 index 000000000..78205d29b --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/FinanceOverview.tsx @@ -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 = { "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 ( + + + + + + + + + + + + + + {(tab) => } + + + + + + + + + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx new file mode 100644 index 000000000..892f351e6 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/MarketingOverview.tsx @@ -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 ( + + + {(tab) => ( + + + {/* Statuses live on the profile, not the company — so active vs + pending vs suspended is only meaningful per trade role. */} + + + )} + + + + + + + + + + + + + + + {(tab) => ( + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + + + + )} + + + + + + + + + {ops.data ? ( + + ) : null} + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx new file mode 100644 index 000000000..f30468c48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OccOverview.tsx @@ -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 ( + + + {(tab) => ( + + + {/* Yard and type are long-tailed lists — bars read better than + donuts, and the donuts stay at lg 4 so their legends fit. */} + + + {/* 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. */} + + + + + + + + + + + + + + + + + )} + + + + {(tab) => ( + + + + + + + + + ({ + label: row.trainSet, + value: row.hours, + }))} + valueLabel="Hours" + emptyMessage="No train set has two recorded departures in this period" + /> + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx new file mode 100644 index 000000000..544def510 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/OperationsOverview.tsx @@ -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 ( + + + {(tab) => { + const wagons = tab.wagonStatusBreakdown ?? []; + const total = sumCounts(wagons); + const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY"); + const assigned = countByStatus(wagons, "ASSIGNED"); + + return ( + + {/* Five cells fit a 1440px screen only without hints — the + status donut below carries the same detail anyway. */} + + + + + + + {/* Status per type answers "how many flat wagons can I use + today", which the plain per-type count could not. */} + + + + + + + + ); + }} + + + + + + + + + + + + + + + {(tab) => ( + + + + + + + + + ({ + label: item.label, + value: item.tons, + }))} + valueLabel="Tons" + emptyMessage="No cargo recorded" + /> + + + + + + )} + + + + {(tab) => ( + + + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx new file mode 100644 index 000000000..334b59624 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/TrainLoadCard.tsx @@ -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 = { + 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 ( + + {loads.length === 0 ? ( + + No scheduled trains + + ) : ( + + {loads.map((load) => { + const percent = load.wagonsTotal + ? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100) + : 0; + return ( + + + + + {load.trainNumber} + + + {humanize(load.direction)} + + + {load.date} + + + + {load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "} + {Math.round(load.tons).toLocaleString()} t + + + = 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"} + size="sm" + radius="xl" + /> + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx b/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx new file mode 100644 index 000000000..421284689 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/layouts/layout-kit.tsx @@ -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 ( + + {children} + + ); +} + +/** One page band: eyebrow + content, with a staggered entrance by index. */ +export function Band({ + index, + title, + children, +}: { + index: number; + title: string; + children: ReactNode; +}) { + return ( + + {title} + {children} + + ); +} + +/** Minimal shape of the react-query result a band consumes. */ +interface BandQuery { + 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({ + index, + title, + query, + children, +}: { + index: number; + title: string; + query: BandQuery; + children: (data: T) => ReactNode; +}) { + return ( + + {query.isLoading ? ( + + ) : query.isError || !query.data ? ( + } + color="gray" + variant="light" + title={`${title} unavailable`} + > + This section could not be loaded for your account. + + ) : ( + children(query.data) + )} + + ); +} + +/** 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 = { 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 })); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts new file mode 100644 index 000000000..509f17a3c --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/role-dashboards.config.ts @@ -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 = { + 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"; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx index 5b7815eb0..4e0fcdc10 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx @@ -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({ {dateLabel} + {label ? ( + + {label} + + ) : null} formatCurrency(n, "ETB")} />, + value: formatCompactCurrency(n, "ETB")} />, hint: formatCurrency(current.revenueUsd, "USD"), icon: Banknote, color: "yellow", diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 01a80db61..15e2f4425 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -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)} 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)}% ) : null} diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 1ee429bad..d1bd381b9 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -200,6 +200,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) => diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index ba74f3206..0989af525 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -139,6 +139,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", }, diff --git a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts index f46fe865f..7225ee5bc 100644 --- a/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts +++ b/apps/edr-freight-web/backoffice/src/hooks/useOverview.ts @@ -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), diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index aa29fa0cf..0d1f5a6e7 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -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 = { "7d": "7d", "30d": "30d", "90d": "90d" }; -const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; -/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */ -function SectionTitle({ children }: { children: string }) { - return ( - - {children} - - ); -} - -/** One page band: eyebrow + content, with a staggered entrance by index. */ -function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) { - return ( - - {title} - {children} - - ); -} +/** Which composition each role sees below the hero. */ +const LAYOUTS: Record 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("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 ? (
@@ -115,60 +118,7 @@ const OverviewPage = () => { ) : data ? ( - - {/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */} - - - - - - - - - - - - {/* Band 2 — where the money runs, and what's waiting on someone. */} - - - - - - - - - - - - {/* Band 3 — the network now, and when demand arrives. */} - - - - - - - - - - - - {/* Band 4 — the booking pipeline, full width so every stage bar has room. */} - - - - + ) : null} ); diff --git a/apps/edr-freight-web/backoffice/src/services/overview.service.ts b/apps/edr-freight-web/backoffice/src/services/overview.service.ts index 71e7fb671..9229b0b4f 100644 --- a/apps/edr-freight-web/backoffice/src/services/overview.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/overview.service.ts @@ -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 => { + const response = await client.get(O.FLEET, { + params: range ? { range } : undefined, + }); + return unwrap(response); + }, + + /** Document + invoice queues; not range-scoped — these are open work items. */ + getClearanceTab: async (): Promise => { + const response = await client.get(O.CLEARANCE); + return unwrap(response); + }, + getCustomersTab: async (range?: OverviewRange): Promise => { const response = await client.get(O.CUSTOMERS, { params: range ? { range } : undefined, diff --git a/apps/edr-freight-web/backoffice/src/types/overview.ts b/apps/edr-freight-web/backoffice/src/types/overview.ts index 38778da49..85cb5fa21 100644 --- a/apps/edr-freight-web/backoffice/src/types/overview.ts +++ b/apps/edr-freight-web/backoffice/src/types/overview.ts @@ -22,6 +22,11 @@ export type { IOverviewLabelCount, IOverviewPaymentMethodBreakdown, IOverviewCurrencyAmount, + IOverviewMatrixCell, + IOverviewTrainLoad, + IOverviewTurnaround, + IOverviewFleetTab, + IOverviewClearanceTab, IOverviewBookingsTab, IOverviewContractsTab, IOverviewBillingTab, diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index 4972db10d..ede6d6b04 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -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; } From 6cee9a26769014ccfb941efc6aa2cab6264b6e7f Mon Sep 17 00:00:00 2001 From: Nathnael Date: Thu, 13 Aug 2026 19:13:18 +0000 Subject: [PATCH 2/2] fix:contract page --- .../src/pages/contracts/ContractViewPage.tsx | 58 ++++++++++++++----- .../src/pages/contracts/contract-sign-bar.css | 11 ---- 2 files changed, 42 insertions(+), 27 deletions(-) delete mode 100644 apps/edr-freight-web/portal/src/pages/contracts/contract-sign-bar.css diff --git a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx index 92b9ed640..312ecb62b 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/ContractViewPage.tsx @@ -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 ( - +