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

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

View File

@@ -0,0 +1,154 @@
import { Grid, Stack } from "@mantine/core";
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
import {
useOverviewClearanceTab,
useOverviewOperationsTab,
} from "@/hooks/useOverview";
import {
AsyncBand,
Band,
DIRECTION_SERIES,
formatDayLabel,
labelsToDonut,
pivotMatrix,
toDonut,
type RoleOverviewProps,
} from "./layout-kit";
/**
* The GL desks (Ethiopia / Djibouti) work cargo through clearance: containers
* and cargo state first, the trains carrying them second, and the bookings
* waiting on a human third. Clearance-document counts are not aggregated by
* the overview API yet, so this stops at cargo state.
*/
export function ClearanceOverview({ data, range }: RoleOverviewProps) {
const ops = useOverviewOperationsTab(range, true);
const clearance = useOverviewClearanceTab(true);
const handovers = pivotMatrix(clearance.data?.handoversByMile);
return (
<Stack gap="xl" mt="xl">
{/* Work queue first: it is what a GL desk acts on, and it is the band
that always has rows even when no cargo is in the yard. */}
<Band index={1} title="Waiting on someone">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewAttentionCard
bookings={data.kpis.bookings}
contracts={data.kpis.contracts}
billing={data.kpis.billing}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
</Grid.Col>
</Grid>
</Band>
<AsyncBand index={2} title="Documents & invoices" query={clearance}>
{(tab) => (
<Grid gap="md">
{/* Donuts stay at lg 4 — their legends ellipsise below ~300px. */}
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Booking documents"
data={toDonut(tab.bookingDocumentsByStatus)}
emptyMessage="No documents uploaded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Contract documents"
data={toDonut(tab.contractDocumentsByStatus)}
emptyMessage="No documents uploaded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Invoices by status"
data={toDonut(tab.invoicesByStatus)}
emptyMessage="No invoices raised"
/>
</Grid.Col>
<Grid.Col span={12}>
<OverviewStackedBarChart
title="Handover papers"
data={handovers.rows}
series={handovers.series}
xKey="group"
emptyMessage="No handovers generated"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<AsyncBand index={3} title="Cargo & containers" query={ops}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="Container status"
data={toDonut(tab.containerStatusBreakdown)}
emptyMessage="No containers tracked"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="Containers by size"
data={labelsToDonut(tab.containersBySize)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="Cargo status"
data={toDonut(tab.cargoStatusBreakdown)}
emptyMessage="No cargo recorded"
/>
</Grid.Col>
<Grid.Col span={12}>
<OverviewHorizontalBarChart
title="Cargo tonnage by type"
data={(tab.cargoTonnageByType ?? []).map((item) => ({
label: item.label,
value: item.tons,
}))}
valueLabel="Tons"
emptyMessage="No cargo recorded"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<AsyncBand index={4} title="Train movement" query={ops}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Train departures by direction"
data={tab.departureTrend ?? []}
series={DIRECTION_SERIES}
formatXLabel={formatDayLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Schedule status"
data={toDonut(tab.scheduleStatusBreakdown)}
emptyMessage="No train schedules yet"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
</Stack>
);
}

View File

@@ -0,0 +1,76 @@
import { Grid, Stack } from "@mantine/core";
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
import { Band, type RoleOverviewProps } from "./layout-kit";
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
/**
* The default layout — money, attention, network, pipeline. Kept for the CEO,
* director and org-manager roles, and for anyone whose role has no dedicated
* dashboard (superadmin, IAM admins).
*/
export function ExecutiveOverview({ data, range }: RoleOverviewProps) {
return (
<Stack gap="xl" mt="xl">
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
<Band index={1} title="Revenue & volume">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewRevenueVolumeChart
bookingTrend={data.bookingTrend}
paymentTrend={data.paymentTrend}
previousPaymentTrend={data.previousPaymentTrend}
rangeDays={RANGE_DAYS[range] ?? 30}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewRevenueMix
byDirection={data.revenueByDirection}
byFreightType={data.revenueByFreightType}
/>
</Grid.Col>
</Grid>
</Band>
{/* Band 2 — where the money runs, and what's waiting on someone. */}
<Band index={2} title="Money flow & attention">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewSankeyFlow flows={data.revenueFlows} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewAttentionCard
bookings={data.kpis.bookings}
contracts={data.kpis.contracts}
billing={data.kpis.billing}
/>
</Grid.Col>
</Grid>
</Band>
{/* Band 3 — the network now, and when demand arrives. */}
<Band index={3} title="Network & rhythm">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewNetworkCard kpis={data.kpis.operations} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
</Grid.Col>
</Grid>
</Band>
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
<Band index={4} title="Pipeline">
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
</Band>
</Stack>
);
}

View File

@@ -0,0 +1,58 @@
import { Grid, Stack } from "@mantine/core";
import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel";
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
import { useOverviewBillingTab } from "@/hooks/useOverview";
import { AsyncBand, Band, type RoleOverviewProps } from "./layout-kit";
const RANGE_DAYS: Record<string, number> = { "7d": 7, "30d": 30, "90d": 90 };
/** Money only: what was earned, how it was collected, and what is still owed. */
export function FinanceOverview({ data, range }: RoleOverviewProps) {
const billing = useOverviewBillingTab(range, true);
return (
<Stack gap="xl" mt="xl">
<Band index={1} title="Revenue & volume">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewRevenueVolumeChart
bookingTrend={data.bookingTrend}
paymentTrend={data.paymentTrend}
previousPaymentTrend={data.previousPaymentTrend}
rangeDays={RANGE_DAYS[range] ?? 30}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewRevenueMix
byDirection={data.revenueByDirection}
byFreightType={data.revenueByFreightType}
/>
</Grid.Col>
</Grid>
</Band>
<AsyncBand index={2} title="Payments" query={billing}>
{(tab) => <OverviewBillingTabPanel data={tab} />}
</AsyncBand>
<Band index={3} title="Money flow & attention">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewSankeyFlow flows={data.revenueFlows} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewAttentionCard
bookings={data.kpis.bookings}
contracts={data.kpis.contracts}
billing={data.kpis.billing}
/>
</Grid.Col>
</Grid>
</Band>
</Stack>
);
}

View File

@@ -0,0 +1,154 @@
import { Grid, Stack } from "@mantine/core";
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel";
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
import {
useOverviewClearanceTab,
useOverviewContractsTab,
useOverviewCustomersTab,
useOverviewOperationsTab,
} from "@/hooks/useOverview";
import { humanize } from "@/lib/format";
import {
AsyncBand,
Band,
DIRECTION_SERIES,
enumLabelsToBars,
enumLabelsToDonut,
formatDayLabel,
pivotMatrix,
toDonut,
type RoleOverviewProps,
} from "./layout-kit";
/**
* Customer-facing view: who is buying, what they booked, what they signed and
* what it earned. Customs-declaration document counts and the Djibouti invoice
* queue are part of the marketing brief but have no overview aggregation yet.
*/
export function MarketingOverview({ data, range }: RoleOverviewProps) {
const customers = useOverviewCustomersTab(range, true);
const contracts = useOverviewContractsTab(range, true);
const ops = useOverviewOperationsTab(range, true);
const clearance = useOverviewClearanceTab(true);
const profilesByType = pivotMatrix(customers.data?.profilesByTypeStatus);
return (
<Stack gap="xl" mt="xl">
<AsyncBand index={1} title="Customers" query={customers}>
{(tab) => (
<Stack gap="md">
<OverviewCustomersTabPanel data={tab} />
{/* Statuses live on the profile, not the company — so active vs
pending vs suspended is only meaningful per trade role. */}
<OverviewStackedBarChart
title="Profiles by trade role and status"
data={profilesByType.rows}
series={profilesByType.series}
xKey="group"
formatXLabel={humanize}
emptyMessage="No customer profiles yet"
/>
</Stack>
)}
</AsyncBand>
<Band index={2} title="Booking demand">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewDonutChart
title="Bookings by status"
data={toDonut(data.bookingsByStatus)}
emptyMessage="No bookings yet"
/>
</Grid.Col>
</Grid>
</Band>
<AsyncBand index={3} title="Contracts" query={contracts}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Contracts by status"
data={toDonut(tab.contractsByStatus)}
emptyMessage="No contracts yet"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Contracts by kind"
data={enumLabelsToDonut(tab.contractsByKind)}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewHorizontalBarChart
title="Contracts by freight type"
data={enumLabelsToBars(tab.contractsByFreightType)}
valueLabel="Contracts"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<AsyncBand index={4} title="Documents & invoicing" query={clearance}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="Customs documents"
data={toDonut(tab.bookingDocumentsByStatus)}
emptyMessage="No documents uploaded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewDonutChart
title="Invoices by status"
data={toDonut(tab.invoicesByStatus)}
emptyMessage="No invoices raised"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 4 }}>
<OverviewHorizontalBarChart
title="Invoices by type"
data={enumLabelsToBars(tab.invoicesByType)}
valueLabel="Invoices"
emptyMessage="No invoices raised"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<Band index={5} title="Revenue & movement">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewRevenueMix
byDirection={data.revenueByDirection}
byFreightType={data.revenueByFreightType}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 8 }}>
{ops.data ? (
<OverviewStackedBarChart
title="Train departures by direction"
data={ops.data.departureTrend ?? []}
series={DIRECTION_SERIES}
formatXLabel={formatDayLabel}
emptyMessage="No scheduled departures in this period"
/>
) : null}
</Grid.Col>
</Grid>
</Band>
</Stack>
);
}

