"use client"; import { useMemo, useState } from "react"; import { ArrowDown, ArrowUp, CalendarDays, ChartGantt, ChevronDown, CircleDot, Columns3, Filter, FolderKanban, FolderMinus, List, SignalHigh, SlidersHorizontal, X, Tag, User, UserMinus, UserPen, Waves, } from "lucide-react"; import { Button } from "@multica/ui/components/ui/button"; 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 { ProjectIcon } from "../../projects/components/project-icon"; import { ActorAvatar } from "../../common/actor-avatar"; 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 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 type { Issue } from "@multica/core/types"; 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"; 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[]; 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++; 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", }; function useIssueCounts(allIssues: Issue[]) { 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(); let noAssignee = 0; let noProject = 0; 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); } } } return { status, priority, assignee, creator, noAssignee, project, noProject, label }; }, [allIssues]); } // --------------------------------------------------------------------------- // 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)}
)}
); } // --------------------------------------------------------------------------- // 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)} )} ); } // --------------------------------------------------------------------------- // IssuesHeader // --------------------------------------------------------------------------- export function IssuesHeader({ scopedIssues, allowGantt = false, dateFilter = null, onDateFilterChange, }: { scopedIssues: Issue[]; allowGantt?: boolean; dateFilter?: IssueDateFilter | null; onDateFilterChange?: (filter: IssueDateFilter | 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, ); // Scope the chip to whatever issues this page is currently showing. // /issues uses the full workspace minus Members/Agents pill filtering; // passing the visible-issue id set lets the chip count match the list // length when the filter is on. const scopedIssueIds = useMemo( () => new Set(scopedIssues.map((i) => i.id)), [scopedIssues], ); 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, }: { 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; }) { const { t } = useT("issues"); 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 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 act = useViewStoreApi().getState(); const counts = useIssueCounts(scopedIssues); const showDateFilter = !!onDateFilterChange; const activeFilterCount = getActiveFilterCount({ statusFilters, priorityFilters, assigneeFilters, includeNoAssignee, creatorFilters, projectFilters, includeNoProject, labelFilters, dateFilter: showDateFilter ? dateFilter : null, }); const hasActiveFilters = activeFilterCount > 0; const SORT_LABEL_KEY: Record = { position: "sort_manual", priority: "sort_priority", start_date: "sort_start_date", due_date: "sort_due_date", created_at: "sort_created", 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 sortLabel = t(($) => $.display[SORT_LABEL_KEY[sortBy]]); const groupingLabel = t(($) => $.display[GROUPING_LABEL_KEY[grouping]]); const swimlaneGroupingLabel = t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[swimlaneGrouping]]); 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 */} {hasActiveFilters ? t(($) => $.filters.active_count, { count: activeFilterCount }) : t(($) => $.filters.tooltip)} {hasActiveFilters && ( {activeFilterCount} )} {hasActiveFilters && ( { e.preventDefault(); e.stopPropagation(); act.clearFilters(); onDateFilterChange?.(null); }} onPointerDown={(e) => e.stopPropagation()} > )} } /> } /> {t(($) => $.filters.tooltip)} {/* Status */} {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 */} {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 */} {t(($) => $.filters.section_assignee)} {(assigneeFilters.length > 0 || includeNoAssignee) && ( {assigneeFilters.length + (includeNoAssignee ? 1 : 0)} )} {/* Creator */} {t(($) => $.filters.section_creator)} {creatorFilters.length > 0 && ( {creatorFilters.length} )} {/* Project */} {t(($) => $.filters.section_project)} {(projectFilters.length > 0 || includeNoProject) && ( {projectFilters.length + (includeNoProject ? 1 : 0)} )} {/* Label */} {t(($) => $.filters.section_label)} {labelFilters.length > 0 && ( {labelFilters.length} )} {/* Reset */} {hasActiveFilters && ( <> { act.clearFilters(); onDateFilterChange?.(null); }} > {t(($) => $.filters.reset)} )} {/* Display settings */} {sortBy === "position" ? ( ) : sortDirection === "asc" ? ( ) : ( )} {sortLabel} } /> } /> {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]])} ))}
)} {viewMode === "swimlane" && (
{t(($) => $.display.grouping_section)}
{swimlaneGroupingLabel} } /> act.setSwimlaneGrouping(v as SwimlaneGrouping)} > {SWIMLANE_GROUPINGS.map((value) => ( {t(($) => $.display[SWIMLANE_GROUPING_LABEL_KEY[value]])} ))}
)}
{t(($) => $.display.ordering_section)}
{sortLabel} } /> act.setSortBy(v as SortField)}> {SORT_OPTIONS.map((opt) => ( {t(($) => $.display[SORT_LABEL_KEY[opt.value]])} ))} {sortBy !== "position" && ( )}
{t(($) => $.display.card_properties_section)}
{CARD_PROPERTY_OPTIONS.map((opt) => ( ))}
{/* 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 === "swimlane" ? ( ) : viewMode === "gantt" && allowGantt ? ( ) : ( )} {viewMode === "board" ? t(($) => $.view.board) : viewMode === "swimlane" ? t(($) => $.view.swimlane) : viewMode === "gantt" && allowGantt ? t(($) => $.view.gantt) : t(($) => $.view.list)} } /> } /> {viewMode === "board" ? t(($) => $.view.tooltip_board) : 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)}> {t(($) => $.view.board)} {t(($) => $.view.list)} {t(($) => $.view.swimlane)} {allowGantt && ( {t(($) => $.view.gantt)} )} )}
); }