"use client"; import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, CalendarDays, ChartGantt, ChevronDown, CircleDot, Columns3, Filter, FolderKanban, FolderMinus, List, Rows3, SignalHigh, SlidersHorizontal, X, Tag, Table2, User, UserMinus, UserPen, Waves, } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; import { Spinner } from "@multica/ui/components/ui/spinner"; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@multica/ui/components/ui/dropdown-menu"; import { Popover, PopoverTrigger, PopoverContent, } from "@multica/ui/components/ui/popover"; import { Calendar } from "@multica/ui/components/ui/calendar"; import { Switch } from "@multica/ui/components/ui/switch"; import { ALL_STATUSES, PRIORITY_ORDER, } from "@multica/core/issues/config"; import { StatusIcon, PriorityIcon } from "."; import { useQuery } from "@tanstack/react-query"; import { useWorkspaceId } from "@multica/core/hooks"; import { memberListOptions, agentListOptions, squadListOptions } from "@multica/core/workspace/queries"; import { projectListOptions } from "@multica/core/projects/queries"; import { labelListOptions } from "@multica/core/labels/queries"; import { propertyListOptions } from "@multica/core/properties"; import { propertyIdFromViewKey } from "@multica/core/issues/stores/view-store"; import type { Issue, IssueProperty, IssueTableFacetSpec, IssueTableFacetsResponse, } from "@multica/core/types"; import { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; import { PropertyIcon } from "../../common/property-icon"; import { LabelChip } from "../../labels/label-chip"; import { SORT_OPTIONS, GROUPING_OPTIONS, SWIMLANE_GROUPINGS, CARD_PROPERTY_OPTIONS, type ActorFilterValue, type IssueDateField, type IssueDateFilter, type SortField, type IssueGrouping, type SwimlaneGrouping, type TableGrouping, type ViewMode, } from "@multica/core/issues/stores/view-store"; import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context"; import { addDaysDateOnly, dateOnlyToLocalDate, formatDateOnly, toDateOnly, todayDateOnly } from "@multica/core/issues/date"; import { useIssuesScopeStore, type IssuesScope, } from "@multica/core/issues/stores/issues-scope-store"; import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"; import { useT } from "../../i18n"; import { matchesPinyin } from "../../editor/extensions/pinyin-match"; import { FILTER_ITEM_CLASS, HoverCheck } from "../../common/hover-check"; import { WorkspaceAgentWorkingChip } from "./workspace-agent-working-chip"; import { TableColumnPicker } from "./table-view"; type LocalDateRange = { from: Date | undefined; to?: Date; }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getActiveFilterCount(state: { statusFilters: string[]; priorityFilters: string[]; assigneeFilters: ActorFilterValue[]; includeNoAssignee: boolean; creatorFilters: ActorFilterValue[]; projectFilters: string[]; includeNoProject: boolean; labelFilters: string[]; propertyFilters?: Record; dateFilter?: IssueDateFilter | null; }) { let count = 0; if (state.statusFilters.length > 0) count++; if (state.priorityFilters.length > 0) count++; if (state.assigneeFilters.length > 0 || state.includeNoAssignee) count++; if (state.creatorFilters.length > 0) count++; if (state.projectFilters.length > 0 || state.includeNoProject) count++; if (state.labelFilters.length > 0) count++; for (const selected of Object.values(state.propertyFilters ?? {})) { if (selected.length > 0) count++; } if (state.dateFilter) count++; return count; } function shortDateLabel(dateOnly: string) { return formatDateOnly(dateOnly, { month: "short", day: "numeric" }) || dateOnly; } function normalizeDateRange(from: Date, to: Date) { return from <= to ? [from, to] as const : [to, from] as const; } const DATE_FIELD_LABEL_KEY: Record = { created_at: "date_field_created", updated_at: "date_field_updated", }; /** Feeding this to useIssueCounts hides every per-option badge (badges only * render at count > 0) without touching the option lists themselves. */ const NO_COUNT_ISSUES: Issue[] = []; function useIssueCounts( allIssues: Issue[], serverFacets?: IssueTableFacetsResponse, ) { return useMemo(() => { const status = new Map(); const priority = new Map(); const assignee = new Map(); const creator = new Map(); const project = new Map(); const label = new Map(); // property definition id → option key → count. Checkbox values count // under the "true"/"false" pseudo-option keys the filter store uses. const property = new Map>(); let noAssignee = 0; let noProject = 0; if (serverFacets) { for (const facet of serverFacets.facets) { const target = facet.kind === "status" ? status : facet.kind === "priority" ? priority : facet.kind === "assignee" ? assignee : facet.kind === "creator" ? creator : facet.kind === "project" ? project : facet.kind === "label" ? label : null; if (facet.kind === "property" && facet.property_id) { property.set( facet.property_id, new Map(facet.values.map((value) => [value.key, value.count])), ); continue; } for (const value of facet.values) { if (facet.kind === "assignee" && value.key === "__none__") { noAssignee = value.count; } else if (facet.kind === "project" && value.key === "__none__") { noProject = value.count; } else { target?.set(value.key, value.count); } } } return { status, priority, assignee, creator, noAssignee, project, noProject, label, property }; } for (const issue of allIssues) { status.set(issue.status, (status.get(issue.status) ?? 0) + 1); priority.set(issue.priority, (priority.get(issue.priority) ?? 0) + 1); if (!issue.assignee_id) { noAssignee++; } else { const aKey = `${issue.assignee_type}:${issue.assignee_id}`; assignee.set(aKey, (assignee.get(aKey) ?? 0) + 1); } const cKey = `${issue.creator_type}:${issue.creator_id}`; creator.set(cKey, (creator.get(cKey) ?? 0) + 1); if (!issue.project_id) { noProject++; } else { project.set(issue.project_id, (project.get(issue.project_id) ?? 0) + 1); } if (issue.labels) { for (const l of issue.labels) { label.set(l.id, (label.get(l.id) ?? 0) + 1); } } for (const [propertyId, value] of Object.entries(issue.properties ?? {})) { const optionKeys = typeof value === "string" ? [value] : Array.isArray(value) ? value : typeof value === "boolean" ? [String(value)] : []; if (optionKeys.length === 0) continue; let perOption = property.get(propertyId); if (!perOption) { perOption = new Map(); property.set(propertyId, perOption); } for (const key of optionKeys) { perOption.set(key, (perOption.get(key) ?? 0) + 1); } } } return { status, priority, assignee, creator, noAssignee, project, noProject, label, property }; }, [allIssues, serverFacets]); } // --------------------------------------------------------------------------- // Scope config // --------------------------------------------------------------------------- const SCOPE_VALUES: IssuesScope[] = ["all", "members", "agents"]; // --------------------------------------------------------------------------- // Actor sub-menu content (shared between Assignee and Creator) // --------------------------------------------------------------------------- function ActorSubContent({ counts, selected, onToggle, showNoAssignee, includeNoAssignee, onToggleNoAssignee, noAssigneeCount, showSquads = true, }: { counts: Map; selected: ActorFilterValue[]; onToggle: (value: ActorFilterValue) => void; showNoAssignee?: boolean; includeNoAssignee?: boolean; onToggleNoAssignee?: () => void; noAssigneeCount?: number; showSquads?: boolean; }) { const { t } = useT("issues"); const [search, setSearch] = useState(""); const wsId = useWorkspaceId(); const { data: members = [] } = useQuery(memberListOptions(wsId)); const { data: agents = [] } = useQuery(agentListOptions(wsId)); const { data: squads = [] } = useQuery(squadListOptions(wsId)); const query = search.trim().toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(query) || matchesPinyin(m.name, query), ); const filteredAgents = agents.filter((a) => !a.archived_at && (a.name.toLowerCase().includes(query) || matchesPinyin(a.name, query)), ); const filteredSquads = squads.filter((s) => !s.archived_at && (s.name.toLowerCase().includes(query) || matchesPinyin(s.name, query)), ); const isSelected = (type: "member" | "agent" | "squad", id: string) => selected.some((f) => f.type === type && f.id === id); return ( <>
setSearch(e.target.value)} placeholder={t(($) => $.filters.placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" autoFocus />
{showNoAssignee && (!query || "no assignee".includes(query) || "unassigned".includes(query)) && ( onToggleNoAssignee?.()} className={FILTER_ITEM_CLASS} > {t(($) => $.filters.no_assignee)} {(noAssigneeCount ?? 0) > 0 && ( {noAssigneeCount} )} )} {filteredMembers.length > 0 && ( {t(($) => $.filters.members_group)} {filteredMembers.map((m) => { const checked = isSelected("member", m.user_id); const count = counts.get(`member:${m.user_id}`) ?? 0; return ( onToggle({ type: "member", id: m.user_id }) } className={FILTER_ITEM_CLASS} > {m.name} {count > 0 && ( {count} )} ); })} )} {filteredAgents.length > 0 && ( {t(($) => $.filters.agents_group)} {filteredAgents.map((a) => { const checked = isSelected("agent", a.id); const count = counts.get(`agent:${a.id}`) ?? 0; return ( onToggle({ type: "agent", id: a.id }) } className={FILTER_ITEM_CLASS} > {a.name} {count > 0 && ( {count} )} ); })} )} {showSquads && filteredSquads.length > 0 && ( {t(($) => $.filters.squads_group)} {filteredSquads.map((s) => { const checked = isSelected("squad", s.id); const count = counts.get(`squad:${s.id}`) ?? 0; return ( onToggle({ type: "squad", id: s.id }) } className={FILTER_ITEM_CLASS} > {s.name} {count > 0 && ( {count} )} ); })} )} {filteredMembers.length === 0 && filteredAgents.length === 0 && (!showSquads || filteredSquads.length === 0) && search && (
{t(($) => $.filters.no_results)}
)}
); } // --------------------------------------------------------------------------- // Project sub-menu content // --------------------------------------------------------------------------- function ProjectSubContent({ counts, selected, onToggle, includeNoProject, onToggleNoProject, noProjectCount, }: { counts: Map; selected: string[]; onToggle: (projectId: string) => void; includeNoProject: boolean; onToggleNoProject: () => void; noProjectCount: number; }) { const { t } = useT("issues"); const [search, setSearch] = useState(""); const wsId = useWorkspaceId(); const { data: projects = [] } = useQuery(projectListOptions(wsId)); const query = search.trim().toLowerCase(); const filtered = projects.filter((p) => p.title.toLowerCase().includes(query), ); return ( <>
setSearch(e.target.value)} placeholder={t(($) => $.filters.placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" autoFocus />
{(!query || "no project".includes(query) || "unassigned".includes(query)) && ( onToggleNoProject()} className={FILTER_ITEM_CLASS} > {t(($) => $.filters.no_project)} {noProjectCount > 0 && ( {noProjectCount} )} )} {filtered.map((p) => { const checked = selected.includes(p.id); const count = counts.get(p.id) ?? 0; return ( onToggle(p.id)} className={FILTER_ITEM_CLASS} > {p.title} {count > 0 && ( {count} )} ); })} {filtered.length === 0 && search && (
{t(($) => $.filters.no_results)}
)}
); } // --------------------------------------------------------------------------- // Label sub-menu content // --------------------------------------------------------------------------- function LabelSubContent({ counts, selected, onToggle, }: { counts: Map; selected: string[]; onToggle: (labelId: string) => void; }) { const { t } = useT("issues"); const [search, setSearch] = useState(""); const wsId = useWorkspaceId(); const { data: labels = [] } = useQuery(labelListOptions(wsId)); const query = search.trim().toLowerCase(); const filtered = labels.filter((l) => l.name.toLowerCase().includes(query)); return ( <>
setSearch(e.target.value)} placeholder={t(($) => $.filters.placeholder)} className="w-full bg-transparent text-sm placeholder:text-muted-foreground outline-none" autoFocus />
{filtered.map((l) => { const checked = selected.includes(l.id); const count = counts.get(l.id) ?? 0; return ( onToggle(l.id)} className={FILTER_ITEM_CLASS} > {count > 0 && ( {count} )} ); })} {filtered.length === 0 && (
{search ? t(($) => $.filters.no_results) : t(($) => $.filters.no_labels)}
)}
); } /** * Option checkboxes for one custom property inside the Filter dropdown. * Select/multi_select list the definition's options (dot + name); checkbox * definitions expose the "true"/"false" pseudo-options with Yes/No labels. */ function PropertyFilterOptions({ property, counts, selected, onToggle, }: { property: IssueProperty; counts: Map | undefined; selected: string[]; onToggle: (optionId: string) => void; }) { const { t } = useT("issues"); const options = property.type === "checkbox" ? [ { id: "true", name: t(($) => $.pickers.custom_property.true_label), color: undefined }, { id: "false", name: t(($) => $.pickers.custom_property.false_label), color: undefined }, ] : (property.config.options ?? []).map((option) => ({ id: option.id, name: option.name, color: option.color as string | undefined, })); return ( <> {options.map((option) => { const checked = selected.includes(option.id); const count = counts?.get(option.id) ?? 0; return ( onToggle(option.id)} className={FILTER_ITEM_CLASS} > {option.color && ( )} {option.name} {count > 0 && ( {count} )} ); })} ); } // --------------------------------------------------------------------------- // Date sub-menu content // --------------------------------------------------------------------------- function DateSubContent({ value, onChange, }: { value: IssueDateFilter | null; onChange: (filter: IssueDateFilter | null) => void; }) { const { t } = useT("issues"); const [field, setField] = useState(value?.field ?? "created_at"); const [range, setRange] = useState(() => { if (!value) return undefined; const from = dateOnlyToLocalDate(value.from); if (!from) return undefined; return { from, to: dateOnlyToLocalDate(value.to) }; }); const setFieldValue = (next: IssueDateField) => { setField(next); if (value) onChange({ ...value, field: next }); }; const applyPreset = (days: 1 | 3 | 7) => { onChange({ field, from: addDaysDateOnly(1 - days), to: todayDateOnly(), }); }; const applyCustom = () => { if (!range?.from) return; const [from, to] = normalizeDateRange(range.from, range.to ?? range.from); onChange({ field, from: toDateOnly(from), to: toDateOnly(to), }); }; return ( <> {t(($) => $.filters.date_field)} setFieldValue(next as IssueDateField)}> {(["created_at", "updated_at"] as const).map((option) => ( {t(($) => $.filters[DATE_FIELD_LABEL_KEY[option]])} ))} applyPreset(1)}> {t(($) => $.filters.date_today)} applyPreset(3)}> {t(($) => $.filters.date_last_3_days)} applyPreset(7)}> {t(($) => $.filters.date_last_7_days)}
{t(($) => $.filters.date_custom_range)} } /> setRange(next)} captionLayout="dropdown" />
{value && ( <> { setRange(undefined); onChange(null); }} > {t(($) => $.filters.date_clear)} )} ); } // --------------------------------------------------------------------------- // ViewRefreshIndicator // --------------------------------------------------------------------------- /** * Neutral "view is revalidating" slot for the header controls cluster. * Driven by the surface's isRefreshing (placeholder-backed revalidation: * sort/date change, grouped-board filter change) — trigger-agnostic on * purpose, so any view change that puts a request in flight lights it. * * The slot is fixed-width so appearing/disappearing never shifts the * controls; the 300ms appear delay keeps fast networks indicator-free * (NN/g: sub-second responses need no feedback) while slow ones get a * "working on it" signal instead of a dead click. */ export function ViewRefreshIndicator({ active }: { active: boolean }) { return ( {active && ( )} ); } // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- export function IssuesHeader({ scopedIssues, allowGantt = false, dateFilter = null, onDateFilterChange, isRefreshing = false, facetCountsExact = true, tableFacetCounts, onTableFacetChange, }: { scopedIssues: Issue[]; allowGantt?: boolean; dateFilter?: IssueDateFilter | null; onDateFilterChange?: (filter: IssueDateFilter | null) => void; isRefreshing?: boolean; /** See IssueDisplayControls.facetCountsExact. */ facetCountsExact?: boolean; tableFacetCounts?: IssueTableFacetsResponse; onTableFacetChange?: (facet: IssueTableFacetSpec | null) => void; }) { const { t } = useT("issues"); const scope = useIssuesScopeStore((s) => s.scope); const setScope = useIssuesScopeStore((s) => s.setScope); // Bind the workspace agents-working chip to the active view store so // shared IssuesHeader consumers (/issues and project detail) toggle the // same filter state as the rest of the display controls. /my-issues keeps // its own sibling header and passes chip state explicitly. const agentRunningFilter = useViewStore((s) => s.agentRunningFilter); const toggleAgentRunningFilter = useViewStore( (s) => s.toggleAgentRunningFilter, ); const SCOPE_LABEL_KEY: Record = { all: "all_label", members: "members_label", agents: "agents_label", }; const SCOPE_DESC_KEY: Record = { all: "all_description", members: "members_description", agents: "agents_description", }; const scopeLabel = t(($) => $.scope[SCOPE_LABEL_KEY[scope]]); return (
{/* Left: scope buttons */}
{SCOPE_VALUES.map((s) => ( setScope(s)} > {t(($) => $.scope[SCOPE_LABEL_KEY[s]])} } /> {t(($) => $.scope[SCOPE_DESC_KEY[s]])} ))}
{scopeLabel} } /> setScope(value as IssuesScope)}> {SCOPE_VALUES.map((s) => ( {t(($) => $.scope[SCOPE_LABEL_KEY[s]])} ))}
{agentRunningFilter && ( {t(($) => $.agent_activity.filter_active_label)} )}
); } export function IssueDisplayControls({ scopedIssues, hideViewToggle = false, allowGantt = false, dateFilter = null, onDateFilterChange, facetCountsExact = true, tableFacetCounts, onTableFacetChange, }: { scopedIssues: Issue[]; hideViewToggle?: boolean; dateFilter?: IssueDateFilter | null; onDateFilterChange?: (filter: IssueDateFilter | null) => void; // Only Project Detail renders ; other surfaces (global /issues, // /my-issues, actor panel) ignore viewMode === "gantt" and would silently // fall back to List if the option were exposed there. Keep Gantt opt-in. allowGantt?: boolean; /** * Whether `scopedIssues` covers the surface's full window. Table does not * use loaded rows for counts; server-paged List, Board, and Swimlane follow * the same rule. They supply `tableFacetCounts` from the backend instead, * so badges remain exact without downloading all issues. */ facetCountsExact?: boolean; tableFacetCounts?: IssueTableFacetsResponse; onTableFacetChange?: (facet: IssueTableFacetSpec | null) => void; }) { const { t } = useT("issues"); const [tableGroupMenuOpen, setTableGroupMenuOpen] = useState(false); const [viewMenuOpen, setViewMenuOpen] = useState(false); const viewMode = useViewStore((s) => s.viewMode); const statusFilters = useViewStore((s) => s.statusFilters); const priorityFilters = useViewStore((s) => s.priorityFilters); const assigneeFilters = useViewStore((s) => s.assigneeFilters); const includeNoAssignee = useViewStore((s) => s.includeNoAssignee); const creatorFilters = useViewStore((s) => s.creatorFilters); const projectFilters = useViewStore((s) => s.projectFilters); const includeNoProject = useViewStore((s) => s.includeNoProject); const labelFilters = useViewStore((s) => s.labelFilters); const propertyFilters = useViewStore((s) => s.propertyFilters); const cardPropertyIds = useViewStore((s) => s.cardPropertyIds); const sortBy = useViewStore((s) => s.sortBy); const sortDirection = useViewStore((s) => s.sortDirection); const grouping = useViewStore((s) => s.grouping); const swimlaneGrouping = useViewStore((s) => s.swimlaneGrouping); const cardProperties = useViewStore((s) => s.cardProperties); const tableGrouping = useViewStore((s) => s.tableGrouping ?? "none"); const tableHierarchy = useViewStore((s) => s.tableHierarchy ?? true); const showSubIssues = useViewStore((s) => s.showSubIssues); const act = useViewStoreApi().getState(); const headerWsId = useWorkspaceId(); // Active custom-property catalog: drives the filter sections, dynamic // sort/grouping options, and the card-property toggles below. const { data: workspaceProperties = [] } = useQuery(propertyListOptions(headerWsId)); const propertyById = useMemo( () => new Map(workspaceProperties.map((p) => [p.id, p])), [workspaceProperties], ); const filterableProperties = useMemo( () => workspaceProperties.filter((p) => p.type === "select" || p.type === "multi_select" || p.type === "checkbox", ), [workspaceProperties], ); const sortableProperties = useMemo( () => workspaceProperties.filter((p) => p.type === "number" || p.type === "date"), [workspaceProperties], ); const groupableProperties = useMemo( () => workspaceProperties.filter((p) => p.type === "select"), [workspaceProperties], ); const tableGroupableProperties = useMemo( () => workspaceProperties.filter((p) => ["select", "checkbox"].includes(p.type), ), [workspaceProperties], ); const counts = useIssueCounts( facetCountsExact ? scopedIssues : NO_COUNT_ISSUES, tableFacetCounts, ); const showDateFilter = !!onDateFilterChange; // Only count filters whose definition is still active — a filter pinned to // an archived definition is stripped by the surface controller and must // not light the badge for an effect that no longer exists. const effectivePropertyFilters = useMemo(() => { const activeIds = new Set(filterableProperties.map((p) => p.id)); return Object.fromEntries( Object.entries(propertyFilters).filter(([id, selected]) => selected.length > 0 && activeIds.has(id)), ); }, [filterableProperties, propertyFilters]); const activeFilterCount = getActiveFilterCount({ statusFilters, priorityFilters, propertyFilters: effectivePropertyFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, dateFilter: showDateFilter ? dateFilter : null, }); const hasActiveFilters = activeFilterCount > 0; const SORT_LABEL_KEY: Record = { position: "sort_manual", status: "sort_status", priority: "sort_priority", start_date: "sort_start_date", due_date: "sort_due_date", created_at: "sort_created", updated_at: "sort_updated", title: "sort_title", }; const GROUPING_LABEL_KEY: Record = { status: "group_status", assignee: "group_assignee", }; const SWIMLANE_GROUPING_LABEL_KEY: Record = { parent: "group_parent", project: "group_project", assignee: "group_assignee", }; const CARD_PROPERTY_LABEL_KEY: Record = { priority: "card_priority", description: "card_description", assignee: "card_assignee", startDate: "card_start_date", dueDate: "card_due_date", project: "card_project", labels: "card_labels", childProgress: "card_child_progress", }; const dateFilterLabel = showDateFilter && dateFilter ? `${t(($) => $.filters[DATE_FIELD_LABEL_KEY[dateFilter.field]])}: ${ dateFilter.from === dateFilter.to ? shortDateLabel(dateFilter.from) : `${shortDateLabel(dateFilter.from)} - ${shortDateLabel(dateFilter.to)}` }` : null; const sortPropertyId = propertyIdFromViewKey(sortBy); const groupingPropertyId = propertyIdFromViewKey(grouping); const tableGroupingPropertyId = propertyIdFromViewKey(tableGrouping); // Property-backed sort/grouping label straight from the definition name; // a stale persisted id (archived/deleted definition) falls back to the // manual/status default label. const sortLabel = sortPropertyId ? propertyById.get(sortPropertyId)?.name ?? t(($) => $.display.sort_manual) : t(($) => $.display[SORT_LABEL_KEY[sortBy as keyof typeof SORT_LABEL_KEY]]); const groupingLabel = groupingPropertyId ? propertyById.get(groupingPropertyId)?.name ?? t(($) => $.display.group_status) : t(($) => $.display[GROUPING_LABEL_KEY[grouping as keyof typeof GROUPING_LABEL_KEY]]); const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]); const effectiveTableGrouping = tableGroupingPropertyId && !propertyById.has(tableGroupingPropertyId) ? "none" : tableGrouping; const tableGroupingLabel = tableGroupingPropertyId ? propertyById.get(tableGroupingPropertyId)?.name ?? t(($) => $.table.group_none) : effectiveTableGrouping === "status" ? t(($) => $.table.columns.status) : effectiveTableGrouping === "assignee" ? t(($) => $.table.columns.assignee) : t(($) => $.table.group_none); const controlButtonClass = "h-8 w-8 gap-1 px-0 text-muted-foreground md:h-7 md:w-auto md:px-2.5"; return (
{/* Filter */} { if (!open) onTableFacetChange?.(null); }} > {hasActiveFilters ? t(($) => $.filters.active_count, { count: activeFilterCount }) : t(($) => $.filters.tooltip)} {hasActiveFilters && ( {activeFilterCount} )} } /> } /> {t(($) => $.filters.tooltip)} {/* Status */} onTableFacetChange?.(open ? { kind: "status" } : null) } > {t(($) => $.filters.section_status)} {statusFilters.length > 0 && ( {statusFilters.length} )} {ALL_STATUSES.map((s) => { const checked = statusFilters.includes(s); const count = counts.status.get(s) ?? 0; return ( act.toggleStatusFilter(s)} className={FILTER_ITEM_CLASS} > {t(($) => $.status[s])} {count > 0 && ( {t(($) => $.filters.issue_count, { count })} )} ); })} {/* Priority */} onTableFacetChange?.(open ? { kind: "priority" } : null) } > {t(($) => $.filters.section_priority)} {priorityFilters.length > 0 && ( {priorityFilters.length} )} {PRIORITY_ORDER.map((p) => { const checked = priorityFilters.includes(p); const count = counts.priority.get(p) ?? 0; return ( act.togglePriorityFilter(p)} className={FILTER_ITEM_CLASS} > {t(($) => $.priority[p])} {count > 0 && ( {t(($) => $.filters.issue_count, { count })} )} ); })} {showDateFilter && onDateFilterChange && ( {t(($) => $.filters.section_date)} {dateFilterLabel && ( {dateFilterLabel} )} )} {/* Assignee */} onTableFacetChange?.(open ? { kind: "assignee" } : null) } > {t(($) => $.filters.section_assignee)} {(assigneeFilters.length > 0 || includeNoAssignee) && ( {assigneeFilters.length + (includeNoAssignee ? 1 : 0)} )} {/* Creator */} onTableFacetChange?.(open ? { kind: "creator" } : null) } > {t(($) => $.filters.section_creator)} {creatorFilters.length > 0 && ( {creatorFilters.length} )} {/* Project */} onTableFacetChange?.(open ? { kind: "project" } : null) } > {t(($) => $.filters.section_project)} {(projectFilters.length > 0 || includeNoProject) && ( {projectFilters.length + (includeNoProject ? 1 : 0)} )} {/* Label */} onTableFacetChange?.(open ? { kind: "label" } : null) } > {t(($) => $.filters.section_label)} {labelFilters.length > 0 && ( {labelFilters.length} )} {/* Custom properties — one sub per filterable definition */} {filterableProperties.length > 0 && } {filterableProperties.map((property) => { const selected = propertyFilters[property.id] ?? []; return ( onTableFacetChange?.( open ? { kind: "property", property_id: property.id } : null, ) } > {property.icon ? ( ) : ( )} {property.name} {selected.length > 0 && ( {selected.length} )} act.togglePropertyFilter(property.id, optionId)} /> ); })} {/* Reset */} {hasActiveFilters && ( <> { act.clearFilters(); onDateFilterChange?.(null); }} > {t(($) => $.filters.reset)} )} {hasActiveFilters && ( $.filters.reset)} onClick={() => { act.clearFilters(); onDateFilterChange?.(null); }} className="hidden text-muted-foreground md:inline-flex" > } /> {t(($) => $.filters.reset)} )} {viewMode === "table" && ( {effectiveTableGrouping === "none" ? t(($) => $.table.group_label) : t(($) => $.table.group_active, { group: tableGroupingLabel, })} } /> { act.setTableGrouping(value as TableGrouping); setTableGroupMenuOpen(false); }} > {t(($) => $.table.group_none)} {t(($) => $.table.columns.status)} {t(($) => $.table.columns.assignee)} {tableGroupableProperties.map((property) => ( {property.name} ))} )} {viewMode === "table" && ( {t(($) => $.table.columns.section)} } /> )} {/* Display settings */} {t(($) => $.display.button)} } /> } /> {t(($) => $.display.tooltip)} {viewMode === "board" && (
{t(($) => $.display.grouping_section)}
{groupingLabel} } /> act.setGrouping(v as IssueGrouping)}> {GROUPING_OPTIONS.map((opt) => ( {t(($) => $.display[GROUPING_LABEL_KEY[opt.value]])} ))} {groupableProperties.map((property) => ( {property.name} ))}
)} {viewMode === "swimlane" && (
{t(($) => $.display.grouping_section)}
{swimlaneGroupingLabel} } /> act.setSwimlaneGrouping(v as SwimlaneGrouping)} > {SWIMLANE_GROUPINGS.map((value) => ( {t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])} ))}
)} {viewMode === "table" && (
)}
{t(($) => $.display.ordering_section)}
{sortLabel} } /> act.setSortBy(v as SortField)}> {SORT_OPTIONS.map((opt) => ( {t(($) => $.display[SORT_LABEL_KEY[opt.value as keyof typeof SORT_LABEL_KEY]])} ))} {sortableProperties.map((property) => ( {property.name} ))} {sortBy !== "position" && ( )}
{viewMode !== "table" && (
{t(($) => $.display.card_properties_section)}
{CARD_PROPERTY_OPTIONS.map((opt) => ( ))} {workspaceProperties.map((property) => ( ))}
)}
{/* View toggle. If a store has `viewMode === "gantt"` persisted but this surface doesn't render Gantt, fall back to "list" so the trigger icon matches what's actually on screen. */} {!hideViewToggle && ( {viewMode === "board" ? ( ) : viewMode === "table" ? ( ) : viewMode === "swimlane" ? ( ) : viewMode === "gantt" && allowGantt ? ( ) : ( )} {viewMode === "board" ? t(($) => $.view.board) : viewMode === "table" ? t(($) => $.view.table) : viewMode === "swimlane" ? t(($) => $.view.swimlane) : viewMode === "gantt" && allowGantt ? t(($) => $.view.gantt) : t(($) => $.view.list)} } /> } /> {viewMode === "board" ? t(($) => $.view.tooltip_board) : viewMode === "table" ? t(($) => $.view.tooltip_table) : viewMode === "swimlane" ? t(($) => $.view.tooltip_swimlane) : viewMode === "gantt" && allowGantt ? t(($) => $.view.tooltip_gantt) : t(($) => $.view.tooltip_list)} {t(($) => $.view.section)} { act.setViewMode(v as ViewMode); setViewMenuOpen(false); }} > {t(($) => $.view.board)} {t(($) => $.view.list)} {t(($) => $.view.table)} {t(($) => $.view.swimlane)} {allowGantt && ( {t(($) => $.view.gantt)} )} )}
); }