mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-16 19:29:26 +02:00
feat(agents,squads): close the filter/column consistency gaps
Apply the principle "every categorical column is filterable" where it was missing: - agents: add a Model filter (model was a categorical column with no filter). Distinct non-empty models from the in-scope rows. - squads: add filters entirely (it had leader/creator columns + a column-toggle panel but no Filter button — the only such outlier). Leader (agent) + Creator (member) filters, with the result count and the same Filter dropdown shape as the other lists. Store gains SquadListFilters + toggleFilter/clearFilters + migration-safe filters deep-merge. autopilots creator stays default-hidden per product call (not every "who made it" must be visible). Filter stores' partialize tests updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -49,12 +49,15 @@ export interface AgentListFilters {
|
||||
* toggleFilter. So owner-as-filter and Mine never coexist, which keeps
|
||||
* the axis orthogonal (no "mine + owner=someone-else = empty" state). */
|
||||
owners: string[];
|
||||
/** Runtime-native model identifiers (e.g. claude / codex / gpt-…). */
|
||||
models: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_AGENT_FILTERS: AgentListFilters = {
|
||||
availability: [],
|
||||
runtimes: [],
|
||||
owners: [],
|
||||
models: [],
|
||||
};
|
||||
|
||||
// User-hideable columns. Name and the structural columns (checkbox, kebab)
|
||||
|
||||
@@ -3,9 +3,11 @@ export {
|
||||
SQUAD_SCOPES,
|
||||
SQUAD_SORT_DEFAULT_DIRECTION,
|
||||
SQUAD_DEFAULT_HIDDEN_COLUMNS,
|
||||
EMPTY_SQUAD_FILTERS,
|
||||
type SquadsScope,
|
||||
type SquadsViewState,
|
||||
type SquadSortField,
|
||||
type SquadSortDirection,
|
||||
type SquadColumnKey,
|
||||
type SquadListFilters,
|
||||
} from "./view-store";
|
||||
|
||||
@@ -53,6 +53,7 @@ describe("useSquadsViewStore", () => {
|
||||
expect(raw).not.toBeNull();
|
||||
const parsed = JSON.parse(raw as string);
|
||||
expect(Object.keys(parsed.state).sort()).toEqual([
|
||||
"filters",
|
||||
"hiddenColumns",
|
||||
"scope",
|
||||
"sortDirection",
|
||||
|
||||
@@ -43,11 +43,26 @@ export type SquadColumnKey = "members" | "creator" | "created";
|
||||
* workspace-admin only), so labelling it Owner would mislead. */
|
||||
export const SQUAD_DEFAULT_HIDDEN_COLUMNS: SquadColumnKey[] = ["created"];
|
||||
|
||||
/** Multi-select filters — the categorical columns (leader, creator). Empty
|
||||
* array per dimension = inactive. */
|
||||
export interface SquadListFilters {
|
||||
/** Leader agent ids. */
|
||||
leaders: string[];
|
||||
/** Creator member user ids. */
|
||||
creators: string[];
|
||||
}
|
||||
|
||||
export const EMPTY_SQUAD_FILTERS: SquadListFilters = {
|
||||
leaders: [],
|
||||
creators: [],
|
||||
};
|
||||
|
||||
export interface SquadsViewState {
|
||||
scope: SquadsScope;
|
||||
sortField: SquadSortField;
|
||||
sortDirection: SquadSortDirection;
|
||||
hiddenColumns: SquadColumnKey[];
|
||||
filters: SquadListFilters;
|
||||
setScope: (scope: SquadsScope) => void;
|
||||
/** Header click: toggles direction on the active field, otherwise switches
|
||||
* to the field with its default direction. */
|
||||
@@ -56,6 +71,8 @@ export interface SquadsViewState {
|
||||
setSortField: (field: SquadSortField) => void;
|
||||
setSortDirection: (direction: SquadSortDirection) => void;
|
||||
toggleColumn: (key: SquadColumnKey) => void;
|
||||
toggleFilter: (key: keyof SquadListFilters, value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
@@ -63,6 +80,7 @@ const DEFAULTS = {
|
||||
sortField: "name" as SquadSortField,
|
||||
sortDirection: SQUAD_SORT_DEFAULT_DIRECTION.name,
|
||||
hiddenColumns: SQUAD_DEFAULT_HIDDEN_COLUMNS,
|
||||
filters: EMPTY_SQUAD_FILTERS,
|
||||
};
|
||||
|
||||
export const useSquadsViewStore = create<SquadsViewState>()(
|
||||
@@ -97,6 +115,15 @@ export const useSquadsViewStore = create<SquadsViewState>()(
|
||||
? state.hiddenColumns.filter((k) => k !== key)
|
||||
: [...state.hiddenColumns, key],
|
||||
})),
|
||||
toggleFilter: (key, value) =>
|
||||
set((state) => {
|
||||
const list = state.filters[key] as string[];
|
||||
const next = list.includes(value)
|
||||
? list.filter((v) => v !== value)
|
||||
: [...list, value];
|
||||
return { filters: { ...state.filters, [key]: next } };
|
||||
}),
|
||||
clearFilters: () => set({ filters: EMPTY_SQUAD_FILTERS }),
|
||||
}),
|
||||
{
|
||||
name: "multica_squads_view",
|
||||
@@ -108,12 +135,19 @@ export const useSquadsViewStore = create<SquadsViewState>()(
|
||||
sortField: state.sortField,
|
||||
sortDirection: state.sortDirection,
|
||||
hiddenColumns: state.hiddenColumns,
|
||||
filters: state.filters,
|
||||
}),
|
||||
// On rehydrate, if the new workspace has no persisted value, reset to
|
||||
// the defaults instead of leaking the previous workspace's state.
|
||||
// Deep-merge filters so a pre-filters payload backfills defaults.
|
||||
merge: (persisted, current) => {
|
||||
if (!persisted) return { ...current, ...DEFAULTS };
|
||||
return { ...current, ...(persisted as Partial<SquadsViewState>) };
|
||||
const p = persisted as Partial<SquadsViewState>;
|
||||
return {
|
||||
...current,
|
||||
...p,
|
||||
filters: { ...EMPTY_SQUAD_FILTERS, ...(p.filters ?? {}) },
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
@@ -77,6 +77,7 @@ export function countActiveFilterDimensions(
|
||||
if (filters.availability.length > 0) count++;
|
||||
if (filters.runtimes.length > 0) count++;
|
||||
if (filters.owners.length > 0) count++;
|
||||
if (filters.models.length > 0) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -144,9 +145,12 @@ export function AgentListToolbar({
|
||||
// Owner options: members who own at least one agent in the current scope.
|
||||
const memberById = new Map(members.map((m) => [m.user_id, m]));
|
||||
const ownerCounts = new Map<string, number>();
|
||||
const modelCounts = new Map<string, number>();
|
||||
for (const row of allRows) {
|
||||
const oid = row.agent.owner_id;
|
||||
if (oid) ownerCounts.set(oid, (ownerCounts.get(oid) ?? 0) + 1);
|
||||
const model = row.agent.model;
|
||||
if (model) modelCounts.set(model, (modelCounts.get(model) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const SCOPE_LABELS: Record<AgentsScope, string> = {
|
||||
@@ -402,6 +406,36 @@ export function AgentListToolbar({
|
||||
})}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
|
||||
{/* Model — runtime-native model id (categorical column → filter) */}
|
||||
{modelCounts.size > 0 && (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex-1">
|
||||
{t(($) => $.toolbar.section_model)}
|
||||
</span>
|
||||
{filters.models.length > 0 && (
|
||||
<span className="text-xs font-medium text-primary">
|
||||
{filters.models.length}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-72 w-auto min-w-44 overflow-y-auto">
|
||||
{[...modelCounts.entries()].map(([model, count]) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={model}
|
||||
checked={filters.models.includes(model)}
|
||||
onCheckedChange={() => onToggleFilter("models", model)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={filters.models.includes(model)} />
|
||||
<span className="min-w-0 truncate">{model}</span>
|
||||
{countBadge(count)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
|
||||
@@ -947,6 +947,12 @@ export function AgentsPage(_props: AgentsPageProps = {}) {
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.models.length > 0 &&
|
||||
!filters.models.includes(row.agent.model)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"direction_asc": "Ascending",
|
||||
"direction_desc": "Descending",
|
||||
"section_columns": "Columns",
|
||||
"section_owner": "Owner"
|
||||
"section_owner": "Owner",
|
||||
"section_model": "Model"
|
||||
},
|
||||
"actions": {
|
||||
"selected_one": "{{count}} selected",
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
"sort_by": "Sort by",
|
||||
"direction_asc": "Ascending",
|
||||
"direction_desc": "Descending",
|
||||
"section_columns": "Columns"
|
||||
"section_columns": "Columns",
|
||||
"result_count_title": "Matching squads / all squads",
|
||||
"filter_label": "Filter",
|
||||
"filter_active_count_one": "{{count}} filter",
|
||||
"filter_active_count_other": "{{count}} filters",
|
||||
"clear_filters": "Clear filters"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,8 @@
|
||||
"direction_asc": "昇順",
|
||||
"direction_desc": "降順",
|
||||
"section_columns": "列",
|
||||
"section_owner": "オーナー"
|
||||
"section_owner": "オーナー",
|
||||
"section_model": "モデル"
|
||||
},
|
||||
"actions": {
|
||||
"selected_one": "{{count}} 件選択中",
|
||||
|
||||
@@ -90,6 +90,11 @@
|
||||
"sort_by": "並び替え",
|
||||
"direction_asc": "昇順",
|
||||
"direction_desc": "降順",
|
||||
"section_columns": "列"
|
||||
"section_columns": "列",
|
||||
"result_count_title": "該当スカッド / すべてのスカッド",
|
||||
"filter_label": "フィルター",
|
||||
"filter_active_count_one": "{{count}} 件のフィルター",
|
||||
"filter_active_count_other": "{{count}} 件のフィルター",
|
||||
"clear_filters": "フィルターをクリア"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,7 +471,8 @@
|
||||
"direction_asc": "오름차순",
|
||||
"direction_desc": "내림차순",
|
||||
"section_columns": "열",
|
||||
"section_owner": "소유자"
|
||||
"section_owner": "소유자",
|
||||
"section_model": "모델"
|
||||
},
|
||||
"actions": {
|
||||
"selected_one": "{{count}}개 선택됨",
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
"sort_by": "정렬",
|
||||
"direction_asc": "오름차순",
|
||||
"direction_desc": "내림차순",
|
||||
"section_columns": "열"
|
||||
"section_columns": "열",
|
||||
"result_count_title": "일치하는 스쿼드 / 전체 스쿼드",
|
||||
"filter_label": "필터",
|
||||
"filter_active_count_one": "필터 {{count}}개",
|
||||
"filter_active_count_other": "필터 {{count}}개",
|
||||
"clear_filters": "필터 지우기"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +459,8 @@
|
||||
"direction_asc": "升序",
|
||||
"direction_desc": "降序",
|
||||
"section_columns": "列",
|
||||
"section_owner": "Owner"
|
||||
"section_owner": "Owner",
|
||||
"section_model": "模型"
|
||||
},
|
||||
"actions": {
|
||||
"selected_one": "已选 {{count}} 项",
|
||||
|
||||
@@ -93,6 +93,11 @@
|
||||
"sort_by": "排序",
|
||||
"direction_asc": "升序",
|
||||
"direction_desc": "降序",
|
||||
"section_columns": "列"
|
||||
"section_columns": "列",
|
||||
"result_count_title": "当前结果 / 全部小队",
|
||||
"filter_label": "筛选",
|
||||
"filter_active_count_one": "{{count}} 项筛选",
|
||||
"filter_active_count_other": "{{count}} 项筛选",
|
||||
"clear_filters": "清除筛选"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
Filter,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
@@ -29,6 +31,7 @@ import {
|
||||
SQUAD_SCOPES,
|
||||
SQUAD_DEFAULT_HIDDEN_COLUMNS,
|
||||
type SquadColumnKey,
|
||||
type SquadListFilters,
|
||||
type SquadsScope,
|
||||
type SquadSortField,
|
||||
} from "@multica/core/squads/stores";
|
||||
@@ -44,10 +47,14 @@ import {
|
||||
} from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import {
|
||||
@@ -73,6 +80,7 @@ import {
|
||||
} from "@multica/ui/components/ui/tooltip";
|
||||
import { ActorAvatar as ActorAvatarBase } from "@multica/ui/components/common/actor-avatar";
|
||||
import { ActorAvatar } from "../../common/actor-avatar";
|
||||
import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check";
|
||||
import { AppLink } from "../../navigation";
|
||||
import { PageHeader } from "../../layout/page-header";
|
||||
import { useT } from "../../i18n";
|
||||
@@ -405,10 +413,23 @@ function SquadListHeader({
|
||||
const COLUMN_KEYS: SquadColumnKey[] = ["members", "creator", "created"];
|
||||
const SORT_FIELDS: SquadSortField[] = ["name", "members", "created"];
|
||||
|
||||
interface ActorOption {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
function SquadListToolbar({
|
||||
scope,
|
||||
onScopeChange,
|
||||
scopeCounts,
|
||||
filters,
|
||||
onToggleFilter,
|
||||
onClearFilters,
|
||||
leaderOptions,
|
||||
creatorOptions,
|
||||
visibleCount,
|
||||
totalCount,
|
||||
sortField,
|
||||
sortDirection,
|
||||
onSortFieldChange,
|
||||
@@ -419,6 +440,13 @@ function SquadListToolbar({
|
||||
scope: SquadsScope;
|
||||
onScopeChange: (scope: SquadsScope) => void;
|
||||
scopeCounts: Record<SquadsScope, number>;
|
||||
filters: SquadListFilters;
|
||||
onToggleFilter: (key: keyof SquadListFilters, value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
leaderOptions: ActorOption[];
|
||||
creatorOptions: ActorOption[];
|
||||
visibleCount: number;
|
||||
totalCount: number;
|
||||
sortField: SquadSortField;
|
||||
sortDirection: ListGridSortDirection;
|
||||
onSortFieldChange: (field: SquadSortField) => void;
|
||||
@@ -427,6 +455,13 @@ function SquadListToolbar({
|
||||
onToggleColumn: (key: SquadColumnKey) => void;
|
||||
}) {
|
||||
const { t } = useT("squads");
|
||||
const activeFilterCount =
|
||||
(filters.leaders.length > 0 ? 1 : 0) +
|
||||
(filters.creators.length > 0 ? 1 : 0);
|
||||
const hasActiveFilters = activeFilterCount > 0;
|
||||
const countBadge = (n: number) => (
|
||||
<span className="ml-auto pl-3 text-xs text-muted-foreground">{n}</span>
|
||||
);
|
||||
const SCOPE_LABELS: Record<SquadsScope, string> = {
|
||||
mine: t(($) => $.scope.mine),
|
||||
all: t(($) => $.scope.all),
|
||||
@@ -495,8 +530,111 @@ function SquadListToolbar({
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
title={t(($) => $.toolbar.result_count_title)}
|
||||
className="hidden shrink-0 text-xs tabular-nums text-muted-foreground md:inline"
|
||||
>
|
||||
{visibleCount} / {totalCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{/* Filter */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={hasActiveFilters ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
hasActiveFilters
|
||||
? "h-8 w-8 gap-1 bg-brand px-0 text-white hover:bg-brand/90 md:w-auto md:px-2.5"
|
||||
: "h-8 w-8 gap-1 px-0 text-muted-foreground md:w-auto md:px-2.5"
|
||||
}
|
||||
>
|
||||
<Filter className="size-3.5" />
|
||||
{hasActiveFilters ? (
|
||||
<>
|
||||
<span className="hidden md:inline">
|
||||
{t(($) => $.toolbar.filter_active_count, { count: activeFilterCount })}
|
||||
</span>
|
||||
<span className="tabular-nums md:hidden">{activeFilterCount}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="hidden md:inline">{t(($) => $.toolbar.filter_label)}</span>
|
||||
)}
|
||||
{hasActiveFilters && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label={t(($) => $.toolbar.clear_filters)}
|
||||
className="-mr-1 ml-0.5 hidden rounded-sm p-0.5 hover:bg-white/20 md:inline-flex"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClearFilters();
|
||||
}}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="end" className="w-auto">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex-1">{t(($) => $.page.table.leader)}</span>
|
||||
{filters.leaders.length > 0 && (
|
||||
<span className="text-xs font-medium text-primary">{filters.leaders.length}</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-72 w-auto min-w-48 overflow-y-auto">
|
||||
{leaderOptions.map((o) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={o.id}
|
||||
checked={filters.leaders.includes(o.id)}
|
||||
onCheckedChange={() => onToggleFilter("leaders", o.id)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={filters.leaders.includes(o.id)} />
|
||||
<ActorAvatar actorType="agent" actorId={o.id} size={16} />
|
||||
<span className="min-w-0 truncate">{o.name}</span>
|
||||
{countBadge(o.count)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<span className="flex-1">{t(($) => $.page.table.creator)}</span>
|
||||
{filters.creators.length > 0 && (
|
||||
<span className="text-xs font-medium text-primary">{filters.creators.length}</span>
|
||||
)}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="max-h-72 w-auto min-w-48 overflow-y-auto">
|
||||
{creatorOptions.map((o) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={o.id}
|
||||
checked={filters.creators.includes(o.id)}
|
||||
onCheckedChange={() => onToggleFilter("creators", o.id)}
|
||||
className={FILTER_ITEM_CLASS}
|
||||
>
|
||||
<HoverCheck checked={filters.creators.includes(o.id)} />
|
||||
<ActorAvatar actorType="member" actorId={o.id} size={16} />
|
||||
<span className="min-w-0 truncate">{o.name}</span>
|
||||
{countBadge(o.count)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Display settings */}
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
@@ -602,6 +740,7 @@ function SquadListToolbar({
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -651,6 +790,9 @@ export function SquadsPage() {
|
||||
const handleSortFieldSelect = useSquadsViewStore((s) => s.setSortField);
|
||||
const setSortDirection = useSquadsViewStore((s) => s.setSortDirection);
|
||||
const toggleColumn = useSquadsViewStore((s) => s.toggleColumn);
|
||||
const filters = useSquadsViewStore((s) => s.filters);
|
||||
const toggleFilter = useSquadsViewStore((s) => s.toggleFilter);
|
||||
const clearFilters = useSquadsViewStore((s) => s.clearFilters);
|
||||
|
||||
const isColVisible = (key: SquadColumnKey) => !hiddenColumns.includes(key);
|
||||
|
||||
@@ -662,13 +804,60 @@ export function SquadsPage() {
|
||||
return { mine, all: squads.length };
|
||||
}, [squads, currentUser]);
|
||||
|
||||
const rows = useMemo<Squad[]>(() => {
|
||||
const inScope = squads.filter((s) => {
|
||||
// Rows within the current scope, unfiltered — filter option lists + the
|
||||
// "n / total" denominator derive from this.
|
||||
const scopeRows = useMemo<Squad[]>(() => {
|
||||
return squads.filter((s) => {
|
||||
if (scope === "mine") {
|
||||
return !!currentUser && s.creator_id === currentUser.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [squads, scope, currentUser]);
|
||||
|
||||
const leaderOptions = useMemo(() => {
|
||||
const m = new Map<string, { id: string; name: string; count: number }>();
|
||||
for (const s of scopeRows) {
|
||||
const e = m.get(s.leader_id);
|
||||
if (e) e.count += 1;
|
||||
else
|
||||
m.set(s.leader_id, {
|
||||
id: s.leader_id,
|
||||
name: agentsById.get(s.leader_id)?.name ?? s.leader_id.slice(0, 8),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return [...m.values()];
|
||||
}, [scopeRows, agentsById]);
|
||||
|
||||
const creatorOptions = useMemo(() => {
|
||||
const m = new Map<string, { id: string; name: string; count: number }>();
|
||||
for (const s of scopeRows) {
|
||||
const e = m.get(s.creator_id);
|
||||
if (e) e.count += 1;
|
||||
else
|
||||
m.set(s.creator_id, {
|
||||
id: s.creator_id,
|
||||
name: membersById.get(s.creator_id)?.name ?? s.creator_id.slice(0, 8),
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
return [...m.values()];
|
||||
}, [scopeRows, membersById]);
|
||||
|
||||
const rows = useMemo<Squad[]>(() => {
|
||||
const inScope = scopeRows.filter((s) => {
|
||||
if (filters.leaders.length > 0 && !filters.leaders.includes(s.leader_id)) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
filters.creators.length > 0 &&
|
||||
!filters.creators.includes(s.creator_id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const dir = sortDirection === "asc" ? 1 : -1;
|
||||
const sorted = [...inScope];
|
||||
sorted.sort((a, b) => {
|
||||
@@ -685,7 +874,7 @@ export function SquadsPage() {
|
||||
return a.name.localeCompare(b.name) * dir;
|
||||
});
|
||||
return sorted;
|
||||
}, [squads, scope, currentUser, sortField, sortDirection]);
|
||||
}, [scopeRows, filters, sortField, sortDirection]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
@@ -735,6 +924,13 @@ export function SquadsPage() {
|
||||
scope={scope}
|
||||
onScopeChange={setScope}
|
||||
scopeCounts={scopeCounts}
|
||||
filters={filters}
|
||||
onToggleFilter={toggleFilter}
|
||||
onClearFilters={clearFilters}
|
||||
leaderOptions={leaderOptions}
|
||||
creatorOptions={creatorOptions}
|
||||
visibleCount={rows.length}
|
||||
totalCount={scopeRows.length}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSortFieldChange={handleSortFieldSelect}
|
||||
|
||||
Reference in New Issue
Block a user