mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
refactor(dashboard): extract scope hook and header actions to satisfy governance caps
Split DashboardPage's scope/paging state into useDashboardScope and the header controls into DashboardHeaderActions, and flatten parseDashboardDrilldownParams' hasAny check, keeping every function under the complexity/line-count gates.
This commit is contained in:
parent
1271d3c11e
commit
827f8ca8ba
4 changed files with 185 additions and 102 deletions
|
|
@ -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 (
|
||||
<Box className="flex items-center gap-2">
|
||||
{isRefreshing && <CircularProgress size={16} />}
|
||||
{showReset && (
|
||||
<Button size="small" onClick={onResetFilters}>
|
||||
Reset filters
|
||||
</Button>
|
||||
)}
|
||||
{canViewAll ? (
|
||||
<DashboardScopePicker
|
||||
scope={scope}
|
||||
onChange={onScopeChange}
|
||||
users={dispatcherUsers}
|
||||
usersLoading={dispatcherUsersLoading}
|
||||
currentUserId={currentUserId}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
<Text variant="description" tone="muted">
|
||||
My WOs
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<DashboardRangeToggle value={range} onChange={onRangeChange} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
|
||||
const [scope, setScope] = useState<DashboardScope>(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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<DashboardRangeKey>(DEFAULT_DASHBOARD_RANGE);
|
||||
const [scope, setScope] = useState<DashboardScope>(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() {
|
|||
<PageHeader
|
||||
title="Dashboard"
|
||||
actions={
|
||||
<Box className="flex items-center gap-2">
|
||||
{isRefreshing && <CircularProgress size={16} />}
|
||||
{showReset && (
|
||||
<Button size="small" onClick={resetFilters}>
|
||||
Reset filters
|
||||
</Button>
|
||||
)}
|
||||
{canViewAll ? (
|
||||
<DashboardScopePicker
|
||||
scope={scope}
|
||||
onChange={changeScope}
|
||||
users={dispatcherUsers.data ?? []}
|
||||
usersLoading={dispatcherUsers.isLoading}
|
||||
currentUserId={user?.id}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="flex shrink-0 items-center gap-1.5 whitespace-nowrap"
|
||||
style={{ fontSize: 13, fontWeight: 500, color: "var(--muted-foreground)" }}
|
||||
>
|
||||
<Users size={13} aria-hidden style={{ color: "var(--muted-foreground)" }} />
|
||||
<Text variant="description" tone="muted">
|
||||
My WOs
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<DashboardRangeToggle value={range} onChange={changeRange} />
|
||||
</Box>
|
||||
<DashboardHeaderActions
|
||||
isRefreshing={isRefreshing}
|
||||
showReset={scope.showReset}
|
||||
onResetFilters={scope.resetFilters}
|
||||
canViewAll={canViewAll}
|
||||
scope={scope.scope}
|
||||
onScopeChange={scope.changeScope}
|
||||
dispatcherUsers={dispatcherUsers.data ?? []}
|
||||
dispatcherUsersLoading={dispatcherUsers.isLoading}
|
||||
currentUserId={scope.currentUserId}
|
||||
range={range}
|
||||
onRangeChange={scope.changeRange}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
|
|
@ -189,8 +117,8 @@ export default function DashboardPage() {
|
|||
performance={performance}
|
||||
workloadPage={workloadPage}
|
||||
performancePage={performancePage}
|
||||
onWorkloadPageChange={setWorkloadPage}
|
||||
onPerformancePageChange={setPerformancePage}
|
||||
onWorkloadPageChange={scope.setWorkloadPage}
|
||||
onPerformancePageChange={scope.setPerformancePage}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue