diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx index f99dec8..6479501 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/AnalyticsView.tsx @@ -8,7 +8,7 @@ import SurveyedResultsPieChart from "./SurveyedResultsPieChart"; import DampMouldRiskPanel from "./DampMouldRiskPanel"; import CompletionTrendsChart from "./CompletionTrendsChart"; import SurveyIssuesPanel from "./SurveyIssuesPanel"; -import GroupFilter, { type GroupOption } from "./GroupFilter"; +import GroupFilter, { type GroupNode } from "./GroupFilter"; import { STAGE_COLORS, STAGE_ORDER } from "./types"; import type { ProjectData, @@ -313,7 +313,7 @@ interface AnalyticsViewProps { ) => void; majorConditionDeals: ClassifiedDeal[]; totalDeals: number; - availableGroups: GroupOption[]; + availableGroups: GroupNode[]; groupFilter: string[]; onGroupFilterChange: (next: string[]) => void; groupFilterActive: boolean; diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/GroupFilter.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/GroupFilter.tsx index 3832c2c..d9714da 100644 --- a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/GroupFilter.tsx +++ b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/GroupFilter.tsx @@ -1,6 +1,7 @@ "use client"; -import { ChevronDown, Layers } from "lucide-react"; +import { Fragment } from "react"; +import { ChevronDown, Layers, Minus } from "lucide-react"; import { DropdownMenu, DropdownMenuCheckboxItem, @@ -11,14 +12,28 @@ import { } from "@/app/shadcn_components/ui/dropdown-menu"; import { Card } from "@/app/shadcn_components/ui/card"; -export type GroupOption = { +export type GroupLeaf = { value: string; + // Full label, used in the trigger when this leaf is selected outside the + // context of a fully-checked parent. label: string; + // Short label rendered under a parent header (the parent already provides + // context). Falls back to `label`. + shortLabel?: string; muted?: boolean; }; +export type GroupNode = + | { kind: "leaf"; leaf: GroupLeaf } + | { + kind: "parent"; + label: string; + muted?: boolean; + children: GroupLeaf[]; + }; + interface GroupFilterProps { - options: GroupOption[]; + options: GroupNode[]; selected: string[]; onChange: (next: string[]) => void; variant?: "card" | "inline"; @@ -34,7 +49,9 @@ function GroupDropdown({ triggerClassName: string; align?: "start" | "end"; }) { - const toggle = (value: string, checked: boolean) => { + const selectedSet = new Set(selected); + + const toggleLeaf = (value: string, checked: boolean) => { if (checked) { onChange([...selected, value]); } else { @@ -42,16 +59,44 @@ function GroupDropdown({ } }; - const selectedLabels = selected - .map((v) => options.find((o) => o.value === v)?.label) - .filter((l): l is string => l !== undefined); + const toggleParent = (childValues: string[]) => { + const allChecked = childValues.every((v) => selectedSet.has(v)); + if (allChecked) { + const remove = new Set(childValues); + onChange(selected.filter((v) => !remove.has(v))); + } else { + const merged = new Set(selected); + for (const v of childValues) merged.add(v); + onChange(Array.from(merged)); + } + }; + + // Trigger chunks: fully-selected parents collapse to the parent's label; + // standalone leaves and partial-parent children contribute their own labels. + const triggerChunks: string[] = []; + for (const node of options) { + if (node.kind === "leaf") { + if (selectedSet.has(node.leaf.value)) triggerChunks.push(node.leaf.label); + } else { + const childValues = node.children.map((c) => c.value); + const allSelected = + childValues.length > 0 && childValues.every((v) => selectedSet.has(v)); + if (allSelected) { + triggerChunks.push(node.label); + } else { + for (const child of node.children) { + if (selectedSet.has(child.value)) triggerChunks.push(child.label); + } + } + } + } const triggerLabel = - selectedLabels.length === 0 + triggerChunks.length === 0 ? "All groups" - : selectedLabels.length === 1 - ? selectedLabels[0] - : `${selectedLabels[0]} +${selectedLabels.length - 1}`; + : triggerChunks.length === 1 + ? triggerChunks[0] + : `${triggerChunks[0]} +${triggerChunks.length - 1}`; return ( @@ -66,7 +111,7 @@ function GroupDropdown({ Groups @@ -82,21 +127,81 @@ function GroupDropdown({
- {options.map((opt) => ( - toggle(opt.value, !!val)} - onSelect={(e) => e.preventDefault()} - className="text-sm" - > - {opt.muted ? ( - {opt.label} - ) : ( - opt.label - )} - - ))} + {options.map((node, i) => { + const needsSeparator = i > 0; + if (node.kind === "leaf") { + return ( + + {needsSeparator && } + toggleLeaf(node.leaf.value, !!v)} + onSelect={(e) => e.preventDefault()} + className="text-sm" + > + {node.leaf.muted ? ( + + {node.leaf.label} + + ) : ( + node.leaf.label + )} + + + ); + } + + const childValues = node.children.map((c) => c.value); + const selectedCount = childValues.filter((v) => + selectedSet.has(v), + ).length; + const parentState: boolean | "indeterminate" = + selectedCount === 0 + ? false + : selectedCount === childValues.length + ? true + : "indeterminate"; + + return ( + + {needsSeparator && } + toggleParent(childValues)} + onSelect={(e) => e.preventDefault()} + className="text-sm font-semibold" + > + {parentState === "indeterminate" && ( + + + + )} + {node.muted ? ( + {node.label} + ) : ( + node.label + )} + + {node.children.map((child) => ( + toggleLeaf(child.value, !!v)} + onSelect={(e) => e.preventDefault()} + className="text-sm pl-12" + > + {child.muted ? ( + + {child.shortLabel ?? child.label} + + ) : ( + (child.shortLabel ?? child.label) + )} + + ))} + + ); + })}
diff --git a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx index 225fee1..dd8b3bf 100644 Binary files a/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx and b/src/app/portfolio/[slug]/(portfolio)/your-projects/live/LiveTracker.tsx differ