visual improvement on groups

This commit is contained in:
Jun-te Kim 2026-05-27 16:49:57 +00:00
parent 7086a63c1e
commit 27dcc218ce
3 changed files with 134 additions and 29 deletions

View file

@ -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;

View file

@ -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 (
<DropdownMenu>
@ -66,7 +111,7 @@ function GroupDropdown({
</DropdownMenuTrigger>
<DropdownMenuContent
align={align}
className="min-w-[220px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
className="min-w-[240px] max-w-[360px] w-[--radix-dropdown-menu-trigger-width]"
>
<DropdownMenuLabel className="text-xs text-gray-500 flex items-center justify-between">
<span>Groups</span>
@ -82,21 +127,81 @@ function GroupDropdown({
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-72 overflow-y-auto">
{options.map((opt) => (
<DropdownMenuCheckboxItem
key={opt.value}
checked={selected.includes(opt.value)}
onCheckedChange={(val) => toggle(opt.value, !!val)}
onSelect={(e) => e.preventDefault()}
className="text-sm"
>
{opt.muted ? (
<span className="italic text-gray-500">{opt.label}</span>
) : (
opt.label
)}
</DropdownMenuCheckboxItem>
))}
{options.map((node, i) => {
const needsSeparator = i > 0;
if (node.kind === "leaf") {
return (
<Fragment key={`leaf-${node.leaf.value}`}>
{needsSeparator && <DropdownMenuSeparator />}
<DropdownMenuCheckboxItem
checked={selectedSet.has(node.leaf.value)}
onCheckedChange={(v) => toggleLeaf(node.leaf.value, !!v)}
onSelect={(e) => e.preventDefault()}
className="text-sm"
>
{node.leaf.muted ? (
<span className="italic text-gray-500">
{node.leaf.label}
</span>
) : (
node.leaf.label
)}
</DropdownMenuCheckboxItem>
</Fragment>
);
}
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 (
<Fragment key={`parent-${node.label}`}>
{needsSeparator && <DropdownMenuSeparator />}
<DropdownMenuCheckboxItem
checked={parentState}
onCheckedChange={() => toggleParent(childValues)}
onSelect={(e) => e.preventDefault()}
className="text-sm font-semibold"
>
{parentState === "indeterminate" && (
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<Minus className="h-4 w-4" />
</span>
)}
{node.muted ? (
<span className="italic text-gray-500">{node.label}</span>
) : (
node.label
)}
</DropdownMenuCheckboxItem>
{node.children.map((child) => (
<DropdownMenuCheckboxItem
key={child.value}
checked={selectedSet.has(child.value)}
onCheckedChange={(v) => toggleLeaf(child.value, !!v)}
onSelect={(e) => e.preventDefault()}
className="text-sm pl-12"
>
{child.muted ? (
<span className="italic text-gray-500">
{child.shortLabel ?? child.label}
</span>
) : (
(child.shortLabel ?? child.label)
)}
</DropdownMenuCheckboxItem>
))}
</Fragment>
);
})}
</div>
</DropdownMenuContent>
</DropdownMenu>