Make the Work board filter functional
This commit is contained in:
1 parent
ea1af95536
commit
881747341e
3 files changed
+201
-3
No files matched your search
@@ -72,6 +72,51 @@
|
||||
.option-card .opt-title { font-size: 13.5px; font-weight: 540; }
|
||||
.option-card .opt-sub { font-size: 12px; color: var(--text-3); margin-top: 4px; }
|
||||
|
||||
.popover-anchor { position: relative; display: inline-flex; }
|
||||
.popover-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 35;
|
||||
min-width: 208px;
|
||||
max-width: 280px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--r-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.popover-panel.end { right: 0; }
|
||||
.popover-panel.start { left: 0; }
|
||||
.menu-label {
|
||||
padding: 6px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 540;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.menu-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
background: transparent;
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.menu-row:hover { background: var(--hover); color: var(--text-1); }
|
||||
.menu-row.active { background: var(--panel-3); color: var(--text-1); font-weight: 540; }
|
||||
.menu-row.danger { color: var(--red); }
|
||||
.menu-row.danger:hover { background: rgba(210, 59, 59, 0.1); color: var(--red); }
|
||||
.menu-row .menu-check { margin-left: auto; color: var(--accent); display: inline-flex; }
|
||||
.menu-divider { height: 1px; background: var(--border-soft); margin: 4px 0; }
|
||||
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 4px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Avatar, Button, Icon, IconButton, Pill, Segmented } from "../shared/ui";
|
||||
import { MenuDivider, MenuLabel, MenuRow, Popover } from "../shared/Popover";
|
||||
import { TopbarActions } from "../shared/TopbarActions";
|
||||
import {
|
||||
TASK_PRIORITY,
|
||||
TASK_STATUS,
|
||||
type AgentRecord,
|
||||
type GoalRecord,
|
||||
type ProviderRecord,
|
||||
type ProjectRecord,
|
||||
type TaskPriority,
|
||||
type TaskRecord,
|
||||
type TaskRunRecord,
|
||||
type TaskStatus,
|
||||
@@ -35,8 +38,23 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const [view, setView] = useState<View>("board");
|
||||
const [newTaskOpen, setNewTaskOpen] = useState(false);
|
||||
const [priorityFilter, setPriorityFilter] = useState<TaskPriority | "all">("all");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState<string>("all");
|
||||
|
||||
const tasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
|
||||
const allTasks = useMemo(() => snapshot.tasks.filter((task) => task.orgId === orgId), [snapshot.tasks, orgId]);
|
||||
const tasks = useMemo(
|
||||
() =>
|
||||
allTasks.filter((task) => {
|
||||
if (priorityFilter !== "all" && task.priority !== priorityFilter) return false;
|
||||
if (assigneeFilter !== "all" && task.assigneeAgentId !== assigneeFilter) return false;
|
||||
return true;
|
||||
}),
|
||||
[allTasks, priorityFilter, assigneeFilter],
|
||||
);
|
||||
const assigneeCount = useMemo(
|
||||
() => new Set(tasks.map((task) => task.assigneeAgentId).filter((id): id is string => id !== null)).size,
|
||||
[tasks],
|
||||
);
|
||||
const agents = useMemo(() => snapshot.agents.filter((agent) => agent.orgId === orgId), [snapshot.agents, orgId]);
|
||||
const projects = useMemo(() => snapshot.projects.filter((project) => project.orgId === orgId), [snapshot.projects, orgId]);
|
||||
const goals = useMemo(() => snapshot.goals.filter((goal) => goal.orgId === orgId), [snapshot.goals, orgId]);
|
||||
@@ -57,12 +75,18 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
<div className="page-wide">
|
||||
<TopbarActions>
|
||||
<Segmented value={view} onChange={setView} options={VIEW_OPTIONS} />
|
||||
<Button tone="ghost"><Icon name="filter" size={13} /></Button>
|
||||
<WorkFilter
|
||||
agents={agents}
|
||||
priority={priorityFilter}
|
||||
assignee={assigneeFilter}
|
||||
onPriority={setPriorityFilter}
|
||||
onAssignee={setAssigneeFilter}
|
||||
/>
|
||||
<Button tone="primary" onClick={() => setNewTaskOpen(true)}><Icon name="plus" size={13} /> New task</Button>
|
||||
</TopbarActions>
|
||||
<h1 className="page-h1">Work</h1>
|
||||
<p className="page-sub">
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {agents.length} {agents.length === 1 ? "assignee" : "assignees"}
|
||||
{tasks.length} {tasks.length === 1 ? "task" : "tasks"} · {assigneeCount} {assigneeCount === 1 ? "assignee" : "assignees"}
|
||||
</p>
|
||||
|
||||
{view === "board" && (
|
||||
@@ -87,6 +111,61 @@ export function WorkPage({ snapshot, actions }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WorkFilter({
|
||||
agents,
|
||||
priority,
|
||||
assignee,
|
||||
onPriority,
|
||||
onAssignee,
|
||||
}: {
|
||||
agents: AgentRecord[];
|
||||
priority: TaskPriority | "all";
|
||||
assignee: string;
|
||||
onPriority: (next: TaskPriority | "all") => void;
|
||||
onAssignee: (next: string) => void;
|
||||
}) {
|
||||
const active = priority !== "all" || assignee !== "all";
|
||||
const priorities: ReadonlyArray<{ id: TaskPriority | "all"; label: string }> = [
|
||||
{ id: "all", label: "All priorities" },
|
||||
{ id: TASK_PRIORITY.HIGH, label: "High" },
|
||||
{ id: TASK_PRIORITY.NORMAL, label: "Normal" },
|
||||
{ id: TASK_PRIORITY.LOW, label: "Low" },
|
||||
];
|
||||
return (
|
||||
<Popover
|
||||
trigger={({ toggle }) => (
|
||||
<button type="button" className="button ghost" aria-label="Filter tasks" onClick={toggle}>
|
||||
<Icon name="filter" size={13} color={active ? "var(--accent)" : "currentColor"} />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{() => (
|
||||
<>
|
||||
<MenuLabel>Priority</MenuLabel>
|
||||
{priorities.map((option) => (
|
||||
<MenuRow key={option.id} active={priority === option.id} onClick={() => onPriority(option.id)}>
|
||||
{option.label}
|
||||
{priority === option.id && <span className="menu-check"><Icon name="check" size={13} /></span>}
|
||||
</MenuRow>
|
||||
))}
|
||||
<MenuDivider />
|
||||
<MenuLabel>Assignee</MenuLabel>
|
||||
<MenuRow active={assignee === "all"} onClick={() => onAssignee("all")}>
|
||||
All assignees
|
||||
{assignee === "all" && <span className="menu-check"><Icon name="check" size={13} /></span>}
|
||||
</MenuRow>
|
||||
{agents.map((agent) => (
|
||||
<MenuRow key={agent.id} active={assignee === agent.id} onClick={() => onAssignee(agent.id)}>
|
||||
{agent.name}
|
||||
{assignee === agent.id && <span className="menu-check"><Icon name="check" size={13} /></span>}
|
||||
</MenuRow>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardView({
|
||||
tasks,
|
||||
agents,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/** Anchored popover used for filter and overflow menus. Closes on outside
|
||||
* click or Escape so triggers across the app stay functional and consistent. */
|
||||
export function Popover({
|
||||
align = "end",
|
||||
trigger,
|
||||
children,
|
||||
}: {
|
||||
align?: "start" | "end";
|
||||
trigger: (state: { open: boolean; toggle: () => void }) => ReactNode;
|
||||
children: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (anchorRef.current && !anchorRef.current.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onPointerDown);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="popover-anchor" ref={anchorRef}>
|
||||
{trigger({ open, toggle: () => setOpen((value) => !value) })}
|
||||
{open && (
|
||||
<div className={`popover-panel ${align}`.trim()} role="menu">
|
||||
{children(() => setOpen(false))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuLabel({ children }: { children: ReactNode }) {
|
||||
return <div className="menu-label">{children}</div>;
|
||||
}
|
||||
|
||||
export function MenuRow({
|
||||
active = false,
|
||||
danger = false,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`menu-row ${active ? "active" : ""} ${danger ? "danger" : ""}`.replace(/\s+/g, " ").trim()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MenuDivider() {
|
||||
return <div className="menu-divider" />;
|
||||
}
|
||||
Reference in new issue
Block a user