mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* The GL desks (Ethiopia / Djibouti) work cargo through clearance: containers
|
||||
* and cargo state first, the trains carrying them second, and the bookings
|
||||
* waiting on a human third. Clearance-document counts are not aggregated by
|
||||
* the overview API yet, so this stops at cargo state.
|
||||
*/
|
||||
export function ClearanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const handovers = pivotMatrix(clearance.data?.handoversByMile);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Work queue first: it is what a GL desk acts on, and it is the band
|
||||
that always has rows even when no cargo is in the yard. */}
|
||||
<Band index={1} title="Waiting on someone">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Documents & invoices" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
{/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */}
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Booking documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contract documents"
|
||||
data={toDonut(tab.contractDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewStackedBarChart
|
||||
title="Handover papers"
|
||||
data={handovers.rows}
|
||||
series={handovers.series}
|
||||
xKey="group"
|
||||
emptyMessage="No handovers generated"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Cargo & containers" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Cargo status"
|
||||
data={toDonut(tab.cargoStatusBreakdown)}
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/**
|
||||
* The default layout — money, attention, network, pipeline. Kept for the CEO,
|
||||
* director and org-manager roles, and for anyone whose role has no dedicated
|
||||
* dashboard (superadmin, IAM admins).
|
||||
*/
|
||||
export function ExecutiveOverview({ data, range }: RoleOverviewProps) {
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { useOverviewBillingTab } from "@/hooks/useOverview";
|
||||
import { AsyncBand, Band, type RoleOverviewProps } from "./layout-kit";
|
||||
|
||||
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/** Money only: what was earned, how it was collected, and what is still owed. */
|
||||
export function FinanceOverview({ data, range }: RoleOverviewProps) {
|
||||
const billing = useOverviewBillingTab(range, true);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range] ?? 30}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={2} title="Payments" query={billing}>
|
||||
{(tab) => <OverviewBillingTabPanel data={tab} />}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={3} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import {
|
||||
useOverviewClearanceTab,
|
||||
useOverviewContractsTab,
|
||||
useOverviewCustomersTab,
|
||||
useOverviewOperationsTab,
|
||||
} from "@/hooks/useOverview";
|
||||
import { humanize } from "@/lib/format";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
enumLabelsToBars,
|
||||
enumLabelsToDonut,
|
||||
formatDayLabel,
|
||||
pivotMatrix,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Customer-facing view: who is buying, what they booked, what they signed and
|
||||
* what it earned. Customs-declaration document counts and the Djibouti invoice
|
||||
* queue are part of the marketing brief but have no overview aggregation yet.
|
||||
*/
|
||||
export function MarketingOverview({ data, range }: RoleOverviewProps) {
|
||||
const customers = useOverviewCustomersTab(range, true);
|
||||
const contracts = useOverviewContractsTab(range, true);
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const clearance = useOverviewClearanceTab(true);
|
||||
const profilesByType = pivotMatrix(customers.data?.profilesByTypeStatus);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Customers" query={customers}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewCustomersTabPanel data={tab} />
|
||||
{/* Statuses live on the profile, not the company — so active vs
|
||||
pending vs suspended is only meaningful per trade role. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Profiles by trade role and status"
|
||||
data={profilesByType.rows}
|
||||
series={profilesByType.series}
|
||||
xKey="group"
|
||||
formatXLabel={humanize}
|
||||
emptyMessage="No customer profiles yet"
|
||||
/>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Contracts" query={contracts}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by status"
|
||||
data={toDonut(tab.contractsByStatus)}
|
||||
emptyMessage="No contracts yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Contracts by kind"
|
||||
data={enumLabelsToDonut(tab.contractsByKind)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Contracts by freight type"
|
||||
data={enumLabelsToBars(tab.contractsByFreightType)}
|
||||
valueLabel="Contracts"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Documents & invoicing" query={clearance}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Customs documents"
|
||||
data={toDonut(tab.bookingDocumentsByStatus)}
|
||||
emptyMessage="No documents uploaded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Invoices by status"
|
||||
data={toDonut(tab.invoicesByStatus)}
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 4 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Invoices by type"
|
||||
data={enumLabelsToBars(tab.invoicesByType)}
|
||||
valueLabel="Invoices"
|
||||
emptyMessage="No invoices raised"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={5} title="Revenue & movement">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{ops.data ? (
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={ops.data.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
) : null}
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CalendarClock, Send, Timer, Train, Truck } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import {
|
||||
AsyncBand,
|
||||
DIRECTION_SERIES,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Operation control centre view: what rolling stock sits where, and what is
|
||||
* moving today. Locomotive availability per station and train turn-around are
|
||||
* part of the OCC brief but have no overview aggregation yet — they are absent
|
||||
* rather than approximated.
|
||||
*/
|
||||
export function OccOverview({ range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const wagonsByYard = pivotMatrix(fleet.data?.wagonStatusByYard);
|
||||
const locomotivesByYard = pivotMatrix(fleet.data?.locomotivesByYard);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Network right now" query={ops}>
|
||||
{(tab) => (
|
||||
<Stack gap="md">
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{
|
||||
label: "Active trains",
|
||||
value: tab.kpis?.trainsActive ?? 0,
|
||||
icon: Train,
|
||||
accent: "emerald",
|
||||
},
|
||||
{
|
||||
label: "Departures due",
|
||||
value: tab.kpis?.schedulesUpcoming ?? 0,
|
||||
icon: CalendarClock,
|
||||
accent: "sky",
|
||||
},
|
||||
{
|
||||
label: "Dispatched today",
|
||||
value: tab.kpis?.dispatchedToday ?? 0,
|
||||
icon: Send,
|
||||
accent: "amber",
|
||||
},
|
||||
// Labels stay short: five cells share ~1100px at 1440 wide,
|
||||
// and a hint pushes the last two into truncation.
|
||||
{
|
||||
label: "Wagons free",
|
||||
value: tab.kpis?.wagonsAvailable ?? 0,
|
||||
icon: Truck,
|
||||
},
|
||||
{
|
||||
label: "Turnaround",
|
||||
value:
|
||||
fleet.data?.avgTurnaroundHours != null
|
||||
? `${fleet.data.avgTurnaroundHours}h`
|
||||
: "—",
|
||||
icon: Timer,
|
||||
accent: "violet",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Yard and type are long-tailed lists — bars read better than
|
||||
donuts, and the donuts stay at lg 4 so their legends fit. */}
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
{/* Per-yard status, not a plain per-yard count: the OCC needs to
|
||||
know which of the wagons standing at a yard can actually run. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by yard and status"
|
||||
data={wagonsByYard.rows}
|
||||
series={wagonsByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons positioned"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by type"
|
||||
data={labelsToBars(tab.wagonsByType)}
|
||||
valueLabel="Wagons"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Wagon status (${sumCounts(tab.wagonStatusBreakdown)})`}
|
||||
data={toDonut(tab.wagonStatusBreakdown)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Container status"
|
||||
data={toDonut(tab.containerStatusBreakdown)}
|
||||
emptyMessage="No containers tracked"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={2} title="Locomotives" query={fleet}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Locomotives by station and status"
|
||||
data={locomotivesByYard.rows}
|
||||
series={locomotivesByYard.series}
|
||||
xKey="group"
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title={`Locomotive status (${sumCounts(tab.locomotiveStatusBreakdown)})`}
|
||||
data={toDonut(tab.locomotiveStatusBreakdown)}
|
||||
emptyMessage="No locomotives registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Turnaround per train set (departure to departure)"
|
||||
data={(tab.turnaroundByTrain ?? []).map((row) => ({
|
||||
label: row.trainSet,
|
||||
value: row.hours,
|
||||
}))}
|
||||
valueLabel="Hours"
|
||||
emptyMessage="No train set has two recorded departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={3} title="Train movement" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Schedule status"
|
||||
data={toDonut(tab.scheduleStatusBreakdown)}
|
||||
emptyMessage="No train schedules yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { CircleCheck, Link2, OctagonAlert, Truck, Wrench } from "lucide-react";
|
||||
import { Grid, Stack } from "@mantine/core";
|
||||
|
||||
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
|
||||
import { TrainLoadCard } from "./TrainLoadCard";
|
||||
import {
|
||||
AsyncBand,
|
||||
Band,
|
||||
DIRECTION_SERIES,
|
||||
countByStatus,
|
||||
formatDayLabel,
|
||||
labelsToBars,
|
||||
labelsToDonut,
|
||||
pivotMatrix,
|
||||
sumCounts,
|
||||
toDonut,
|
||||
type RoleOverviewProps,
|
||||
} from "./layout-kit";
|
||||
|
||||
/**
|
||||
* Wagon fleet, booking demand and freight moved — the operations officer's
|
||||
* three questions. Wagon counts come from the status breakdown rather than the
|
||||
* headline KPI so every lifecycle state (including detained / out of service)
|
||||
* is accounted for against the same total.
|
||||
*/
|
||||
export function OperationsOverview({ data, range }: RoleOverviewProps) {
|
||||
const ops = useOverviewOperationsTab(range, true);
|
||||
const fleet = useOverviewFleetTab(range, true);
|
||||
const statusByType = pivotMatrix(fleet.data?.wagonStatusByType);
|
||||
const bookingsByPort = pivotMatrix(ops.data?.bookingStatusByPort);
|
||||
|
||||
return (
|
||||
<Stack gap="xl" mt="xl">
|
||||
<AsyncBand index={1} title="Wagon fleet" query={ops}>
|
||||
{(tab) => {
|
||||
const wagons = tab.wagonStatusBreakdown ?? [];
|
||||
const total = sumCounts(wagons);
|
||||
const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY");
|
||||
const assigned = countByStatus(wagons, "ASSIGNED");
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Five cells fit a 1440px screen only without hints — the
|
||||
status donut below carries the same detail anyway. */}
|
||||
<OverviewKpiStrip
|
||||
items={[
|
||||
{ label: "Total wagons", value: total, icon: Truck },
|
||||
{
|
||||
label: "Available",
|
||||
value: available,
|
||||
icon: CircleCheck,
|
||||
accent: "emerald",
|
||||
},
|
||||
{ label: "Assigned", value: assigned, icon: Link2, accent: "sky" },
|
||||
{
|
||||
label: "Maintenance",
|
||||
value: countByStatus(wagons, "MAINTENANCE"),
|
||||
icon: Wrench,
|
||||
accent: "amber",
|
||||
},
|
||||
{
|
||||
label: "Detained / OOS",
|
||||
value: countByStatus(wagons, "DETAINED", "OUT_OF_SERVICE"),
|
||||
icon: OctagonAlert,
|
||||
accent: "rose",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Wagons by status"
|
||||
data={toDonut(wagons)}
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
{/* Status per type answers "how many flat wagons can I use
|
||||
today", which the plain per-type count could not. */}
|
||||
<OverviewStackedBarChart
|
||||
title="Wagons by type and status"
|
||||
data={statusByType.rows}
|
||||
series={statusByType.series}
|
||||
xKey="group"
|
||||
emptyMessage="No wagons registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={12}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Wagons by yard"
|
||||
data={labelsToBars(tab.wagonsByYard)}
|
||||
valueLabel="Wagons"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
</AsyncBand>
|
||||
|
||||
<Band index={2} title="Booking demand">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewDonutChart
|
||||
title="Bookings by status"
|
||||
data={toDonut(data.bookingsByStatus)}
|
||||
emptyMessage="No bookings yet"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
<AsyncBand index={3} title="Freight moved" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Train departures by direction"
|
||||
data={tab.departureTrend ?? []}
|
||||
series={DIRECTION_SERIES}
|
||||
formatXLabel={formatDayLabel}
|
||||
emptyMessage="No scheduled departures in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewDonutChart
|
||||
title="Train status"
|
||||
data={toDonut(tab.trainStatusBreakdown)}
|
||||
emptyMessage="No trains registered"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewHorizontalBarChart
|
||||
title="Cargo tonnage by type"
|
||||
data={(tab.cargoTonnageByType ?? []).map((item) => ({
|
||||
label: item.label,
|
||||
value: item.tons,
|
||||
}))}
|
||||
valueLabel="Tons"
|
||||
emptyMessage="No cargo recorded"
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, md: 6 }}>
|
||||
<OverviewDonutChart
|
||||
title="Containers by size"
|
||||
data={labelsToDonut(tab.containersBySize)}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
|
||||
<AsyncBand index={4} title="Trains & ports" query={ops}>
|
||||
{(tab) => (
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<TrainLoadCard loads={tab.trainLoads ?? []} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewStackedBarChart
|
||||
title="Bookings by port and status"
|
||||
data={bookingsByPort.rows}
|
||||
series={bookingsByPort.series}
|
||||
xKey="group"
|
||||
emptyMessage="No bookings in this period"
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
)}
|
||||
</AsyncBand>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TrainFront } from "lucide-react";
|
||||
import { Badge, Group, Progress, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import type { IOverviewTrainLoad } from "@/types/overview";
|
||||
import { SummaryCard } from "@/components/overview/summary/SummaryCard";
|
||||
|
||||
const DIRECTION_COLOR: Record<string, string> = {
|
||||
IMPORT: "blue",
|
||||
EXPORT: "yellow",
|
||||
DOMESTIC: "violet",
|
||||
};
|
||||
|
||||
/**
|
||||
* Wagon fill per scheduled train: the bar is allocated slots against the train
|
||||
* set's own wagon count, so a short train at 100% reads as full rather than as
|
||||
* a small number next to a big one.
|
||||
*/
|
||||
export function TrainLoadCard({ loads = [] }: { loads: IOverviewTrainLoad[] }) {
|
||||
return (
|
||||
<SummaryCard
|
||||
icon={TrainFront}
|
||||
accent="blue"
|
||||
title="Wagons allocated per train"
|
||||
subtitle="Slots filled and tonnage loaded"
|
||||
>
|
||||
{loads.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="xl">
|
||||
No scheduled trains
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{loads.map((load) => {
|
||||
const percent = load.wagonsTotal
|
||||
? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<Stack key={load.scheduleId} gap={4}>
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={600} truncate>
|
||||
{load.trainNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={DIRECTION_COLOR[load.direction] ?? "gray"}
|
||||
style={{ textTransform: "none" }}
|
||||
>
|
||||
{humanize(load.direction)}
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed">
|
||||
{load.date}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||
{load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "}
|
||||
{Math.round(load.tons).toLocaleString()} t
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={percent}
|
||||
color={percent >= 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SummaryCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Skeleton, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { humanize } from "@/lib/format";
|
||||
import { overviewChartColors } from "@/components/overview/overview.styles";
|
||||
import type {
|
||||
IOverviewDashboard,
|
||||
IOverviewLabelCount,
|
||||
IOverviewMatrixCell,
|
||||
IOverviewStatusCount,
|
||||
OverviewRange,
|
||||
} from "@/types/overview";
|
||||
|
||||
/** Every role layout takes the same summary payload + the selected range. */
|
||||
export interface RoleOverviewProps {
|
||||
data: IOverviewDashboard;
|
||||
range: OverviewRange;
|
||||
}
|
||||
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
export function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
export function Band({
|
||||
index,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal shape of the react-query result a band consumes. */
|
||||
interface BandQuery<T> {
|
||||
data?: T;
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A band fed by one of the per-domain overview endpoints. Loading shows a
|
||||
* skeleton, a failure degrades to an inline notice — a role dashboard stitches
|
||||
* several endpoints together and one 403 (a role without that domain's
|
||||
* permission) must not blank the whole page.
|
||||
*/
|
||||
export function AsyncBand<T>({
|
||||
index,
|
||||
title,
|
||||
query,
|
||||
children,
|
||||
}: {
|
||||
index: number;
|
||||
title: string;
|
||||
query: BandQuery<T>;
|
||||
children: (data: T) => ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Band index={index} title={title}>
|
||||
{query.isLoading ? (
|
||||
<Skeleton height={300} radius="lg" />
|
||||
) : query.isError || !query.data ? (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="gray"
|
||||
variant="light"
|
||||
title={`${title} unavailable`}
|
||||
>
|
||||
This section could not be loaded for your account.
|
||||
</Alert>
|
||||
) : (
|
||||
children(query.data)
|
||||
)}
|
||||
</Band>
|
||||
);
|
||||
}
|
||||
|
||||
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
|
||||
export const DIRECTION_SERIES = [
|
||||
{ key: "exportCount", label: "Export", color: "#D98A0B" },
|
||||
{ key: "importCount", label: "Import", color: "#0369a1" },
|
||||
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
|
||||
];
|
||||
|
||||
export function formatDayLabel(date: string) {
|
||||
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export function sumCounts(items: IOverviewStatusCount[] = []) {
|
||||
return items.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) {
|
||||
return items
|
||||
.filter((item) => statuses.includes(item.status))
|
||||
.reduce((sum, item) => sum + item.count, 0);
|
||||
}
|
||||
|
||||
/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */
|
||||
export function toDonut(items: IOverviewStatusCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.status), value: item.count }));
|
||||
}
|
||||
|
||||
/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */
|
||||
export function labelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
export function labelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: item.label, value: item.count }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Matrix cells → the row/series shape OverviewStackedBarChart wants: one row
|
||||
* per `group`, one series per distinct `series` value, colors fixed by the
|
||||
* series' position so a status keeps its color across charts.
|
||||
*/
|
||||
export function pivotMatrix(cells: IOverviewMatrixCell[] = []) {
|
||||
const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort();
|
||||
const groups = [...new Set(cells.map((cell) => cell.group))];
|
||||
|
||||
const rows = groups.map((group) => {
|
||||
const row: Record<string, string | number> = { group };
|
||||
for (const key of seriesKeys) row[key] = 0;
|
||||
for (const cell of cells) {
|
||||
if (cell.group === group) row[cell.series] = cell.count;
|
||||
}
|
||||
return row;
|
||||
});
|
||||
|
||||
const series = seriesKeys.map((key, index) => ({
|
||||
key,
|
||||
label: humanize(key),
|
||||
color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length],
|
||||
}));
|
||||
|
||||
return { rows, series };
|
||||
}
|
||||
|
||||
/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */
|
||||
export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ name: humanize(item.label), value: item.count }));
|
||||
}
|
||||
|
||||
export function enumLabelsToBars(items: IOverviewLabelCount[] = []) {
|
||||
return items.map((item) => ({ label: humanize(item.label), value: item.count }));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AuthUser } from "@/auth/types";
|
||||
import { getPositionKeys } from "@/lib/permissions";
|
||||
|
||||
/** One overview composition. Every backoffice user lands on exactly one of these. */
|
||||
export type OverviewLayoutKey =
|
||||
| "executive"
|
||||
| "operations"
|
||||
| "occ"
|
||||
| "marketing"
|
||||
| "finance"
|
||||
| "clearance";
|
||||
|
||||
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
|
||||
executive: "Executive dashboard",
|
||||
operations: "Operations dashboard",
|
||||
occ: "Control centre dashboard",
|
||||
marketing: "Marketing dashboard",
|
||||
finance: "Finance dashboard",
|
||||
clearance: "Clearance & logistics dashboard",
|
||||
};
|
||||
|
||||
/**
|
||||
* Role/position key → layout, in match priority order: a user holding several
|
||||
* of these keys gets the first match, so the specific operational view wins
|
||||
* over the broad executive one. Position keys are matched too because the IAM
|
||||
* payload models the GL desks as positions (`ethiopian_gl`) on some accounts
|
||||
* and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
|
||||
*/
|
||||
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
|
||||
["edr_operations_officer", "operations"],
|
||||
["truck_machinery_chief", "operations"],
|
||||
["edr_line_staff", "occ"],
|
||||
["edr_gl_ethiopia", "clearance"],
|
||||
["edr_gl_djibouti", "clearance"],
|
||||
["ethiopian_gl", "clearance"],
|
||||
["djibouti_gl", "clearance"],
|
||||
["edr_marketing", "marketing"],
|
||||
["edr_finance", "finance"],
|
||||
["edr_director", "executive"],
|
||||
["edr_ceo", "executive"],
|
||||
["edr_org_manager", "executive"],
|
||||
];
|
||||
|
||||
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */
|
||||
export function resolveOverviewLayout(
|
||||
user: AuthUser | null | undefined,
|
||||
): OverviewLayoutKey {
|
||||
const held = new Set(getPositionKeys(user));
|
||||
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
|
||||
}
|
||||
@@ -36,6 +36,8 @@ interface OverviewHeroProps {
|
||||
generatedAt?: string;
|
||||
onRefresh: () => void;
|
||||
isRefreshing?: boolean;
|
||||
/** Name of the role-specific layout rendered below, e.g. "Operations dashboard". */
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +51,7 @@ export function OverviewHero({
|
||||
generatedAt,
|
||||
onRefresh,
|
||||
isRefreshing,
|
||||
label,
|
||||
}: OverviewHeroProps) {
|
||||
const { user } = useAuth();
|
||||
const fullName = (user?.name?.en ?? user?.name?.am)?.trim();
|
||||
@@ -97,6 +100,19 @@ export function OverviewHero({
|
||||
<Text c="rgba(255,255,255,0.75)" size="sm">
|
||||
{dateLabel}
|
||||
</Text>
|
||||
{label ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.16)",
|
||||
color: "rgba(255,255,255,0.9)",
|
||||
textTransform: "none",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
) : null}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
|
||||
@@ -12,6 +12,16 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */
|
||||
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency,
|
||||
notation: "compact",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */
|
||||
function pctDelta(current: number, previous: number): number | undefined {
|
||||
if (previous === 0) return undefined;
|
||||
@@ -40,7 +50,7 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi
|
||||
? [
|
||||
{
|
||||
label: `Revenue (${rangeLabel})`,
|
||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
|
||||
value: <CountUp value={current.revenueEtb} format={(n) => formatCompactCurrency(n, "ETB")} />,
|
||||
hint: formatCurrency(current.revenueUsd, "USD"),
|
||||
icon: Banknote,
|
||||
color: "yellow",
|
||||
|
||||
@@ -19,8 +19,8 @@ export interface KpiItem {
|
||||
*/
|
||||
color?: string;
|
||||
/**
|
||||
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
|
||||
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
|
||||
* Optional percent change vs a prior period, rendered as a tinted ▲/▼ pill
|
||||
* next to the value (green up, red down; zero and null hidden).
|
||||
*/
|
||||
delta?: number;
|
||||
/**
|
||||
@@ -64,7 +64,9 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
key={item.label}
|
||||
{...(linkProps as Record<string, unknown>)}
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-3 px-5 py-4",
|
||||
// min-w-0 lets a crowded strip (five cells, long labels)
|
||||
// truncate its labels instead of overflowing the card.
|
||||
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
|
||||
index > 0 &&
|
||||
"border-t border-edr-border sm:border-l sm:border-t-0",
|
||||
item.href &&
|
||||
@@ -115,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
|
||||
}}
|
||||
>
|
||||
{item.delta > 0 ? "▲" : "▼"}
|
||||
{Math.abs(item.delta)}
|
||||
{Math.abs(item.delta)}%
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -242,6 +242,8 @@ export const QUERY_KEYS = {
|
||||
["overview", "billing", range ?? "30d"] as const,
|
||||
operationsTab: (range?: string) =>
|
||||
["overview", "operations", range ?? "30d"] as const,
|
||||
fleetTab: (range?: string) => ["overview", "fleet", range ?? "30d"] as const,
|
||||
clearanceTab: () => ["overview", "clearance"] as const,
|
||||
customersTab: (range?: string) =>
|
||||
["overview", "customers", range ?? "30d"] as const,
|
||||
staffTab: (range?: string) =>
|
||||
|
||||
@@ -166,6 +166,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACTS: "/overview/contracts",
|
||||
BILLING: "/overview/billing",
|
||||
OPERATIONS: "/overview/operations",
|
||||
FLEET: "/overview/fleet",
|
||||
CLEARANCE: "/overview/clearance",
|
||||
CUSTOMERS: "/overview/customers",
|
||||
STAFF: "/overview/staff",
|
||||
},
|
||||
|
||||
@@ -43,6 +43,22 @@ export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean)
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewFleetTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.fleetTab(range),
|
||||
queryFn: () => overviewService.getFleetTab(range),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewClearanceTab(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.clearanceTab(),
|
||||
queryFn: () => overviewService.getClearanceTab(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useOverviewCustomersTab(range: OverviewRange, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.OVERVIEW.customersTab(range),
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactElement } from "react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { PageContainer } from "@/components/page";
|
||||
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
|
||||
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
|
||||
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
|
||||
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
|
||||
import { FinanceOverview } from "@/components/overview/layouts/FinanceOverview";
|
||||
import { MarketingOverview } from "@/components/overview/layouts/MarketingOverview";
|
||||
import { OccOverview } from "@/components/overview/layouts/OccOverview";
|
||||
import { OperationsOverview } from "@/components/overview/layouts/OperationsOverview";
|
||||
import type { RoleOverviewProps } from "@/components/overview/layouts/layout-kit";
|
||||
import {
|
||||
OVERVIEW_LAYOUT_LABEL,
|
||||
resolveOverviewLayout,
|
||||
type OverviewLayoutKey,
|
||||
} from "@/components/overview/role-dashboards.config";
|
||||
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
|
||||
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
|
||||
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
|
||||
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
|
||||
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
|
||||
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
|
||||
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { useOverview } from "@/hooks/useOverview";
|
||||
import type { OverviewRange } from "@/types/overview";
|
||||
import "@/components/overview/summary/overview-summary.css";
|
||||
|
||||
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
|
||||
const RANGE_DAYS: Record<OverviewRange, number> = { "7d": 7, "30d": 30, "90d": 90 };
|
||||
|
||||
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
|
||||
function SectionTitle({ children }: { children: string }) {
|
||||
return (
|
||||
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/** One page band: eyebrow + content, with a staggered entrance by index. */
|
||||
function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
/** Which composition each role sees below the hero. */
|
||||
const LAYOUTS: Record<OverviewLayoutKey, (props: RoleOverviewProps) => ReactElement> = {
|
||||
executive: ExecutiveOverview,
|
||||
operations: OperationsOverview,
|
||||
occ: OccOverview,
|
||||
marketing: MarketingOverview,
|
||||
finance: FinanceOverview,
|
||||
clearance: ClearanceOverview,
|
||||
};
|
||||
|
||||
function OverviewSkeleton() {
|
||||
return (
|
||||
@@ -54,8 +50,14 @@ function OverviewSkeleton() {
|
||||
const OverviewPage = () => {
|
||||
const [range, setRange] = useState<OverviewRange>("30d");
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
|
||||
|
||||
// Hero, range control and headline KPIs are role-neutral; everything below
|
||||
// them is chosen by role key.
|
||||
const layoutKey = resolveOverviewLayout(user);
|
||||
const RoleLayout = LAYOUTS[layoutKey];
|
||||
|
||||
const accessDenied =
|
||||
(error as { response?: { status?: number } } | null)?.response?.status === 403;
|
||||
|
||||
@@ -74,6 +76,7 @@ const OverviewPage = () => {
|
||||
generatedAt={data?.generatedAt}
|
||||
onRefresh={handleRefresh}
|
||||
isRefreshing={isFetching && !isLoading}
|
||||
label={OVERVIEW_LAYOUT_LABEL[layoutKey]}
|
||||
/>
|
||||
{data ? (
|
||||
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
|
||||
@@ -115,60 +118,7 @@ const OverviewPage = () => {
|
||||
<OverviewSkeleton />
|
||||
</Stack>
|
||||
) : data ? (
|
||||
<Stack gap="xl" mt="xl">
|
||||
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
|
||||
<Band index={1} title="Revenue & volume">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 8 }}>
|
||||
<OverviewRevenueVolumeChart
|
||||
bookingTrend={data.bookingTrend}
|
||||
paymentTrend={data.paymentTrend}
|
||||
previousPaymentTrend={data.previousPaymentTrend}
|
||||
rangeDays={RANGE_DAYS[range]}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 4 }}>
|
||||
<OverviewRevenueMix
|
||||
byDirection={data.revenueByDirection}
|
||||
byFreightType={data.revenueByFreightType}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 2 — where the money runs, and what's waiting on someone. */}
|
||||
<Band index={2} title="Money flow & attention">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewSankeyFlow flows={data.revenueFlows} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewAttentionCard
|
||||
bookings={data.kpis.bookings}
|
||||
contracts={data.kpis.contracts}
|
||||
billing={data.kpis.billing}
|
||||
/>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 3 — the network now, and when demand arrives. */}
|
||||
<Band index={3} title="Network & rhythm">
|
||||
<Grid gap="md">
|
||||
<Grid.Col span={{ base: 12, lg: 5 }}>
|
||||
<OverviewNetworkCard kpis={data.kpis.operations} />
|
||||
</Grid.Col>
|
||||
<Grid.Col span={{ base: 12, lg: 7 }}>
|
||||
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
</Band>
|
||||
|
||||
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
|
||||
<Band index={4} title="Pipeline">
|
||||
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
|
||||
</Band>
|
||||
</Stack>
|
||||
<RoleLayout data={data} range={range} />
|
||||
) : null}
|
||||
</PageContainer>
|
||||
);
|
||||
|
||||
@@ -4,9 +4,11 @@ import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
IOverviewBillingTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewClearanceTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewCustomersTab,
|
||||
IOverviewDashboard,
|
||||
IOverviewFleetTab,
|
||||
IOverviewOperationsTab,
|
||||
IOverviewStaffTab,
|
||||
OverviewRange,
|
||||
@@ -52,6 +54,19 @@ export const overviewService = {
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getFleetTab: async (range?: OverviewRange): Promise<IOverviewFleetTab> => {
|
||||
const response = await client.get<IOverviewFleetTab>(O.FLEET, {
|
||||
params: range ? { range } : undefined,
|
||||
});
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
/** Document + invoice queues; not range-scoped — these are open work items. */
|
||||
getClearanceTab: async (): Promise<IOverviewClearanceTab> => {
|
||||
const response = await client.get<IOverviewClearanceTab>(O.CLEARANCE);
|
||||
return unwrap(response);
|
||||
},
|
||||
|
||||
getCustomersTab: async (range?: OverviewRange): Promise<IOverviewCustomersTab> => {
|
||||
const response = await client.get<IOverviewCustomersTab>(O.CUSTOMERS, {
|
||||
params: range ? { range } : undefined,
|
||||
|
||||
@@ -22,6 +22,11 @@ export type {
|
||||
IOverviewLabelCount,
|
||||
IOverviewPaymentMethodBreakdown,
|
||||
IOverviewCurrencyAmount,
|
||||
IOverviewMatrixCell,
|
||||
IOverviewTrainLoad,
|
||||
IOverviewTurnaround,
|
||||
IOverviewFleetTab,
|
||||
IOverviewClearanceTab,
|
||||
IOverviewBookingsTab,
|
||||
IOverviewContractsTab,
|
||||
IOverviewBillingTab,
|
||||
|
||||
@@ -33,8 +33,6 @@ import { contractsService } from "@/services/contracts.service";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
import "./contract-sign-bar.css";
|
||||
|
||||
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
|
||||
|
||||
/**
|
||||
@@ -227,9 +225,29 @@ export default function ContractViewPage() {
|
||||
return (
|
||||
<Box
|
||||
p={{ base: "md", md: "xl" }}
|
||||
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
|
||||
style={{
|
||||
// Lock the page to the viewport so the contract iframe is the ONLY
|
||||
// scroll container — page scroll + iframe scroll together made the
|
||||
// sign flow slippery. The negative margin cancels AppShell.Main's
|
||||
// global 112px bottom clearance (see AppLayout) for this page only;
|
||||
// the sign bar below already keeps content clear of the chat FAB.
|
||||
height: "calc(100dvh - var(--app-shell-header-offset, 72px))",
|
||||
marginBottom: -112,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<Box maw={920} mx="auto">
|
||||
<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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user