diff --git a/apps/web/src/components/allocations/AllocationsClient.tsx b/apps/web/src/components/allocations/AllocationsClient.tsx index e562946..110158d 100644 --- a/apps/web/src/components/allocations/AllocationsClient.tsx +++ b/apps/web/src/components/allocations/AllocationsClient.tsx @@ -22,6 +22,13 @@ import { useViewPrefs } from "~/hooks/useViewPrefs.js"; import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js"; import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js"; import { SuccessToast } from "~/components/ui/SuccessToast.js"; +import { + collapseAllAllocationGroups, + createInitialCollapsedAllocationGroups, + expandAllAllocationGroups, + toggleCollapsedAllocationGroup, + type CollapsedAllocationGroups, +} from "./allocationGroupState.js"; /** Left-border color by allocation status for instant visual scanning */ const STATUS_LEFT_BORDER: Record = { @@ -190,7 +197,9 @@ export function AllocationsClient() { if (typeof window === "undefined") return "grouped"; return (localStorage.getItem("capakraken:allocations:viewMode") as "grouped" | "flat") ?? "grouped"; }); - const [collapsedGroups, setCollapsedGroups] = useState | "all">("all"); + const [collapsedGroups, setCollapsedGroups] = useState( + () => createInitialCollapsedAllocationGroups(), + ); // Track expanded project sub-groups: key = "resourceId::projectId" const [expandedSubGroups, setExpandedSubGroups] = useState>(new Set()); @@ -293,26 +302,15 @@ export function AllocationsClient() { const groupIds = useMemo(() => groups.map((g) => g.resourceId), [groups]); const toggleGroup = useCallback((resourceId: string) => { - setCollapsedGroups((prev) => { - // "all" → expand just this one (materialize all IDs minus clicked) - if (prev === "all") { - const next = new Set(groupIds); - next.delete(resourceId); - return next; - } - const next = new Set(prev); - if (next.has(resourceId)) next.delete(resourceId); - else next.add(resourceId); - return next; - }); + setCollapsedGroups((prev) => toggleCollapsedAllocationGroup(prev, groupIds, resourceId)); }, [groupIds]); const collapseAll = useCallback(() => { - setCollapsedGroups("all"); + setCollapsedGroups(collapseAllAllocationGroups()); }, []); const expandAll = useCallback(() => { - setCollapsedGroups(new Set()); + setCollapsedGroups(expandAllAllocationGroups()); }, []); const toggleSubGroup = useCallback((resourceId: string, projectId: string) => { diff --git a/apps/web/src/components/allocations/allocationGroupState.test.ts b/apps/web/src/components/allocations/allocationGroupState.test.ts new file mode 100644 index 0000000..f161ccb --- /dev/null +++ b/apps/web/src/components/allocations/allocationGroupState.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + collapseAllAllocationGroups, + createInitialCollapsedAllocationGroups, + expandAllAllocationGroups, + toggleCollapsedAllocationGroup, +} from "./allocationGroupState.js"; + +describe("allocationGroupState", () => { + it("starts grouped allocations expanded so rows are visible on first load", () => { + const initial = createInitialCollapsedAllocationGroups(); + + expect(initial).toBeInstanceOf(Set); + expect([...initial]).toEqual([]); + }); + + it("collapses and re-expands a single group from the expanded default", () => { + const collapsed = toggleCollapsedAllocationGroup(createInitialCollapsedAllocationGroups(), ["r1", "r2"], "r1"); + expect(collapsed).toBeInstanceOf(Set); + expect([...(collapsed as Set)]).toEqual(["r1"]); + + const expandedAgain = toggleCollapsedAllocationGroup(collapsed, ["r1", "r2"], "r1"); + expect(expandedAgain).toBeInstanceOf(Set); + expect([...(expandedAgain as Set)]).toEqual([]); + }); + + it("keeps collapse-all behavior available for bulk controls", () => { + expect(collapseAllAllocationGroups()).toBe("all"); + + const expandedOnlyR2 = toggleCollapsedAllocationGroup("all", ["r1", "r2"], "r2"); + expect(expandedOnlyR2).toBeInstanceOf(Set); + expect([...(expandedOnlyR2 as Set)].sort()).toEqual(["r1"]); + + const expandedAll = expandAllAllocationGroups(); + expect(expandedAll).toBeInstanceOf(Set); + expect([...(expandedAll as Set)]).toEqual([]); + }); +}); diff --git a/apps/web/src/components/allocations/allocationGroupState.ts b/apps/web/src/components/allocations/allocationGroupState.ts new file mode 100644 index 0000000..6a56bf9 --- /dev/null +++ b/apps/web/src/components/allocations/allocationGroupState.ts @@ -0,0 +1,33 @@ +export type CollapsedAllocationGroups = Set | "all"; + +export function createInitialCollapsedAllocationGroups(): CollapsedAllocationGroups { + return new Set(); +} + +export function toggleCollapsedAllocationGroup( + previous: CollapsedAllocationGroups, + groupIds: string[], + resourceId: string, +): CollapsedAllocationGroups { + if (previous === "all") { + const next = new Set(groupIds); + next.delete(resourceId); + return next; + } + + const next = new Set(previous); + if (next.has(resourceId)) { + next.delete(resourceId); + } else { + next.add(resourceId); + } + return next; +} + +export function collapseAllAllocationGroups(): CollapsedAllocationGroups { + return "all"; +} + +export function expandAllAllocationGroups(): CollapsedAllocationGroups { + return new Set(); +}