View File

@@ -0,0 +1,181 @@
import { CalendarClock, Send, Timer, Train, Truck } from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
import {
AsyncBand,
DIRECTION_SERIES,
formatDayLabel,
labelsToBars,
pivotMatrix,
sumCounts,
toDonut,
type RoleOverviewProps,
} from "./layout-kit";
/**
* Operation control centre view: what rolling stock sits where, and what is
* moving today. Locomotive availability per station and train turn-around are
* part of the OCC brief but have no overview aggregation yet — they are absent
* rather than approximated.
*/
export function OccOverview({ range }: RoleOverviewProps) {
const ops = useOverviewOperationsTab(range, true);
const fleet = useOverviewFleetTab(range, true);
const wagonsByYard = pivotMatrix(fleet.data?.wagonStatusByYard);
const locomotivesByYard = pivotMatrix(fleet.data?.locomotivesByYard);
return (
<Stack gap="xl" mt="xl">
<AsyncBand index={1} title="Network right now" query={ops}>
{(tab) => (
<Stack gap="md">
<OverviewKpiStrip
items={[
{
label: "Active trains",
value: tab.kpis?.trainsActive ?? 0,
icon: Train,
accent: "emerald",
},
{
label: "Departures due",
value: tab.kpis?.schedulesUpcoming ?? 0,
icon: CalendarClock,
accent: "sky",
},
{
label: "Dispatched today",
value: tab.kpis?.dispatchedToday ?? 0,
icon: Send,
accent: "amber",
},
// Labels stay short: five cells share ~1100px at 1440 wide,
// and a hint pushes the last two into truncation.
{
label: "Wagons free",
value: tab.kpis?.wagonsAvailable ?? 0,
icon: Truck,
},
{
label: "Turnaround",
value:
fleet.data?.avgTurnaroundHours != null
? `${fleet.data.avgTurnaroundHours}h`
: "—",
icon: Timer,
accent: "violet",
},
]}
/>
{/* Yard and type are long-tailed lists — bars read better than
donuts, and the donuts stay at lg 4 so their legends fit. */}
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
{/* Per-yard status, not a plain per-yard count: the OCC needs to
know which of the wagons standing at a yard can actually run. */}
<OverviewStackedBarChart
title="Wagons by yard and status"
data={wagonsByYard.rows}
series={wagonsByYard.series}
xKey="group"
emptyMessage="No wagons positioned"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewHorizontalBarChart
title="Wagons by type"
data={labelsToBars(tab.wagonsByType)}
valueLabel="Wagons"
emptyMessage="No wagons registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title={`Wagon status (${sumCounts(tab.wagonStatusBreakdown)})`}
data={toDonut(tab.wagonStatusBreakdown)}
emptyMessage="No wagons registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Train status"
data={toDonut(tab.trainStatusBreakdown)}
emptyMessage="No trains registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Container status"
data={toDonut(tab.containerStatusBreakdown)}
emptyMessage="No containers tracked"
/>
</Grid.Col>
</Grid>
</Stack>
)}
</AsyncBand>
<AsyncBand index={2} title="Locomotives" query={fleet}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewStackedBarChart
title="Locomotives by station and status"
data={locomotivesByYard.rows}
series={locomotivesByYard.series}
xKey="group"
emptyMessage="No locomotives registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewDonutChart
title={`Locomotive status (${sumCounts(tab.locomotiveStatusBreakdown)})`}
data={toDonut(tab.locomotiveStatusBreakdown)}
emptyMessage="No locomotives registered"
/>
</Grid.Col>
<Grid.Col span={12}>
<OverviewHorizontalBarChart
title="Turnaround per train set (departure to departure)"
data={(tab.turnaroundByTrain ?? []).map((row) => ({
label: row.trainSet,
value: row.hours,
}))}
valueLabel="Hours"
emptyMessage="No train set has two recorded departures in this period"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<AsyncBand index={3} title="Train movement" query={ops}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Departures by direction"
data={tab.departureTrend ?? []}
series={DIRECTION_SERIES}
formatXLabel={formatDayLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Schedule status"
data={toDonut(tab.scheduleStatusBreakdown)}
emptyMessage="No train schedules yet"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
</Stack>
);
}

View File

@@ -0,0 +1,181 @@
import { CircleCheck, Link2, OctagonAlert, Truck, Wrench } from "lucide-react";
import { Grid, Stack } from "@mantine/core";
import { OverviewDonutChart } from "@/components/overview/OverviewDonutChart";
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
import { OverviewKpiStrip } from "@/components/overview/OverviewKpiStrip";
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
import { useOverviewFleetTab, useOverviewOperationsTab } from "@/hooks/useOverview";
import { TrainLoadCard } from "./TrainLoadCard";
import {
AsyncBand,
Band,
DIRECTION_SERIES,
countByStatus,
formatDayLabel,
labelsToBars,
labelsToDonut,
pivotMatrix,
sumCounts,
toDonut,
type RoleOverviewProps,
} from "./layout-kit";
/**
* Wagon fleet, booking demand and freight moved — the operations officer's
* three questions. Wagon counts come from the status breakdown rather than the
* headline KPI so every lifecycle state (including detained / out of service)
* is accounted for against the same total.
*/
export function OperationsOverview({ data, range }: RoleOverviewProps) {
const ops = useOverviewOperationsTab(range, true);
const fleet = useOverviewFleetTab(range, true);
const statusByType = pivotMatrix(fleet.data?.wagonStatusByType);
const bookingsByPort = pivotMatrix(ops.data?.bookingStatusByPort);
return (
<Stack gap="xl" mt="xl">
<AsyncBand index={1} title="Wagon fleet" query={ops}>
{(tab) => {
const wagons = tab.wagonStatusBreakdown ?? [];
const total = sumCounts(wagons);
const available = countByStatus(wagons, "AVAILABLE", "IMPORT_READY", "EXPORT_READY");
const assigned = countByStatus(wagons, "ASSIGNED");
return (
<Stack gap="md">
{/* Five cells fit a 1440px screen only without hints — the
status donut below carries the same detail anyway. */}
<OverviewKpiStrip
items={[
{ label: "Total wagons", value: total, icon: Truck },
{
label: "Available",
value: available,
icon: CircleCheck,
accent: "emerald",
},
{ label: "Assigned", value: assigned, icon: Link2, accent: "sky" },
{
label: "Maintenance",
value: countByStatus(wagons, "MAINTENANCE"),
icon: Wrench,
accent: "amber",
},
{
label: "Detained / OOS",
value: countByStatus(wagons, "DETAINED", "OUT_OF_SERVICE"),
icon: OctagonAlert,
accent: "rose",
},
]}
/>
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Wagons by status"
data={toDonut(wagons)}
emptyMessage="No wagons registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 8 }}>
{/* Status per type answers "how many flat wagons can I use
today", which the plain per-type count could not. */}
<OverviewStackedBarChart
title="Wagons by type and status"
data={statusByType.rows}
series={statusByType.series}
xKey="group"
emptyMessage="No wagons registered"
/>
</Grid.Col>
<Grid.Col span={12}>
<OverviewHorizontalBarChart
title="Wagons by yard"
data={labelsToBars(tab.wagonsByYard)}
valueLabel="Wagons"
/>
</Grid.Col>
</Grid>
</Stack>
);
}}
</AsyncBand>
<Band index={2} title="Booking demand">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewDonutChart
title="Bookings by status"
data={toDonut(data.bookingsByStatus)}
emptyMessage="No bookings yet"
/>
</Grid.Col>
</Grid>
</Band>
<AsyncBand index={3} title="Freight moved" query={ops}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewStackedBarChart
title="Train departures by direction"
data={tab.departureTrend ?? []}
series={DIRECTION_SERIES}
formatXLabel={formatDayLabel}
emptyMessage="No scheduled departures in this period"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewDonutChart
title="Train status"
data={toDonut(tab.trainStatusBreakdown)}
emptyMessage="No trains registered"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewHorizontalBarChart
title="Cargo tonnage by type"
data={(tab.cargoTonnageByType ?? []).map((item) => ({
label: item.label,
value: item.tons,
}))}
valueLabel="Tons"
emptyMessage="No cargo recorded"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 6 }}>
<OverviewDonutChart
title="Containers by size"
data={labelsToDonut(tab.containersBySize)}
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
<AsyncBand index={4} title="Trains & ports" query={ops}>
{(tab) => (
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<TrainLoadCard loads={tab.trainLoads ?? []} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewStackedBarChart
title="Bookings by port and status"
data={bookingsByPort.rows}
series={bookingsByPort.series}
xKey="group"
emptyMessage="No bookings in this period"
/>
</Grid.Col>
</Grid>
)}
</AsyncBand>
</Stack>
);
}

