mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
Merge pull request #261 from Sea-Haven-Industries/feat/ab/sh-392-dashboard-unassigned
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
SH-392: Unassigned KPI tile on the Dashboard
This commit is contained in:
commit
358ec3e890
11 changed files with 169 additions and 43 deletions
Binary file not shown.
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 145 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 125 KiB |
|
|
@ -191,7 +191,11 @@ async function mockDashboardApi(page: Page, userRoles: string) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (pathname.includes("/Dashboard/Stats")) return fulfillJson(route, { data: STATS });
|
||||
if (pathname.includes("/Dashboard/Stats")) {
|
||||
// The server reports no unassigned work for a dispatcher-scoped view.
|
||||
const unassigned = userRoles === "Dispatcher" ? 0 : 11;
|
||||
return fulfillJson(route, { data: { ...STATS, unassigned } });
|
||||
}
|
||||
if (pathname.includes("/Dashboard/Workload")) return fulfillJson(route, { data: WORKLOAD });
|
||||
if (pathname.includes("/Dashboard/Performance"))
|
||||
return fulfillJson(route, { data: PERFORMANCE });
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
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 { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import {
|
||||
addDaysIso,
|
||||
businessTodayIso,
|
||||
|
|
@ -77,6 +78,18 @@ export function workOrderRegionDrilldownSearch(
|
|||
return search;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Unassigned tile's list: 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", ASSIGNEE_FILTER_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,13 @@
|
|||
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 { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||
import { buildAdvancedFilterChips } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||
import { toBoardSearchQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||
|
||||
describe("parseDashboardDrilldownParams", () => {
|
||||
it("returns null when no recognized param is present", () => {
|
||||
|
|
@ -60,6 +67,41 @@ 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("opens the Unassigned tile link as the Unassigned option of Assigned to", () => {
|
||||
const filters = parseDashboardDrilldownParams(unassignedDrilldownSearch("this-week"));
|
||||
expect(filters?.dispatchers).toEqual(new Set([ASSIGNEE_FILTER_UNASSIGNED]));
|
||||
|
||||
const chips = buildAdvancedFilterChips(filters!, { dispatcherNameOf: (id) => id });
|
||||
const assignedTo = chips.filter((chip) => chip.group === "Assigned to");
|
||||
expect(assignedTo.map((chip) => chip.value)).toEqual(["Unassigned"]);
|
||||
|
||||
const cleared = assignedTo[0]!.clear(filters!);
|
||||
expect(cleared.dispatchers.size).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects partial or malformed date ranges", () => {
|
||||
expect(
|
||||
parseDashboardDrilldownParams(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue