diff --git a/ui/src/components/IssuesList.tsx b/ui/src/components/IssuesList.tsx index c99c9b7c..0b094674 100644 --- a/ui/src/components/IssuesList.tsx +++ b/ui/src/components/IssuesList.tsx @@ -1,6 +1,10 @@ import { useMemo, useState, useCallback } from "react"; import { Link } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { useDialog } from "../context/DialogContext"; +import { useCompany } from "../context/CompanyContext"; +import { issuesApi } from "../api/issues"; +import { queryKeys } from "../lib/queryKeys"; import { groupBy } from "../lib/groupBy"; import { formatDate } from "../lib/utils"; import { StatusIcon } from "./StatusIcon"; @@ -11,7 +15,8 @@ import { Button } from "@/components/ui/button"; import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"; import { Checkbox } from "@/components/ui/checkbox"; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/ui/collapsible"; -import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight } from "lucide-react"; +import { CircleDot, Plus, Filter, ArrowUpDown, Layers, Check, X, ChevronRight, List, Columns3 } from "lucide-react"; +import { KanbanBoard } from "./KanbanBoard"; import type { Issue } from "@paperclip/shared"; /* ── Helpers ── */ @@ -32,6 +37,7 @@ export type IssueViewState = { sortField: "status" | "priority" | "title" | "created" | "updated"; sortDir: "asc" | "desc"; groupBy: "status" | "priority" | "assignee" | "none"; + viewMode: "list" | "board"; }; const defaultViewState: IssueViewState = { @@ -41,6 +47,7 @@ const defaultViewState: IssueViewState = { sortField: "status", sortDir: "asc", groupBy: "status", + viewMode: "list", }; const quickFilterPresets = [ @@ -215,6 +222,24 @@ export function IssuesList({
+ {/* View mode toggle */} +
+ + +
+ {/* Filter */} @@ -335,85 +360,89 @@ export function IssuesList({ - {/* Sort */} - - - - - -
- {([ - ["status", "Status"], - ["priority", "Priority"], - ["title", "Title"], - ["created", "Created"], - ["updated", "Updated"], - ] as const).map(([field, label]) => ( - - ))} -
-
-
+ {/* Sort (list view only) */} + {viewState.viewMode === "list" && ( + + + + + +
+ {([ + ["status", "Status"], + ["priority", "Priority"], + ["title", "Title"], + ["created", "Created"], + ["updated", "Updated"], + ] as const).map(([field, label]) => ( + + ))} +
+
+
+ )} - {/* Group */} - - - - - -
- {([ - ["status", "Status"], - ["priority", "Priority"], - ["assignee", "Assignee"], - ["none", "None"], - ] as const).map(([value, label]) => ( - - ))} -
-
-
+ {/* Group (list view only) */} + {viewState.viewMode === "list" && ( + + + + + +
+ {([ + ["status", "Status"], + ["priority", "Priority"], + ["assignee", "Assignee"], + ["none", "None"], + ] as const).map(([value, label]) => ( + + ))} +
+
+
+ )}
{isLoading &&

Loading...

} {error &&

{error.message}

} - {!isLoading && filtered.length === 0 && ( + {!isLoading && filtered.length === 0 && viewState.viewMode === "list" && ( )} - {groupedContent.map((group) => ( - - {group.label && ( -
- - - - {group.label} - - - -
- )} - - {group.items.map((issue) => ( - - {/* Spacer matching caret width so status icon aligns with group title */} -
-
{ e.preventDefault(); e.stopPropagation(); }}> - onUpdateIssue(issue.id, { status: s })} - /> -
- - {issue.identifier ?? issue.id.slice(0, 8)} - - {issue.title} -
- {liveIssueIds?.has(issue.id) && ( - - - - - - Live - - )} - {issue.assigneeAgentId && (() => { - const name = agentName(issue.assigneeAgentId); - return name - ? - : {issue.assigneeAgentId.slice(0, 8)}; - })()} - - {formatDate(issue.createdAt)} + {viewState.viewMode === "board" ? ( + + ) : ( + groupedContent.map((group) => ( + + {group.label && ( +
+ + + + {group.label} -
- - ))} - -
- ))} + + +
+ )} + + {group.items.map((issue) => ( + + {/* Spacer matching caret width so status icon aligns with group title */} +
+
{ e.preventDefault(); e.stopPropagation(); }}> + onUpdateIssue(issue.id, { status: s })} + /> +
+ + {issue.identifier ?? issue.id.slice(0, 8)} + + {issue.title} +
+ {liveIssueIds?.has(issue.id) && ( + + + + + + Live + + )} + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name + ? + : {issue.assigneeAgentId.slice(0, 8)}; + })()} + + {formatDate(issue.createdAt)} + +
+ + ))} + + + )) + )}
); } diff --git a/ui/src/components/KanbanBoard.tsx b/ui/src/components/KanbanBoard.tsx new file mode 100644 index 00000000..5b47ed83 --- /dev/null +++ b/ui/src/components/KanbanBoard.tsx @@ -0,0 +1,274 @@ +import { useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + type DragStartEvent, + type DragEndEvent, + type DragOverEvent, +} from "@dnd-kit/core"; +import { useDroppable } from "@dnd-kit/core"; +import { CSS } from "@dnd-kit/utilities"; +import { + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { StatusIcon } from "./StatusIcon"; +import { PriorityIcon } from "./PriorityIcon"; +import { Identity } from "./Identity"; +import type { Issue } from "@paperclip/shared"; + +const boardStatuses = [ + "backlog", + "todo", + "in_progress", + "in_review", + "blocked", + "done", + "cancelled", +]; + +function statusLabel(status: string): string { + return status.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +interface Agent { + id: string; + name: string; +} + +interface KanbanBoardProps { + issues: Issue[]; + agents?: Agent[]; + liveIssueIds?: Set; + onUpdateIssue: (id: string, data: Record) => void; +} + +/* ── Droppable Column ── */ + +function KanbanColumn({ + status, + issues, + agents, + liveIssueIds, +}: { + status: string; + issues: Issue[]; + agents?: Agent[]; + liveIssueIds?: Set; +}) { + const { setNodeRef, isOver } = useDroppable({ id: status }); + + return ( +
+
+ + + {statusLabel(status)} + + + {issues.length} + +
+
+ i.id)} + strategy={verticalListSortingStrategy} + > + {issues.map((issue) => ( + + ))} + +
+
+ ); +} + +/* ── Draggable Card ── */ + +function KanbanCard({ + issue, + agents, + isLive, + isOverlay, +}: { + issue: Issue; + agents?: Agent[]; + isLive?: boolean; + isOverlay?: boolean; +}) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: issue.id, data: { issue } }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const agentName = (id: string | null) => { + if (!id || !agents) return null; + return agents.find((a) => a.id === id)?.name ?? null; + }; + + return ( +
+ { + // Prevent navigation during drag + if (isDragging) e.preventDefault(); + }} + > +
+ + {issue.identifier ?? issue.id.slice(0, 8)} + + {isLive && ( + + + + + )} +
+

{issue.title}

+
+ + {issue.assigneeAgentId && (() => { + const name = agentName(issue.assigneeAgentId); + return name ? ( + + ) : ( + + {issue.assigneeAgentId.slice(0, 8)} + + ); + })()} +
+ +
+ ); +} + +/* ── Main Board ── */ + +export function KanbanBoard({ + issues, + agents, + liveIssueIds, + onUpdateIssue, +}: KanbanBoardProps) { + const [activeId, setActiveId] = useState(null); + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) + ); + + const columnIssues = useMemo(() => { + const grouped: Record = {}; + for (const status of boardStatuses) { + grouped[status] = []; + } + for (const issue of issues) { + if (grouped[issue.status]) { + grouped[issue.status].push(issue); + } + } + return grouped; + }, [issues]); + + const activeIssue = useMemo( + () => (activeId ? issues.find((i) => i.id === activeId) : null), + [activeId, issues] + ); + + function handleDragStart(event: DragStartEvent) { + setActiveId(event.active.id as string); + } + + function handleDragEnd(event: DragEndEvent) { + setActiveId(null); + const { active, over } = event; + if (!over) return; + + const issueId = active.id as string; + const issue = issues.find((i) => i.id === issueId); + if (!issue) return; + + // Determine target status: the "over" could be a column id (status string) + // or another card's id. Find which column the "over" belongs to. + let targetStatus: string | null = null; + + if (boardStatuses.includes(over.id as string)) { + targetStatus = over.id as string; + } else { + // It's a card - find which column it's in + const targetIssue = issues.find((i) => i.id === over.id); + if (targetIssue) { + targetStatus = targetIssue.status; + } + } + + if (targetStatus && targetStatus !== issue.status) { + onUpdateIssue(issueId, { status: targetStatus }); + } + } + + function handleDragOver(_event: DragOverEvent) { + // Could be used for visual feedback; keeping simple for now + } + + return ( + +
+ {boardStatuses.map((status) => ( + + ))} +
+ + {activeIssue ? ( + + ) : null} + +
+ ); +}