View File

@@ -0,0 +1,74 @@
import { TrainFront } from "lucide-react";
import { Badge, Group, Progress, Stack, Text } from "@mantine/core";
import { humanize } from "@/lib/format";
import type { IOverviewTrainLoad } from "@/types/overview";
import { SummaryCard } from "@/components/overview/summary/SummaryCard";
const DIRECTION_COLOR: Record<string, string> = {
IMPORT: "blue",
EXPORT: "yellow",
DOMESTIC: "violet",
};
/**
* Wagon fill per scheduled train: the bar is allocated slots against the train
* set's own wagon count, so a short train at 100% reads as full rather than as
* a small number next to a big one.
*/
export function TrainLoadCard({ loads = [] }: { loads: IOverviewTrainLoad[] }) {
return (
<SummaryCard
icon={TrainFront}
accent="blue"
title="Wagons allocated per train"
subtitle="Slots filled and tonnage loaded"
>
{loads.length === 0 ? (
<Text size="sm" c="dimmed" ta="center" py="xl">
No scheduled trains
</Text>
) : (
<Stack gap="sm">
{loads.map((load) => {
const percent = load.wagonsTotal
? Math.round((load.wagonsAllocated / load.wagonsTotal) * 100)
: 0;
return (
<Stack key={load.scheduleId} gap={4}>
<Group justify="space-between" gap="xs" wrap="nowrap">
<Group gap={8} wrap="nowrap" style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{load.trainNumber}
</Text>
<Badge
size="xs"
variant="light"
color={DIRECTION_COLOR[load.direction] ?? "gray"}
style={{ textTransform: "none" }}
>
{humanize(load.direction)}
</Badge>
<Text size="xs" c="dimmed">
{load.date}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{load.wagonsAllocated}/{load.wagonsTotal} wagons ·{" "}
{Math.round(load.tons).toLocaleString()} t
</Text>
</Group>
<Progress
value={percent}
color={percent >= 90 ? "edr-green" : percent > 0 ? "yellow" : "gray"}
size="sm"
radius="xl"
/>
</Stack>
);
})}
</Stack>
)}
</SummaryCard>
);
}

