mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
SH-392: Unassigned KPI tile on the Dashboard
Shows open work orders with no dispatcher for the selected period and drills into the same list on the board (dispatcher Unassigned, open statuses, same dates; All time includes undated work orders).
This commit is contained in:
parent
51ca244524
commit
ed931ea2eb
8 changed files with 150 additions and 42 deletions
|
|
@ -0,0 +1,71 @@
|
|||
import { useNavigate } from "react-router";
|
||||
import AssignmentLateOutlinedIcon from "@mui/icons-material/AssignmentLateOutlined";
|
||||
import EventOutlinedIcon from "@mui/icons-material/EventOutlined";
|
||||
import PendingActionsIcon from "@mui/icons-material/PendingActions";
|
||||
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
|
||||
import { Box } from "@mui/material";
|
||||
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
|
||||
import type { DashboardRangeKey } from "@/domain/dashboard/types/dashboard-range";
|
||||
import type { DashboardStats } from "@/domain/dashboard/types/dashboard-stats";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
unassignedDrilldownSearch,
|
||||
UPLIFTS_ROUTE,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
interface DashboardKpiCardsProps {
|
||||
stats?: DashboardStats;
|
||||
loading: boolean;
|
||||
range: DashboardRangeKey;
|
||||
}
|
||||
|
||||
export function DashboardKpiCards({ stats, loading, range }: DashboardKpiCardsProps) {
|
||||
const navigate = useNavigate();
|
||||
const scheduledTomorrow = stats?.scheduledTomorrow ?? 0;
|
||||
const pendingUplifts = stats?.pendingUplifts ?? 0;
|
||||
const avetaPending = stats?.avetaPending ?? 0;
|
||||
const unassigned = stats?.unassigned ?? 0;
|
||||
|
||||
return (
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<DashboardKpiCard
|
||||
title="Scheduled Tomorrow"
|
||||
icon={<EventOutlinedIcon />}
|
||||
tone="primary"
|
||||
value={stats?.scheduledTomorrow}
|
||||
loading={loading}
|
||||
active={scheduledTomorrow > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Pending Uplifts"
|
||||
icon={<PendingActionsIcon />}
|
||||
tone="info"
|
||||
value={stats?.pendingUplifts}
|
||||
loading={loading}
|
||||
active={pendingUplifts > 0}
|
||||
onSelect={() => navigate(UPLIFTS_ROUTE)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Aveta Pending"
|
||||
icon={<ShieldOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats?.avetaPending}
|
||||
loading={loading}
|
||||
active={avetaPending > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Unassigned"
|
||||
icon={<AssignmentLateOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats?.unassigned}
|
||||
loading={loading}
|
||||
active={unassigned > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(unassignedDrilldownSearch(range)))}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,11 +1,7 @@
|
|||
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 } from "@mui/material";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { DashboardHeaderActions } from "@/app/(protected)/dashboard/_components/dashboard-header-actions";
|
||||
import { DashboardKpiCard } from "@/app/(protected)/dashboard/_components/dashboard-kpi-card";
|
||||
import { DashboardKpiCards } from "@/app/(protected)/dashboard/_components/dashboard-kpi-cards";
|
||||
import { DashboardTrendCard } from "@/app/(protected)/dashboard/_components/dashboard-trend-card";
|
||||
import { DispatcherTablesSection } from "@/app/(protected)/dashboard/_components/dispatcher-tables-section";
|
||||
import { StatusDistributionCard } from "@/app/(protected)/dashboard/_components/status-distribution-card";
|
||||
|
|
@ -19,15 +15,8 @@ import { useDashboardStats } from "@/domain/dashboard/use-cases/use-dashboard-st
|
|||
import { useDashboardVendorInsights } from "@/domain/dashboard/use-cases/use-dashboard-vendor-insights";
|
||||
import { useDashboardWorkload } from "@/domain/dashboard/use-cases/use-dashboard-workload";
|
||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
scheduledTomorrowDrilldownSearch,
|
||||
UPLIFTS_ROUTE,
|
||||
workOrderDrilldownUrl,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const scope = useDashboardScope();
|
||||
const { canViewAll, range, dispatcherId, workloadPage, performancePage } = scope;
|
||||
|
||||
|
|
@ -62,35 +51,7 @@ export default function DashboardPage() {
|
|||
/>
|
||||
}
|
||||
/>
|
||||
<Box className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<DashboardKpiCard
|
||||
title="Scheduled Tomorrow"
|
||||
icon={<EventOutlinedIcon />}
|
||||
tone="primary"
|
||||
value={stats.data?.scheduledTomorrow}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.scheduledTomorrow ?? 0) > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(scheduledTomorrowDrilldownSearch()))}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Pending Uplifts"
|
||||
icon={<PendingActionsIcon />}
|
||||
tone="info"
|
||||
value={stats.data?.pendingUplifts}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.pendingUplifts ?? 0) > 0}
|
||||
onSelect={() => navigate(UPLIFTS_ROUTE)}
|
||||
/>
|
||||
<DashboardKpiCard
|
||||
title="Aveta Pending"
|
||||
icon={<ShieldOutlinedIcon />}
|
||||
tone="warning"
|
||||
value={stats.data?.avetaPending}
|
||||
loading={stats.isLoading}
|
||||
active={(stats.data?.avetaPending ?? 0) > 0}
|
||||
onSelect={() => navigate(workOrderDrilldownUrl(avetaPendingDrilldownSearch()))}
|
||||
/>
|
||||
</Box>
|
||||
<DashboardKpiCards stats={stats.data} loading={stats.isLoading} range={range} />
|
||||
<Box className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<WorkOrderBreakdownCard
|
||||
breakdown={stats.data?.breakdown ?? []}
|
||||
|
|
|
|||
|
|
@ -251,6 +251,7 @@ function mapDashboardStats(raw: unknown): DashboardStats {
|
|||
scheduledTomorrow: toNumber(readField(data, "scheduledTomorrow")),
|
||||
pendingUplifts: toNumber(readField(data, "pendingUplifts")),
|
||||
avetaPending: toNumber(readField(data, "avetaPending")),
|
||||
unassigned: toNumber(readField(data, "unassigned")),
|
||||
breakdown: mapBreakdown(readField(data, "breakdown") ?? readField(data, "workOrderBreakdown")),
|
||||
statusDistribution: mapStatusDistribution(readField(data, "statusDistribution")),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export interface DashboardStats {
|
|||
scheduledTomorrow: number;
|
||||
pendingUplifts: number;
|
||||
avetaPending: number;
|
||||
/** Open WOs with no dispatcher in the selected period (SH-392). */
|
||||
unassigned: number;
|
||||
breakdown: DashboardBreakdownRow[];
|
||||
statusDistribution: DashboardStatusBucketRow[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
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 { BOARD_DISPATCHER_UNASSIGNED } from "@/domain/work-orders/types/work-order-board";
|
||||
import {
|
||||
addDaysIso,
|
||||
businessTodayIso,
|
||||
|
|
@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch(
|
|||
return search;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Unassigned tile's list (SH-392): open WOs with no dispatcher in the
|
||||
* Dashboard period, the same set the backend counts. All time sends no dates,
|
||||
* so the board searches every week, undated WOs included.
|
||||
*/
|
||||
export function unassignedDrilldownSearch(range: DashboardRangeKey): URLSearchParams {
|
||||
const search = workOrderRangeDrilldownSearch(range);
|
||||
search.set("dispatchers", BOARD_DISPATCHER_UNASSIGNED);
|
||||
search.set("statuses", OPEN_WIZARD_STATUSES.join(","));
|
||||
return search;
|
||||
}
|
||||
|
||||
export function scheduledTomorrowDrilldownSearch(
|
||||
today: string = businessTodayIso(),
|
||||
): URLSearchParams {
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ const statsData = {
|
|||
scheduledTomorrow: 3,
|
||||
pendingUplifts: 5,
|
||||
avetaPending: 0,
|
||||
unassigned: 4,
|
||||
breakdown: [
|
||||
{ status: "PM", count: 7 },
|
||||
{ status: "Completed", count: 0 },
|
||||
|
|
@ -88,6 +89,11 @@ function renderDashboard() {
|
|||
return renderWithProviders(<DashboardPage />);
|
||||
}
|
||||
|
||||
function lastNavigation(): string {
|
||||
const calls = navigate.mock.calls;
|
||||
return String(calls[calls.length - 1]?.[0]);
|
||||
}
|
||||
|
||||
describe("DashboardPage", () => {
|
||||
beforeEach(() => {
|
||||
authUser.current = { id: "99", userRoles: "Admin" };
|
||||
|
|
@ -120,6 +126,7 @@ describe("DashboardPage", () => {
|
|||
"Scheduled Tomorrow",
|
||||
"Pending Uplifts",
|
||||
"Aveta Pending",
|
||||
"Unassigned",
|
||||
"Work Order Breakdown",
|
||||
"Status Distribution",
|
||||
"Dispatcher Workload",
|
||||
|
|
@ -257,6 +264,29 @@ describe("DashboardPage", () => {
|
|||
expect(lastRequestedPage).toBe(1);
|
||||
});
|
||||
|
||||
it("shows unassigned WOs for the selected period and drills into that list (SH-392)", () => {
|
||||
renderDashboard();
|
||||
|
||||
const card = screen.getByRole("button", { name: /Unassigned/ });
|
||||
expect(card).toHaveTextContent("4");
|
||||
fireEvent.click(card);
|
||||
|
||||
const weekUrl = new URL(lastNavigation(), "http://x");
|
||||
expect(weekUrl.pathname).toBe("/workorders");
|
||||
expect(weekUrl.searchParams.get("dispatchers")).toBe("__unassigned__");
|
||||
expect(weekUrl.searchParams.get("statuses")).not.toContain("Completed");
|
||||
expect(weekUrl.searchParams.get("dateFrom")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(weekUrl.searchParams.get("dateTo")).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "All time" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /Unassigned/ }));
|
||||
|
||||
const allTimeUrl = new URL(lastNavigation(), "http://x");
|
||||
expect(allTimeUrl.searchParams.get("dispatchers")).toBe("__unassigned__");
|
||||
expect(allTimeUrl.searchParams.has("dateFrom")).toBe(false);
|
||||
expect(allTimeUrl.searchParams.has("dateTo")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps drilldown dates deterministic on the business clock", () => {
|
||||
const today = businessTodayIso();
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ describe("dashboardApi", () => {
|
|||
ScheduledTomorrow: "3",
|
||||
PendingUplifts: 2,
|
||||
AvetaPending: "1",
|
||||
Unassigned: "6",
|
||||
Breakdown: [
|
||||
{ Status: "Scheduled", Count: "7" },
|
||||
{ Status: "Completed", Count: 4 },
|
||||
|
|
@ -52,6 +53,7 @@ describe("dashboardApi", () => {
|
|||
scheduledTomorrow: 3,
|
||||
pendingUplifts: 2,
|
||||
avetaPending: 1,
|
||||
unassigned: 6,
|
||||
breakdown: [
|
||||
{ status: "Scheduled", count: 7 },
|
||||
{ status: "Completed", count: 4 },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
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";
|
||||
import {
|
||||
avetaPendingDrilldownSearch,
|
||||
unassignedDrilldownSearch,
|
||||
} from "@/domain/dashboard/utils/work-order-drilldown-links";
|
||||
import { NON_TERMINAL_LIFECYCLE_STATUSES } from "@/domain/work-orders/types/work-order-board";
|
||||
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
describe("parseDashboardDrilldownParams", () => {
|
||||
it("returns null when no recognized param is present", () => {
|
||||
|
|
@ -60,6 +65,29 @@ describe("parseDashboardDrilldownParams", () => {
|
|||
expect(filters?.regions).toEqual(new Set(["California"]));
|
||||
});
|
||||
|
||||
it("turns the Unassigned tile link into the board search the Dashboard counts (SH-392)", () => {
|
||||
const allTime = parseDashboardDrilldownParams(unassignedDrilldownSearch("all-time"));
|
||||
expect(allTime).not.toBeNull();
|
||||
expect(toBoardSearchQueryParams({ filters: allTime! })).toMatchObject({
|
||||
dispatchers: ["__unassigned__"],
|
||||
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||
datePreset: "Custom",
|
||||
dateFrom: "2000-01-01",
|
||||
dateTo: "2099-12-31",
|
||||
includeDateless: true,
|
||||
});
|
||||
|
||||
const week = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
|
||||
const weekParams = toBoardSearchQueryParams({ filters: week! });
|
||||
expect(week?.dateRange).toBe("custom");
|
||||
expect(weekParams).toMatchObject({
|
||||
dispatchers: ["__unassigned__"],
|
||||
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||
datePreset: "Custom",
|
||||
});
|
||||
expect(weekParams.includeDateless).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects partial or malformed date ranges", () => {
|
||||
expect(
|
||||
parseDashboardDrilldownParams(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue