From fd75628e9df8ef23b6832f8b89a694c4e7be3cd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Wed, 1 Apr 2026 15:16:57 +0200 Subject: [PATCH] fix(allocations): recover from fully filtered empty state --- .../allocations/AllocationsClient.tsx | 63 ++++++++++++++++- .../allocationVisibilityState.test.ts | 68 +++++++++++++++++++ .../allocations/allocationVisibilityState.ts | 56 +++++++++++++++ 3 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/allocations/allocationVisibilityState.test.ts create mode 100644 apps/web/src/components/allocations/allocationVisibilityState.ts diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index 110158d..4e9d2d8 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useMemo, useCallback } from "react"; +import { useState, useEffect, useMemo, useCallback, useRef } from "react"; import { formatDate } from "~/lib/format.js"; import { trpc } from "~/lib/trpc/client.js"; import { AllocationModal } from "./AllocationModal.js"; @@ -29,6 +29,7 @@ import { toggleCollapsedAllocationGroup, type CollapsedAllocationGroups, } from "./allocationGroupState.js"; +import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js"; /** Left-border color by allocation status for instant visual scanning */ const STATUS_LEFT_BORDER: Record = { @@ -202,6 +203,7 @@ export function AllocationsClient() { ); // Track expanded project sub-groups: key = "resourceId::projectId" const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); + const hasEvaluatedInitialVisibility = useRef(false); const toggleViewMode = useCallback(() => { setViewMode((prev) => { @@ -351,6 +353,49 @@ export function AllocationsClient() { ...(hideDraftProjects ? [{ label: "Hiding draft projects", onRemove: () => setHideDraftProjects(false) }] : []), ]; + const emptyState = getAllocationEmptyState({ + totalAssignments: assignmentList.length, + totalDemands: demandList.length, + visibleAssignments: filteredAllocations.length, + visibleDemands: filteredDemands.length, + hidePastProjects, + hideCompletedProjects, + hideDraftProjects, + }); + + useEffect(() => { + if (isLoading || hasEvaluatedInitialVisibility.current) { + return; + } + + hasEvaluatedInitialVisibility.current = true; + + if ( + shouldAutoRelaxAllocationFilters({ + totalAssignments: assignmentList.length, + totalDemands: demandList.length, + visibleAssignments: filteredAllocations.length, + visibleDemands: filteredDemands.length, + hidePastProjects, + hideCompletedProjects, + hideDraftProjects, + }) + ) { + setHidePastProjects(false); + setHideCompletedProjects(false); + setHideDraftProjects(false); + } + }, [ + assignmentList.length, + demandList.length, + filteredAllocations.length, + filteredDemands.length, + hideCompletedProjects, + hideDraftProjects, + hidePastProjects, + isLoading, + ]); + function formatPeriod(alloc: AllocationWithDetails) { return formatDate(alloc.startDate) + " \u2192 " + formatDate(alloc.endDate); } @@ -633,7 +678,21 @@ export function AllocationsClient() { {!isLoading && sorted.length === 0 && ( - No assignments found. + +
+

{emptyState.title}

+

{emptyState.detail}

+ {emptyState.showResetAction && ( + + )} +
+ )} diff --git a/apps/web/src/components/allocations/allocationVisibilityState.test.ts b/apps/web/src/components/allocations/allocationVisibilityState.test.ts new file mode 100644 index 0000000..18a31d8 --- /dev/null +++ b/apps/web/src/components/allocations/allocationVisibilityState.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { getAllocationEmptyState, shouldAutoRelaxAllocationFilters } from "./allocationVisibilityState.js"; + +describe("allocationVisibilityState", () => { + it("auto-relaxes default project filters when they hide every available row", () => { + expect( + shouldAutoRelaxAllocationFilters({ + totalAssignments: 8, + totalDemands: 2, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toBe(true); + }); + + it("does not auto-relax when there is no underlying allocation data", () => { + expect( + shouldAutoRelaxAllocationFilters({ + totalAssignments: 0, + totalDemands: 0, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toBe(false); + }); + + it("builds a filter-aware empty state when rows are hidden", () => { + expect( + getAllocationEmptyState({ + totalAssignments: 5, + totalDemands: 1, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: true, + hideCompletedProjects: true, + hideDraftProjects: false, + }), + ).toEqual({ + title: "No assignments match the active filters.", + detail: "5 assignments and 1 demand are hidden by the active project filters.", + showResetAction: true, + }); + }); + + it("keeps the plain empty state when there is genuinely no data", () => { + expect( + getAllocationEmptyState({ + totalAssignments: 0, + totalDemands: 0, + visibleAssignments: 0, + visibleDemands: 0, + hidePastProjects: false, + hideCompletedProjects: false, + hideDraftProjects: false, + }), + ).toEqual({ + title: "No assignments found.", + detail: "Create a planning entry or relax the current project filters once data is available.", + showResetAction: false, + }); + }); +}); diff --git a/apps/web/src/components/allocations/allocationVisibilityState.ts b/apps/web/src/components/allocations/allocationVisibilityState.ts new file mode 100644 index 0000000..0cf37d4 --- /dev/null +++ b/apps/web/src/components/allocations/allocationVisibilityState.ts @@ -0,0 +1,56 @@ +export interface AllocationVisibilityInput { + totalAssignments: number; + totalDemands: number; + visibleAssignments: number; + visibleDemands: number; + hidePastProjects: boolean; + hideCompletedProjects: boolean; + hideDraftProjects: boolean; +} + +export interface AllocationEmptyState { + title: string; + detail: string; + showResetAction: boolean; +} + +export function shouldAutoRelaxAllocationFilters(input: AllocationVisibilityInput): boolean { + const totalRows = input.totalAssignments + input.totalDemands; + const visibleRows = input.visibleAssignments + input.visibleDemands; + const hasActiveProjectFilters = input.hidePastProjects || input.hideCompletedProjects || input.hideDraftProjects; + + return totalRows > 0 && visibleRows === 0 && hasActiveProjectFilters; +} + +export function getAllocationEmptyState(input: AllocationVisibilityInput): AllocationEmptyState { + const totalRows = input.totalAssignments + input.totalDemands; + + if (shouldAutoRelaxAllocationFilters(input)) { + const assignmentLabel = input.totalAssignments === 1 ? "assignment" : "assignments"; + const demandLabel = input.totalDemands === 1 ? "demand" : "demands"; + const hiddenSummary = + input.totalDemands > 0 + ? `${input.totalAssignments} ${assignmentLabel} and ${input.totalDemands} ${demandLabel} are hidden by the active project filters.` + : `${input.totalAssignments} ${assignmentLabel} are hidden by the active project filters.`; + + return { + title: "No assignments match the active filters.", + detail: hiddenSummary, + showResetAction: true, + }; + } + + if (totalRows === 0) { + return { + title: "No assignments found.", + detail: "Create a planning entry or relax the current project filters once data is available.", + showResetAction: false, + }; + } + + return { + title: "No assignments found.", + detail: "No visible assignment rows remain after the current filters were applied.", + showResetAction: false, + }; +}