View File

@@ -0,0 +1,164 @@
import type { ReactNode } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, Skeleton, Stack, Text } from "@mantine/core";
import { humanize } from "@/lib/format";
import { overviewChartColors } from "@/components/overview/overview.styles";
import type {
IOverviewDashboard,
IOverviewLabelCount,
IOverviewMatrixCell,
IOverviewStatusCount,
OverviewRange,
} from "@/types/overview";
/** Every role layout takes the same summary payload + the selected range. */
export interface RoleOverviewProps {
data: IOverviewDashboard;
range: OverviewRange;
}
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
export function SectionTitle({ children }: { children: string }) {
return (
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
{children}
</Text>
);
}
/** One page band: eyebrow + content, with a staggered entrance by index. */
export function Band({
index,
title,
children,
}: {
index: number;
title: string;
children: ReactNode;
}) {
return (
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
<SectionTitle>{title}</SectionTitle>
{children}
</Stack>
);
}
/** Minimal shape of the react-query result a band consumes. */
interface BandQuery<T> {
data?: T;
isLoading: boolean;
isError: boolean;
}
/**
* A band fed by one of the per-domain overview endpoints. Loading shows a
* skeleton, a failure degrades to an inline notice — a role dashboard stitches
* several endpoints together and one 403 (a role without that domain's
* permission) must not blank the whole page.
*/
export function AsyncBand<T>({
index,
title,
query,
children,
}: {
index: number;
title: string;
query: BandQuery<T>;
children: (data: T) => ReactNode;
}) {
return (
<Band index={index} title={title}>
{query.isLoading ? (
<Skeleton height={300} radius="lg" />
) : query.isError || !query.data ? (
<Alert
icon={<AlertCircle size={16} />}
color="gray"
variant="light"
title={`${title} unavailable`}
>
This section could not be loaded for your account.
</Alert>
) : (
children(query.data)
)}
</Band>
);
}
/** Fixed direction colors (CVD-validated pair + violet): color follows the entity. */
export const DIRECTION_SERIES = [
{ key: "exportCount", label: "Export", color: "#D98A0B" },
{ key: "importCount", label: "Import", color: "#0369a1" },
{ key: "domesticCount", label: "Domestic", color: "#7c3aed" },
];
export function formatDayLabel(date: string) {
return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function sumCounts(items: IOverviewStatusCount[] = []) {
return items.reduce((sum, item) => sum + item.count, 0);
}
export function countByStatus(items: IOverviewStatusCount[] = [], ...statuses: string[]) {
return items
.filter((item) => statuses.includes(item.status))
.reduce((sum, item) => sum + item.count, 0);
}
/** Status breakdown → donut slices; statuses are enum keys, so humanize them. */
export function toDonut(items: IOverviewStatusCount[] = []) {
return items.map((item) => ({ name: humanize(item.status), value: item.count }));
}
/** Label breakdown → donut slices. Labels are names (yards, sizes) — left verbatim. */
export function labelsToDonut(items: IOverviewLabelCount[] = []) {
return items.map((item) => ({ name: item.label, value: item.count }));
}
export function labelsToBars(items: IOverviewLabelCount[] = []) {
return items.map((item) => ({ label: item.label, value: item.count }));
}
/**
* Matrix cells → the row/series shape OverviewStackedBarChart wants: one row
* per `group`, one series per distinct `series` value, colors fixed by the
* series' position so a status keeps its color across charts.
*/
export function pivotMatrix(cells: IOverviewMatrixCell[] = []) {
const seriesKeys = [...new Set(cells.map((cell) => cell.series))].sort();
const groups = [...new Set(cells.map((cell) => cell.group))];
const rows = groups.map((group) => {
const row: Record<string, string | number> = { group };
for (const key of seriesKeys) row[key] = 0;
for (const cell of cells) {
if (cell.group === group) row[cell.series] = cell.count;
}
return row;
});
const series = seriesKeys.map((key, index) => ({
key,
label: humanize(key),
color: overviewChartColors.pipeline[index % overviewChartColors.pipeline.length],
}));
return { rows, series };
}
/** Same, for breakdowns whose labels are enum values (ONE_TIME, BULK, …). */
export function enumLabelsToDonut(items: IOverviewLabelCount[] = []) {
return items.map((item) => ({ name: humanize(item.label), value: item.count }));
}
export function enumLabelsToBars(items: IOverviewLabelCount[] = []) {
return items.map((item) => ({ label: humanize(item.label), value: item.count }));
}

View File

@@ -0,0 +1,50 @@
import type { AuthUser } from "@/auth/types";
import { getPositionKeys } from "@/lib/permissions";
/** One overview composition. Every backoffice user lands on exactly one of these. */
export type OverviewLayoutKey =
| "executive"
| "operations"
| "occ"
| "marketing"
| "finance"
| "clearance";
export const OVERVIEW_LAYOUT_LABEL: Record<OverviewLayoutKey, string> = {
executive: "Executive dashboard",
operations: "Operations dashboard",
occ: "Control centre dashboard",
marketing: "Marketing dashboard",
finance: "Finance dashboard",
clearance: "Clearance & logistics dashboard",
};
/**
* Role/position key → layout, in match priority order: a user holding several
* of these keys gets the first match, so the specific operational view wins
* over the broad executive one. Position keys are matched too because the IAM
* payload models the GL desks as positions (`ethiopian_gl`) on some accounts
* and as roles (`edr_gl_ethiopia`) on others — see `getPositionKeys`.
*/
const ROLE_LAYOUTS: Array<[key: string, layout: OverviewLayoutKey]> = [
["edr_operations_officer", "operations"],
["truck_machinery_chief", "operations"],
["edr_line_staff", "occ"],
["edr_gl_ethiopia", "clearance"],
["edr_gl_djibouti", "clearance"],
["ethiopian_gl", "clearance"],
["djibouti_gl", "clearance"],
["edr_marketing", "marketing"],
["edr_finance", "finance"],
["edr_director", "executive"],
["edr_ceo", "executive"],
["edr_org_manager", "executive"],
];
/** Unmapped roles (superadmin, IAM admins, new roles) keep the executive layout. */
export function resolveOverviewLayout(
user: AuthUser | null | undefined,
): OverviewLayoutKey {
const held = new Set(getPositionKeys(user));
return ROLE_LAYOUTS.find(([key]) => held.has(key))?.[1] ?? "executive";
}

View File

@@ -36,6 +36,8 @@ interface OverviewHeroProps {
generatedAt?: string;
onRefresh: () => void;
isRefreshing?: boolean;
/** Name of the role-specific layout rendered below, e.g. "Operations dashboard". */
label?: string;
}
/**
@@ -49,6 +51,7 @@ export function OverviewHero({
generatedAt,
onRefresh,
isRefreshing,
label,
}: OverviewHeroProps) {
const { user } = useAuth();
const fullName = (user?.name?.en ?? user?.name?.am)?.trim();
@@ -97,6 +100,19 @@ export function OverviewHero({
<Text c="rgba(255,255,255,0.75)" size="sm">
{dateLabel}
</Text>
{label ? (
<Badge
size="sm"
variant="light"
style={{
background: "rgba(255,255,255,0.16)",
color: "rgba(255,255,255,0.9)",
textTransform: "none",
}}
>
{label}
</Badge>
) : null}
<Badge
size="sm"
variant="light"

View File

@@ -12,6 +12,16 @@ function formatCurrency(amount: number, currency: "ETB" | "USD") {
}).format(amount);
}
/** Compact form ("ETB 58.6M") — the hero cell is too narrow for nine digits. */
function formatCompactCurrency(amount: number, currency: "ETB" | "USD") {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency,
notation: "compact",
maximumFractionDigits: 1,
}).format(amount);
}
/** Period-over-period % change, or undefined when there's no prior baseline to compare against. */
function pctDelta(current: number, previous: number): number | undefined {
if (previous === 0) return undefined;
@@ -40,7 +50,7 @@ export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: Overvi
? [
{
label: `Revenue (${rangeLabel})`,
value: <CountUp value={current.revenueEtb} format={(n) => formatCurrency(n, "ETB")} />,
value: <CountUp value={current.revenueEtb} format={(n) => formatCompactCurrency(n, "ETB")} />,
hint: formatCurrency(current.revenueUsd, "USD"),
icon: Banknote,
color: "yellow",

View File

@@ -19,8 +19,8 @@ export interface KpiItem {
*/
color?: string;
/**
* Optional change vs a prior period, rendered as a ▲/▼ chip next to the value
* (green up, red down, muted zero). E.g. today's count minus yesterday's.
* Optional percent change vs a prior period, rendered as a tinted ▲/▼ pill
* next to the value (green up, red down; zero and null hidden).
*/
delta?: number;
/**
@@ -64,7 +64,9 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
key={item.label}
{...(linkProps as Record<string, unknown>)}
className={cn(
"flex flex-1 items-center gap-3 px-5 py-4",
// min-w-0 lets a crowded strip (five cells, long labels)
// truncate its labels instead of overflowing the card.
"flex min-w-0 flex-1 items-center gap-3 px-5 py-4",
index > 0 &&
"border-t border-edr-border sm:border-l sm:border-t-0",
item.href &&
@@ -115,7 +117,7 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) {
}}
>
{item.delta > 0 ? "▲" : "▼"}
{Math.abs(item.delta)}
{Math.abs(item.delta)}%
</Text>
) : null}
</div>

View File

@@ -242,6 +242,8 @@ export const QUERY_KEYS = {
["overview", "billing", range ?? "30d"] as const,
operationsTab: (range?: string) =>
["overview", "operations", range ?? "30d"] as const,
fleetTab: (range?: string) => ["overview", "fleet", range ?? "30d"] as const,
clearanceTab: () => ["overview", "clearance"] as const,
customersTab: (range?: string) =>
["overview", "customers", range ?? "30d"] as const,
staffTab: (range?: string) =>

View File

@@ -166,6 +166,8 @@ export const URL_CONSTANTS = {
CONTRACTS: "/overview/contracts",
BILLING: "/overview/billing",
OPERATIONS: "/overview/operations",
FLEET: "/overview/fleet",
CLEARANCE: "/overview/clearance",
CUSTOMERS: "/overview/customers",
STAFF: "/overview/staff",
},

View File

@@ -43,6 +43,22 @@ export function useOverviewOperationsTab(range: OverviewRange, enabled: boolean)
});
}
export function useOverviewFleetTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.fleetTab(range),
queryFn: () => overviewService.getFleetTab(range),
enabled,
});
}
export function useOverviewClearanceTab(enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.clearanceTab(),
queryFn: () => overviewService.getClearanceTab(),
enabled,
});
}
export function useOverviewCustomersTab(range: OverviewRange, enabled: boolean) {
return useQuery({
queryKey: QUERY_KEYS.OVERVIEW.customersTab(range),

View File

@@ -1,44 +1,40 @@
import { useState } from "react";
import { useState, type ReactElement } from "react";
import { AlertCircle } from "lucide-react";
import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core";
import { Alert, Button, Skeleton, Stack } from "@mantine/core";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/auth/useAuth";
import { PageContainer } from "@/components/page";
import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap";
import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard";
import { ClearanceOverview } from "@/components/overview/layouts/ClearanceOverview";
import { ExecutiveOverview } from "@/components/overview/layouts/ExecutiveOverview";
import { FinanceOverview } from "@/components/overview/layouts/FinanceOverview";
import { MarketingOverview } from "@/components/overview/layouts/MarketingOverview";
import { OccOverview } from "@/components/overview/layouts/OccOverview";
import { OperationsOverview } from "@/components/overview/layouts/OperationsOverview";
import type { RoleOverviewProps } from "@/components/overview/layouts/layout-kit";
import {
OVERVIEW_LAYOUT_LABEL,
resolveOverviewLayout,
type OverviewLayoutKey,
} from "@/components/overview/role-dashboards.config";
import { OverviewHero } from "@/components/overview/summary/OverviewHero";
import { OverviewHeroKpis } from "@/components/overview/summary/OverviewHeroKpis";
import { OverviewNetworkCard } from "@/components/overview/summary/OverviewNetworkCard";
import { OverviewPipelineFunnel } from "@/components/overview/summary/OverviewPipelineFunnel";
import { OverviewRevenueMix } from "@/components/overview/summary/OverviewRevenueMix";
import { OverviewRevenueVolumeChart } from "@/components/overview/summary/OverviewRevenueVolumeChart";
import { OverviewSankeyFlow } from "@/components/overview/summary/OverviewSankeyFlow";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useOverview } from "@/hooks/useOverview";
import type { OverviewRange } from "@/types/overview";
import "@/components/overview/summary/overview-summary.css";
const RANGE_LABEL: Record<OverviewRange, string> = { "7d": "7d", "30d": "30d", "90d": "90d" };
const RANGE_DAYS: Record<OverviewRange, number> = { "7d": 7, "30d": 30, "90d": 90 };
/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */
function SectionTitle({ children }: { children: string }) {
return (
<Text fw={700} fz="sm" tt="uppercase" c="edr-muted" style={{ letterSpacing: 0.4 }}>
{children}
</Text>
);
}
/** One page band: eyebrow + content, with a staggered entrance by index. */
function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) {
return (
<Stack gap="sm" className="ov-band" style={{ animationDelay: `${index * 70}ms` }}>
<SectionTitle>{title}</SectionTitle>
{children}
</Stack>
);
}
/** Which composition each role sees below the hero. */
const LAYOUTS: Record<OverviewLayoutKey, (props: RoleOverviewProps) => ReactElement> = {
executive: ExecutiveOverview,
operations: OperationsOverview,
occ: OccOverview,
marketing: MarketingOverview,
finance: FinanceOverview,
clearance: ClearanceOverview,
};
function OverviewSkeleton() {
return (
@@ -54,8 +50,14 @@ function OverviewSkeleton() {
const OverviewPage = () => {
const [range, setRange] = useState<OverviewRange>("30d");
const queryClient = useQueryClient();
const { user } = useAuth();
const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range);
// Hero, range control and headline KPIs are role-neutral; everything below
// them is chosen by role key.
const layoutKey = resolveOverviewLayout(user);
const RoleLayout = LAYOUTS[layoutKey];
const accessDenied =
(error as { response?: { status?: number } } | null)?.response?.status === 403;
@@ -74,6 +76,7 @@ const OverviewPage = () => {
generatedAt={data?.generatedAt}
onRefresh={handleRefresh}
isRefreshing={isFetching && !isLoading}
label={OVERVIEW_LAYOUT_LABEL[layoutKey]}
/>
{data ? (
<div style={{ marginTop: -52, paddingInline: 20, position: "relative" }}>
@@ -115,60 +118,7 @@ const OverviewPage = () => {
<OverviewSkeleton />
</Stack>
) : data ? (
<Stack gap="xl" mt="xl">
{/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */}
<Band index={1} title="Revenue & volume">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 8 }}>
<OverviewRevenueVolumeChart
bookingTrend={data.bookingTrend}
paymentTrend={data.paymentTrend}
previousPaymentTrend={data.previousPaymentTrend}
rangeDays={RANGE_DAYS[range]}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 4 }}>
<OverviewRevenueMix
byDirection={data.revenueByDirection}
byFreightType={data.revenueByFreightType}
/>
</Grid.Col>
</Grid>
</Band>
{/* Band 2 — where the money runs, and what's waiting on someone. */}
<Band index={2} title="Money flow & attention">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewSankeyFlow flows={data.revenueFlows} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewAttentionCard
bookings={data.kpis.bookings}
contracts={data.kpis.contracts}
billing={data.kpis.billing}
/>
</Grid.Col>
</Grid>
</Band>
{/* Band 3 — the network now, and when demand arrives. */}
<Band index={3} title="Network & rhythm">
<Grid gap="md">
<Grid.Col span={{ base: 12, lg: 5 }}>
<OverviewNetworkCard kpis={data.kpis.operations} />
</Grid.Col>
<Grid.Col span={{ base: 12, lg: 7 }}>
<OverviewActivityHeatmap cells={data.bookingHeatmap} />
</Grid.Col>
</Grid>
</Band>
{/* Band 4 — the booking pipeline, full width so every stage bar has room. */}
<Band index={4} title="Pipeline">
<OverviewPipelineFunnel data={data.bookingsByPipeline} />
</Band>
</Stack>
<RoleLayout data={data} range={range} />
) : null}
</PageContainer>
);

View File

@@ -4,9 +4,11 @@ import { URL_CONSTANTS } from "@/constants/URLS";
import type {
IOverviewBillingTab,
IOverviewBookingsTab,
IOverviewClearanceTab,
IOverviewContractsTab,
IOverviewCustomersTab,
IOverviewDashboard,
IOverviewFleetTab,
IOverviewOperationsTab,
IOverviewStaffTab,
OverviewRange,
@@ -52,6 +54,19 @@ export const overviewService = {
return unwrap(response);
},
getFleetTab: async (range?: OverviewRange): Promise<IOverviewFleetTab> => {
const response = await client.get<IOverviewFleetTab>(O.FLEET, {
params: range ? { range } : undefined,
});
return unwrap(response);
},
/** Document + invoice queues; not range-scoped — these are open work items. */
getClearanceTab: async (): Promise<IOverviewClearanceTab> => {
const response = await client.get<IOverviewClearanceTab>(O.CLEARANCE);
return unwrap(response);
},
getCustomersTab: async (range?: OverviewRange): Promise<IOverviewCustomersTab> => {
const response = await client.get<IOverviewCustomersTab>(O.CUSTOMERS, {
params: range ? { range } : undefined,

View File

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

View File

@@ -33,8 +33,6 @@ import { contractsService } from "@/services/contracts.service";
import { api } from "@/services/api";
import { extractApiError } from "@/utils/result";
import "./contract-sign-bar.css";
const CONSENT_TEXT = "I have read the entire contract and agree to its terms.";
/**
@@ -227,9 +225,29 @@ export default function ContractViewPage() {
return (
<Box
p={{ base: "md", md: "xl" }}
pb={data.canSignCustomer ? { base: 220, sm: 160, md: 120 } : undefined}
style={{
// Lock the page to the viewport so the contract iframe is the ONLY
// scroll container — page scroll + iframe scroll together made the
// sign flow slippery. The negative margin cancels AppShell.Main's
// global 112px bottom clearance (see AppLayout) for this page only;
// the sign bar below already keeps content clear of the chat FAB.
height: "calc(100dvh - var(--app-shell-header-offset, 72px))",
marginBottom: -112,
display: "flex",
flexDirection: "column",
}}
>
<Box maw={920} mx="auto">
<Box
maw={920}
mx="auto"
w="100%"
style={{
flex: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
}}
>
<Group justify="space-between" wrap="wrap" gap="sm" mb="md">
<Button
variant="subtle"
@@ -272,15 +290,24 @@ export default function ContractViewPage() {
</Alert>
)}
<Paper withBorder radius="lg" p={0} style={{ overflow: "hidden" }}>
<Paper
withBorder
radius="lg"
p={0}
// minHeight keeps the document usable on short/landscape viewports;
// the page then overflows and window-scrolls a little, which beats
// an unreadably squashed iframe.
style={{ overflow: "hidden", flex: 1, minHeight: 220 }}
>
<iframe
ref={iframeRef}
srcDoc={data.html}
title="Contract document"
onLoad={handleIframeLoad}
style={{
display: "block",
width: "100%",
minHeight: "80vh",
height: "100%",
border: "none",
background: "white",
}}
@@ -293,19 +320,18 @@ export default function ContractViewPage() {
withBorder
radius="lg"
p="md"
mt="md"
w="100%"
maw={920}
mx="auto"
style={{
position: "fixed",
bottom: 0,
left: "var(--sign-bar-left, 0px)",
right: 0,
zIndex: 100,
borderTop: "1px solid var(--mantine-color-gray-3)",
// Above SupportWidget's Affix (zIndex 300) — the fixed chat FAB
// shares this bottom-right corner and would otherwise render on
// top of the required agree-and-sign bar.
position: "relative",
zIndex: 301,
background: "var(--mantine-color-body)",
paddingBottom: "max(env(safe-area-inset-bottom, 0px), 16px)",
maxHeight: "80vh",
overflowY: "auto",
}}
className="contract-sign-bar"
>
<Box maw={920} mx="auto">
<Group justify="flex-start" align="flex-start" wrap="wrap" gap="sm">

View File

@@ -1,11 +0,0 @@
/* Keeps the fixed sign bar confined to the content area (right of the
navbar) instead of spanning the full viewport and drifting off-center. */
.contract-sign-bar {
--sign-bar-left: 0px;
}
@media (min-width: 48em) {
.contract-sign-bar {
--sign-bar-left: 260px;
}
}