diff --git a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts index 005dad08a..1bd22c7c0 100644 --- a/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts +++ b/apps/edr-freight-api/src/modules/overview/dto/overview-response.dto.ts @@ -21,6 +21,7 @@ export class OverviewContractKpisDto { export class OverviewOperationsKpisDto { @ApiProperty() trainsActive!: number; @ApiProperty() wagonsAvailable!: number; + @ApiProperty() wagonsTotal!: number; @ApiProperty() containersInTransit!: number; @ApiProperty() cargoesLoaded!: number; @ApiProperty() schedulesUpcoming!: number; @@ -108,6 +109,41 @@ export class OverviewRecentContractDto { @ApiProperty() createdAt!: string; } +export class OverviewPeriodTotalsDto { + @ApiProperty() bookingsCreated!: number; + @ApiProperty() revenueEtb!: number; + @ApiProperty() revenueUsd!: number; + @ApiProperty() tons!: number; +} + +export class OverviewRevenueSliceDto { + @ApiProperty() label!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewTonsTrendPointDto { + @ApiProperty({ example: '2026-06-01' }) date!: string; + @ApiProperty() tons!: number; +} + +export class OverviewRevenueFlowDto { + @ApiProperty() direction!: string; + @ApiProperty() freightType!: string; + @ApiProperty() amountEtb!: number; + @ApiProperty() amountUsd!: number; +} + +export class OverviewHeatmapCellDto { + @ApiProperty({ description: 'ISO weekday, 1 = Monday … 7 = Sunday' }) + dow!: number; + + @ApiProperty({ description: '3-hour block, 0 = 00–03 … 7 = 21–24' }) + block!: number; + + @ApiProperty() count!: number; +} + export class OverviewResponseDto { @ApiProperty({ type: OverviewKpisDto }) kpis!: OverviewKpisDto; @@ -124,8 +160,29 @@ export class OverviewResponseDto { @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) paymentTrend!: OverviewPaymentTrendPointDto[]; - @ApiProperty({ type: [OverviewRecentBookingDto] }) - recentBookings!: OverviewRecentBookingDto[]; + @ApiProperty({ type: OverviewPeriodTotalsDto }) + current!: OverviewPeriodTotalsDto; + + @ApiProperty({ type: OverviewPeriodTotalsDto }) + previous!: OverviewPeriodTotalsDto; + + @ApiProperty({ type: [OverviewRevenueSliceDto] }) + revenueByDirection!: OverviewRevenueSliceDto[]; + + @ApiProperty({ type: [OverviewRevenueSliceDto] }) + revenueByFreightType!: OverviewRevenueSliceDto[]; + + @ApiProperty({ type: [OverviewPaymentTrendPointDto] }) + previousPaymentTrend!: OverviewPaymentTrendPointDto[]; + + @ApiProperty({ type: [OverviewTonsTrendPointDto] }) + tonsTrend!: OverviewTonsTrendPointDto[]; + + @ApiProperty({ type: [OverviewRevenueFlowDto] }) + revenueFlows!: OverviewRevenueFlowDto[]; + + @ApiProperty({ type: [OverviewHeatmapCellDto] }) + bookingHeatmap!: OverviewHeatmapCellDto[]; @ApiProperty() generatedAt!: string; } diff --git a/apps/edr-freight-api/src/modules/overview/overview.repository.ts b/apps/edr-freight-api/src/modules/overview/overview.repository.ts index c61320d8c..18b6bb54d 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.repository.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.repository.ts @@ -155,6 +155,7 @@ export class OverviewRepository { async getOperationsKpis(): Promise<{ trainsActive: number; wagonsAvailable: number; + wagonsTotal: number; containersInTransit: number; cargoesLoaded: number; schedulesUpcoming: number; @@ -163,6 +164,7 @@ export class OverviewRepository { const [ trainsActive, wagonsAvailable, + wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, @@ -185,6 +187,10 @@ export class OverviewRepository { status: Freight.WagonStatus.Available, }) .getCount(), + this.wagonRepository + .createQueryBuilder("wagon") + .where("wagon.deleted_at IS NULL") + .getCount(), this.containerRepository .createQueryBuilder("container") .where("container.deleted_at IS NULL") @@ -218,6 +224,7 @@ export class OverviewRepository { return { trainsActive, wagonsAvailable, + wagonsTotal, containersInTransit, cargoesLoaded, schedulesUpcoming, @@ -345,9 +352,16 @@ export class OverviewRepository { ); } + /** + * Daily successful-payment revenue for a `days`-wide window shifted back by + * `offsetDays` — `0` (default) is the current window ending today, + * `offsetDays: days` is the immediately preceding window (the ghost-line + * comparison series on the overview chart). + */ async getPaymentTrend( days: number, dirs?: string[], + offsetDays = 0, ): Promise<{ date: string; amountEtb: number; amountUsd: number }[]> { const scope = bookingRefScopeSql("payment.ref_id", dirs); const rows = await this.paymentRepository @@ -366,8 +380,8 @@ export class OverviewRepository { ) .where("payment.status = :status", { status: "success" }) .andWhere( - `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, - { days }, + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND COALESCE(payment.paid_at, payment.created_at) < CURRENT_DATE - :offsetDays::int + 1`, + { days, offsetDays }, ) .andWhere(scope.sql, scope.params) .groupBy(`COALESCE(payment.paid_at, payment.created_at)::date`) @@ -546,6 +560,247 @@ export class OverviewRepository { })); } + /** + * Bookings created, revenue and tonnage for one `days`-wide window, shifted + * back by `offsetDays`. Called twice by the service — `offsetDays: 0` for + * the current period, `offsetDays: days` for the immediately preceding + * one-of-the-same-length period — so the page can show a real vs-prior-period + * delta instead of a bare count. + */ + async getPeriodTotals( + days: number, + offsetDays: number, + dirs?: string[], + ): Promise<{ + bookingsCreated: number; + revenueEtb: number; + revenueUsd: number; + tons: number; + }> { + const bookingScope = directionScopeSql("booking.trade_direction", dirs); + const paymentScope = bookingRefScopeSql("payment.ref_id", dirs); + const cargoScope = directionScopeSql("booking.trade_direction", dirs); + const windowSql = (column: string) => + `${column} >= CURRENT_DATE - :offsetDays::int - :days::int + 1 AND ${column} < CURRENT_DATE - :offsetDays::int + 1`; + + const [bookingsCreated, revenueRow, tonsRow] = await Promise.all([ + this.bookingRepository + .createQueryBuilder("booking") + .where("booking.deleted_at IS NULL") + .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(bookingScope.sql, bookingScope.params) + .andWhere(windowSql("booking.created_at"), { days, offsetDays }) + .getCount(), + this.paymentRepository + .createQueryBuilder("payment") + .select( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "revenueEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "revenueUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + windowSql("COALESCE(payment.paid_at, payment.created_at)"), + { days, offsetDays }, + ) + .andWhere(paymentScope.sql, paymentScope.params) + .getRawOne<{ revenueEtb: string; revenueUsd: string }>(), + this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") + .select(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") + .where("cargo.deleted_at IS NULL") + .andWhere(windowSql("cargo.created_at"), { days, offsetDays }) + .andWhere(cargoScope.sql, cargoScope.params) + .getRawOne<{ tons: string }>(), + ]); + + return { + bookingsCreated, + revenueEtb: Number(revenueRow?.revenueEtb ?? 0), + revenueUsd: Number(revenueRow?.revenueUsd ?? 0), + tons: Number(tonsRow?.tons ?? 0), + }; + } + + /** Revenue for the selected range, split by booking trade direction. */ + async getRevenueByDirection( + days: number, + dirs?: string[], + ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.trade_direction", "label") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.trade_direction IS NOT NULL") + .groupBy("booking.trade_direction") + .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + label: row.label, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** Revenue for the selected range, split by booking freight type. */ + async getRevenueByFreightType( + days: number, + dirs?: string[], + ): Promise<{ label: string; amountEtb: number; amountUsd: number }[]> { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.freight_type", "label") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.freight_type IS NOT NULL") + .groupBy("booking.freight_type") + .getRawMany<{ label: string; amountEtb: string; amountUsd: string }>(); + + return rows.map((row) => ({ + label: row.label, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** Daily cargo tonnage for the selected range — hero sparkline series. */ + async getTonsTrend( + days: number, + dirs?: string[], + ): Promise<{ date: string; tons: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); + const rows = await this.cargoRepository + .createQueryBuilder("cargo") + .leftJoin(Booking, "booking", "booking.id = cargo.booking_id") + .select(`to_char(cargo.created_at::date, 'YYYY-MM-DD')`, "date") + .addSelect(`COALESCE(SUM(cargo.weight), 0) / 1000`, "tons") + .where("cargo.deleted_at IS NULL") + .andWhere(scope.sql, scope.params) + .andWhere(`cargo.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy("cargo.created_at::date") + .orderBy("cargo.created_at::date", "ASC") + .getRawMany<{ date: string; tons: string }>(); + + return rows.map((row) => ({ date: row.date, tons: Number(row.tons) })); + } + + /** + * Revenue for the selected range as direction → freight-type flows — the + * Sankey on the overview. One row per (direction, freight type) pair. + */ + async getRevenueFlows( + days: number, + dirs?: string[], + ): Promise< + { + direction: string; + freightType: string; + amountEtb: number; + amountUsd: number; + }[] + > { + const scope = bookingRefScopeSql("payment.ref_id", dirs); + const rows = await this.paymentRepository + .createQueryBuilder("payment") + .leftJoin(Booking, "booking", "booking.id::text = payment.ref_id") + .select("booking.trade_direction", "direction") + .addSelect("booking.freight_type", "freightType") + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'ETB'), 0)`, + "amountEtb", + ) + .addSelect( + `COALESCE(SUM(payment.amount) FILTER (WHERE payment.currency = 'USD'), 0)`, + "amountUsd", + ) + .where("payment.status = :status", { status: "success" }) + .andWhere( + `COALESCE(payment.paid_at, payment.created_at) >= CURRENT_DATE - :days::int + 1`, + { days }, + ) + .andWhere(scope.sql, scope.params) + .andWhere("booking.trade_direction IS NOT NULL") + .andWhere("booking.freight_type IS NOT NULL") + .groupBy("booking.trade_direction") + .addGroupBy("booking.freight_type") + .getRawMany<{ + direction: string; + freightType: string; + amountEtb: string; + amountUsd: string; + }>(); + + return rows.map((row) => ({ + direction: row.direction, + freightType: row.freightType, + amountEtb: Number(row.amountEtb), + amountUsd: Number(row.amountUsd), + })); + } + + /** + * Booking arrivals bucketed by ISO weekday (1 = Mon … 7 = Sun) and 3-hour + * block (0 = 00–03 … 7 = 21–24) — the demand-rhythm heatmap. Buckets use + * the database server's timezone, same as every ::date grouping here. + */ + async getBookingHeatmap( + days: number, + dirs?: string[], + ): Promise<{ dow: number; block: number; count: number }[]> { + const scope = directionScopeSql("booking.trade_direction", dirs); + const rows = await this.bookingRepository + .createQueryBuilder("booking") + .select("EXTRACT(ISODOW FROM booking.created_at)::int", "dow") + .addSelect("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int", "block") + .addSelect("COUNT(*)::int", "count") + .where("booking.deleted_at IS NULL") + .andWhere(EXCLUDE_GENERAL_CONTRACT_BOOKINGS) + .andWhere(scope.sql, scope.params) + .andWhere(`booking.created_at >= CURRENT_DATE - :days::int + 1`, { days }) + .groupBy("EXTRACT(ISODOW FROM booking.created_at)::int") + .addGroupBy("FLOOR(EXTRACT(HOUR FROM booking.created_at) / 3)::int") + .getRawMany<{ dow: string; block: string; count: string }>(); + + return rows.map((row) => ({ + dow: Number(row.dow), + block: Number(row.block), + count: Number(row.count), + })); + } + async getTrainStatusBreakdown(): Promise< { status: string; count: number }[] > { diff --git a/apps/edr-freight-api/src/modules/overview/overview.service.ts b/apps/edr-freight-api/src/modules/overview/overview.service.ts index 399bf4f72..a10bb8816 100644 --- a/apps/edr-freight-api/src/modules/overview/overview.service.ts +++ b/apps/edr-freight-api/src/modules/overview/overview.service.ts @@ -56,7 +56,14 @@ export class OverviewService { bookingTrend, statusCounts, paymentTrend, - recentBookings, + current, + previous, + revenueByDirection, + revenueByFreightType, + previousPaymentTrend, + tonsTrend, + revenueFlows, + bookingHeatmap, ] = await Promise.all([ this.overviewRepository.getBookingKpis(dirs), this.overviewRepository.getContractKpis(dirs), @@ -67,7 +74,14 @@ export class OverviewService { this.overviewRepository.getBookingTrend(days, dirs), this.overviewRepository.getStatusCounts(dirs), this.overviewRepository.getPaymentTrend(days, dirs), - this.overviewRepository.getRecentBookings(8, dirs), + this.overviewRepository.getPeriodTotals(days, 0, dirs), + this.overviewRepository.getPeriodTotals(days, days, dirs), + this.overviewRepository.getRevenueByDirection(days, dirs), + this.overviewRepository.getRevenueByFreightType(days, dirs), + this.overviewRepository.getPaymentTrend(days, dirs, days), + this.overviewRepository.getTonsTrend(days, dirs), + this.overviewRepository.getRevenueFlows(days, dirs), + this.overviewRepository.getBookingHeatmap(days, dirs), ]); const { bookingsByPipeline, bookingsByStatus } = @@ -86,10 +100,14 @@ export class OverviewService { bookingsByStatus, bookingsByPipeline, paymentTrend, - recentBookings: recentBookings.map((row) => ({ - ...row, - createdAt: row.createdAt.toISOString(), - })), + current, + previous, + revenueByDirection, + revenueByFreightType, + previousPaymentTrend, + tonsTrend, + revenueFlows, + bookingHeatmap, generatedAt: new Date().toISOString(), }; } diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index a9b9db226..b9b96de08 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -40,6 +40,8 @@ import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import FinanceHubPage from "./pages/invoices/FinanceHubPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; +import OverviewDomainPage from "./pages/dashboard/OverviewDomainPage"; +import { OVERVIEW_DOMAINS } from "./components/overview/overview-domains.config"; import ReportsIndexRedirect from "./pages/reports/ReportsIndexRedirect"; import ReportPage from "./pages/reports/ReportPage"; import AuditLogsPage from "./pages/AuditLogsPage"; @@ -222,6 +224,21 @@ const App = () => { } /> + {/* One drill-down route per overview domain — the old per-tab charts, + now each on its own page. Single source of truth for the + permission gate is OVERVIEW_DOMAINS, shared with the summary + page's "View all" links. */} + {OVERVIEW_DOMAINS.map((domain) => ( + + + + } + /> + ))} navigate(`/dashboard/booking-requests/${row.id}`)} - aria-label="View booking" - > - - - ); + return null; } // Toolbar: lay every action out as a button row. diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx index 7d1fff022..fa21d0d9f 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/BookingStatusBadge.tsx @@ -1,6 +1,7 @@ import { Badge, Group } from "@mantine/core"; import { Link2 } from "lucide-react"; import { BOOKING_STATUS_STYLES } from "@/features/bookings/booking-status.config"; +import { humanize } from "@/lib/format"; const statusColorMap: Record = { DRAFT: "gray", @@ -40,7 +41,7 @@ export function BookingStatusBadge({ partnerReference, }: BookingStatusBadgeProps) { const style = BOOKING_STATUS_STYLES[status] ?? { - label: status, + label: humanize(status), color: "gray", }; const color = statusColorMap[status] ?? "gray"; diff --git a/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx new file mode 100644 index 000000000..b97f827d4 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/common/FilterToggle.tsx @@ -0,0 +1,34 @@ +import { ActionIcon, Indicator } from "@mantine/core"; +import { Filter } from "lucide-react"; + +export interface FilterToggleProps { + /** Number of active advanced filters — shown as a badge on the button. */ + count: number; + expanded: boolean; + onClick: () => void; +} + +/** Toggle for the collapsible advanced-filters row on list pages. */ +export function FilterToggle({ count, expanded, onClick }: FilterToggleProps) { + return ( + + + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx index 1bf9b55e7..3a3c71449 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractMilestonesTimeline.tsx @@ -10,6 +10,7 @@ import { import { Group, Stack, Text, Timeline, Tooltip } from "@mantine/core"; import type { Freight } from "@edr/types"; +import { formatDate } from "@/lib/format"; import { CONTRACT_APPROVAL_ROLE_LABELS, HAZARDOUS_APPROVAL_ROLE_PERMISSION, @@ -54,10 +55,6 @@ function formatAgo(iso: string): string { return "just now"; } -function formatDate(iso: string): string { - return new Date(iso).toLocaleDateString(undefined, { dateStyle: "medium" }); -} - type MilestoneIcon = typeof Send; interface Milestone { diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx deleted file mode 100644 index 862bc1562..000000000 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractStatusTabs.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { Badge, ScrollArea, Tabs } from "@mantine/core"; -import { - ClipboardCheck, - FileSignature, - Inbox, - LayoutGrid, - ShieldCheck, - Truck, - XCircle, -} from "lucide-react"; - -import "@/components/overview/overview.css"; -import { - CONTRACT_LIST_TABS, - type ContractStatusTabKey, -} from "@/features/contracts/contract-status.config"; - -const TAB_ICONS: Record = { - all: , - intake: , - in_approval: , - approved_contract: , - clearance: , - active: , - closed: , -}; - -interface ContractStatusTabsProps { - active: ContractStatusTabKey; - onChange: (tab: ContractStatusTabKey) => void; - counts?: Partial>; -} - -export function ContractStatusTabs({ - active, - onChange, - counts, -}: ContractStatusTabsProps) { - return ( - onChange((value as ContractStatusTabKey) ?? "all")} - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - - {CONTRACT_LIST_TABS.map((tab) => { - const isActive = active === tab.key; - const count = counts?.[tab.key]; - return ( - - {count} - - ) : undefined - } - > - {tab.label} - - ); - })} - - - - ); -} - -export type { ContractStatusTabKey }; diff --git a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx index edcc4a517..90dea5adb 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx +++ b/apps/edr-freight-web/backoffice/src/components/customers/badges.tsx @@ -116,8 +116,9 @@ export function CompanyNationalityBadge({ /** * Profile chips for a company row: one chip per role (Importer / Exporter / …) - * carrying its reference code. Caps at three (a company has at most three - * profiles); any extra collapse into a `+N` chip. + * carrying its reference code, colored by the profile's status (green active, + * amber pending, red rejected/blacklisted). Caps at three (a company has at + * most three profiles); any extra collapse into a `+N` chip. */ export function ProfileChips({ profiles, @@ -152,14 +153,15 @@ export function ProfileChips({ withArrow > - {humanize(profile.type)} · {profile.reference} + {humanize(profile.type)} + {profile.reference ? ` · ${profile.reference}` : ""} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/customers/format.ts b/apps/edr-freight-web/backoffice/src/components/customers/format.ts index 0397c1cee..341170931 100644 --- a/apps/edr-freight-web/backoffice/src/components/customers/format.ts +++ b/apps/edr-freight-web/backoffice/src/components/customers/format.ts @@ -1,38 +1,2 @@ -/** Shared formatting helpers for the customer-management pages. */ - -/** snake_case / SCREAMING_CASE → Title Case. */ -export function humanize(value: string): string { - return value - .toLowerCase() - .split(/[_\s]+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); -} - -export function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - -export function formatMoney(amount: number, currency: string): string { - return new Intl.NumberFormat(undefined, { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function formatBytes(bytes: number): string { - if (!bytes) return "0 B"; - const units = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(1024)); - const value = bytes / Math.pow(1024, i); - return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; -} +/** @deprecated import from "@/lib/format" (or ../../lib/format) instead. */ +export { humanize, formatDate, formatDateTime, formatMoney, formatBytes } from "../../lib/format"; diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index 20794f72b..636310e38 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -36,6 +36,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Dashboard summary and key metrics", }, }, + { + prefix: "/dashboard/overview/bookings", + meta: { title: "Bookings", subtitle: "Booking volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/contracts", + meta: { title: "Contracts", subtitle: "Contract volume, pipeline, and recent activity" }, + }, + { + prefix: "/dashboard/overview/billing", + meta: { title: "Billing", subtitle: "Revenue, payments, and collection status" }, + }, + { + prefix: "/dashboard/overview/operations", + meta: { title: "Operations", subtitle: "Trains, schedules, containers, and cargo" }, + }, + { + prefix: "/dashboard/overview/fleet", + meta: { title: "Fleet", subtitle: "Wagon and train fleet status" }, + }, + { + prefix: "/dashboard/overview/customers", + meta: { title: "Customers", subtitle: "Customer growth and top accounts" }, + }, + { + prefix: "/dashboard/overview/staff", + meta: { title: "Staff", subtitle: "Employee and user account status" }, + }, { prefix: "/dashboard/profile", meta: { diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx deleted file mode 100644 index ea99d098e..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewKpiSection.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import { - AlertCircle, - Banknote, - Box, - Clock, - Container, - CreditCard, - FileText, - Train, - Truck, - UserCheck, - Users, - Wallet, -} from "lucide-react"; -import { Group, Paper, Stack, Text } from "@mantine/core"; - -import type { IOverviewKpis } from "@/types/overview"; -import { OverviewKpiCard } from "./OverviewKpiCard"; - -function formatCurrency(amount: number, currency: "ETB" | "USD") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - maximumFractionDigits: 0, - }).format(amount); -} - -export function OverviewKpiSection({ kpis }: { kpis: IOverviewKpis }) { - const bookingItems = [ - { - label: "Active bookings", - value: kpis.bookings.totalActive, - icon: FileText, - accent: "emerald" as const, - }, - { - label: "Needs action", - value: kpis.bookings.needsAction, - icon: AlertCircle, - accent: "amber" as const, - }, - { - label: "Urgent", - value: kpis.bookings.urgent, - icon: Clock, - accent: "rose" as const, - }, - { - label: "In approval", - value: kpis.bookings.inApproval, - icon: UserCheck, - accent: "sky" as const, - }, - { - label: "Submitted today", - value: kpis.bookings.submittedToday, - icon: FileText, - }, - ]; - - const operationsItems = [ - { - label: "Active trains", - value: kpis.operations.trainsActive, - icon: Train, - accent: "emerald" as const, - }, - { - label: "Wagons available", - value: kpis.operations.wagonsAvailable, - icon: Truck, - }, - { - label: "Containers in transit", - value: kpis.operations.containersInTransit, - icon: Container, - }, - { - label: "Cargoes loaded", - value: kpis.operations.cargoesLoaded, - icon: Box, - }, - ]; - - const billingItems = [ - { - label: "Revenue MTD (ETB)", - value: formatCurrency(kpis.billing.revenueMtdEtb, "ETB"), - icon: Banknote, - accent: "emerald" as const, - }, - { - label: "Revenue MTD (USD)", - value: formatCurrency(kpis.billing.revenueMtdUsd, "USD"), - icon: Wallet, - }, - { - label: "Pending payments", - value: kpis.billing.pendingPayments, - icon: CreditCard, - accent: "amber" as const, - }, - { - label: "Successful MTD", - value: kpis.billing.successfulPaymentsMtd, - icon: Banknote, - }, - ]; - - const peopleItems = [ - { - label: "Total customers", - value: kpis.customers.totalCustomers, - icon: Users, - }, - { - label: "New this month", - value: kpis.customers.newCustomersThisMonth, - icon: Users, - accent: "emerald" as const, - }, - { - label: "Active employees", - value: kpis.staff.activeEmployees, - icon: UserCheck, - }, - { - label: "Active users", - value: kpis.staff.activeUsers, - icon: Users, - }, - ]; - - const sections = [ - { title: "Bookings", items: bookingItems }, - { title: "Operations", items: operationsItems }, - { title: "Billing", items: billingItems }, - { title: "Customers & staff", items: peopleItems }, - ]; - - return ( - - {sections.map((section) => ( - - - {section.title} - - - {section.items.map((item) => ( - - ))} - - - ))} - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx deleted file mode 100644 index 529981add..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewPageHeader.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ActionIcon, Group, SegmentedControl, Text } from "@mantine/core"; -import { RefreshCw } from "lucide-react"; - -import type { OverviewRange } from "@/types/overview"; - -const RANGE_OPTIONS = [ - { label: "7 days", value: "7d" }, - { label: "30 days", value: "30d" }, - { label: "90 days", value: "90d" }, -]; - -function formatRelativeTime(iso: string | undefined) { - if (!iso) return "—"; - const diffMs = Date.now() - new Date(iso).getTime(); - const minutes = Math.floor(diffMs / 60_000); - if (minutes < 1) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h ago`; - return new Date(iso).toLocaleString(); -} - -interface OverviewPageHeaderProps { - range: OverviewRange; - onRangeChange: (range: OverviewRange) => void; - generatedAt?: string; - onRefresh: () => void; - isRefreshing?: boolean; -} - -export function OverviewPageHeader({ - range, - onRangeChange, - generatedAt, - onRefresh, - isRefreshing, -}: OverviewPageHeaderProps) { - return ( - - - Updated {formatRelativeTime(generatedAt)} - - - onRangeChange(value as OverviewRange)} - data={RANGE_OPTIONS} - size="sm" - radius="lg" - color="edr-green" - /> - - - - - - ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx index b13f5c473..e1ceaec1c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/OverviewRecentBookingsTable.tsx @@ -1,9 +1,11 @@ -import { useNavigate } from "react-router-dom"; -import { Paper, Stack, Table, Text } from "@mantine/core"; +import { History } from "lucide-react"; +import { Link, useNavigate } from "react-router-dom"; +import { Table, Text } from "@mantine/core"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; import type { IOverviewRecentBooking } from "@/types/overview"; +import { SummaryCard } from "./summary/SummaryCard"; function formatAmount(amount: number | null, currency: string | null) { if (amount == null) return "—"; @@ -23,56 +25,90 @@ export function OverviewRecentBookingsTable({ const navigate = useNavigate(); return ( - - - Recent bookings - {bookings.length === 0 ? ( - - No recent bookings - - ) : ( - - - - Reference - Customer - Status - Priority - Amount - Created - - - - {bookings.map((booking) => ( - navigate(`/dashboard/booking-requests/${booking.id}`)} - > - - - {booking.reference} - - - {booking.customerLabel} - - - - - - - + + View all → + + } + > + {bookings.length === 0 ? ( + + No recent bookings + + ) : ( +
+ + + {["Reference", "Customer", "Status", "Priority", "Amount", "Created"].map( + (header) => ( + + {header} + + ), + )} + + + + {bookings.map((booking) => ( + navigate(`/dashboard/booking-requests/${booking.id}`)} + > + + + {booking.reference} + + + + + {booking.customerLabel} + + + + + + + + + + {formatAmount(booking.totalAmount, booking.paymentCurrency)} - - + + + + {new Date(booking.createdAt).toLocaleDateString()} - - - ))} - -
- )} -
-
+ + + + ))} + + + )} + ); } diff --git a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx b/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx deleted file mode 100644 index cf71198a6..000000000 --- a/apps/edr-freight-web/backoffice/src/components/overview/OverviewTabContent.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { AlertCircle } from "lucide-react"; -import { Alert, Button, Center, Loader, Paper, Skeleton, Stack } from "@mantine/core"; - -import { - useOverviewBillingTab, - useOverviewBookingsTab, - useOverviewContractsTab, - useOverviewCustomersTab, - useOverviewOperationsTab, - useOverviewStaffTab, -} from "@/hooks/useOverview"; -import type { OverviewRange, OverviewTabKey } from "@/types/overview"; -import { OverviewBillingTabPanel } from "./tabs/OverviewBillingTabPanel"; -import { OverviewBookingsTabPanel } from "./tabs/OverviewBookingsTabPanel"; -import { OverviewContractsTabPanel } from "./tabs/OverviewContractsTabPanel"; -import { OverviewCustomersTabPanel } from "./tabs/OverviewCustomersTabPanel"; -import { OverviewFleetTabPanel } from "./tabs/OverviewFleetTabPanel"; -import { OverviewOperationsTabPanel } from "./tabs/OverviewOperationsTabPanel"; -import { OverviewStaffTabPanel } from "./tabs/OverviewStaffTabPanel"; - -function TabSkeleton() { - return ( - - - - - - ); -} - -interface OverviewTabContentProps { - tab: OverviewTabKey; - range: OverviewRange; -} - -export function OverviewTabContent({ tab, range }: OverviewTabContentProps) { - const bookings = useOverviewBookingsTab(range, tab === "bookings"); - const contracts = useOverviewContractsTab(range, tab === "contracts"); - const billing = useOverviewBillingTab(range, tab === "billing"); - // Fleet reuses the operations dataset — same query key, so switching between - // the two tabs costs one fetch. - const operations = useOverviewOperationsTab( - range, - tab === "operations" || tab === "fleet", - ); - const customers = useOverviewCustomersTab(range, tab === "customers"); - const staff = useOverviewStaffTab(range, tab === "staff"); - - const query = - tab === "bookings" - ? bookings - : tab === "contracts" - ? contracts - : tab === "billing" - ? billing - : tab === "operations" || tab === "fleet" - ? operations - : tab === "customers" - ? customers - : staff; - - const { isLoading, isError, refetch, isFetching } = query; - - if (isLoading) { - return ; - } - - if (isError || !query.data) { - return ( - - } - color="red" - title="Failed to load tab data" - variant="light" - > - - Could not load {tab} metrics. Please try again. - - - - - ); - } - - return ( - - {isFetching && ( -
- -
- )} - - {tab === "bookings" && bookings.data && ( - - )} - {tab === "contracts" && contracts.data && ( - - )} - {tab === "billing" && billing.data && ( - - )} - {tab === "operations" && operations.data && ( - - )} - {tab === "fleet" && operations.data && ( - - )} - {tab === "customers" && customers.data && ( - - )} - {tab === "staff" && staff.data && ( - - )} -
- ); -} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts new file mode 100644 index 000000000..4d106eeeb --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview-domains.config.ts @@ -0,0 +1,88 @@ +import { + Banknote, + FileSignature, + FileText, + Train, + TrainFront, + UserCheck, + Users, + type LucideIcon, +} from "lucide-react"; + +import { FREIGHT_PERMS } from "@/lib/permissions"; +import type { OverviewTabKey } from "@/types/overview"; + +/** + * Single source of truth for the seven overview drill-down pages — used both + * to build the `/dashboard/overview/:domain` routes in App.tsx and to render + * each page's header in OverviewDomainPage. One list, no duplicated permission + * arrays to drift out of sync. + */ +export const OVERVIEW_DOMAINS: Array<{ + key: OverviewTabKey; + label: string; + subtitle: string; + icon: LucideIcon; + /** Any of these keys grants the page. */ + permission: string[]; +}> = [ + { + key: "bookings", + label: "Bookings", + subtitle: "Booking volume, pipeline, and recent activity", + icon: FileText, + permission: [FREIGHT_PERMS.bookings.view], + }, + { + key: "contracts", + label: "Contracts", + subtitle: "Contract volume, pipeline, and recent activity", + icon: FileSignature, + permission: [FREIGHT_PERMS.contracts.view], + }, + { + key: "billing", + label: "Billing", + subtitle: "Revenue, payments, and collection status", + icon: Banknote, + permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], + }, + { + key: "operations", + label: "Operations", + subtitle: "Trains, schedules, containers, and cargo", + icon: Train, + permission: [ + FREIGHT_PERMS.trainScheduling.view, + FREIGHT_PERMS.warehouseInventory.view, + FREIGHT_PERMS.firstMile.view, + FREIGHT_PERMS.lastMile.view, + ], + }, + { + key: "fleet", + label: "Fleet", + subtitle: "Wagon and train fleet status", + icon: TrainFront, + permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], + }, + { + key: "customers", + label: "Customers", + subtitle: "Customer growth and top accounts", + icon: Users, + permission: [FREIGHT_PERMS.customers.view], + }, + { + key: "staff", + label: "Staff", + subtitle: "Employee and user account status", + icon: UserCheck, + permission: [ + FREIGHT_PERMS.admin, + FREIGHT_PERMS.staff.roles.view, + FREIGHT_PERMS.staff.employeeRegistration.view, + FREIGHT_PERMS.staff.roleAssignment.view, + ], + }, +]; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/overview.css b/apps/edr-freight-web/backoffice/src/components/overview/overview.css index 86b468df3..10b358bbd 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/overview.css +++ b/apps/edr-freight-web/backoffice/src/components/overview/overview.css @@ -1,5 +1,8 @@ /* ============================================================ - EDR Freight — Overview page styles (hero controls + tabs) + EDR Freight — shared "premium" tab bar + segmented control styles. + Named after the overview page they were first built for, but now shared + by BookingStatusTabs, ContractStatusTabs, and ReceiveInventoryModal — + do not remove `.ov-tablist` / `.ov-tab` without checking those importers. ============================================================ */ /* ---- Hero range segmented control (on gradient) ---- */ @@ -19,7 +22,7 @@ color: var(--mantine-color-edr-green-7); } -/* ---- Premium tab bar ---- */ +/* ---- Premium tab bar (BookingStatusTabs, ContractStatusTabs, ReceiveInventoryModal) ---- */ .ov-tablist { display: flex; flex-wrap: wrap; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx new file mode 100644 index 000000000..28802e321 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/CountUp.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from "react"; + +const DURATION_MS = 750; + +function prefersReducedMotion() { + return ( + typeof window !== "undefined" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches + ); +} + +/** + * Animates a number from 0 to `value` (ease-out) on mount and whenever the + * value changes — the hero-KPI count-up. Renders the final value immediately + * when the user prefers reduced motion. + */ +export function CountUp({ + value, + format = (n) => Math.round(n).toLocaleString(), +}: { + value: number; + format?: (n: number) => string; +}) { + const [display, setDisplay] = useState(() => (prefersReducedMotion() ? value : 0)); + const frame = useRef(0); + + useEffect(() => { + if (prefersReducedMotion()) { + setDisplay(value); + return; + } + const start = performance.now(); + const tick = (now: number) => { + const t = Math.min(1, (now - start) / DURATION_MS); + const eased = 1 - (1 - t) ** 3; + setDisplay(value * eased); + if (t < 1) frame.current = requestAnimationFrame(tick); + }; + frame.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame.current); + }, [value]); + + return <>{format(display)}; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx new file mode 100644 index 000000000..7524c1f20 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewActivityHeatmap.tsx @@ -0,0 +1,125 @@ +import { Fragment } from "react"; +import { CalendarClock } from "lucide-react"; +import { Badge, Group, Stack, Text, Tooltip } from "@mantine/core"; + +import type { IOverviewHeatmapCell } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** ISO weekday order, 1 = Monday. */ +const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; +/** 3-hour blocks, 0 = 00–03 … 7 = 21–24. */ +const BLOCK_LABELS = ["12a", "3a", "6a", "9a", "12p", "3p", "6p", "9p"]; + +/** Map an intensity in (0, 1] to the brand-green ramp; zero stays neutral. */ +function cellColor(count: number, max: number) { + if (count === 0) return "var(--mantine-color-gray-1)"; + const shade = Math.max(1, Math.min(7, Math.ceil((count / max) * 7))); + return `var(--mantine-color-edr-green-${shade})`; +} + +interface OverviewActivityHeatmapProps { + cells: IOverviewHeatmapCell[]; +} + +/** + * When demand arrives: booking submissions by weekday × 3-hour block for the + * selected range. The bright cells (and the peak badge) are the hours the + * intake team needs to be staffed for. + */ +export function OverviewActivityHeatmap({ cells = [] }: OverviewActivityHeatmapProps) { + const countByCell = new Map(cells.map((c) => [`${c.dow}-${c.block}`, c.count])); + const max = Math.max(0, ...cells.map((c) => c.count)); + const peak = cells.reduce( + (best, c) => (c.count > (best?.count ?? 0) ? c : best), + null, + ); + + return ( + + Peak {DAY_LABELS[peak.dow - 1]} {BLOCK_LABELS[peak.block]} + + ) : null + } + > + {max === 0 ? ( + + No bookings in this period + + ) : ( + +
+ + {BLOCK_LABELS.map((label) => ( + + {label} + + ))} + {DAY_LABELS.map((day, dayIndex) => ( + + + {day} + + {BLOCK_LABELS.map((_, block) => { + const count = countByCell.get(`${dayIndex + 1}-${block}`) ?? 0; + return ( + +
+ + ); + })} + + ))} +
+ + + Less + + {[0, 2, 4, 6].map((shade) => ( +
+ ))} + + More + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx new file mode 100644 index 000000000..c36db5e57 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewAttentionCard.tsx @@ -0,0 +1,149 @@ +import { + AlertCircle, + Banknote, + BellRing, + Check, + ChevronRight, + Clock, + FileSignature, + ShieldCheck, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Group, Stack, Text, ThemeIcon } from "@mantine/core"; + +import type { IOverviewBillingKpis, IOverviewBookingKpis, IOverviewContractKpis } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +interface OverviewAttentionCardProps { + bookings: IOverviewBookingKpis; + contracts: IOverviewContractKpis; + billing: IOverviewBillingKpis; +} + +/** + * The work queue, demoted below the money/volume story but still one glance + * away — this page is read by executives first, staff second. Every row + * links to the real filtered (or closest available) list; none are dead ends. + */ +export function OverviewAttentionCard({ bookings, contracts, billing }: OverviewAttentionCardProps) { + const rows = [ + { + key: "needsAction", + label: "Bookings needing action", + count: bookings.needsAction ?? 0, + icon: AlertCircle, + href: "/dashboard/booking-requests", + }, + { + key: "urgent", + label: "Urgent bookings", + count: bookings.urgent ?? 0, + icon: Clock, + href: "/dashboard/booking-requests", + }, + { + key: "contractsApproval", + label: "Contracts in approval", + count: contracts.inApproval ?? 0, + icon: FileSignature, + href: "/dashboard/contract-requests", + }, + { + key: "contractsClearance", + label: "Contracts in clearance", + count: contracts.inClearance ?? 0, + icon: ShieldCheck, + href: "/dashboard/contracts/clearance", + }, + { + key: "pendingPayments", + label: "Pending payments", + count: billing.pendingPayments ?? 0, + icon: Banknote, + href: "/dashboard/payments", + }, + ]; + const openItems = rows.reduce((sum, row) => sum + row.count, 0); + const allClear = openItems === 0; + + return ( + + {openItems.toLocaleString()} open + + ) + } + > + {allClear ? ( + + + + + All clear + + Nothing waiting on you right now. + + + ) : ( + + {rows.map((row) => { + const Icon = row.icon; + const active = row.count > 0; + return ( + + + + + + + + {row.label} + + + + + {row.count} + + + + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx new file mode 100644 index 000000000..5b7815eb0 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHero.tsx @@ -0,0 +1,141 @@ +import { RefreshCw } from "lucide-react"; +import { ActionIcon, Badge, Group, SegmentedControl, Stack, Text } from "@mantine/core"; + +import { useAuth } from "@/auth/useAuth"; +import { formatDateTime } from "@/lib/format"; +import { freightBrand } from "@/theme/freight-brand"; +import type { OverviewRange } from "@/types/overview"; +import "@/components/overview/overview.css"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function formatRelativeTime(iso: string | undefined) { + if (!iso) return "—"; + const diffMs = Date.now() - new Date(iso).getTime(); + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return formatDateTime(iso); +} + +function greeting(hour: number) { + if (hour < 12) return "Good morning"; + if (hour < 18) return "Good afternoon"; + return "Good evening"; +} + +interface OverviewHeroProps { + range: OverviewRange; + onRangeChange: (range: OverviewRange) => void; + generatedAt?: string; + onRefresh: () => void; + isRefreshing?: boolean; +} + +/** + * Brand-gradient greeting banner: time-of-day greeting with the signed-in + * user's first name, today's date, freshness badge, and the range controls. + * Extra bottom padding leaves room for the KPI strip to overlap it. + */ +export function OverviewHero({ + range, + onRangeChange, + generatedAt, + onRefresh, + isRefreshing, +}: OverviewHeroProps) { + const { user } = useAuth(); + const fullName = (user?.name?.en ?? user?.name?.am)?.trim(); + const firstName = fullName ? fullName.split(/\s+/)[0] : undefined; + const now = new Date(); + const dateLabel = now.toLocaleDateString(undefined, { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + }); + + return ( +
+ {/* Soft highlight so the flat gradient reads as a lit surface. */} +
+ + + + {greeting(now.getHours())} + {firstName ? `, ${firstName}` : ""} 👋 + + + + {dateLabel} + + + Updated {formatRelativeTime(generatedAt)} + + + + + onRangeChange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + classNames={{ + root: "ov-seg-root", + indicator: "ov-seg-indicator", + label: "ov-seg-label", + }} + /> + + + + + +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx new file mode 100644 index 000000000..fa250a817 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewHeroKpis.tsx @@ -0,0 +1,73 @@ +import { Banknote, FileSignature, FileText, Package } from "lucide-react"; + +import { KpiStrip, type KpiItem } from "@/components/page"; +import type { IOverviewKpis, IOverviewPeriodTotals } from "@/types/overview"; +import { CountUp } from "./CountUp"; + +function formatCurrency(amount: number, currency: "ETB" | "USD") { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + maximumFractionDigits: 0, + }).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; + return Math.round(((current - previous) / previous) * 100); +} + +interface OverviewHeroKpisProps { + kpis: IOverviewKpis; + current: IOverviewPeriodTotals; + previous: IOverviewPeriodTotals; + rangeLabel: string; +} + +/** + * The four numbers an executive reads first: money and volume for the + * selected range, plus what's currently in flight. Revenue and cargo carry a + * real vs-prior-period delta; the two workflow snapshots don't, because + * "active bookings/contracts" is a point-in-time gauge, not a period total — + * showing a delta for it would mean inventing a comparison that isn't real. + */ +export function OverviewHeroKpis({ kpis, current, previous, rangeLabel }: OverviewHeroKpisProps) { + const items: KpiItem[] = [ + // An API deployed before the overview revamp omits the period totals — + // drop the two tiles that need them rather than crash (or hide the strip). + ...(current + ? [ + { + label: `Revenue (${rangeLabel})`, + value: formatCurrency(n, "ETB")} />, + hint: formatCurrency(current.revenueUsd, "USD"), + icon: Banknote, + color: "yellow", + delta: pctDelta(current.revenueEtb, previous?.revenueEtb ?? 0), + }, + { + label: "Cargo moved", + value: `${Math.round(n).toLocaleString()} t`} />, + icon: Package, + color: "edr-green", + delta: pctDelta(current.tons, previous?.tons ?? 0), + }, + ] + : []), + { + label: "Active bookings", + value: , + icon: FileText, + color: "edr-green", + }, + { + label: "Active contracts", + value: , + icon: FileSignature, + color: "edr-green", + }, + ]; + + return ; +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx new file mode 100644 index 000000000..9c9bef563 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewNetworkCard.tsx @@ -0,0 +1,91 @@ +import { + CalendarClock, + Container as ContainerIcon, + Send, + Train, + TrainFront, +} from "lucide-react"; +import { Group, SimpleGrid, Stack, Text } from "@mantine/core"; + +import { MiniRing } from "@/components/common/MiniGraph"; +import type { IOverviewOperationsKpis } from "@/types/overview"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +const STATS: Array<{ + key: keyof IOverviewOperationsKpis; + label: string; + icon: typeof Train; +}> = [ + { key: "trainsActive", label: "Trains active", icon: Train }, + { key: "dispatchedToday", label: "Dispatched today", icon: Send }, + { key: "schedulesUpcoming", label: "Upcoming departures", icon: CalendarClock }, + { key: "containersInTransit", label: "Containers in transit", icon: ContainerIcon }, +]; + +/** Network snapshot: four operational stats plus real wagon-utilization (available / total), not a decorative gauge. */ +export function OverviewNetworkCard({ kpis }: { kpis: IOverviewOperationsKpis }) { + // An API deployed before the overview revamp omits the wagon counters. + const wagonsTotal = kpis.wagonsTotal ?? 0; + const wagonsAvailable = kpis.wagonsAvailable ?? 0; + const utilizationPct = wagonsTotal > 0 ? (wagonsAvailable / wagonsTotal) * 100 : null; + + return ( + + View fleet → + + } + > + + + + + {utilizationPct != null ? `${Math.round(utilizationPct)}%` : "—"} + + + + + Wagons available + + + {wagonsAvailable.toLocaleString()} + + {" "} + / {wagonsTotal.toLocaleString()} + + + + + + + {STATS.map((stat) => { + const Icon = stat.icon; + return ( + + + + + + + + {stat.label} + + + + ); + })} + + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx new file mode 100644 index 000000000..a1c18c281 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewPipelineFunnel.tsx @@ -0,0 +1,113 @@ +import { Filter } from "lucide-react"; +import { Link } from "react-router-dom"; +import { Badge, Stack, Text } from "@mantine/core"; + +import { BOOKING_LIST_TABS } from "@/features/bookings/booking-status.config"; +import type { IOverviewPipelineCount } from "@/types/overview"; +import { SummaryCard } from "./SummaryCard"; + +/** Sequential green ramp from the theme's own edr-green scale — rising intensity as bookings progress through the pipeline. */ +const RAMP_SHADES = [3, 4, 5, 5, 6, 6, 7, 8, 9]; + +interface OverviewPipelineFunnelProps { + data: IOverviewPipelineCount[]; +} + +/** Booking pipeline by stage, in workflow order. Each row deep-links to the exact statuses it represents. */ +export function OverviewPipelineFunnel({ data = [] }: OverviewPipelineFunnelProps) { + const rows = data + .map((item) => ({ + ...item, + tab: BOOKING_LIST_TABS.find((t) => t.key === item.stage), + })) + .filter((row) => row.tab); + const maxCount = Math.max(1, ...rows.map((r) => r.count)); + const total = rows.reduce((sum, r) => sum + r.count, 0); + const hasData = total > 0; + + return ( + + {total.toLocaleString()} in pipeline + + ) : null + } + > + {!hasData ? ( + + No bookings in pipeline + + ) : ( + + {rows.map((row, index) => { + const statuses = row.tab?.statuses; + const href = statuses?.length + ? `/dashboard/booking-requests?statuses=${statuses.join(",")}` + : "/dashboard/booking-requests"; + const shade = RAMP_SHADES[Math.min(index, RAMP_SHADES.length - 1)]; + + return ( + + + {row.tab?.label ?? row.stage} + +
+
+
+ + {row.count} + + + ); + })} + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx new file mode 100644 index 000000000..0603ac095 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueMix.tsx @@ -0,0 +1,143 @@ +import { PieChart } from "lucide-react"; +import { Stack, Text } from "@mantine/core"; + +import type { IOverviewRevenueSlice } from "@/types/overview"; +import { DIRECTION_COLORS, FLOW_FALLBACK_COLOR, FREIGHT_TYPE_COLORS } from "./flow-colors"; +import { CountUp } from "./CountUp"; +import { SummaryCard } from "./SummaryCard"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const DIRECTION_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", +}; + +const FREIGHT_TYPE_LABELS: Record = { + CONTAINER: "Container", + BULK: "Bulk", +}; + +/** One breakdown's proportion bar + legend, sized by ETB revenue (no FX rate exists to fold USD in). */ +function MixRow({ + title, + slices, + labels, + colors, +}: { + title: string; + slices: IOverviewRevenueSlice[]; + labels: Record; + colors: Record; +}) { + const total = slices.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + + {title} + + {total === 0 ? ( + + No revenue in this period + + ) : ( + <> +
+ {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ ))} +
+ + {slices + .filter((s) => s.amountEtb > 0) + .map((s) => ( +
+ + + {labels[s.label] ?? s.label} + + {formatEtb(s.amountEtb)} + + {Math.round((s.amountEtb / total) * 100)}% + +
+ ))} +
+ + )} + + ); +} + +interface OverviewRevenueMixProps { + byDirection: IOverviewRevenueSlice[]; + byFreightType: IOverviewRevenueSlice[]; +} + +/** ETB revenue split two ways — trade direction and freight type — anchored by the range total. */ +export function OverviewRevenueMix({ byDirection = [], byFreightType = [] }: OverviewRevenueMixProps) { + const total = byDirection.reduce((sum, s) => sum + s.amountEtb, 0); + + return ( + + +
+ + + + + attributed to a trade direction + +
+ + +
+
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx new file mode 100644 index 000000000..04a359e92 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewRevenueVolumeChart.tsx @@ -0,0 +1,172 @@ +import { + Area, + Bar, + CartesianGrid, + ComposedChart, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ChartColumnBig } from "lucide-react"; +import { Group, Text } from "@mantine/core"; + +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; +import { overviewChartColors } from "../overview.styles"; +import { chartAxisTick, chartGridStroke, chartTooltipStyle } from "./chart-style"; +import { mergeTrend } from "./mergeTrend"; +import { SummaryCard } from "./SummaryCard"; + +function formatDateLabel(date: string) { + return new Date(`${date}T00:00:00`).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +const compact = new Intl.NumberFormat("en-US", { notation: "compact" }); + +/** Dot-and-label legend row rendered in the card header instead of recharts' default. */ +function LegendDot({ color, dashed, label }: { color: string; dashed?: boolean; label: string }) { + return ( + + {dashed ? ( + + + + ) : ( + + )} + + {label} + + + ); +} + +interface OverviewRevenueVolumeChartProps { + bookingTrend: IOverviewTrendPoint[]; + paymentTrend: IOverviewPaymentTrendPoint[]; + previousPaymentTrend: IOverviewPaymentTrendPoint[]; + rangeDays: number; +} + +/** + * Volume and money in one read: bars are bookings created per day, the gold + * area is ETB revenue per day, and the dashed ghost line is the preceding + * period's revenue shifted onto the same axis — "are we pacing ahead of last + * period" at a glance. + */ +export function OverviewRevenueVolumeChart({ + bookingTrend = [], + paymentTrend = [], + previousPaymentTrend = [], + rangeDays, +}: OverviewRevenueVolumeChartProps) { + const data = mergeTrend(bookingTrend, paymentTrend, previousPaymentTrend, rangeDays); + const hasData = data.some((point) => point.bookings > 0 || point.revenueEtb > 0); + + return ( + + + + + + } + > + {!hasData ? ( + + No activity in this period + + ) : ( + + + + + + + + + + + + compact.format(Number(value))} + tick={chartAxisTick} + axisLine={false} + tickLine={false} + width={44} + /> + formatDateLabel(String(value))} + formatter={(value, name) => + name === "Bookings" ? [value, name] : [formatEtb(Number(value)), name] + } + contentStyle={chartTooltipStyle} + /> + + + + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx new file mode 100644 index 000000000..b7974ea48 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/OverviewSankeyFlow.tsx @@ -0,0 +1,170 @@ +import { Waypoints } from "lucide-react"; +import { ResponsiveContainer, Sankey, Tooltip, type SankeyLinkProps } from "recharts"; +import { Text } from "@mantine/core"; + +import type { IOverviewRevenueFlow } from "@/types/overview"; +import { chartTooltipStyle } from "./chart-style"; +import { SummaryCard } from "./SummaryCard"; +import { + DIRECTION_COLORS, + FLOW_FALLBACK_COLOR, + FLOW_LABELS, + FREIGHT_TYPE_COLORS, +} from "./flow-colors"; + +function formatEtb(amount: number) { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "ETB", + maximumFractionDigits: 0, + }).format(amount); +} + +interface SankeyNodeDatum { + name: string; + key: string; + color: string; +} + +/** Build recharts Sankey data: direction nodes on the left, freight types on the right. */ +function toSankeyData(flows: IOverviewRevenueFlow[]) { + const active = flows.filter((f) => f.amountEtb > 0); + const nodes: SankeyNodeDatum[] = []; + const indexByKey = new Map(); + const nodeIndex = (key: string, color: string) => { + const existing = indexByKey.get(key); + if (existing != null) return existing; + nodes.push({ name: FLOW_LABELS[key] ?? key, key, color }); + indexByKey.set(key, nodes.length - 1); + return nodes.length - 1; + }; + + // Register directions first so they all land on the left column. + for (const flow of active) { + nodeIndex(flow.direction, DIRECTION_COLORS[flow.direction] ?? FLOW_FALLBACK_COLOR); + } + const links = active.map((flow) => ({ + source: indexByKey.get(flow.direction)!, + target: nodeIndex( + flow.freightType, + FREIGHT_TYPE_COLORS[flow.freightType] ?? FLOW_FALLBACK_COLOR, + ), + value: flow.amountEtb, + })); + + return { nodes, links }; +} + +function FlowNode({ + x, + y, + width, + height, + index, + payload, +}: { + x: number; + y: number; + width: number; + height: number; + index: number; + payload: { name?: string; value?: number; color?: string }; +}) { + // Labels sit to the right of every bar: the right margin reserves room for + // the last column, and the pale ribbons stay readable under the left one. + return ( + + + + {payload.name} + + + {formatEtb(payload.value ?? 0)} + + + ); +} + +/** Ribbon tinted by its source direction — the corridor keeps its color across the chart. */ +function FlowLink({ + sourceX, + targetX, + sourceY, + targetY, + sourceControlX, + targetControlX, + linkWidth, + index, + payload, +}: SankeyLinkProps) { + // Custom node fields (color) ride along on the layout node recharts hands back. + const source = payload.source as { color?: string }; + return ( + + ); +} + +interface OverviewSankeyFlowProps { + flows: IOverviewRevenueFlow[]; +} + +/** + * Where the money runs: ETB revenue as ribbons from trade direction to + * freight type. Ribbon thickness is proportional to revenue, so the biggest + * corridor is unmissable. + */ +export function OverviewSankeyFlow({ flows = [] }: OverviewSankeyFlowProps) { + const data = toSankeyData(flows); + + return ( + + {data.links.length === 0 ? ( + + No revenue in this period + + ) : ( + + + formatEtb(Number(value))} + contentStyle={chartTooltipStyle} + /> + + + )} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx new file mode 100644 index 000000000..cfd022fe9 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/SummaryCard.tsx @@ -0,0 +1,65 @@ +import type { LucideIcon } from "lucide-react"; +import { Group, Stack, Text, ThemeIcon } from "@mantine/core"; +import type { ElementType, ReactNode } from "react"; +import { Link } from "react-router-dom"; + +import "./overview-summary.css"; + +interface SummaryCardProps { + icon: LucideIcon; + /** Mantine color for the icon chip. */ + accent?: string; + title: string; + subtitle?: string; + /** Right side of the header — a legend, badge, or link. */ + action?: ReactNode; + /** Makes the whole card a link (adds the hover lift). */ + to?: string; + minHeight?: number; + children: ReactNode; +} + +/** + * Shared chrome for every overview card: soft gradient surface, layered + * shadow, icon-chip header with title/subtitle, optional action slot. + * One look for the whole page instead of eight flat white boxes. + */ +export function SummaryCard({ + icon: Icon, + accent = "edr-green", + title, + subtitle, + action, + to, + minHeight = 340, + children, +}: SummaryCardProps) { + const Root: ElementType = to ? Link : "div"; + return ( + + + + + + + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {action} + + {children} + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts new file mode 100644 index 000000000..9f7dbae8d --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/chart-style.ts @@ -0,0 +1,14 @@ +import type { CSSProperties } from "react"; + +/** Shared recharts styling for the overview summary charts. */ +export const chartGridStroke = "#EEF1F5"; + +export const chartAxisTick = { fontSize: 11, fill: "#8fa0b2" } as const; + +export const chartTooltipStyle: CSSProperties = { + borderRadius: 12, + border: "1px solid #EEF1F5", + boxShadow: "0 8px 24px rgba(16, 32, 47, 0.1)", + fontSize: 12, + padding: "8px 12px", +}; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts new file mode 100644 index 000000000..f3c7a2ace --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/flow-colors.ts @@ -0,0 +1,25 @@ +/** + * Fixed colors + labels for trade directions and freight types, shared by the + * revenue mix and Sankey so the same entity is always the same color (and + * matches the operations departure chart's direction hexes). + */ +export const DIRECTION_COLORS: Record = { + EXPORT: "#D98A0B", + IMPORT: "#0369a1", + DOMESTIC: "#7c3aed", +}; + +export const FREIGHT_TYPE_COLORS: Record = { + CONTAINER: "#1B9E7A", + BULK: "#34D9AE", +}; + +export const FLOW_LABELS: Record = { + IMPORT: "Import", + EXPORT: "Export", + DOMESTIC: "Domestic", + CONTAINER: "Container", + BULK: "Bulk", +}; + +export const FLOW_FALLBACK_COLOR = "#94a3b8"; diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts new file mode 100644 index 000000000..369b19237 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; + +import { mergeTrend } from "./mergeTrend"; + +describe("mergeTrend", () => { + it("unions dates from both trends, zero-filling the side that has no row", () => { + const result = mergeTrend( + [ + { date: "2026-06-01", count: 3 }, + { date: "2026-06-02", count: 5 }, + ], + [{ date: "2026-06-02", amountEtb: 1000, amountUsd: 0 }], + ); + + expect(result).toEqual([ + { date: "2026-06-01", bookings: 3, revenueEtb: 0 }, + { date: "2026-06-02", bookings: 5, revenueEtb: 1000 }, + ]); + }); + + it("sorts chronologically regardless of input order", () => { + const result = mergeTrend( + [{ date: "2026-06-03", count: 1 }], + [{ date: "2026-06-01", amountEtb: 500, amountUsd: 0 }], + ); + + expect(result.map((p) => p.date)).toEqual(["2026-06-01", "2026-06-03"]); + }); + + it("returns an empty series when both trends are empty", () => { + expect(mergeTrend([], [])).toEqual([]); + }); + + it("shifts the previous period forward so day N lands on day N of the current window", () => { + const result = mergeTrend( + [{ date: "2026-06-08", count: 2 }], + [], + [ + // 7d range: 2026-06-01 + 7 = 2026-06-08 (existing row), 06-02 + 7 = 06-09 (new row) + { date: "2026-06-01", amountEtb: 400, amountUsd: 0 }, + { date: "2026-06-02", amountEtb: 250, amountUsd: 0 }, + ], + 7, + ); + + expect(result).toEqual([ + { date: "2026-06-08", bookings: 2, revenueEtb: 0, prevRevenueEtb: 400 }, + { date: "2026-06-09", bookings: 0, revenueEtb: 0, prevRevenueEtb: 250 }, + ]); + }); + + it("shifts across a month boundary without timezone drift", () => { + const result = mergeTrend([], [], [{ date: "2026-05-28", amountEtb: 100, amountUsd: 0 }], 7); + expect(result[0].date).toBe("2026-06-04"); + }); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts new file mode 100644 index 000000000..0ddc0d705 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/mergeTrend.ts @@ -0,0 +1,63 @@ +import type { IOverviewPaymentTrendPoint, IOverviewTrendPoint } from "@/types/overview"; + +export interface RevenueVolumePoint { + date: string; + bookings: number; + revenueEtb: number; + /** Same-offset day of the preceding period — the ghost comparison line. */ + prevRevenueEtb?: number; +} + +/** `date` (YYYY-MM-DD) plus `days` days, in UTC so no DST/timezone drift. */ +export function shiftDate(date: string, days: number): string { + const parsed = new Date(`${date}T00:00:00Z`); + parsed.setUTCDate(parsed.getUTCDate() + days); + return parsed.toISOString().slice(0, 10); +} + +/** + * Merge the booking-count trend and the payment trend into one date-keyed + * series for the combined volume/revenue chart. Both trends only carry rows + * for days with activity (no zero-filled gaps), so this unions the dates + * rather than assuming they line up. + * + * When the previous period's payment trend is provided, each of its days is + * shifted forward by `shiftDays` (the range length) so day N of the prior + * window lands on day N of the current one, and lands in `prevRevenueEtb`. + */ +export function mergeTrend( + bookingTrend: IOverviewTrendPoint[], + paymentTrend: IOverviewPaymentTrendPoint[], + previousPaymentTrend: IOverviewPaymentTrendPoint[] = [], + shiftDays = 0, +): RevenueVolumePoint[] { + const byDate = new Map(); + + for (const point of bookingTrend) { + byDate.set(point.date, { date: point.date, bookings: point.count, revenueEtb: 0 }); + } + for (const point of paymentTrend) { + const existing = byDate.get(point.date); + if (existing) { + existing.revenueEtb = point.amountEtb; + } else { + byDate.set(point.date, { date: point.date, bookings: 0, revenueEtb: point.amountEtb }); + } + } + for (const point of previousPaymentTrend) { + const date = shiftDate(point.date, shiftDays); + const existing = byDate.get(date); + if (existing) { + existing.prevRevenueEtb = point.amountEtb; + } else { + byDate.set(date, { + date, + bookings: 0, + revenueEtb: 0, + prevRevenueEtb: point.amountEtb, + }); + } + } + + return [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css new file mode 100644 index 000000000..0391010b8 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/overview/summary/overview-summary.css @@ -0,0 +1,65 @@ +/* Staggered fade-up entrance for the overview bands. Delay is set inline per + band; disabled entirely for reduced-motion users. */ +.ov-band { + animation: ov-rise 420ms ease-out both; +} + +@keyframes ov-rise { + from { + opacity: 0; + transform: translateY(14px); + } + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .ov-band { + animation: none; + } +} + +/* ---- Shared card chrome (SummaryCard) ---- */ +.ov-card { + display: block; + height: 100%; + background: linear-gradient(180deg, #ffffff 0%, #fbfdfc 100%); + border: 1px solid var(--mantine-color-edr-border-0); + border-radius: 20px; + padding: 20px; + color: inherit; + text-decoration: none; + box-shadow: + 0 1px 2px rgba(16, 32, 47, 0.04), + 0 12px 32px -18px rgba(16, 32, 47, 0.14); + transition: + box-shadow 180ms ease, + transform 180ms ease, + border-color 180ms ease; +} +/* The lift is a click affordance — only linked cards get it. */ +.ov-card--link:hover { + box-shadow: + 0 2px 4px rgba(16, 32, 47, 0.05), + 0 20px 44px -18px rgba(16, 32, 47, 0.2); + border-color: var(--mantine-color-edr-green-2); + transform: translateY(-2px); +} + +/* Soft inset panel for grouping content inside a card. */ +.ov-inset { + background: var(--mantine-color-gray-0); + border: 1px solid var(--mantine-color-gray-1); + border-radius: 14px; +} + +/* Interactive list row inside a card. */ +.ov-row { + border-radius: 12px; + transition: background 140ms ease; +} +.ov-row:hover { + background: var(--mantine-color-gray-0); +} diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx index cacba63e1..696741873 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewBookingsTabPanel.tsx @@ -59,12 +59,6 @@ export function OverviewBookingsTabPanel({ data }: OverviewBookingsTabPanelProps accent: "sky", hint: "Pending sign-off", }, - { - label: "Submitted today", - value: data.kpis.submittedToday, - icon: FileText, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx index e185f40d2..9265da37d 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewContractsTabPanel.tsx @@ -76,12 +76,6 @@ export function OverviewContractsTabPanel({ accent: "rose", hint: "Customs / documents", }, - { - label: "Created today", - value: data.kpis.createdToday, - icon: FileSignature, - hint: "New since midnight", - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx index 675a81b32..b335ccf9c 100644 --- a/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/overview/tabs/OverviewOperationsTabPanel.tsx @@ -1,5 +1,4 @@ import { - Box, CalendarClock, Container as ContainerIcon, Send, @@ -78,11 +77,6 @@ export function OverviewOperationsTabPanel({ data }: OverviewOperationsTabPanelP value: data.kpis.containersInTransit, icon: ContainerIcon, }, - { - label: "Cargoes loaded", - value: data.kpis.cargoesLoaded, - icon: Box, - }, ]} /> diff --git a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx index 6b90d6c8a..01a80db61 100644 --- a/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx +++ b/apps/edr-freight-web/backoffice/src/components/page/KpiStrip.tsx @@ -103,8 +103,16 @@ export function KpiStrip({ items, loading = false }: KpiStripProps) { component="span" fz="xs" fw={700} - c={item.delta > 0 ? "edr-green" : "red"} - style={{ whiteSpace: "nowrap" }} + c={item.delta > 0 ? "edr-green.7" : "red.7"} + style={{ + whiteSpace: "nowrap", + background: + item.delta > 0 + ? "var(--mantine-color-edr-green-0)" + : "var(--mantine-color-red-0)", + borderRadius: 999, + padding: "1px 7px", + }} > {item.delta > 0 ? "▲" : "▼"} {Math.abs(item.delta)} diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts index 8b12d33e6..5fd393268 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/options.ts @@ -99,18 +99,8 @@ export const formatDays = (value: number | null | undefined) => { return `${rounded} ${rounded === 1 ? 'day' : 'days'}`; }; -export const formatDate = (value: string | null | undefined) => { - if (!value) return '—'; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return '—'; - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -}; +// Despite the name, this has always rendered date + time — hence formatDateTime. +export { formatDateTime as formatDate } from '@/lib/format'; // Name fields (warehouse / fee rule / allocation rule) accept letters and spaces only — no numbers. export const lettersOnly = (value: string) => value.replace(/[^A-Za-z\s]/g, ''); diff --git a/apps/edr-freight-web/backoffice/src/lib/format.ts b/apps/edr-freight-web/backoffice/src/lib/format.ts new file mode 100644 index 000000000..4c5c6b639 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/lib/format.ts @@ -0,0 +1,62 @@ +/** Shared display-formatting helpers for the backoffice. */ + +/** snake_case / SCREAMING_CASE → Title Case. */ +export function humanize(value: string): string { + return value + .toLowerCase() + .split(/[_\s]+/) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +export function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function formatDateTime(value: string | null | undefined): string { + if (!value) return "—"; + const d = new Date(value); + return Number.isNaN(d.getTime()) + ? "—" + : d.toLocaleString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** Pass `fractionDigits` where cents matter; the default matches the legacy whole-figure display. */ +export function formatMoney( + amount: number, + currency: string, + fractionDigits?: number, +): string { + return new Intl.NumberFormat(undefined, { + style: "currency", + currency, + ...(fractionDigits === undefined + ? { maximumFractionDigits: 0 } + : { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + }), + }).format(amount); +} + +export function formatBytes(bytes: number): string { + if (!bytes) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index fdade7980..37609ea40 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -61,6 +61,7 @@ import { import { WarehouseInfoCard } from "@/components/warehouses"; import { getStatusMeta } from "@/features/bookings/booking-status.config"; import { toBookingListRow } from "@/features/bookings/mapBookingListRow"; +import { formatDateTime, formatMoney } from "@/lib/format"; import { cargoTonsAndItems } from "@/utils/cargoWeight"; import type { BookingDetail } from "@/types/booking"; import { @@ -186,7 +187,7 @@ export default function BookingRequestDetailPage() { const kpis: KpiItem[] = [ { label: "Total value", - value: `${booking.paymentCurrency} ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}`, + value: formatMoney(amount, booking.paymentCurrency, 2), hint: booking.paymentStatus, icon: Wallet, color: "edr-green", @@ -326,7 +327,7 @@ export default function BookingRequestDetailPage() { {booking.holdExpiresAt && booking.schedulingStatus === "HOLDING" ? ( - Hold expires {new Date(booking.holdExpiresAt).toLocaleString()} + Hold expires {formatDateTime(booking.holdExpiresAt)} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx index d55c45c3a..4bd84a247 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestsPage.tsx @@ -5,6 +5,7 @@ import { Button, Card, Checkbox, + Collapse, Group, Modal, MultiSelect, @@ -36,6 +37,8 @@ import { useNavigate, useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { BookingActionsMenu } from "@/components/bookings/BookingActionsMenu"; +import { FilterToggle } from "@/components/common/FilterToggle"; +import { formatDate, humanize } from "@/lib/format"; import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge"; import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge"; // BookingStatusTabs / Operations* queues removed — replaced by booking-kind tabs. @@ -50,7 +53,6 @@ import { useBookingList, useBookingListSummary, } from "@/hooks/bookings/useBookings"; -import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink"; import { api } from "@/services/api"; import type { BookingListFilter } from "@/services/bookings.service"; import { trainSchedulingService } from "@/services/trainScheduling.service"; @@ -116,18 +118,6 @@ function endOfDayIso(d: Date): string { return x.toISOString(); } -function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - export default function BookingRequestsPage() { const navigate = useNavigate(); // Deep links land here pre-filtered (?statuses=A,B&tradeDirection=IMPORT) — @@ -159,6 +149,11 @@ export default function BookingRequestsPage() { const [createdTo, setCreatedTo] = useState(null); const [scheduledFrom, setScheduledFrom] = useState(null); const [scheduledTo, setScheduledTo] = useState(null); + // Direction is the only deep-linkable advanced filter — open the panel so a + // deep link never hides its own filter. + const [showAdvanced, setShowAdvanced] = useState(() => + Boolean(paramDirection), + ); const [allocateOpen, setAllocateOpen] = useState(false); const [allocateIds, setAllocateIds] = useState([]); // Paid bookings with no train attached (staff removed them or a sweep @@ -185,6 +180,7 @@ export default function BookingRequestsPage() { const next = paramStatuses.split(",").filter(Boolean); setStatusFilter((prev) => (prev.join(",") === next.join(",") ? prev : next)); setDirectionFilter(paramDirection); + if (paramDirection) setShowAdvanced(true); }, [paramStatuses, paramDirection]); const filter: BookingListFilter = useMemo(() => { @@ -278,6 +274,10 @@ export default function BookingRequestsPage() { (createdFrom || createdTo ? 1 : 0) + (scheduledFrom || scheduledTo ? 1 : 0); + // Badge on the advanced-filters toggle — active filters hidden behind it. + const advancedFilterCount = + activeFilterCount - (kindFilter ? 1 : 0) - (statusFilter.length ? 1 : 0); + const clearFilters = useCallback(() => { setKindFilter(null); setStatusFilter([]); @@ -390,13 +390,22 @@ export default function BookingRequestsPage() { header: () => Booking, cell: ({ row }) => { const b = row.original; + const isGeneral = b.bookingKind === "GENERAL_CONTRACT"; return (
-
-

{b.reference}

+
+
+

{b.reference}

+ + {isGeneral ? "General" : "One-time"} + +

{b.customerLabel} @@ -406,49 +415,6 @@ export default function BookingRequestsPage() { ); }, }, - { - id: "contract", - header: () => Contract, - cell: ({ row }) => { - const ref = row.original.contractReference; - return ( -

- {ref ? ( - // Fall back to plain text when the id is missing — the reference is - // still worth showing, it just has nowhere to link to. - (row.original.contractId ? ( - - ) : ( - {ref} - )) - ) : ( - - )} -
- ); - }, - }, - { - id: "bookingKind", - header: () => Type, - cell: ({ row }) => { - const isGeneral = row.original.bookingKind === "GENERAL_CONTRACT"; - return ( -
- - {isGeneral ? "General" : "One-time"} - -
- ); - }, - }, { id: "route", header: () => Route, @@ -464,15 +430,15 @@ export default function BookingRequestsPage() {
- {b.tradeDirection} + {humanize(b.tradeDirection)} - {b.freightType} + {humanize(b.freightType)}
@@ -621,7 +587,7 @@ export default function BookingRequestsPage() { - + } @@ -649,11 +615,6 @@ export default function BookingRequestsPage() { style={{ flex: 1, minWidth: "200px" }} radius="lg" /> - - {total} record{total !== 1 ? "s" : ""} - - - - - (); const navigate = useNavigate(); diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx index dfbb26d25..cade8cc34 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ContractRequestsPage.tsx @@ -5,6 +5,7 @@ import { Box, Button, Card, + Collapse, Group, MultiSelect, Select, @@ -32,29 +33,25 @@ import { User, X, } from "lucide-react"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useNavigate } from "react-router-dom"; -import { ContractApprovalProgressCell } from "@/components/contracts/ContractApprovalProgressCell"; -import { - ContractCourtBadge, - ContractStatusBadge, -} from "@/components/contracts/ContractStatusBadge"; -import { - ContractStatusTabs, - type ContractStatusTabKey, -} from "@/components/contracts/ContractStatusTabs"; +import { ContractStatusBadge } from "@/components/contracts/ContractStatusBadge"; +import { FilterToggle } from "@/components/common/FilterToggle"; import { bookingTable } from "@/components/bookings/booking-ui.styles"; import { KpiStrip, PageContainer, PageHeader } from "@/components/page"; import { CONTRACT_LIST_TABS, CONTRACT_STATUS_STYLES, + contractCourt, } from "@/features/contracts/contract-status.config"; +import { formatContractApprovalProgress } from "@/features/contracts/contract-approval-progress"; import { getStaffRowAction, toContractListRow, type ContractListRow, } from "@/features/contracts/mapContractListRow"; +import { formatDate, humanize } from "@/lib/format"; import { useContractList, useContractListSummary, @@ -68,25 +65,13 @@ import { type ColumnDef, } from "@edr/ui-common"; -function getStatusesForTab(tab: ContractStatusTabKey): string | undefined { - const match = CONTRACT_LIST_TABS.find((t) => t.key === tab); - if (!match?.statuses?.length) return undefined; - return match.statuses.join(","); -} - -/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */ -function getStatusOptionsForTab( - tab: ContractStatusTabKey, -): { value: string; label: string }[] { - const match = CONTRACT_LIST_TABS.find((t) => t.key === tab); - const statuses = match?.statuses?.length - ? match.statuses - : CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []); - return statuses.map((s) => ({ +/** Every filterable status — the pill tabs are gone, so the select carries them all. */ +const STATUS_OPTIONS = CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []).map( + (s) => ({ value: s, label: CONTRACT_STATUS_STYLES[s]?.label ?? s, - })); -} + }), +); const TRADE_DIRECTION_OPTIONS = [ { value: "IMPORT", label: "Import" }, @@ -117,8 +102,11 @@ const SORT_OPTIONS = [ { value: "contractValidUntil:DESC", label: "Expiring latest" }, ]; -/** Every column is 120px wide and wraps its content instead of truncating. */ -const COLUMN_WIDTH = 120; +/** + * Per-column widths — they must sum to the table's min-w (960px, set on the + * containerClassName below) because table-fixed distributes any difference. + */ +const COLUMN_WIDTHS = { contract: 250, route: 270, status: 250, validity: 190 }; const COLUMN_META = { headerClassName: "whitespace-normal break-words", cellClassName: "whitespace-normal break-words align-top", @@ -138,24 +126,11 @@ function endOfDayIso(d: Date): string { return x.toISOString(); } -function formatDate(value: string | null | undefined): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - export default function ContractRequestsPage() { const navigate = useNavigate(); const { pagination, setPagination } = usePagination({ pageSize: 10 }); const [query, setQuery] = useState(""); const [debouncedQuery] = useDebouncedValue(query, 300); - const [activeTab, setActiveTab] = useState("all"); // Filter controls (empty/null = "all"). const [statusFilter, setStatusFilter] = useState([]); const { filterOptions } = useMyTradeAccess(); @@ -168,12 +143,8 @@ export default function ContractRequestsPage() { const [createdFrom, setCreatedFrom] = useState(null); const [createdTo, setCreatedTo] = useState(null); const [sort, setSort] = useState("createdAt:DESC"); - - const tabStatuses = getStatusesForTab(activeTab); - const statusOptions = useMemo( - () => getStatusOptionsForTab(activeTab), - [activeTab], - ); + // All filters start empty (no URL params on this page), so collapsed is safe. + const [showAdvanced, setShowAdvanced] = useState(false); const resetPage = useCallback(() => { setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); @@ -186,16 +157,11 @@ export default function ContractRequestsPage() { pageSize: pagination.pageSize, sortBy, sortOrder, - tab: activeTab, + // Kept as the React Query cache-key discriminator (tabs themselves are gone). + tab: "all", // Server-side free-text search (contract reference, customer name). ...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}), - // Explicit status picks narrow within the tab; otherwise the tab's - // status group applies. - ...(statusFilter.length - ? { statuses: statusFilter.join(",") } - : tabStatuses - ? { statuses: tabStatuses } - : {}), + ...(statusFilter.length ? { statuses: statusFilter.join(",") } : {}), ...(directionFilter ? { tradeDirection: directionFilter } : {}), ...(freightTypeFilter ? { freightType: freightTypeFilter } : {}), ...(kindFilter ? { contractKind: kindFilter } : {}), @@ -206,8 +172,6 @@ export default function ContractRequestsPage() { }, [ pagination.pageIndex, pagination.pageSize, - activeTab, - tabStatuses, debouncedQuery, statusFilter, directionFilter, @@ -227,6 +191,10 @@ export default function ContractRequestsPage() { (currencyFilter ? 1 : 0) + (createdFrom || createdTo ? 1 : 0); + // Badge on the advanced-filters toggle — active filters hidden behind it. + const advancedFilterCount = + activeFilterCount - (statusFilter.length ? 1 : 0) - (kindFilter ? 1 : 0); + const clearFilters = useCallback(() => { setStatusFilter([]); setDirectionFilter(null); @@ -273,7 +241,7 @@ export default function ContractRequestsPage() { const columns: ColumnDef[] = [ { id: "contract", - size: COLUMN_WIDTH, + size: COLUMN_WIDTHS.contract, meta: COLUMN_META, header: () => Customer, cell: ({ row }) => { @@ -296,7 +264,7 @@ export default function ContractRequestsPage() { }, { id: "route", - size: COLUMN_WIDTH, + size: COLUMN_WIDTHS.route, meta: COLUMN_META, header: () => Route, cell: ({ row }) => { @@ -311,7 +279,7 @@ export default function ContractRequestsPage() {
{directionLabel(c.tradeDirection)} @@ -319,7 +287,7 @@ export default function ContractRequestsPage() { variant="secondary" className="h-5 bg-muted/40 px-1.5 text-[10px] font-medium" > - {c.freightType} + {humanize(c.freightType)}
@@ -328,47 +296,77 @@ export default function ContractRequestsPage() { }, { id: "status", - size: COLUMN_WIDTH, + size: COLUMN_WIDTHS.status, meta: COLUMN_META, header: () => Status, - cell: ({ row }) => ( -
- -
- ), - }, - { - id: "court", - size: COLUMN_WIDTH, - meta: COLUMN_META, - header: () => ( - Waiting on - ), - cell: ({ row }) => ( -
- -
- ), - }, - { - id: "approval", - size: COLUMN_WIDTH, - meta: COLUMN_META, - header: () => Approval, - cell: ({ row }) => , + cell: ({ row }) => { + const c = row.original; + const court = contractCourt(c.status); + const progress = formatContractApprovalProgress( + c.status, + c.approvalSteps, + ); + const action = getStaffRowAction(c); + // One dimmed line: who it's waiting on, the staff verb, and the + // approval chain when one exists. "Open" adds nothing — row click + // already opens the detail page. + const pieces: ReactNode[] = []; + if (court) { + pieces.push(court === "customer" ? "With customer" : "With EDR"); + } + if (action && action.variant === "filled") { + pieces.push( + // "View & sign" pointed at the contract-view page, not the + // detail page — keep that deep link as an inline link. + action.to(c.id).endsWith("/view") ? ( + + ) : ( + action.label + ), + ); + } + if ((c.approvalSteps ?? []).length > 0) { + pieces.push(progress.label); + if (!progress.complete && progress.detail.startsWith("Next:")) { + pieces.push(progress.detail); + } + } + return ( +
+ + {pieces.length ? ( +
+ {pieces.map((piece, i) => ( + + {i > 0 ? " · " : null} + {piece} + + ))} +
+ ) : null} +
+ ); + }, }, { id: "validity", - size: COLUMN_WIDTH, + size: COLUMN_WIDTHS.validity, meta: COLUMN_META, header: () => Validity, cell: ({ row }) => { const c = row.original; + const isGeneral = c.contractKind === "GENERAL"; return ( - + {c.validUntil @@ -382,58 +380,22 @@ export default function ContractRequestsPage() { From {formatDate(c.validFrom)} ) : null} + + {isGeneral ? ( + + General + + ) : ( + "One-time" + )} + ); }, }, - { - id: "kind", - size: COLUMN_WIDTH, - meta: COLUMN_META, - header: () => Kind, - cell: ({ row }) => { - const isGeneral = row.original.contractKind === "GENERAL"; - return ( - - {isGeneral ? ( - - General - - ) : ( - "One-time" - )} - - ); - }, - }, - { - id: "actions", - size: COLUMN_WIDTH, - meta: COLUMN_META, - header: () => Action, - cell: ({ row }) => { - const action = getStaffRowAction(row.original); - if (!action) return null; - return ( - - ); - }, - }, ]; return ( @@ -486,22 +448,11 @@ export default function ContractRequestsPage() { ]} /> - { - setActiveTab(tab); - // Status picks belong to the previous tab's option set — reset. - setStatusFilter([]); - setPagination({ pageIndex: 0, pageSize: pagination.pageSize }); - }} - counts={tabCounts} - /> - - + } @@ -541,16 +492,11 @@ export default function ContractRequestsPage() { style={{ minWidth: 170 }} aria-label="Sort contracts" /> - - {total} record{total !== 1 ? "s" : ""} - - - { setStatusFilter(v); @@ -562,6 +508,38 @@ export default function ContractRequestsPage() { style={{ minWidth: 220 }} aria-label="Filter by status" /> + - - {activeFilterCount > 0 ? ( - - ) : null} + @@ -672,7 +627,7 @@ export default function ContractRequestsPage() { manualPagination: true, pageCount, }} - // table-fixed makes the per-column 120px widths stick; without + // table-fixed makes the per-column widths stick; without // it auto-layout re-widens columns once cells wrap. containerClassName="border-0 shadow-none bg-transparent [&_table]:table-fixed [&_table]:min-w-[960px]" footer={DataTableFooter} diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx index 77ad33f56..76f2bc5b0 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/GlDjiboutiClearanceListPage.tsx @@ -1,3 +1,4 @@ +import { formatDate } from "@/lib/format"; import { directionLabel } from "@/lib/utils"; import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; @@ -51,18 +52,6 @@ const prettyStatus = (s?: string | null) => .replace(/_/g, " ") .replace(/^\w/, (c) => c.toUpperCase()); -function formatDate(value?: string | null): string { - if (!value) return "—"; - const d = new Date(value); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - function yardLabel( yard?: { label?: string; code?: string; name?: string } | null, fallback = "—", @@ -661,9 +650,6 @@ export default function GlDjiboutiClearanceListPage() { Clear ) : null} - - {total} record{total !== 1 ? "s" : ""} - diff --git a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx index 81b037ae6..73b9907f5 100644 --- a/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/contracts/ShipmentRequestsPage.tsx @@ -294,7 +294,7 @@ export default function ShipmentRequestsPage() { {row.original.contractReference} {row.original.customerName ? ( - + {row.original.customerName} ) : null} diff --git a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx index 815615a1b..23120f98f 100644 --- a/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/customers/CustomersPage.tsx @@ -147,7 +147,7 @@ export default function CustomersPage() {
- + {c.name} @@ -156,6 +156,9 @@ export default function CustomersPage() { TIN {c.tin} {c.country ? ` · ${c.country}` : ""} + + +
); @@ -164,16 +167,9 @@ export default function CustomersPage() { { id: "profiles", header: "Profiles", - cell: ({ row }) => ( - - ), - }, - { - id: "status", - header: "Status", cell: ({ row }) => { - // A draft's profiles are all `pending` by construction, so the - // "N pending" review hint would be a lie until they submit. + // A draft's profiles are all `pending` by construction — the + // status-colored chips would be a lie until they submit. if (isOnboardingDraft(row.original)) { return ( @@ -183,23 +179,7 @@ export default function CustomersPage() { ); } - const pending = (row.original.companyProfiles ?? []).filter( - (p) => p.status === "pending", - ).length; - return ( - - - {pending > 0 ? ( - 1 ? "s" : ""} awaiting approval`} - > - - {pending} pending - - - ) : null} - - ); + return ; }, }, { @@ -229,6 +209,7 @@ export default function CustomersPage() { c="dimmed" className="inline-flex items-center gap-1" truncate + maw={200} > {c.email}
@@ -247,16 +228,6 @@ export default function CustomersPage() { ), }, - { - id: "approved", - header: "Approved", - meta: { headerClassName: "text-right", cellClassName: "text-right" }, - cell: ({ row }) => ( - - {row.original.approvedAt ? formatDate(row.original.approvedAt) : "—"} - - ), - }, ], [], ); @@ -376,9 +347,6 @@ export default function CustomersPage() { }} data={SORT_OPTIONS.map((o) => ({ ...o }))} /> - - {total} record{total !== 1 ? "s" : ""} - diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx new file mode 100644 index 000000000..25ede8b4e --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewDomainPage.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { useParams } from "react-router-dom"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { ActionIcon, Alert, Button, Center, Loader, Paper, SegmentedControl, Skeleton, Stack } from "@mantine/core"; + +import { PageContainer, PageHeader } from "@/components/page"; +import { OVERVIEW_DOMAINS } from "@/components/overview/overview-domains.config"; +import { OverviewBillingTabPanel } from "@/components/overview/tabs/OverviewBillingTabPanel"; +import { OverviewBookingsTabPanel } from "@/components/overview/tabs/OverviewBookingsTabPanel"; +import { OverviewContractsTabPanel } from "@/components/overview/tabs/OverviewContractsTabPanel"; +import { OverviewCustomersTabPanel } from "@/components/overview/tabs/OverviewCustomersTabPanel"; +import { OverviewFleetTabPanel } from "@/components/overview/tabs/OverviewFleetTabPanel"; +import { OverviewOperationsTabPanel } from "@/components/overview/tabs/OverviewOperationsTabPanel"; +import { OverviewStaffTabPanel } from "@/components/overview/tabs/OverviewStaffTabPanel"; +import { + useOverviewBillingTab, + useOverviewBookingsTab, + useOverviewContractsTab, + useOverviewCustomersTab, + useOverviewOperationsTab, + useOverviewStaffTab, +} from "@/hooks/useOverview"; +import type { OverviewRange, OverviewTabKey } from "@/types/overview"; + +const RANGE_OPTIONS = [ + { label: "7 days", value: "7d" }, + { label: "30 days", value: "30d" }, + { label: "90 days", value: "90d" }, +]; + +function DomainSkeleton() { + return ( + + + + + + ); +} + +/** + * One domain's full depth — what used to be a tab panel on the overview page + * is now its own page, reached from that domain's "View all →" link. Same + * per-domain hooks and panel components as before; only the tab-switch + * wrapper (OverviewTabContent) is gone, replaced by a route param. + */ +export default function OverviewDomainPage() { + const { domain } = useParams<{ domain: OverviewTabKey }>(); + const [range, setRange] = useState("30d"); + + const meta = OVERVIEW_DOMAINS.find((d) => d.key === domain); + + const bookings = useOverviewBookingsTab(range, domain === "bookings"); + const contracts = useOverviewContractsTab(range, domain === "contracts"); + const billing = useOverviewBillingTab(range, domain === "billing"); + // Fleet reuses the operations dataset — same query key, so switching between + // the two pages costs no extra fetch. + const operations = useOverviewOperationsTab( + range, + domain === "operations" || domain === "fleet", + ); + const customers = useOverviewCustomersTab(range, domain === "customers"); + const staff = useOverviewStaffTab(range, domain === "staff"); + + const query = + domain === "bookings" + ? bookings + : domain === "contracts" + ? contracts + : domain === "billing" + ? billing + : domain === "operations" || domain === "fleet" + ? operations + : domain === "customers" + ? customers + : staff; + + const { isLoading, isError, refetch, isFetching } = query; + + return ( + + + setRange(value as OverviewRange)} + data={RANGE_OPTIONS} + size="sm" + radius="lg" + color="edr-green" + /> + void refetch()} + loading={isFetching && !isLoading} + > + + + + } + /> + + {isLoading ? ( + + ) : isError || !query.data ? ( + + } color="red" title="Failed to load" variant="light"> + + Could not load {meta?.label ?? "this"} metrics. Please try again. + + + + + ) : ( + + {isFetching && ( +
+ +
+ )} + + {domain === "bookings" && bookings.data && } + {domain === "contracts" && contracts.data && } + {domain === "billing" && billing.data && } + {domain === "operations" && operations.data && ( + + )} + {domain === "fleet" && operations.data && } + {domain === "customers" && customers.data && } + {domain === "staff" && staff.data && } +
+ )} +
+ ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx index 008149adb..aa29fa0cf 100644 --- a/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/dashboard/OverviewPage.tsx @@ -1,141 +1,61 @@ import { useState } from "react"; -import { - AlertCircle, - Banknote, - FileSignature, - FileText, - Train, - TrainFront, - UserCheck, - Users, -} from "lucide-react"; -import { - Alert, - Badge, - Button, - Container, - Skeleton, - Stack, - Tabs, -} from "@mantine/core"; +import { AlertCircle } from "lucide-react"; +import { Alert, Button, Grid, Skeleton, Stack, Text } from "@mantine/core"; import { useQueryClient } from "@tanstack/react-query"; -import { useAuth } from "@/auth/useAuth"; -import { OverviewPageHeader } from "@/components/overview/OverviewPageHeader"; -import { OverviewTabContent } from "@/components/overview/OverviewTabContent"; -import "@/components/overview/overview.css"; +import { PageContainer } from "@/components/page"; +import { OverviewActivityHeatmap } from "@/components/overview/summary/OverviewActivityHeatmap"; +import { OverviewAttentionCard } from "@/components/overview/summary/OverviewAttentionCard"; +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 { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; -import type { OverviewRange, OverviewTabKey } from "@/types/overview"; +import type { OverviewRange } from "@/types/overview"; +import "@/components/overview/summary/overview-summary.css"; -const TAB_ITEMS: Array<{ - value: OverviewTabKey; - label: string; - icon: typeof FileText; - kpiKey: - | "bookings" - | "contracts" - | "billing" - | "operations" - | "customers" - | "staff"; - metricKey: string; - /** Any of these keys grants the tab. */ - permission: string[]; -}> = [ - { - value: "bookings", - label: "Bookings", - icon: FileText, - kpiKey: "bookings", - metricKey: "totalActive", - permission: [FREIGHT_PERMS.bookings.view], - }, - { - value: "contracts", - label: "Contracts", - icon: FileSignature, - kpiKey: "contracts", - metricKey: "totalActive", - permission: [FREIGHT_PERMS.contracts.view], - }, - { - value: "billing", - label: "Billing", - icon: Banknote, - kpiKey: "billing", - metricKey: "successfulPaymentsMtd", - permission: [FREIGHT_PERMS.bookings.view, FREIGHT_PERMS.payments.view], - }, - { - value: "operations", - label: "Operations", - icon: Train, - kpiKey: "operations", - metricKey: "trainsActive", - permission: [ - FREIGHT_PERMS.trainScheduling.view, - FREIGHT_PERMS.warehouseInventory.view, - FREIGHT_PERMS.firstMile.view, - FREIGHT_PERMS.lastMile.view, - ], - }, - { - value: "fleet", - label: "Fleet", - icon: TrainFront, - kpiKey: "operations", - metricKey: "wagonsAvailable", - permission: [FREIGHT_PERMS.wagons.view, FREIGHT_PERMS.trainScheduling.view], - }, - { - value: "customers", - label: "Customers", - icon: Users, - kpiKey: "customers", - metricKey: "totalCustomers", - permission: [FREIGHT_PERMS.customers.view], - }, - { - value: "staff", - label: "Staff", - icon: UserCheck, - kpiKey: "staff", - metricKey: "activeEmployees", - permission: [ - FREIGHT_PERMS.admin, - FREIGHT_PERMS.staff.roles.view, - FREIGHT_PERMS.staff.employeeRegistration.view, - FREIGHT_PERMS.staff.roleAssignment.view, - ], - }, -]; +const RANGE_LABEL: Record = { "7d": "7d", "30d": "30d", "90d": "90d" }; +const RANGE_DAYS: Record = { "7d": 7, "30d": 30, "90d": 90 }; -function HeaderSkeleton() { +/** Uppercase section eyebrow — matches the WarehouseDashboardPage convention. */ +function SectionTitle({ children }: { children: string }) { return ( - - - + + {children} + + ); +} + +/** One page band: eyebrow + content, with a staggered entrance by index. */ +function Band({ index, title, children }: { index: number; title: string; children: React.ReactNode }) { + return ( + + {title} + {children} + + ); +} + +function OverviewSkeleton() { + return ( + + + + + ); } const OverviewPage = () => { const [range, setRange] = useState("30d"); - const [activeTab, setActiveTab] = useState("bookings"); const queryClient = useQueryClient(); - const { user } = useAuth(); - const { data: summary, isLoading, isError, error, refetch, isFetching } = useOverview(range); + const { data, isLoading, isError, error, refetch, isFetching } = useOverview(range); - // Permission-scoped view: only tabs the user may see; a restricted role - // (e.g. operations) gets a summary 403 — that is not a connection problem. - const visibleTabs = TAB_ITEMS.filter((tab) => - tab.permission.some((key) => hasPermission(user, key)), - ); - const currentTab = visibleTabs.some((t) => t.value === activeTab) - ? activeTab - : visibleTabs[0]?.value; const accessDenied = (error as { response?: { status?: number } } | null)?.response?.status === 403; @@ -144,97 +64,113 @@ const OverviewPage = () => { void queryClient.invalidateQueries({ queryKey: QUERY_KEYS.OVERVIEW.ROOT }); }; - const getTabBadge = (tab: (typeof TAB_ITEMS)[number]) => { - if (!summary?.kpis) return 0; - const group = summary.kpis[tab.kpiKey] as unknown as Record; - return group[tab.metricKey] ?? 0; - }; - return ( - - - {isLoading && !summary ? ( - - ) : ( - - )} + + {/* Gradient greeting hero; the KPI strip overlaps its bottom edge. */} +
+ + {data ? ( +
+ +
+ ) : null} +
- {isError && !accessDenied && ( - } - color="red" - title="Unable to load dashboard summary" - variant="light" - > - - Check your connection and try again. - - - - )} + {isError && !accessDenied && ( + } + color="red" + title="Unable to load dashboard summary" + variant="light" + mt="lg" + > + + Check your connection and try again. + + + + )} - {visibleTabs.length === 0 && !isLoading && !isError && ( - - Your role has no access to any overview section. - - )} + {accessDenied && ( + + Your role has no access to the overview. + + )} - {visibleTabs.length > 0 && ( - - setActiveTab((value as OverviewTabKey) ?? visibleTabs[0].value) - } - variant="pills" - color="edr-green" - keepMounted={false} - classNames={{ list: "ov-tablist", tab: "ov-tab" }} - > - - {visibleTabs.map((tab) => { - const Icon = tab.icon; - const isActive = currentTab === tab.value; - return ( - } - rightSection={ - summary ? ( - - {getTabBadge(tab)} - - ) : undefined - } - > - {tab.label} - - ); - })} - + {isLoading && !data ? ( + + + + ) : data ? ( + + {/* Band 1 — revenue & volume: growing, making money, pacing vs last period. */} + + + + + + + + + + - {visibleTabs.map((tab) => ( - - - - ))} - - )} -
-
+ {/* Band 2 — where the money runs, and what's waiting on someone. */} + + + + + + + + + + + + {/* Band 3 — the network now, and when demand arrives. */} + + + + + + + + + + + + {/* Band 4 — the booking pipeline, full width so every stage bar has room. */} + + + +
+ ) : null} + ); }; diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx index 3633b1942..9e8105c54 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/DriverDetailPage.tsx @@ -43,17 +43,13 @@ import { fetchViewableFile, } from "@/services/files.service"; import { useToast } from "@/hooks/use-toast"; +import { formatDateTime } from "@/lib/format"; const fmtDate = (iso?: string | null) => { if (!iso) return "—"; const d = new Date(iso); return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); }; -const fmtDateTime = (iso?: string | null) => { - if (!iso) return "—"; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); -}; const meta = (e: FleetHistoryEvent, k: string) => { const v = e.metadata?.[k]; return typeof v === "string" && v ? v : null; @@ -400,7 +396,7 @@ const VehiclesTab = ({ driverId }: { driverId: string }) => { {rows.map((r) => ( {r.plate} - {fmtDateTime(r.at)} + {formatDateTime(r.at)} ))} @@ -425,7 +421,7 @@ const HistoryTab = ({ driverId }: { driverId: string }) => { .join(" · ")} )} - {fmtDateTime(e.createdAt)} + {formatDateTime(e.createdAt)} ))} @@ -471,7 +467,7 @@ const TripsTab = ({ driverId }: { driverId: string }) => { {t.booking} {t.vehicle} {t.status} - {fmtDateTime(t.at)} + {formatDateTime(t.at)} ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx index cb64d3b22..bcfd9731a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/TrackingPage.tsx @@ -35,6 +35,7 @@ import { type GpsDevice, } from "@/services/gps-tracking.service"; import { freightBrand } from "@/theme/freight-brand"; +import { formatDateTime } from "@/lib/format"; // Maps JavaScript API keys are public client-side keys — lock them down by // HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default @@ -51,11 +52,6 @@ const deviceLabel = (d: GpsDevice) => ? [d.vehicle.code, d.vehicle.plateNumber].filter(Boolean).join(" · ") : d.name || d.imei; -const fmtTime = (iso?: string | null) => { - if (!iso) return "—"; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); -}; const StatBox = ({ label, value }: { label: string; value: string }) => ( @@ -530,7 +526,7 @@ export function TrackingPage() { Last fix - {fmtTime(selected.lastFixAt)} + {formatDateTime(selected.lastFixAt)}
{selected.vehicleId && ( @@ -580,7 +576,7 @@ export function TrackingPage() { {toNum(d.lastSpeed) ?? 0} km/h ·{" "} - {fmtTime(d.lastFixAt)} + {formatDateTime(d.lastFixAt)} diff --git a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx index ae785a708..bcfadb698 100644 --- a/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/fleet/VehicleDetailPage.tsx @@ -42,6 +42,7 @@ import { } from "@/services/vehicles.service"; import { driversService } from "@/services/drivers.service"; import { fleetHistoryService } from "@/services/fleet-history.service"; +import { formatDateTime } from "@/lib/format"; interface MaintenanceCost { id: string; @@ -77,11 +78,6 @@ const fmtDate = (iso?: string | null) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); }; -const fmtDateTime = (iso?: string | null) => { - if (!iso) return "—"; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString(); -}; const money = (n?: number | null) => n == null ? "—" : `ETB ${Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 })}`; @@ -359,7 +355,7 @@ const DriverTab = ({ {drivers.map((d) => ( {d.name} - {fmtDateTime(d.at)} + {formatDateTime(d.at)} ))} @@ -388,7 +384,7 @@ const HistoryTab = ({ vehicleId }: { vehicleId: string }) => { .join(" · ")} )} - {fmtDateTime(e.createdAt)} + {formatDateTime(e.createdAt)} ))} diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index afa4fa603..9b0fb9392 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -73,7 +73,7 @@ export default function InvoicesPanel() { id: "billedTo", header: "Billed to", cell: ({ row }) => ( - + {row.original.company?.name ?? "—"} ), @@ -170,9 +170,6 @@ export default function InvoicesPanel() { { label: "Overdue", value: "OVERDUE" }, ]} /> - - {total} record{total !== 1 ? "s" : ""} - ( - + {row.original.company?.name ?? "—"} ), @@ -304,9 +304,6 @@ export default function UsdPaymentsPanel() { { label: "Overdue", value: "OVERDUE" }, ]} /> - - {total} record{total !== 1 ? "s" : ""} - - value ? new Date(value).toLocaleString() : "—"; /** * Per-truck exit papers for an EDR last-mile delivery. Each assigned truck has @@ -87,11 +86,11 @@ export function EdrTruckExitPapersModal({ opened, onClose, record }: EdrTruckExi {load} - {fmt(t.arrivedAt)} + {formatDateTime(t.arrivedAt)} {t.departedAt ? ( - {fmt(t.departedAt)} + {formatDateTime(t.departedAt)} ) : ( Still on site diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx index 192a748c3..ae9bd3b21 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/FirstMilePage.tsx @@ -1139,7 +1139,11 @@ const FirstMilePage = () => { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => customerName(row.original), + cell: ({ row }) => ( + + {customerName(row.original)} + + ), }, { id: "postPayment", @@ -1189,7 +1193,7 @@ const FirstMilePage = () => { id: "exactKm", header: "Actual Distance (KM)", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , + cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : , }, { id: "invoice", diff --git a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx index 99c6e0416..9f7198609 100644 --- a/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/operations/LastMilePage.tsx @@ -1296,7 +1296,11 @@ const LastMilePage = () => { id: "customer", header: "Customer", meta: { headerClassName, cellClassName }, - cell: ({ row }) => customerName(row.original), + cell: ({ row }) => ( + + {customerName(row.original)} + + ), }, { id: "postPayment", @@ -1346,7 +1350,7 @@ const LastMilePage = () => { id: "exactKm", header: "Actual Distance (KM)", meta: { headerClassName, cellClassName }, - cell: ({ row }) => row.original.exactKm != null ? row.original.exactKm : , + cell: ({ row }) => row.original.exactKm != null ? `${row.original.exactKm} km` : , }, { id: "invoice", diff --git a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx index 2d98cc805..a560b3291 100644 --- a/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/payments/PaymentsPage.tsx @@ -8,7 +8,6 @@ import { Select, Stack, Tabs, - Text, TextInput, } from "@mantine/core"; import { @@ -26,6 +25,7 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { KpiStrip } from "@/components/page"; +import { formatDate, formatMoney } from "@/lib/format"; import { api } from "@/services/api"; import type { PaymentMethod, PaymentRow } from "@/services/payments.service"; import { @@ -80,24 +80,6 @@ const STATUS_COLORS: Record = { refunded: "indigo", }; -function formatAmount(amount: number, currency: string): string { - return `${currency} ${Number(amount).toLocaleString(undefined, { - minimumFractionDigits: 2, - })}`; -} - -function formatDate(iso: string | null): string { - if (!iso) return "—"; - const d = new Date(iso); - return Number.isNaN(d.getTime()) - ? "—" - : d.toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - }); -} - const tableHeader = "text-xs font-semibold uppercase tracking-wide text-muted-foreground"; @@ -166,7 +148,7 @@ export default function PaymentsPanel() { header: () => Amount, cell: ({ row }) => ( - {formatAmount(row.original.amount, row.original.currency)} + {formatMoney(row.original.amount, row.original.currency, 2)} ), }, @@ -327,9 +309,6 @@ export default function PaymentsPanel() { }} style={{ minWidth: 180 }} /> - - {total} record{total !== 1 ? "s" : ""} - diff --git a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx index 8527879fc..da01b51ce 100644 --- a/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/settings/ExchangeRateSettingsCard.tsx @@ -15,6 +15,7 @@ import { useSetExchangeFallbackRate, } from "@/hooks/useExchangeSettings"; import type { ExchangeRateSource } from "@/services/exchangeSettings.service"; +import { formatDateTime } from "@/lib/format"; /** Feed health, phrased for an operator rather than a developer. */ function feedLabel(source: ExchangeRateSource | null): { @@ -35,7 +36,7 @@ function feedLabel(source: ExchangeRateSource | null): { } const formatTime = (value: string | null) => - value ? new Date(value).toLocaleString() : "never"; + value ? formatDateTime(value) : "never"; /** * USD→ETB fallback used when the CBE exchange-rate endpoint is unreachable. diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx index 6f329c8d7..f8468181c 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ContainerReturnsPage.tsx @@ -37,6 +37,7 @@ import type { EmptyContainerReturn, EmptyContainerReturnStatus, } from "@/types/importOperations"; +import { formatDateTime } from "@/lib/format"; type ReturnType = "all" | "edr" | "customer"; @@ -635,7 +636,7 @@ export default function ContainerReturnsPage() { {(historyRow.statusHistory ?? []).map((entry: any, idx: number) => ( {RETURN_STATUS_LABEL[entry.status as EmptyContainerReturnStatus] ?? entry.status} - {new Date(entry.changedAt).toLocaleString()} + {formatDateTime(entry.changedAt)} ))} {!(historyRow.statusHistory ?? []).length && ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx index d2cc909f2..a074006d8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/ImportTrucksPage.tsx @@ -38,6 +38,7 @@ import { toReleaseInventoryItem, } from "@/components/warehouses/options"; import { openPdfBlob } from "@/components/warehouses/pdf"; +import { formatDateTime } from "@/lib/format"; import { useListControls } from "@/hooks/useListControls"; import { useToast } from "@/hooks/use-toast"; import { api } from "@/services/api"; @@ -79,8 +80,6 @@ const TRUCK_COLUMNS = [ const money = (amount: number, currency: string) => `${Number(amount).toLocaleString(undefined, { maximumFractionDigits: 2 })} ${currency === "ETB" ? "ETB" : currency}`; -const formatTime = (iso: string | null | undefined) => - iso ? new Date(iso).toLocaleString() : "—"; export interface BookingGroup { bookingId: string; @@ -376,10 +375,10 @@ export function TruckRows({ group }: { group: BookingGroup }) { {t.containers.length ? t.containers.join(", ") : "Bulk"} - {formatTime(t.arrivedAt)} + {formatDateTime(t.arrivedAt)} - {formatTime(t.departedAt)} + {formatDateTime(t.departedAt)} {formatNumber(t.weight)} {money(t.demurrage, feeCurrency)} diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx index 3cc1f1ab6..fa2c704b2 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/InterchangeDocumentsPage.tsx @@ -28,6 +28,7 @@ import { useInterchangeDocuments, } from '@/hooks/useInterchangeDocuments'; import { useToast } from '@/hooks/use-toast'; +import { humanize } from '@/lib/format'; import { interchangeDocumentsService } from '@/services/interchange-documents.service'; import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument'; @@ -336,7 +337,7 @@ export default function InterchangeDocumentsPage() { ), }, - { id: 'direction', header: 'Direction', cell: ({ row }) => row.original.direction }, + { id: 'direction', header: 'Direction', cell: ({ row }) => humanize(row.original.direction) }, { id: 'train', header: 'Train No', cell: ({ row }) => row.original.trainNo ?? '-' }, { id: 'handoverLocation', header: 'Handover Location', cell: ({ row }) => row.original.handoverLocation }, { id: 'handoverFrom', header: 'Handover From', cell: ({ row }) => row.original.handoverFrom }, diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx index f095d94d4..d30f9144b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/TrucksOnSitePage.tsx @@ -18,6 +18,7 @@ import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter"; import { useListControls } from "@/hooks/useListControls"; import { useTrucksOnSite } from "@/hooks/useWarehouses"; import type { TruckOnSite } from "@/types/warehouse"; +import { formatDateTime } from "@/lib/format"; /** * Every truck inside the yard right now, across all bookings. @@ -122,7 +123,7 @@ function Rows({ rows }: { rows: TruckOnSite[] }) { ) : isLongDwell(row.arrivedAt) ? ( diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx index dc4ae4f86..17becfaa6 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseInvoicesPage.tsx @@ -40,6 +40,7 @@ import { buildWarehouseExitPaperPdf } from '@/components/warehouses/warehousePdf import { extractErrorMessage } from '@/components/warehouses/options'; import { useAuth } from '@/auth/useAuth'; import { FREIGHT_PERMS, hasPermission } from '@/lib/permissions'; +import { formatMoney, humanize } from '@/lib/format'; const STATUS_COLOR: Record = { DRAFT: 'gray', @@ -50,7 +51,7 @@ const STATUS_COLOR: Record = { CANCELLED: 'gray', }; -const fmt = (n: number, c: string) => `${Number(n).toLocaleString()} ${c === 'ETB' ? 'Birr (ETB)' : c}`; +const fmt = (n: number, c: string) => formatMoney(n, c, 2); const fmtDate = (d?: string | null) => (d ? new Date(d).toLocaleDateString() : '—'); export default function WarehouseInvoicesPage() { @@ -79,7 +80,7 @@ export default function WarehouseInvoicesPage() { ), }, - { id: 'type', header: 'Type', cell: ({ row }) => row.original.invoiceType.replace(/_/g, ' ') }, + { id: 'type', header: 'Type', cell: ({ row }) => humanize(row.original.invoiceType) }, { id: 'total', header: 'Total', cell: ({ row }) => fmt(row.original.totalAmount, row.original.currency) }, { id: 'paid', header: 'Paid', cell: ({ row }) => fmt(row.original.paidAmount, row.original.currency) }, { diff --git a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx index a6339965a..8c432f92a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseRulesPage.tsx @@ -46,6 +46,7 @@ import { type FeeRuleBasis, type FeeRuleType, } from '@/types/warehouse'; +import { humanize } from '@/lib/format'; const RULE_TYPE_COLOR: Record = { STORAGE_FEE: 'teal', @@ -218,8 +219,8 @@ function AllocationRules() { const allocationColumns: ColumnDef[] = [ { id: 'priority', header: 'Priority', cell: ({ row }) => row.original.priority }, { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, - { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash }, - { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash }, + { id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) }, + { id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) }, { id: 'cargoCode', header: 'Cargo code', cell: ({ row }) => row.original.cargoTypeCode ?? dash }, { id: 'targetYard', @@ -615,10 +616,10 @@ function FeeRules() { ), }, { id: 'name', header: 'Name', cell: ({ row }) => row.original.name }, - { id: 'freight', header: 'Freight', cell: ({ row }) => row.original.freightType ?? dash }, - { id: 'trade', header: 'Trade', cell: ({ row }) => row.original.tradeDirection ?? dash }, + { id: 'freight', header: 'Freight', cell: ({ row }) => (row.original.freightType ? humanize(row.original.freightType) : dash) }, + { id: 'trade', header: 'Trade', cell: ({ row }) => (row.original.tradeDirection ? humanize(row.original.tradeDirection) : dash) }, { id: 'cargo', header: 'Cargo', cell: ({ row }) => row.original.cargoTypeCode ?? dash }, - { id: 'container', header: 'Container', cell: ({ row }) => row.original.containerType ?? dash }, + { id: 'container', header: 'Container', cell: ({ row }) => (row.original.containerType ? humanize(row.original.containerType) : dash) }, { id: 'scope', header: 'Location scope', diff --git a/apps/edr-freight-web/backoffice/src/types/overview.ts b/apps/edr-freight-web/backoffice/src/types/overview.ts index f0553fc22..38778da49 100644 --- a/apps/edr-freight-web/backoffice/src/types/overview.ts +++ b/apps/edr-freight-web/backoffice/src/types/overview.ts @@ -10,6 +10,10 @@ export type { IOverviewTrendPoint, IOverviewDirectionTrendPoint, IOverviewTonnagePoint, + IOverviewPeriodTotals, + IOverviewRevenueSlice, + IOverviewRevenueFlow, + IOverviewHeatmapCell, IOverviewStatusCount, IOverviewPipelineCount, IOverviewPaymentTrendPoint, diff --git a/packages/types/src/freight/overview.ts b/packages/types/src/freight/overview.ts index e3d98bc00..4972db10d 100644 --- a/packages/types/src/freight/overview.ts +++ b/packages/types/src/freight/overview.ts @@ -13,6 +13,7 @@ export interface IOverviewBookingKpis { export interface IOverviewOperationsKpis { trainsActive: number; wagonsAvailable: number; + wagonsTotal: number; containersInTransit: number; cargoesLoaded: number; schedulesUpcoming: number; @@ -99,13 +100,60 @@ export interface IOverviewRecentContract { createdAt: string; } +/** Bookings/revenue/tonnage totals for one range-wide window. */ +export interface IOverviewPeriodTotals { + bookingsCreated: number; + revenueEtb: number; + revenueUsd: number; + tons: number; +} + +/** One label's revenue split, e.g. a trade direction or freight type. */ +export interface IOverviewRevenueSlice { + label: string; + amountEtb: number; + amountUsd: number; +} + +export interface IOverviewTonsTrendPoint { + date: string; + tons: number; +} + +/** One direction → freight-type revenue flow (Sankey link). */ +export interface IOverviewRevenueFlow { + direction: string; + freightType: string; + amountEtb: number; + amountUsd: number; +} + +/** Booking arrivals for one weekday × 3-hour block. */ +export interface IOverviewHeatmapCell { + /** ISO weekday, 1 = Monday … 7 = Sunday. */ + dow: number; + /** 3-hour block, 0 = 00–03 … 7 = 21–24. */ + block: number; + count: number; +} + export interface IOverviewDashboard { kpis: IOverviewKpis; bookingTrend: IOverviewTrendPoint[]; bookingsByStatus: IOverviewStatusCount[]; bookingsByPipeline: IOverviewPipelineCount[]; paymentTrend: IOverviewPaymentTrendPoint[]; - recentBookings: IOverviewRecentBooking[]; + /** Totals for the selected range, ending today. */ + current: IOverviewPeriodTotals; + /** Totals for the immediately preceding range of the same length — the delta baseline. */ + previous: IOverviewPeriodTotals; + revenueByDirection: IOverviewRevenueSlice[]; + revenueByFreightType: IOverviewRevenueSlice[]; + /** The preceding same-length window's daily revenue — ghost-line comparison. */ + previousPaymentTrend: IOverviewPaymentTrendPoint[]; + tonsTrend: IOverviewTonsTrendPoint[]; + revenueFlows: IOverviewRevenueFlow[]; + bookingHeatmap: IOverviewHeatmapCell[]; generatedAt: string; }