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;