diff --git a/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx b/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx
new file mode 100644
index 00000000..e410c72b
--- /dev/null
+++ b/src/app/(protected)/dashboard/_components/dashboard-header-actions.tsx
@@ -0,0 +1,67 @@
+import { Box, Button, CircularProgress } from "@mui/material";
+import { Users } from "lucide-react";
+import { Text } from "@/components/ui/text";
+import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
+import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
+import { DashboardRangeToggle } from "./dashboard-range-toggle";
+import { DashboardScopePicker } from "./dashboard-scope-picker";
+import type { DashboardScope } from "./dashboard-scope";
+
+interface DashboardHeaderActionsProps {
+ isRefreshing: boolean;
+ showReset: boolean;
+ onResetFilters: () => void;
+ canViewAll: boolean;
+ scope: DashboardScope;
+ onScopeChange: (scope: DashboardScope) => void;
+ dispatcherUsers: WorkOrderUser[];
+ dispatcherUsersLoading: boolean;
+ currentUserId: string | number | undefined;
+ range: DashboardRangeKey;
+ onRangeChange: (range: DashboardRangeKey) => void;
+}
+
+export function DashboardHeaderActions({
+ isRefreshing,
+ showReset,
+ onResetFilters,
+ canViewAll,
+ scope,
+ onScopeChange,
+ dispatcherUsers,
+ dispatcherUsersLoading,
+ currentUserId,
+ range,
+ onRangeChange,
+}: DashboardHeaderActionsProps) {
+ return (
+
+ {isRefreshing && }
+ {showReset && (
+
+ )}
+ {canViewAll ? (
+
+ ) : (
+
+
+
+ My WOs
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts
new file mode 100644
index 00000000..868b8d05
--- /dev/null
+++ b/src/app/(protected)/dashboard/_components/use-dashboard-scope.ts
@@ -0,0 +1,87 @@
+import { useState } from "react";
+import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils";
+import { useAuthContext } from "@/providers/auth-context";
+import {
+ DEFAULT_DASHBOARD_RANGE,
+ type DashboardRangeKey,
+} from "@/domain/dashboard/types/dashboard-range";
+import { DASHBOARD_SCOPE_ALL, scopeDispatcherId, type DashboardScope } from "./dashboard-scope";
+
+export interface DashboardScopeState {
+ canViewAll: boolean;
+ currentUserId: string | number | undefined;
+ range: DashboardRangeKey;
+ scope: DashboardScope;
+ /** The `dispatcherId` query param for the scoped endpoints (null = company-wide). */
+ dispatcherId: string | null;
+ workloadPage: number;
+ performancePage: number;
+ showReset: boolean;
+ setWorkloadPage: (page: number) => void;
+ setPerformancePage: (page: number) => void;
+ changeRange: (next: DashboardRangeKey) => void;
+ changeScope: (next: DashboardScope) => void;
+ resetFilters: () => void;
+}
+
+/**
+ * SH-336 dashboard scope/paging state. A Dispatcher (no
+ * `viewAllDispatchersOnDashboard`) is locked to their own work orders and sends
+ * no dispatcherId; Scheduler/Admin drive an explicit scope through the picker.
+ */
+export function useDashboardScope(): DashboardScopeState {
+ const { user } = useAuthContext();
+ const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles);
+
+ const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE);
+ const [scope, setScope] = useState(DASHBOARD_SCOPE_ALL);
+ const [workloadPage, setWorkloadPage] = useState(1);
+ const [performancePage, setPerformancePage] = useState(1);
+
+ const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
+
+ // A range or scope change re-scopes both dispatcher tables, so their page must
+ // reset — otherwise a switch while on page 3 lands on an out-of-range empty
+ // page that reads as a false "no dispatchers" state.
+ const resetPages = () => {
+ setWorkloadPage(1);
+ setPerformancePage(1);
+ };
+
+ const changeRange = (next: DashboardRangeKey) => {
+ setRange(next);
+ resetPages();
+ };
+
+ const changeScope = (next: DashboardScope) => {
+ setScope(next);
+ resetPages();
+ };
+
+ const resetFilters = () => {
+ setRange(DEFAULT_DASHBOARD_RANGE);
+ setScope(DASHBOARD_SCOPE_ALL);
+ resetPages();
+ };
+
+ // A Dispatcher's forced own-WOs scope never triggers the reset affordance (only
+ // an active date range can, for that role); a Scheduler/Admin's non-All scope does.
+ const scopeIsFiltered = canViewAll && scope.mode !== "all";
+ const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
+
+ return {
+ canViewAll,
+ currentUserId: user?.id,
+ range,
+ scope,
+ dispatcherId,
+ workloadPage,
+ performancePage,
+ showReset,
+ setWorkloadPage,
+ setPerformancePage,
+ changeRange,
+ changeScope,
+ resetFilters,
+ };
+}
diff --git a/src/app/(protected)/dashboard/index.tsx b/src/app/(protected)/dashboard/index.tsx
index 60d6748a..60795d8f 100644
--- a/src/app/(protected)/dashboard/index.tsx
+++ b/src/app/(protected)/dashboard/index.tsx
@@ -1,32 +1,18 @@
-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 { Users } from "lucide-react";
+import { Box } from "@mui/material";
import { PageHeader } from "@/components/ui/page-header";
-import { Text } from "@/components/ui/text";
-import { canViewAllDispatchersOnDashboard } from "@/lib/auth/user-utils";
-import { useAuthContext } from "@/providers/auth-context";
+import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
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 { DashboardScopePicker } from "@/app/(protected)/dashboard/_components/dashboard-scope-picker";
-import {
- DASHBOARD_SCOPE_ALL,
- scopeDispatcherId,
- type DashboardScope,
-} from "@/app/(protected)/dashboard/_components/dashboard-scope";
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
+import { useDashboardScope } from "@/app/(protected)/dashboard/_components/use-dashboard-scope";
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";
@@ -42,59 +28,16 @@ import {
export default function DashboardPage() {
const navigate = useNavigate();
- const { user } = useAuthContext();
- // SH-336: only Scheduler/Admin may broaden the Dashboard past their own work
- // orders. A Dispatcher is locked to their own WOs (scoped server-side by
- // backend#126) and never sees the picker.
- const canViewAll = canViewAllDispatchersOnDashboard(user?.userRoles);
-
- const [range, setRange] = useState(DEFAULT_DASHBOARD_RANGE);
- const [scope, setScope] = useState(DASHBOARD_SCOPE_ALL);
- const [workloadPage, setWorkloadPage] = useState(1);
- const [performancePage, setPerformancePage] = useState(1);
+ const scope = useDashboardScope();
+ const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
const dispatcherUsers = useWorkOrderUsers();
-
- // A Dispatcher sends no dispatcherId (the server already scopes them); only the
- // picker, available to Scheduler/Admin, drives an explicit scope.
- const dispatcherId = canViewAll ? scopeDispatcherId(scope, user?.id) : null;
-
const stats = useDashboardStats(range, dispatcherId);
const workload = useDashboardWorkload(range, workloadPage, dispatcherId, canViewAll);
const performance = useDashboardPerformance(range, performancePage, dispatcherId, canViewAll);
const regions = useDashboardRegions(range, dispatcherId);
const vendors = useDashboardVendorInsights();
- // A range change re-scopes both dispatcher tables, so their page must reset —
- // otherwise a switch while on page 3 lands on an out-of-range empty page that
- // reads as a false "no dispatchers" state.
- const resetDispatcherPages = () => {
- setWorkloadPage(1);
- setPerformancePage(1);
- };
-
- const changeRange = (next: DashboardRangeKey) => {
- setRange(next);
- resetDispatcherPages();
- };
-
- const changeScope = (next: DashboardScope) => {
- setScope(next);
- resetDispatcherPages();
- };
-
- const resetFilters = () => {
- setRange(DEFAULT_DASHBOARD_RANGE);
- setScope(DASHBOARD_SCOPE_ALL);
- resetDispatcherPages();
- };
-
- // A Dispatcher's forced own-WOs scope never triggers the reset affordance (there
- // is nothing for them to reset but the date range); a Scheduler/Admin's non-All
- // scope does.
- const scopeIsFiltered = canViewAll && scope.mode !== "all";
- const showReset = range !== DEFAULT_DASHBOARD_RANGE || scopeIsFiltered;
-
const isRefreshing =
[stats, workload, performance, regions, vendors].some((query) => query.isFetching) &&
!stats.isLoading;
@@ -104,34 +47,19 @@ export default function DashboardPage() {
- {isRefreshing && }
- {showReset && (
-
- )}
- {canViewAll ? (
-
- ) : (
-
-
-
- My WOs
-
-
- )}
-
-
+
}
/>
@@ -189,8 +117,8 @@ export default function DashboardPage() {
performance={performance}
workloadPage={workloadPage}
performancePage={performancePage}
- onWorkloadPageChange={setWorkloadPage}
- onPerformancePageChange={setPerformancePage}
+ onWorkloadPageChange={scope.setWorkloadPage}
+ onPerformancePageChange={scope.setPerformancePage}
/>
)}
diff --git a/src/domain/work-orders/utils/dashboard-drilldown-params.ts b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
index c67315c3..bb868e51 100644
--- a/src/domain/work-orders/utils/dashboard-drilldown-params.ts
+++ b/src/domain/work-orders/utils/dashboard-drilldown-params.ts
@@ -75,16 +75,17 @@ export function parseDashboardDrilldownParams(
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 ||
- regions.length > 0 ||
- upliftStatuses.length > 0 ||
- hasUplift;
+ const hasAny = [
+ hasDateRange,
+ avetaOnly,
+ docs.length,
+ statuses.length,
+ types.length,
+ dispatchers.length,
+ regions.length,
+ upliftStatuses.length,
+ hasUplift,
+ ].some(Boolean);
if (!hasAny) {
return null;