From 1b17cbec01b5ea78e27bcbf9502e76f3560c83f4 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 19:37:15 -0300 Subject: [PATCH] feat(dashboard): build dashboard experience (SH-336) --- src/api/api-paths.ts | 5 + src/app/(protected)/dashboard.tsx | 240 ---------------- .../_components/dashboard-bar-list.tsx | 86 ++++++ .../dashboard/_components/dashboard-card.tsx | 44 +++ .../_components/dashboard-kpi-card.tsx | 68 +++++ .../_components/dashboard-range-toggle.tsx | 42 +++ .../_components/dashboard-trend-card.tsx | 119 ++++++++ .../dispatcher-performance-card.tsx | 97 +++++++ .../_components/dispatcher-workload-card.tsx | 91 ++++++ .../_components/vendor-insights-card.tsx | 63 ++++ .../_components/work-order-breakdown-card.tsx | 47 +++ .../work-orders-by-region-card.tsx | 36 +++ src/app/(protected)/dashboard/index.tsx | 123 ++++++++ src/app/(protected)/index.tsx | 241 +--------------- .../_hooks/use-dashboard-drilldown-filters.ts | 30 ++ src/app/(protected)/workorders/index.tsx | 11 +- src/domain/dashboard/api/dashboard-api.ts | 271 +++++++++++++++++- .../dashboard/types/dashboard-performance.ts | 6 + src/domain/dashboard/types/dashboard-range.ts | 10 + .../dashboard/types/dashboard-regions.ts | 6 + src/domain/dashboard/types/dashboard-stats.ts | 14 + src/domain/dashboard/types/dashboard-trend.ts | 25 ++ .../types/dashboard-vendor-insights.ts | 8 + .../dashboard/types/dashboard-workload.ts | 6 + .../use-cases/use-dashboard-performance.ts | 16 ++ .../use-cases/use-dashboard-regions.ts | 16 ++ .../use-cases/use-dashboard-stats.ts | 8 +- .../use-cases/use-dashboard-trend.ts | 17 ++ .../use-dashboard-vendor-insights.ts | 12 + .../use-cases/use-dashboard-workload.ts | 16 ++ .../dashboard/utils/dashboard-range-utils.ts | 73 +++++ .../utils/work-order-drilldown-links.ts | 82 ++++++ .../utils/dashboard-drilldown-params.ts | 109 +++++++ src/infra/query-key/query-key.ts | 12 +- .../dashboard/dashboard-page.test.tsx | 153 ++++++++++ .../domain/dashboard/dashboard-api.test.ts | 182 ++++++++++++ .../dashboard/dashboard-range-utils.test.ts | 68 +++++ .../work-order-drilldown-links.test.ts | 48 ++++ .../dashboard-drilldown-params.test.ts | 60 ++++ 39 files changed, 2068 insertions(+), 493 deletions(-) delete mode 100644 src/app/(protected)/dashboard.tsx create mode 100644 src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx create mode 100644 src/app/(protected)/dashboard/_components/dashboard-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx create mode 100644 src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/vendor-insights-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx create mode 100644 src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx create mode 100644 src/app/(protected)/dashboard/index.tsx create mode 100644 src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts create mode 100644 src/domain/dashboard/types/dashboard-performance.ts create mode 100644 src/domain/dashboard/types/dashboard-range.ts create mode 100644 src/domain/dashboard/types/dashboard-regions.ts create mode 100644 src/domain/dashboard/types/dashboard-trend.ts create mode 100644 src/domain/dashboard/types/dashboard-vendor-insights.ts create mode 100644 src/domain/dashboard/types/dashboard-workload.ts create mode 100644 src/domain/dashboard/use-cases/use-dashboard-performance.ts create mode 100644 src/domain/dashboard/use-cases/use-dashboard-regions.ts create mode 100644 src/domain/dashboard/use-cases/use-dashboard-trend.ts create mode 100644 src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts create mode 100644 src/domain/dashboard/use-cases/use-dashboard-workload.ts create mode 100644 src/domain/dashboard/utils/dashboard-range-utils.ts create mode 100644 src/domain/dashboard/utils/work-order-drilldown-links.ts create mode 100644 src/domain/work-orders/utils/dashboard-drilldown-params.ts create mode 100644 src/test/app/(protected)/dashboard/dashboard-page.test.tsx create mode 100644 src/test/domain/dashboard/dashboard-api.test.ts create mode 100644 src/test/domain/dashboard/dashboard-range-utils.test.ts create mode 100644 src/test/domain/dashboard/work-order-drilldown-links.test.ts create mode 100644 src/test/domain/work-orders/dashboard-drilldown-params.test.ts diff --git a/src/api/api-paths.ts b/src/api/api-paths.ts index 33db519b..b1e90023 100644 --- a/src/api/api-paths.ts +++ b/src/api/api-paths.ts @@ -148,6 +148,11 @@ export const API_PATHS = { }, dashboard: { stats: "Dashboard/Stats", + workload: "Dashboard/Workload", + performance: "Dashboard/Performance", + regions: "Dashboard/Regions", + vendorInsights: "Dashboard/VendorInsights", + trend: "Dashboard/Trend", }, dropdownOptions: { byCategory: "DropdownOptions/ByCategory", diff --git a/src/app/(protected)/dashboard.tsx b/src/app/(protected)/dashboard.tsx deleted file mode 100644 index e1af6bf9..00000000 --- a/src/app/(protected)/dashboard.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import type { ReactNode } from "react"; -import { useNavigate } from "react-router"; -import AddIcon from "@mui/icons-material/Add"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined"; -import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined"; -import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; -import { - Alert, - Box, - CircularProgress, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; - -const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const; - -const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const; - -interface KpiCardProps { - title: string; - description?: string; - value?: string | number; - icon: ReactNode; - color: "success" | "primary" | "info" | "warning"; - onClick: () => void; - loading?: boolean; -} - -function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) { - const colorMap = { - success: "success.main", - primary: "primary.main", - info: "info.main", - warning: "warning.main", - } as const; - - return ( - - - - {icon} - - - - - {title} - - {value !== undefined && ( - - {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} - - )} - {Boolean(description) && ( - - {description} - - )} - - - - ); -} - -export default function DashboardPage() { - const navigate = useNavigate(); - const { data: stats, isLoading, isFetching, error } = useDashboardStats(); - - const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); - - return ( - - - - Dashboard - - {isFetching && !isLoading && } - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load dashboard stats"} - - )} - - } - color="success" - onClick={() => navigate("/workorders/new")} - /> - } - color="primary" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="info" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="warning" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - - - - - Priority Analysis - - - Age of open Work Orders by Priority - - - - - - - - Priority - - Total - - - 0-14 Days - - - 15-29 Days - - - 30-59 Days - - - 60+ Days - - - - - {PRIORITY_LEVELS.map((priority) => ( - - {priority} - — - — - — - — - — - - ))} - -
-
-
- - - - Invoice Analysis - - - - - - - - Status - - Count - - - Amount - - - - - {INVOICE_STATUSES.map((status) => ( - - {status} - 0 - $0.00 - - ))} - -
-
-
-
- ); -} diff --git a/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx b/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx new file mode 100644 index 00000000..f449a957 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-bar-list.tsx @@ -0,0 +1,86 @@ +import { Box, ButtonBase } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +export interface DashboardBarListRow { + id: string; + label: string; + count: number; +} + +interface DashboardBarListProps { + rows: DashboardBarListRow[]; + onSelectRow?: (row: DashboardBarListRow) => void; + isSelectable?: (row: DashboardBarListRow) => boolean; + ariaLabel: string; +} + +function percentOf(count: number, max: number): number { + if (max <= 0 || count <= 0) { + return 0; + } + + return Math.round((count / max) * 100); +} + +function BarListRow({ row, width }: { row: DashboardBarListRow; width: number }) { + return ( + + + + {row.label} + + + {row.count.toLocaleString()} + + + + {width > 0 && ( + + )} + + + ); +} + +export function DashboardBarList({ + rows, + onSelectRow, + isSelectable, + ariaLabel, +}: DashboardBarListProps) { + const max = Math.max(...rows.map((row) => row.count), 0); + + return ( + + {rows.map((row) => { + const width = percentOf(row.count, max); + const selectable = Boolean(onSelectRow) && row.count > 0 && (isSelectable?.(row) ?? true); + + return selectable ? ( + onSelectRow?.(row)} + sx={{ + width: "100%", + textAlign: "left", + borderRadius: 1, + px: 1, + mx: -1, + cursor: "pointer", + }} + > + + + ) : ( + + + + ); + })} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-card.tsx new file mode 100644 index 00000000..71f40dfd --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-card.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import { Box, CircularProgress, Paper } from "@mui/material"; +import { QueryErrorPanel } from "@/components/ui/query-error-panel"; +import { Text } from "@/components/ui/text"; + +interface DashboardCardProps { + title: string; + isLoading?: boolean; + error?: unknown; + isEmpty: boolean; + emptyMessage: string; + children: ReactNode; +} + +export function DashboardCard({ + title, + isLoading = false, + error, + isEmpty, + emptyMessage, + children, +}: DashboardCardProps) { + return ( + + + {title} + + {isLoading && ( + + + + )} + {!isLoading && Boolean(error) && } + {!isLoading && !error && isEmpty && ( + + + {emptyMessage} + + + )} + {!isLoading && !error && !isEmpty && {children}} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx new file mode 100644 index 00000000..69d29c58 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-kpi-card.tsx @@ -0,0 +1,68 @@ +import type { ReactNode } from "react"; +import { Box, ButtonBase, Paper } from "@mui/material"; +import { Text } from "@/components/ui/text"; + +type KpiTone = "success" | "primary" | "info" | "warning"; + +const toneColor: Record = { + success: "success.main", + primary: "primary.main", + info: "info.main", + warning: "warning.main", +}; + +interface DashboardKpiCardProps { + title: string; + value?: number; + icon: ReactNode; + tone: KpiTone; + loading?: boolean; + active?: boolean; + onSelect?: () => void; +} + +export function DashboardKpiCard({ + title, + value, + icon, + tone, + loading = false, + active = false, + onSelect, +}: DashboardKpiCardProps) { + const color = toneColor[tone]; + const content = ( + + + {icon} + + + + {title} + + + {loading ? "…" : (value ?? 0).toLocaleString()} + + + + ); + + return ( + + {active && Boolean(onSelect) ? ( + + {content} + + ) : ( + content + )} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx b/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx new file mode 100644 index 00000000..b97cb672 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-range-toggle.tsx @@ -0,0 +1,42 @@ +import { ToggleButton, ToggleButtonGroup } from "@mui/material"; +import { DASHBOARD_RANGES, type DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; + +const RANGE_LABELS: Record = { + "this-week": "This week", + "last-3-months": "Last 3 months", + today: "Today", + "all-time": "All time", +}; + +interface DashboardRangeToggleProps { + value: DashboardRangeKey; + onChange: (value: DashboardRangeKey) => void; + disabled?: boolean; +} + +export function DashboardRangeToggle({ + value, + onChange, + disabled = false, +}: DashboardRangeToggleProps) { + return ( + { + if (next) { + onChange(next); + } + }} + > + {DASHBOARD_RANGES.map((range) => ( + + {RANGE_LABELS[range]} + + ))} + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx b/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx new file mode 100644 index 00000000..6b273323 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dashboard-trend-card.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from "react"; +import { Box, IconButton } from "@mui/material"; +import ArrowBackOutlinedIcon from "@mui/icons-material/ArrowBackOutlined"; +import ArrowForwardOutlinedIcon from "@mui/icons-material/ArrowForwardOutlined"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardTrendPoint } from "@/domain/dashboard/types/dashboard-trend"; +import { useDashboardTrend } from "@/domain/dashboard/use-cases/use-dashboard-trend"; +import { + businessTodayIso, + dashboardRangeParams, +} from "@/domain/dashboard/utils/dashboard-range-utils"; +import { DashboardCard } from "./dashboard-card"; + +interface DashboardTrendCardProps { + range: DashboardRangeKey; +} + +function trendParams(range: DashboardRangeKey, year: number) { + if (range === "all-time") return { year }; + const params = dashboardRangeParams(range); + return { + ...params, + ...(range === "last-3-months" ? { range: "3m" as const } : {}), + }; +} + +function barHeight(point: DashboardTrendPoint, max: number): number { + if (point.total <= 0 || max <= 0) return 0; + return Math.max(6, Math.round((point.total / max) * 100)); +} + +function TrendBar({ point, max }: { point: DashboardTrendPoint; max: number }) { + const height = barHeight(point, max); + return ( + + + {point.total.toLocaleString()} + + + {height > 0 && ( + + )} + + + {point.label} + + {point.isCurrent && Today} + + ); +} + +export function DashboardTrendCard({ range }: DashboardTrendCardProps) { + const currentYear = Number(businessTodayIso().slice(0, 4)); + const [year, setYear] = useState(currentYear); + const params = useMemo(() => trendParams(range, year), [range, year]); + const trend = useDashboardTrend(params); + const points = trend.data?.points ?? []; + const max = Math.max(...points.map((point) => point.total), 0); + + return ( + + + + + {trend.data?.granularity ? `${trend.data.granularity} buckets` : "Trend"} + + {range === "all-time" && ( + + setYear((value) => value - 1)} + > + + + {year} + setYear((value) => value + 1)} + > + + + + )} + + + {points.map((point) => ( + + ))} + + + The dashed marker and “Today” label identify the current business date. + + + + ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx new file mode 100644 index 00000000..72130baa --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-performance-card.tsx @@ -0,0 +1,97 @@ +import { useNavigate } from "react-router"; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; +import { + workOrderDispatcherDrilldownSearch, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No dispatchers are set up yet."; + +interface DispatcherPerformanceCardProps { + rows: DispatcherPerformanceRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +function formatDays(value: number): string { + return (Math.round(value * 10) / 10).toLocaleString(); +} + +function DispatcherNameCell({ + row, + range, +}: { + row: DispatcherPerformanceRow; + range: DashboardRangeKey; +}) { + const navigate = useNavigate(); + const drillable = Boolean(row.dispatcherId) && row.completionRate > 0; + + if (!drillable) { + return {row.dispatcherName}; + } + + return ( + + navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range))) + } + > + {row.dispatcherName} + + ); +} + +export function DispatcherPerformanceCard({ + rows, + range, + isLoading = false, + error, +}: DispatcherPerformanceCardProps) { + return ( + + + + + + Dispatcher + + Completion % + + + Avg days + + + + + {rows.map((row) => ( + + + + + {`${row.completionRate.toLocaleString()}%`} + + {row.averageResolutionDays == null ? "—" : formatDays(row.averageResolutionDays)} + + + ))} + +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx b/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx new file mode 100644 index 00000000..e0f0c73e --- /dev/null +++ b/src/app/(protected)/dashboard/_components/dispatcher-workload-card.tsx @@ -0,0 +1,91 @@ +import { useNavigate } from "react-router"; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; +import { + workOrderDispatcherDrilldownSearch, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No dispatchers are set up yet."; + +interface DispatcherWorkloadCardProps { + rows: DispatcherWorkloadRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +function DispatcherNameCell({ + row, + range, +}: { + row: DispatcherWorkloadRow; + range: DashboardRangeKey; +}) { + const navigate = useNavigate(); + const drillable = Boolean(row.dispatcherId) && row.totalWorkOrders > 0; + + if (!drillable) { + return {row.dispatcherName}; + } + + return ( + + navigate(workOrderDrilldownUrl(workOrderDispatcherDrilldownSearch(row.dispatcherId, range))) + } + > + {row.dispatcherName} + + ); +} + +export function DispatcherWorkloadCard({ + rows, + range, + isLoading = false, + error, +}: DispatcherWorkloadCardProps) { + return ( + + + + + + Dispatcher + + Open + + + Total + + + + + {rows.map((row) => ( + + + + + {row.openWorkOrders.toLocaleString()} + {row.totalWorkOrders.toLocaleString()} + + ))} + +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx new file mode 100644 index 00000000..0e98910f --- /dev/null +++ b/src/app/(protected)/dashboard/_components/vendor-insights-card.tsx @@ -0,0 +1,63 @@ +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Text } from "@/components/ui/text"; +import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No active vendor has any work orders yet."; + +interface VendorInsightsCardProps { + rows: VendorInsightRow[]; + isLoading?: boolean; + error?: unknown; +} + +export function VendorInsightsCard({ rows, isLoading = false, error }: VendorInsightsCardProps) { + return ( + + + + + + Vendor + + Completion % + + + Reschedule % + + + Avg days + + + Jobs + + + + + {rows.map((row) => ( + + + {row.vendorName} + + {`${row.completionRate.toLocaleString()}%`} + {`${row.rescheduleRate.toLocaleString()}%`} + + {row.averageResolutionDays == null + ? "—" + : row.averageResolutionDays.toLocaleString()} + + {row.totalJobs.toLocaleString()} + + ))} + +
+
+
+ ); +} diff --git a/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx b/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx new file mode 100644 index 00000000..84f32923 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/work-order-breakdown-card.tsx @@ -0,0 +1,47 @@ +import { useNavigate } from "react-router"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardBreakdownRow } from "@/domain/dashboard/types/dashboard-stats"; +import { + workOrderDrilldownUrl, + workOrderTypeDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +interface WorkOrderBreakdownCardProps { + breakdown: DashboardBreakdownRow[]; + range: DashboardRangeKey; + isLoading?: boolean; + error?: unknown; +} + +export function WorkOrderBreakdownCard({ + breakdown, + range, + isLoading = false, + error, +}: WorkOrderBreakdownCardProps) { + const navigate = useNavigate(); + + return ( + + ({ id: row.status, label: row.status, count: row.count }))} + isSelectable={(row) => workOrderTypeDrilldownSearch(row.id, range) !== null} + onSelectRow={(row) => { + const search = workOrderTypeDrilldownSearch(row.id, range); + if (search) navigate(workOrderDrilldownUrl(search)); + }} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx new file mode 100644 index 00000000..3a156152 --- /dev/null +++ b/src/app/(protected)/dashboard/_components/work-orders-by-region-card.tsx @@ -0,0 +1,36 @@ +import type { RegionWorkOrdersRow } from "@/domain/dashboard/types/dashboard-regions"; +import { DashboardBarList } from "./dashboard-bar-list"; +import { DashboardCard } from "./dashboard-card"; + +const EMPTY_MESSAGE = "No work orders match the current filters."; + +interface WorkOrdersByRegionCardProps { + rows: RegionWorkOrdersRow[]; + isLoading?: boolean; + error?: unknown; +} + +export function WorkOrdersByRegionCard({ + rows, + isLoading = false, + error, +}: WorkOrdersByRegionCardProps) { + return ( + + ({ + id: row.region, + label: row.region, + count: row.workOrderCount, + }))} + /> + + ); +} diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx new file mode 100644 index 00000000..92f385bc --- /dev/null +++ b/src/app/(protected)/dashboard/index.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { useNavigate } from "react-router"; +import EventOutlinedIcon from "@mui/icons-material/EventOutlined"; +import PendingActionsIcon from "@mui/icons-material/PendingActions"; +import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined"; +import { Box, Button, CircularProgress } from "@mui/material"; +import { PageHeader } from "@/components/ui/page-header"; +import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card"; +import { DashboardRangeToggle } from "@/app/(protected)/dashboard/_components/dashboard-range-toggle"; +import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card"; +import { DispatcherPerformanceCard } from "@/app/(protected)/dashboard/_components/dispatcher-performance-card"; +import { DispatcherWorkloadCard } from "@/app/(protected)/dashboard/_components/dispatcher-workload-card"; +import { VendorInsightsCard } from "@/app/(protected)/dashboard/_components/vendor-insights-card"; +import { WorkOrderBreakdownCard } from "@/app/(protected)/dashboard/_components/work-order-breakdown-card"; +import { WorkOrdersByRegionCard } from "@/app/(protected)/dashboard/_components/work-orders-by-region-card"; +import { + DEFAULT_DASHBOARD_RANGE, + type DashboardRangeKey, +} from "@/domain/dashboard/types/dashboard-range"; +import { useDashboardPerformance } from "@/domain/dashboard/use-cases/use-dashboard-performance"; +import { useDashboardRegions } from "@/domain/dashboard/use-cases/use-dashboard-regions"; +import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; +import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights"; +import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + UPLIFTS_ROUTE, + workOrderDrilldownUrl, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +export default function DashboardPage() { + const navigate = useNavigate(); + const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE); + const stats = useDashboardStats(range); + const workload = useDashboardWorkload(range); + const performance = useDashboardPerformance(range); + const regions = useDashboardRegions(range); + const vendors = useDashboardVendorInsights(); + + const isRefreshing = + [stats, workload, performance, regions, vendors].some((query) => query.isFetching) && + !stats.isLoading; + + return ( + + + {isRefreshing && } + {range !== DEFAULT_DASHBOARD_RANGE && ( + + )} + + + } + /> + + } + tone="primary" + value={stats.data?.scheduledTomorrow} + loading={stats.isLoading} + active={(stats.data?.scheduledTomorrow ?? 0) > 0} + onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))} + /> + } + tone="info" + value={stats.data?.pendingUplifts} + loading={stats.isLoading} + active={(stats.data?.pendingUplifts ?? 0) > 0} + onSelect={() => navigate(UPLIFTS_ROUTE)} + /> + } + tone="warning" + value={stats.data?.avetaPending} + loading={stats.isLoading} + active={(stats.data?.avetaPending ?? 0) > 0} + onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))} + /> + + + + + + + + + +
+ ); +} diff --git a/src/app/(protected)/index.tsx b/src/app/(protected)/index.tsx index e1af6bf9..b32a2d05 100644 --- a/src/app/(protected)/index.tsx +++ b/src/app/(protected)/index.tsx @@ -1,240 +1,5 @@ -import type { ReactNode } from "react"; -import { useNavigate } from "react-router"; -import AddIcon from "@mui/icons-material/Add"; -import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutlined"; -import FolderOpenOutlinedIcon from "@mui/icons-material/FolderOpenOutlined"; -import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined"; -import { - Alert, - Box, - CircularProgress, - Paper, - Stack, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, -} from "@mui/material"; -import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-stats"; +import DashboardPage from "@/app/(protected)/dashboard"; -const INVOICE_STATUSES = ["Draft", "Challenged", "Pending Approval", "Approved", "Paid"] as const; - -const PRIORITY_LEVELS = [5, 4, 3, 2, 1] as const; - -interface KpiCardProps { - title: string; - description?: string; - value?: string | number; - icon: ReactNode; - color: "success" | "primary" | "info" | "warning"; - onClick: () => void; - loading?: boolean; -} - -function KpiCard({ title, description, value, icon, color, onClick, loading }: KpiCardProps) { - const colorMap = { - success: "success.main", - primary: "primary.main", - info: "info.main", - warning: "warning.main", - } as const; - - return ( - - - - {icon} - - - - - {title} - - {value !== undefined && ( - - {loading ? "..." : typeof value === "number" ? value.toLocaleString() : value} - - )} - {Boolean(description) && ( - - {description} - - )} - - - - ); -} - -export default function DashboardPage() { - const navigate = useNavigate(); - const { data: stats, isLoading, isFetching, error } = useDashboardStats(); - - const formatStat = (value: number | undefined) => (isLoading ? undefined : (value ?? 0)); - - return ( - - - - Dashboard - - {isFetching && !isLoading && } - - {Boolean(error) && ( - - {error instanceof Error ? error.message : "Failed to load dashboard stats"} - - )} - - } - color="success" - onClick={() => navigate("/workorders/new")} - /> - } - color="primary" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="info" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - } - color="warning" - loading={isLoading} - onClick={() => navigate("/workorders")} - /> - - - - - Priority Analysis - - - Age of open Work Orders by Priority - - - - - - - - Priority - - Total - - - 0-14 Days - - - 15-29 Days - - - 30-59 Days - - - 60+ Days - - - - - {PRIORITY_LEVELS.map((priority) => ( - - {priority} - — - — - — - — - — - - ))} - -
-
-
- - - - Invoice Analysis - - - - - - - - Status - - Count - - - Amount - - - - - {INVOICE_STATUSES.map((status) => ( - - {status} - 0 - $0.00 - - ))} - -
-
-
-
- ); +export default function HomePage() { + return ; } diff --git a/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts new file mode 100644 index 00000000..e3e8dd0a --- /dev/null +++ b/src/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters.ts @@ -0,0 +1,30 @@ +import { useEffect, useRef, useState } from "react"; +import { useSearchParams } from "react-router"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; + +export function useDashboardDrilldownFilters( + onApply: (filters: WorkOrderAdvancedFilters) => void, +): boolean { + const [searchParams, setSearchParams] = useSearchParams(); + const appliedRef = useRef(false); + const [fromDashboard, setFromDashboard] = useState(false); + + useEffect(() => { + if (appliedRef.current) { + return; + } + + const drilldown = parseDashboardDrilldownParams(searchParams); + if (!drilldown) { + return; + } + + appliedRef.current = true; + setFromDashboard(true); + onApply(drilldown); + setSearchParams({}, { replace: true }); + }, [searchParams, setSearchParams, onApply]); + + return fromDashboard; +} diff --git a/src/app/(protected)/workorders/index.tsx b/src/app/(protected)/workorders/index.tsx index 571251cb..ec09e8c6 100644 --- a/src/app/(protected)/workorders/index.tsx +++ b/src/app/(protected)/workorders/index.tsx @@ -1,4 +1,5 @@ -import { Box } from "@mui/material"; +import { Box, Link as MuiLink } from "@mui/material"; +import { Link as RouterLink } from "react-router"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorPanel } from "@/components/ui/query-error-panel"; import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner"; @@ -8,6 +9,7 @@ import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/l import { WorkOrdersListPagePanels } from "@/app/(protected)/workorders/_components/list/work-orders-list-page-panels"; import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table"; import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page"; +import { useDashboardDrilldownFilters } from "@/app/(protected)/workorders/_hooks/use-dashboard-drilldown-filters"; import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row"; import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate"; import { useAuthContext } from "@/providers/auth-context"; @@ -24,6 +26,7 @@ export default function WorkOrdersListPage() { const { user } = useAuthContext(); const page = useWorkOrdersListPage(); const { filters, tableData } = page; + const fromDashboard = useDashboardDrilldownFilters(filters.applyAdvancedFilters); return ( @@ -52,6 +55,12 @@ export default function WorkOrdersListPage() { } /> + {fromDashboard && ( + + Back to Dashboard + + )} + ; +function asString(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return ""; +} + +function asRecords(value: unknown): Record[] { + if (!Array.isArray(value)) { + return []; + } + + return value.filter( + (item): item is Record => + typeof item === "object" && item !== null && !Array.isArray(item), + ); +} + +function readField(data: Record, key: string): unknown { + const capitalized = key.charAt(0).toUpperCase() + key.slice(1); + const value = data[key] ?? data[capitalized]; + return value === null ? undefined : value; +} + +function toRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function readCollection(value: unknown, key: string): Record[] { + const data = toRecord(value); + const collection = key ? (readField(data, key) ?? value) : value; + return asRecords(collection); +} + +function rangeSearchParams(range?: DashboardRangeParams): Options | undefined { + if (!range?.dateFrom || !range?.dateTo) { + return undefined; + } + + return { searchParams: { dateFrom: range.dateFrom, dateTo: range.dateTo } }; +} + +function trendSearchParams(params?: DashboardTrendParams): Options | undefined { + if ( + !params || + (!params.dateFrom && !params.dateTo && !params.granularity && !params.range && !params.year) + ) { + return undefined; + } + + const searchParams: Record = {}; + if (params.dateFrom) searchParams.dateFrom = params.dateFrom; + if (params.dateTo) searchParams.dateTo = params.dateTo; + if (params.granularity) searchParams.granularity = params.granularity; + if (params.range) searchParams.range = params.range; + if (params.year) searchParams.year = params.year; + + return { searchParams }; +} + +function mapBreakdownRow(raw: Record): DashboardBreakdownRow { return { - open: toNumber(data.open ?? data.Open), - notDispatched: toNumber(data.notDispatched ?? data.NotDispatched), - completed: toNumber(data.completed ?? data.Completed), + status: + asString(readField(raw, "status") ?? readField(raw, "name") ?? readField(raw, "label")) || + UNMAPPED_REGION_LABEL, + count: toNumber(readField(raw, "count") ?? readField(raw, "value")), }; } +function mapBreakdown(value: unknown): DashboardBreakdownRow[] { + if (Array.isArray(value)) { + return asRecords(value).map(mapBreakdownRow); + } + + if (typeof value === "object" && value !== null) { + return Object.entries(value as Record) + .map(([status, count]) => ({ status, count: toNumber(count) })) + .filter((row) => row.status !== ""); + } + + return []; +} + +function mapDispatcherIdentity(raw: Record): { + dispatcherId: string; + dispatcherName: string; +} { + return { + dispatcherId: asString( + readField(raw, "dispatcherId") ?? readField(raw, "id") ?? readField(raw, "userId"), + ), + dispatcherName: asString(readField(raw, "dispatcherName") ?? readField(raw, "name")), + }; +} + +function mapDashboardStats(raw: unknown): DashboardStats { + const data = toRecord(raw); + const average = readField(data, "averageResolutionDays"); + + return { + total: toNumber(readField(data, "total")), + open: toNumber(readField(data, "open")), + notDispatched: toNumber(readField(data, "notDispatched")), + completed: toNumber(readField(data, "completed")), + dueCount: toNumber(readField(data, "dueCount")), + completedDueCount: toNumber(readField(data, "completedDueCount")), + completionRate: toNumber(readField(data, "completionRate")), + averageResolutionDays: average == null ? null : toNumber(average), + scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")), + pendingUplifts: toNumber(readField(data, "pendingUplifts")), + avetaPending: toNumber(readField(data, "avetaPending")), + breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")), + }; +} + +function mapDispatcherWorkload(raw: unknown): DispatcherWorkloadRow[] { + return readCollection(raw, "items").map((rawRow) => { + const identity = mapDispatcherIdentity(rawRow); + return { + ...identity, + openWorkOrders: toNumber( + readField(rawRow, "openWorkOrders") ?? + readField(rawRow, "openCount") ?? + readField(rawRow, "open"), + ), + totalWorkOrders: toNumber( + readField(rawRow, "totalWorkOrders") ?? + readField(rawRow, "totalCount") ?? + readField(rawRow, "scheduledWorkOrders") ?? + readField(rawRow, "scheduled"), + ), + }; + }); +} + +function mapDispatcherPerformance(raw: unknown): DispatcherPerformanceRow[] { + return readCollection(raw, "items").map((rawRow) => { + const identity = mapDispatcherIdentity(rawRow); + return { + ...identity, + completionRate: toNumber( + readField(rawRow, "completionRate") ?? readField(rawRow, "onTimeRate"), + ), + averageResolutionDays: + readField(rawRow, "averageResolutionDays") == null + ? null + : toNumber(readField(rawRow, "averageResolutionDays")), + }; + }); +} + +function mapRegions(raw: unknown): RegionWorkOrdersRow[] { + return readCollection(raw, "items").map((rawRow) => { + const region = + asString(readField(rawRow, "region") ?? readField(rawRow, "name")) || UNMAPPED_REGION_LABEL; + return { + region, + workOrderCount: toNumber( + readField(rawRow, "workOrderCount") ?? + readField(rawRow, "count") ?? + readField(rawRow, "total"), + ), + }; + }); +} + +function mapVendorInsights(raw: unknown): VendorInsightRow[] { + return readCollection(raw, "items").map((rawRow) => ({ + vendorId: asString( + readField(rawRow, "vendorId") ?? + readField(rawRow, "vendorCompanyId") ?? + readField(rawRow, "id"), + ), + vendorName: asString( + readField(rawRow, "vendorName") ?? + readField(rawRow, "vendorCompanyName") ?? + readField(rawRow, "companyName") ?? + readField(rawRow, "name"), + ), + completionRate: toNumber(readField(rawRow, "completionRate")), + rescheduleRate: toNumber(readField(rawRow, "rescheduleRate")), + averageResolutionDays: + readField(rawRow, "averageResolutionDays") == null + ? null + : toNumber(readField(rawRow, "averageResolutionDays")), + totalJobs: toNumber(readField(rawRow, "totalJobs")), + })); +} + +function mapTrendGranularity(value: unknown): DashboardTrendGranularity | null { + const granularity = asString(value).toLowerCase(); + return granularity === "day" || granularity === "week" || granularity === "month" + ? granularity + : null; +} + +function mapDashboardTrend(raw: unknown): DashboardTrend { + const data = toRecord(raw); + const buckets = readCollection(data, "buckets"); + const legacyPoints = readCollection(data, "points"); + const points = (buckets.length > 0 ? buckets : legacyPoints).map((rawPoint) => ({ + date: asString(readField(rawPoint, "date")), + label: asString( + readField(rawPoint, "label") ?? readField(rawPoint, "period") ?? readField(rawPoint, "date"), + ), + total: toNumber( + readField(rawPoint, "total") ?? readField(rawPoint, "count") ?? readField(rawPoint, "value"), + ), + open: toNumber(readField(rawPoint, "open")), + completed: toNumber(readField(rawPoint, "completed")), + canceled: toNumber(readField(rawPoint, "canceled")), + overdue: toNumber(readField(rawPoint, "overdue")), + isCurrent: readField(rawPoint, "isCurrent") === true, + })); + + return { granularity: mapTrendGranularity(readField(data, "granularity")), points }; +} + export const dashboardApi = { - getStats: async (): Promise => { - const data = await apiGet(API_PATHS.dashboard.stats); - const response = handleApiResponse(data); - return mapDashboardStats(response); + getStats: async (range?: DashboardRangeParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.stats, rangeSearchParams(range)); + return mapDashboardStats(handleApiResponse(data)); + }, + getWorkload: async (range?: DashboardRangeParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.workload, rangeSearchParams(range)); + return mapDispatcherWorkload(handleApiResponse(data)); + }, + getPerformance: async (range?: DashboardRangeParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.performance, rangeSearchParams(range)); + return mapDispatcherPerformance(handleApiResponse(data)); + }, + getRegions: async (range?: DashboardRangeParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.regions, rangeSearchParams(range)); + return mapRegions(handleApiResponse(data)); + }, + getVendorInsights: async (): Promise => { + const data = await apiGet(API_PATHS.dashboard.vendorInsights); + return mapVendorInsights(handleApiResponse(data)); + }, + getTrend: async (params?: DashboardTrendParams): Promise => { + const data = await apiGet(API_PATHS.dashboard.trend, trendSearchParams(params)); + return mapDashboardTrend(handleApiResponse(data)); }, }; diff --git a/src/domain/dashboard/types/dashboard-performance.ts b/src/domain/dashboard/types/dashboard-performance.ts new file mode 100644 index 00000000..dca8f1a4 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-performance.ts @@ -0,0 +1,6 @@ +export interface DispatcherPerformanceRow { + dispatcherId: string; + dispatcherName: string; + completionRate: number; + averageResolutionDays: number | null; +} diff --git a/src/domain/dashboard/types/dashboard-range.ts b/src/domain/dashboard/types/dashboard-range.ts new file mode 100644 index 00000000..15cfd155 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-range.ts @@ -0,0 +1,10 @@ +export const DASHBOARD_RANGES = ["this-week", "last-3-months", "today", "all-time"] as const; + +export type DashboardRangeKey = (typeof DASHBOARD_RANGES)[number]; + +export const DEFAULT_DASHBOARD_RANGE: DashboardRangeKey = "this-week"; + +export interface DashboardRangeParams { + dateFrom?: string; + dateTo?: string; +} diff --git a/src/domain/dashboard/types/dashboard-regions.ts b/src/domain/dashboard/types/dashboard-regions.ts new file mode 100644 index 00000000..c5b1cab9 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-regions.ts @@ -0,0 +1,6 @@ +export const UNMAPPED_REGION_LABEL = "Unmapped/Other"; + +export interface RegionWorkOrdersRow { + region: string; + workOrderCount: number; +} diff --git a/src/domain/dashboard/types/dashboard-stats.ts b/src/domain/dashboard/types/dashboard-stats.ts index 7b7aee4b..82442c01 100644 --- a/src/domain/dashboard/types/dashboard-stats.ts +++ b/src/domain/dashboard/types/dashboard-stats.ts @@ -1,5 +1,19 @@ +export interface DashboardBreakdownRow { + status: string; + count: number; +} + export interface DashboardStats { + total: number; open: number; notDispatched: number; completed: number; + dueCount: number; + completedDueCount: number; + completionRate: number; + averageResolutionDays: number | null; + scheduledTomorrow: number; + pendingUplifts: number; + avetaPending: number; + breakdown: DashboardBreakdownRow[]; } diff --git a/src/domain/dashboard/types/dashboard-trend.ts b/src/domain/dashboard/types/dashboard-trend.ts new file mode 100644 index 00000000..d9c8eff5 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-trend.ts @@ -0,0 +1,25 @@ +export type DashboardTrendGranularity = "day" | "week" | "month"; + +export interface DashboardTrendParams { + dateFrom?: string; + dateTo?: string; + granularity?: DashboardTrendGranularity; + range?: "3m"; + year?: number; +} + +export interface DashboardTrendPoint { + date: string; + label: string; + total: number; + open: number; + completed: number; + canceled: number; + overdue: number; + isCurrent: boolean; +} + +export interface DashboardTrend { + granularity: DashboardTrendGranularity | null; + points: DashboardTrendPoint[]; +} diff --git a/src/domain/dashboard/types/dashboard-vendor-insights.ts b/src/domain/dashboard/types/dashboard-vendor-insights.ts new file mode 100644 index 00000000..b8f1c859 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-vendor-insights.ts @@ -0,0 +1,8 @@ +export interface VendorInsightRow { + vendorId: string; + vendorName: string; + completionRate: number; + rescheduleRate: number; + averageResolutionDays: number | null; + totalJobs: number; +} diff --git a/src/domain/dashboard/types/dashboard-workload.ts b/src/domain/dashboard/types/dashboard-workload.ts new file mode 100644 index 00000000..a7a8f806 --- /dev/null +++ b/src/domain/dashboard/types/dashboard-workload.ts @@ -0,0 +1,6 @@ +export interface DispatcherWorkloadRow { + dispatcherId: string; + dispatcherName: string; + openWorkOrders: number; + totalWorkOrders: number; +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-performance.ts b/src/domain/dashboard/use-cases/use-dashboard-performance.ts new file mode 100644 index 00000000..9fd6c168 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-performance.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DispatcherPerformanceRow } from "@/domain/dashboard/types/dashboard-performance"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardPerformance( + range: DashboardRangeKey, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.performance(range), + queryFn: () => dashboardApi.getPerformance(dashboardRangeParams(range)), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-regions.ts b/src/domain/dashboard/use-cases/use-dashboard-regions.ts new file mode 100644 index 00000000..996fc212 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-regions.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { RegionWorkOrdersRow } from "@/domain/dashboard/types/dashboard-regions"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardRegions( + range: DashboardRangeKey, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.regions(range), + queryFn: () => dashboardApi.getRegions(dashboardRangeParams(range)), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-stats.ts b/src/domain/dashboard/use-cases/use-dashboard-stats.ts index eb1b9a5f..d9e93ee2 100644 --- a/src/domain/dashboard/use-cases/use-dashboard-stats.ts +++ b/src/domain/dashboard/use-cases/use-dashboard-stats.ts @@ -1,12 +1,14 @@ import { useQuery, type UseQueryResult } from "@tanstack/react-query"; import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; import { queryKeys } from "@/infra/query-key/query-key"; -export function useDashboardStats(): UseQueryResult { +export function useDashboardStats(range: DashboardRangeKey): UseQueryResult { return useQuery({ - queryKey: queryKeys.dashboard.stats(), - queryFn: () => dashboardApi.getStats(), + queryKey: queryKeys.dashboard.stats(range), + queryFn: () => dashboardApi.getStats(dashboardRangeParams(range)), staleTime: 60 * 1000, }); } diff --git a/src/domain/dashboard/use-cases/use-dashboard-trend.ts b/src/domain/dashboard/use-cases/use-dashboard-trend.ts new file mode 100644 index 00000000..ca780bb6 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-trend.ts @@ -0,0 +1,17 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { + DashboardTrend, + DashboardTrendParams, +} from "@/domain/dashboard/types/dashboard-trend"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardTrend( + params?: DashboardTrendParams, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.trend(params), + queryFn: () => dashboardApi.getTrend(params), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts b/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts new file mode 100644 index 00000000..1544dd52 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-vendor-insights.ts @@ -0,0 +1,12 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { VendorInsightRow } from "@/domain/dashboard/types/dashboard-vendor-insights"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardVendorInsights(): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.vendorInsights(), + queryFn: () => dashboardApi.getVendorInsights(), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/use-cases/use-dashboard-workload.ts b/src/domain/dashboard/use-cases/use-dashboard-workload.ts new file mode 100644 index 00000000..600fd3c3 --- /dev/null +++ b/src/domain/dashboard/use-cases/use-dashboard-workload.ts @@ -0,0 +1,16 @@ +import { useQuery, type UseQueryResult } from "@tanstack/react-query"; +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; +import type { DispatcherWorkloadRow } from "@/domain/dashboard/types/dashboard-workload"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { dashboardRangeParams } from "@/domain/dashboard/utils/dashboard-range-utils"; +import { queryKeys } from "@/infra/query-key/query-key"; + +export function useDashboardWorkload( + range: DashboardRangeKey, +): UseQueryResult { + return useQuery({ + queryKey: queryKeys.dashboard.workload(range), + queryFn: () => dashboardApi.getWorkload(dashboardRangeParams(range)), + staleTime: 60 * 1000, + }); +} diff --git a/src/domain/dashboard/utils/dashboard-range-utils.ts b/src/domain/dashboard/utils/dashboard-range-utils.ts new file mode 100644 index 00000000..3efc2ca4 --- /dev/null +++ b/src/domain/dashboard/utils/dashboard-range-utils.ts @@ -0,0 +1,73 @@ +import { DEFAULT_DASHBOARD_RANGE } from "@/domain/dashboard/types/dashboard-range"; +import type { + DashboardRangeKey, + DashboardRangeParams, +} from "@/domain/dashboard/types/dashboard-range"; + +export const BUSINESS_TIME_ZONE = "America/New_York"; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +export function businessTodayIso(now: Date = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: BUSINESS_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(now); +} + +export function isIsoDate(value: string): boolean { + return ISO_DATE_PATTERN.test(value); +} + +export function addDaysIso(isoDate: string, days: number): string { + const [year, month, day] = isoDate.split("-").map(Number); + const shifted = new Date(Date.UTC(year, month - 1, day + days)); + return shifted.toISOString().slice(0, 10); +} + +export function mondayOf(isoDate: string): string { + const [year, month, day] = isoDate.split("-").map(Number); + const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay(); + return addDaysIso(isoDate, -((weekday + 6) % 7)); +} + +export function monthStartIso(isoDate: string): string { + return `${isoDate.slice(0, 7)}-01`; +} + +export function monthEndIso(isoDate: string): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return `${isoDate.slice(0, 7)}-${String(lastDay).padStart(2, "0")}`; +} + +function shiftMonthsIso(isoDate: string, months: number): string { + const year = Number(isoDate.slice(0, 4)); + const month = Number(isoDate.slice(5, 7)); + const shifted = new Date(Date.UTC(year, month - 1 + months, 1)); + return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, "0")}-01`; +} + +export function dashboardRangeParams( + range: DashboardRangeKey = DEFAULT_DASHBOARD_RANGE, + today: string = businessTodayIso(), +): DashboardRangeParams { + switch (range) { + case "today": + return { dateFrom: today, dateTo: today }; + case "this-week": { + const monday = mondayOf(today); + return { dateFrom: monday, dateTo: addDaysIso(monday, 4) }; + } + case "last-3-months": + return { + dateFrom: monthStartIso(shiftMonthsIso(today, -2)), + dateTo: monthEndIso(today), + }; + case "all-time": + return {}; + } +} diff --git a/src/domain/dashboard/utils/work-order-drilldown-links.ts b/src/domain/dashboard/utils/work-order-drilldown-links.ts new file mode 100644 index 00000000..a1f89f99 --- /dev/null +++ b/src/domain/dashboard/utils/work-order-drilldown-links.ts @@ -0,0 +1,82 @@ +import { ALL_WIZARD_STATUSES } from "@/domain/work-orders/types/work-order-wizard"; +import { WO_TYPES, type WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { + addDaysIso, + businessTodayIso, + dashboardRangeParams, +} from "@/domain/dashboard/utils/dashboard-range-utils"; + +export const WORK_ORDERS_ROUTE = "/workorders"; +export const UPLIFTS_ROUTE = "/uplifts"; + +export const OPEN_WIZARD_STATUSES: readonly string[] = ALL_WIZARD_STATUSES.filter( + (status) => status !== "Completed", +); + +export function workOrderDrilldownUrl(search: URLSearchParams): string { + const query = search.toString(); + return query ? `${WORK_ORDERS_ROUTE}?${query}` : WORK_ORDERS_ROUTE; +} + +function setDateRange(search: URLSearchParams, range: DashboardRangeKey): void { + const params = dashboardRangeParams(range); + if (params.dateFrom && params.dateTo) { + search.set("dateFrom", params.dateFrom); + search.set("dateTo", params.dateTo); + } +} + +export function workOrderRangeDrilldownSearch(range: DashboardRangeKey): URLSearchParams { + const search = new URLSearchParams(); + setDateRange(search, range); + return search; +} + +export function workOrderStatusDrilldownSearch( + status: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("statuses", status); + return search; +} + +export function workOrderTypeDrilldownSearch( + type: string, + range: DashboardRangeKey, +): URLSearchParams | null { + if (!WO_TYPES.includes(type as WOType)) return null; + const search = workOrderRangeDrilldownSearch(range); + search.set("types", type); + return search; +} + +export function workOrderDispatcherDrilldownSearch( + dispatcherId: string, + range: DashboardRangeKey, +): URLSearchParams { + const search = workOrderRangeDrilldownSearch(range); + search.set("dispatchers", dispatcherId); + return search; +} + +export function scheduledTomorrowDrilldownSearch( + today: string = businessTodayIso(), +): URLSearchParams { + const tomorrow = addDaysIso(today, 1); + const search = new URLSearchParams(); + search.set("dateFrom", tomorrow); + search.set("dateTo", tomorrow); + return search; +} + +export function avetaPendingDrilldownSearch(today: string = businessTodayIso()): URLSearchParams { + const search = new URLSearchParams(); + search.set("dateFrom", today); + search.set("dateTo", addDaysIso(today, 1)); + search.set("aveta", "1"); + search.set("docs", "No"); + search.set("statuses", OPEN_WIZARD_STATUSES.join(",")); + return search; +} diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts new file mode 100644 index 00000000..0d030c6c --- /dev/null +++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts @@ -0,0 +1,109 @@ +import type { CompletionDocFilter } from "@/domain/work-orders/types/work-order-advanced-filters"; +import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { EMPTY_ADVANCED_FILTERS } from "@/domain/work-orders/types/work-order-advanced-filters"; +import { ALL_WIZARD_STATUSES, WO_TYPES } from "@/domain/work-orders/types/work-order-wizard"; +import type { WizardWOStatus, WOType } from "@/domain/work-orders/types/work-order-wizard"; +import type { UpliftFilterStatus } from "@/domain/work-orders/types/work-order-uplift-filter"; +import { cloneAdvancedFilters } from "@/domain/work-orders/utils/advanced-filters-utils"; + +const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +const COMPLETION_DOC_FILTERS: CompletionDocFilter[] = ["No", "Yes", "NN"]; + +const UPLIFT_FILTER_VALUES: UpliftFilterStatus[] = [ + "pending", + "approved", + "auto_approved", + "rejected", +]; + +function parseList(raw: string | null): string[] { + if (!raw) { + return []; + } + + return raw + .split(",") + .map((item) => item.trim()) + .filter((item) => item !== ""); +} + +function parseStatuses(raw: string | null): WizardWOStatus[] { + const valid = new Set(ALL_WIZARD_STATUSES); + return [...new Set(parseList(raw))].filter((status): status is WizardWOStatus => + valid.has(status), + ); +} + +function parseTypes(raw: string | null): WOType[] { + const valid = new Set(WO_TYPES); + return [...new Set(parseList(raw))].filter((type): type is WOType => valid.has(type)); +} + +function parseCompletionDocs(raw: string | null): CompletionDocFilter[] { + const valid = new Set(COMPLETION_DOC_FILTERS); + return [...new Set(parseList(raw))].filter((doc): doc is CompletionDocFilter => valid.has(doc)); +} + +function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] { + const valid = new Set(UPLIFT_FILTER_VALUES); + return [...new Set(parseList(raw))].filter((value): value is UpliftFilterStatus => + valid.has(value), + ); +} + +export function parseDashboardDrilldownParams( + params: URLSearchParams, +): WorkOrderAdvancedFilters | null { + const dateFrom = (params.get("dateFrom") ?? "").trim(); + const dateTo = (params.get("dateTo") ?? "").trim(); + const hasDateRange = ISO_DATE_PATTERN.test(dateFrom) && ISO_DATE_PATTERN.test(dateTo); + const avetaOnly = params.get("aveta") === "1"; + const docs = parseCompletionDocs(params.get("docs")); + const statuses = parseStatuses(params.get("statuses")); + const types = parseTypes(params.get("types")); + const dispatchers = parseList(params.get("dispatchers")); + const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses")); + const hasUplift = params.get("hasUplift") === "1"; + + const hasAny = + hasDateRange || + avetaOnly || + docs.length > 0 || + statuses.length > 0 || + types.length > 0 || + dispatchers.length > 0 || + upliftStatuses.length > 0 || + hasUplift; + + if (!hasAny) { + return null; + } + + const filters = cloneAdvancedFilters(EMPTY_ADVANCED_FILTERS); + + if (hasDateRange) { + filters.dateRange = "custom"; + filters.customFrom = dateFrom; + filters.customTo = dateTo; + } + filters.avetaOnly = avetaOnly; + filters.hasUplift = hasUplift || upliftStatuses.length > 0; + if (docs.length > 0) { + filters.docs = new Set(docs); + } + if (statuses.length > 0) { + filters.statuses = new Set(statuses); + } + if (types.length > 0) { + filters.types = new Set(types); + } + if (dispatchers.length > 0) { + filters.dispatchers = new Set(dispatchers); + } + if (upliftStatuses.length > 0) { + filters.upliftStatuses = new Set(upliftStatuses); + } + + return filters; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index c4a9e284..4a408375 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -1,3 +1,5 @@ +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import type { DashboardTrendParams } from "@/domain/dashboard/types/dashboard-trend"; import type { WorkOrdersListParams } from "@/domain/work-orders/types/work-order-list"; import type { WorkOrderBoardQueryParams, @@ -49,7 +51,15 @@ export const queryKeys = { }, dashboard: { all: ["dashboard"] as const, - stats: () => [...queryKeys.dashboard.all, "stats"] as const, + stats: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "stats", range] as const, + workload: (range: DashboardRangeKey) => + [...queryKeys.dashboard.all, "workload", range] as const, + performance: (range: DashboardRangeKey) => + [...queryKeys.dashboard.all, "performance", range] as const, + regions: (range: DashboardRangeKey) => [...queryKeys.dashboard.all, "regions", range] as const, + vendorInsights: () => [...queryKeys.dashboard.all, "vendorInsights"] as const, + trend: (params?: DashboardTrendParams) => + [...queryKeys.dashboard.all, "trend", params ?? {}] as const, }, dropdownOptions: { all: ["dropdownOptions"] as const, diff --git a/src/test/app/(protected)/dashboard/dashboard-page.test.tsx b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx new file mode 100644 index 00000000..1fcdcc7a --- /dev/null +++ b/src/test/app/(protected)/dashboard/dashboard-page.test.tsx @@ -0,0 +1,153 @@ +import { fireEvent, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range"; +import { renderWithProviders } from "@/test/test-utils"; + +const navigate = vi.fn(); +const statsRange = vi.fn<(range: DashboardRangeKey) => void>(); + +vi.mock("react-router", async (importOriginal) => ({ + ...(await importOriginal()), + useNavigate: () => navigate, +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-workload", () => ({ + useDashboardWorkload: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-performance", () => ({ + useDashboardPerformance: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-regions", () => ({ + useDashboardRegions: () => ({ data: [], isLoading: false, isFetching: false, error: null }), +})); + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-vendor-insights", () => ({ + useDashboardVendorInsights: () => ({ + data: [], + isLoading: false, + isFetching: false, + error: null, + }), +})); + +const statsData = { + scheduledTomorrow: 3, + pendingUplifts: 5, + avetaPending: 0, + breakdown: [ + { status: "PM", count: 7 }, + { status: "Completed", count: 0 }, + ], +}; + +vi.mock("@/domain/dashboard/use-cases/use-dashboard-stats", () => ({ + useDashboardStats: (range: DashboardRangeKey) => { + statsRange(range); + return { data: statsData, isLoading: false, isFetching: false, error: null }; + }, +})); + +import DashboardPage from "@/app/(protected)/dashboard"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; +import { addDaysIso, businessTodayIso } from "@/domain/dashboard/utils/dashboard-range-utils"; + +function renderDashboard() { + return renderWithProviders(); +} + +describe("DashboardPage", () => { + beforeEach(() => { + navigate.mockReset(); + statsRange.mockReset(); + }); + + it("renders the prototype title, range options, and card titles", () => { + renderDashboard(); + + expect(screen.getByRole("heading", { name: "Dashboard" })).toBeInTheDocument(); + for (const label of ["This week", "Last 3 months", "Today", "All time"]) { + expect(screen.getByRole("button", { name: label })).toBeInTheDocument(); + } + for (const title of [ + "Scheduled Tomorrow", + "Pending Uplifts", + "Aveta Pending", + "Work Order Breakdown", + "Dispatcher Workload", + "Dispatcher Performance", + "Work Orders by Region", + "Vendor Insights", + ]) { + expect(screen.getByText(title)).toBeInTheDocument(); + } + expect(statsRange).toHaveBeenCalledWith("this-week"); + }); + + it("exposes positive KPI cards as keyboard-accessible drill-downs", () => { + renderDashboard(); + + const tomorrowCard = screen.getByRole("button", { name: /Scheduled Tomorrow/ }); + expect(tomorrowCard).toBeInTheDocument(); + fireEvent.click(tomorrowCard); + + const today = businessTodayIso(); + expect(navigate).toHaveBeenCalledWith( + `/workorders?${scheduledTomorrowDrilldownSearch(today).toString()}`, + ); + + fireEvent.click(screen.getByRole("button", { name: /Pending Uplifts/ })); + expect(navigate).toHaveBeenCalledWith("/uplifts"); + }); + + it("keeps zero counts inert", () => { + renderDashboard(); + + expect(screen.queryByRole("button", { name: /Aveta Pending/ })).toBeNull(); + + fireEvent.click(screen.getByText("Aveta Pending")); + expect(navigate).not.toHaveBeenCalled(); + + const inertRow = screen.getByText("Completed"); + fireEvent.click(inertRow); + expect(navigate).not.toHaveBeenCalled(); + }); + + it("drills the breakdown status into filtered work orders", () => { + renderDashboard(); + + fireEvent.click(screen.getByText("PM")); + + const calls = navigate.mock.calls; + const [url] = calls[calls.length - 1] ?? []; + expect(String(url)).toContain("/workorders?"); + expect(String(url)).toContain("types=PM"); + }); + + it("switches the stats range from the toggle", () => { + renderDashboard(); + + fireEvent.click(screen.getByRole("button", { name: "Today" })); + + expect(statsRange).toHaveBeenCalledWith("today"); + }); + + it("shows the documented empty states when datasets are empty", () => { + renderDashboard(); + + expect(screen.getByText("No work orders match the current filters.")).toBeInTheDocument(); + expect(screen.getAllByText("No dispatchers are set up yet.")).toHaveLength(2); + expect(screen.getByText("No active vendor has any work orders yet.")).toBeInTheDocument(); + }); + + it("keeps drilldown dates deterministic on the business clock", () => { + const today = businessTodayIso(); + + expect(scheduledTomorrowDrilldownSearch(today).get("dateFrom")).toBe(addDaysIso(today, 1)); + expect(avetaPendingDrilldownSearch(today).get("aveta")).toBe("1"); + }); +}); diff --git a/src/test/domain/dashboard/dashboard-api.test.ts b/src/test/domain/dashboard/dashboard-api.test.ts new file mode 100644 index 00000000..7ac5c918 --- /dev/null +++ b/src/test/domain/dashboard/dashboard-api.test.ts @@ -0,0 +1,182 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const apiGet = vi.fn(); + +vi.mock("@/api/api", () => ({ + apiGet: (...args: unknown[]) => apiGet(...args), +})); + +import { dashboardApi } from "@/domain/dashboard/api/dashboard-api"; + +describe("dashboardApi", () => { + beforeEach(() => { + apiGet.mockReset(); + }); + + it("maps stats tolerantly and sends the range params", async () => { + apiGet.mockResolvedValue({ + Data: { + Total: 12, + Open: 8, + NotDispatched: 2, + Completed: 4, + DueCount: 5, + CompletedDueCount: 3, + CompletionRate: 60, + AverageResolutionDays: 4.5, + ScheduledTomorrow: "3", + PendingUplifts: 2, + AvetaPending: "1", + Breakdown: [ + { Status: "Scheduled", Count: "7" }, + { Status: "Completed", Count: 4 }, + ], + }, + }); + + const stats = await dashboardApi.getStats({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }); + + expect(stats).toEqual({ + total: 12, + open: 8, + notDispatched: 2, + completed: 4, + dueCount: 5, + completedDueCount: 3, + completionRate: 60, + averageResolutionDays: 4.5, + scheduledTomorrow: 3, + pendingUplifts: 2, + avetaPending: 1, + breakdown: [ + { status: "Scheduled", count: 7 }, + { status: "Completed", count: 4 }, + ], + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", { + searchParams: { dateFrom: "2026-09-14", dateTo: "2026-09-18" }, + }); + }); + + it("omits search params for the all-time range", async () => { + apiGet.mockResolvedValue({ scheduledTomorrow: 0, pendingUplifts: 0, avetaPending: 0 }); + + await dashboardApi.getStats({}); + + expect(apiGet).toHaveBeenCalledWith("Dashboard/Stats", undefined); + }); + + it("maps workload rows", async () => { + apiGet.mockResolvedValue({ + Items: [{ DispatcherId: 12, DispatcherName: "Dana Reyes", OpenCount: "4", TotalCount: 6 }], + }); + + await expect(dashboardApi.getWorkload()).resolves.toEqual([ + { + dispatcherId: "12", + dispatcherName: "Dana Reyes", + openWorkOrders: 4, + totalWorkOrders: 6, + }, + ]); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Workload", undefined); + }); + + it("maps performance rows", async () => { + apiGet.mockResolvedValue({ + Items: [ + { + DispatcherId: 12, + DispatcherName: "Dana Reyes", + CompletionRate: 96.5, + AverageResolutionDays: 3.25, + }, + ], + }); + + await expect(dashboardApi.getPerformance()).resolves.toEqual([ + { + dispatcherId: "12", + dispatcherName: "Dana Reyes", + completionRate: 96.5, + averageResolutionDays: 3.25, + }, + ]); + }); + + it("maps unknown regions to Unmapped", async () => { + apiGet.mockResolvedValue({ + Items: [{ Region: "North", Count: 5 }, { Name: " ", WorkOrderCount: 2 }, {}], + }); + + await expect(dashboardApi.getRegions()).resolves.toEqual([ + { region: "North", workOrderCount: 5 }, + { region: "Unmapped/Other", workOrderCount: 2 }, + { region: "Unmapped/Other", workOrderCount: 0 }, + ]); + }); + + it("maps vendor insights with company-name fallback", async () => { + apiGet.mockResolvedValue({ + Items: [ + { + VendorCompanyId: 3, + VendorCompanyName: "Gateway Plumbing", + CompletionRate: 82, + RescheduleRate: 4, + AverageResolutionDays: 3, + TotalJobs: 11, + }, + ], + }); + + await expect(dashboardApi.getVendorInsights()).resolves.toEqual([ + { + vendorId: "3", + vendorName: "Gateway Plumbing", + completionRate: 82, + rescheduleRate: 4, + averageResolutionDays: 3, + totalJobs: 11, + }, + ]); + }); + + it("maps the trend contract and sends date, granularity, and year params", async () => { + apiGet.mockResolvedValue({ + Granularity: "Week", + Buckets: [{ Date: "2026-09-07", Label: "2026-W37", Total: "9", IsCurrent: true }], + }); + + await expect( + dashboardApi.getTrend({ + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + granularity: "month", + year: 2026, + }), + ).resolves.toEqual({ + granularity: "week", + points: [ + { + date: "2026-09-07", + label: "2026-W37", + total: 9, + open: 0, + completed: 0, + canceled: 0, + overdue: 0, + isCurrent: true, + }, + ], + }); + expect(apiGet).toHaveBeenCalledWith("Dashboard/Trend", { + searchParams: { + dateFrom: "2026-09-01", + dateTo: "2026-09-30", + granularity: "month", + year: 2026, + }, + }); + }); +}); diff --git a/src/test/domain/dashboard/dashboard-range-utils.test.ts b/src/test/domain/dashboard/dashboard-range-utils.test.ts new file mode 100644 index 00000000..61317746 --- /dev/null +++ b/src/test/domain/dashboard/dashboard-range-utils.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + addDaysIso, + businessTodayIso, + dashboardRangeParams, + mondayOf, + monthEndIso, + monthStartIso, +} from "@/domain/dashboard/utils/dashboard-range-utils"; + +describe("businessTodayIso", () => { + it("formats the America/New_York calendar date regardless of UTC time", () => { + expect(businessTodayIso(new Date("2026-09-16T23:30:00Z"))).toBe("2026-09-16"); + expect(businessTodayIso(new Date("2026-09-17T02:30:00Z"))).toBe("2026-09-16"); + expect(businessTodayIso(new Date("2026-01-01T04:30:00Z"))).toBe("2025-12-31"); + }); +}); + +describe("addDaysIso", () => { + it("rolls over month and year boundaries", () => { + expect(addDaysIso("2026-01-31", 1)).toBe("2026-02-01"); + expect(addDaysIso("2026-12-31", 1)).toBe("2027-01-01"); + expect(addDaysIso("2026-09-14", -7)).toBe("2026-09-07"); + }); +}); + +describe("mondayOf", () => { + it("returns the Monday of the ISO week", () => { + expect(mondayOf("2026-09-16")).toBe("2026-09-14"); + expect(mondayOf("2026-09-14")).toBe("2026-09-14"); + expect(mondayOf("2026-09-13")).toBe("2026-09-07"); + }); +}); + +describe("monthStartIso / monthEndIso", () => { + it("returns month bounds", () => { + expect(monthStartIso("2026-09-16")).toBe("2026-09-01"); + expect(monthEndIso("2026-09-16")).toBe("2026-09-30"); + expect(monthEndIso("2026-02-16")).toBe("2026-02-28"); + }); +}); + +describe("dashboardRangeParams", () => { + it("maps today to the business date", () => { + expect(dashboardRangeParams("today", "2026-09-16")).toEqual({ + dateFrom: "2026-09-16", + dateTo: "2026-09-16", + }); + }); + + it("maps this week to the Monday-Friday business week", () => { + expect(dashboardRangeParams("this-week", "2026-09-16")).toEqual({ + dateFrom: "2026-09-14", + dateTo: "2026-09-18", + }); + }); + + it("maps last 3 months to the trailing month span", () => { + expect(dashboardRangeParams("last-3-months", "2026-09-16")).toEqual({ + dateFrom: "2026-07-01", + dateTo: "2026-09-30", + }); + }); + + it("maps all time to unbounded params", () => { + expect(dashboardRangeParams("all-time", "2026-09-16")).toEqual({}); + }); +}); diff --git a/src/test/domain/dashboard/work-order-drilldown-links.test.ts b/src/test/domain/dashboard/work-order-drilldown-links.test.ts new file mode 100644 index 00000000..90e629f4 --- /dev/null +++ b/src/test/domain/dashboard/work-order-drilldown-links.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { + avetaPendingDrilldownSearch, + scheduledTomorrowDrilldownSearch, + workOrderDrilldownUrl, + workOrderRangeDrilldownSearch, + workOrderStatusDrilldownSearch, +} from "@/domain/dashboard/utils/work-order-drilldown-links"; + +describe("work-order drilldown links", () => { + it("schedules the tomorrow drilldown on the business clock", () => { + const search = scheduledTomorrowDrilldownSearch("2026-09-16"); + + expect(search.get("dateFrom")).toBe("2026-09-17"); + expect(search.get("dateTo")).toBe("2026-09-17"); + }); + + it("builds the Aveta Pending drilldown per SH-294", () => { + const search = avetaPendingDrilldownSearch("2026-09-16"); + const statuses = (search.get("statuses") ?? "").split(","); + + expect(search.get("dateFrom")).toBe("2026-09-16"); + expect(search.get("dateTo")).toBe("2026-09-17"); + expect(search.get("aveta")).toBe("1"); + expect(search.get("docs")).toBe("No"); + expect(statuses).toContain("Scheduled"); + expect(statuses).not.toContain("Completed"); + }); + + it("carries the range into status drilldowns", () => { + const search = workOrderStatusDrilldownSearch("Scheduled", "today"); + + expect(search.get("dateFrom")).toBe("2026-09-16"); + expect(search.get("dateTo")).toBe("2026-09-16"); + expect(search.get("statuses")).toBe("Scheduled"); + }); + + it("omits dates for the all-time range", () => { + expect(workOrderRangeDrilldownSearch("all-time").toString()).toBe(""); + }); + + it("renders plain or query-bearing work order URLs", () => { + expect(workOrderDrilldownUrl(new URLSearchParams())).toBe("/workorders"); + expect(workOrderDrilldownUrl(new URLSearchParams({ statuses: "Scheduled" }))).toBe( + "/workorders?statuses=Scheduled", + ); + }); +}); diff --git a/src/test/domain/work-orders/dashboard-drilldown-params.test.ts b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts new file mode 100644 index 00000000..8c2f58fd --- /dev/null +++ b/src/test/domain/work-orders/dashboard-drilldown-params.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params"; +import { avetaPendingDrilldownSearch } from "@/domain/dashboard/utils/work-order-drilldown-links"; + +describe("parseDashboardDrilldownParams", () => { + it("returns null when no recognized param is present", () => { + expect(parseDashboardDrilldownParams(new URLSearchParams())).toBeNull(); + expect( + parseDashboardDrilldownParams(new URLSearchParams({ dateFrom: "2026-09-16" })), + ).toBeNull(); + expect(parseDashboardDrilldownParams(new URLSearchParams({ aveta: "0" }))).toBeNull(); + }); + + it("maps dates to a custom advanced-filter range", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ dateFrom: "2026-09-14", dateTo: "2026-09-18" }), + ); + + expect(filters).not.toBeNull(); + expect(filters?.dateRange).toBe("custom"); + expect(filters?.customFrom).toBe("2026-09-14"); + expect(filters?.customTo).toBe("2026-09-18"); + }); + + it("parses the Aveta Pending drilldown and drops unknown statuses", () => { + const params = avetaPendingDrilldownSearch("2026-09-16"); + params.append("statuses", "NotAStatus"); + const filters = parseDashboardDrilldownParams(params); + + expect(filters).not.toBeNull(); + expect(filters?.avetaOnly).toBe(true); + expect(filters?.docs).toEqual(new Set(["No"])); + expect(filters?.statuses).not.toBeNull(); + expect([...(filters?.statuses ?? [])]).not.toContain("Completed"); + expect([...(filters?.statuses ?? [])]).not.toContain("NotAStatus"); + expect(filters?.statuses.has("Scheduled")).toBe(true); + expect(filters?.statuses.has("Pending Quote")).toBe(true); + }); + + it("parses dispatcher and uplift params", () => { + const filters = parseDashboardDrilldownParams( + new URLSearchParams({ + dispatchers: " 12 , 13", + upliftStatuses: "pending,bogus", + }), + ); + + expect(filters?.dispatchers).toEqual(new Set(["12", "13"])); + expect(filters?.hasUplift).toBe(true); + expect(filters?.upliftStatuses).toEqual(new Set(["pending"])); + }); + + it("rejects partial or malformed date ranges", () => { + expect( + parseDashboardDrilldownParams( + new URLSearchParams({ dateFrom: "2026-9-16", dateTo: "2026-09-18" }), + ), + ).toBeNull(); + }); +});