diff --git a/CLAUDE.md b/CLAUDE.md index eca2f19c..87be1702 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,12 +56,19 @@ docker compose down # Stop PostgreSQL - Prefer existing patterns/components over introducing parallel abstractions. - Avoid broad refactors unless required by the task. -## 5. Testing Rules +## 5. UI/UX Rules + +- Prefer `packages/ui` shadcn components over custom implementations. +- Do not introduce extra state (useState, context, reducers) unless explicitly required by the design. +- Pay close attention to **overflow** (truncate long text, scrollable containers), **alignment**, and **spacing** consistency. +- When unsure about interaction or state design, ask — the user will provide direction. + +## 6. Testing Rules - **TypeScript**: Vitest. Mock external/third-party dependencies only. - **Go**: Standard `go test`. Use testcontainers or test database for DB tests. -## 6. Commit Rules +## 7. Commit Rules - Use atomic commits grouped by logical intent. - Conventional format: @@ -72,7 +79,7 @@ docker compose down # Stop PostgreSQL - `test(scope): ...` - `chore(scope): ...` -## 7. Minimum Pre-Push Checks +## 8. Minimum Pre-Push Checks ```bash make check # Runs all checks: typecheck, unit tests, Go tests, E2E @@ -86,7 +93,7 @@ make test # Go tests only pnpm exec playwright test # E2E only (requires backend + frontend running) ``` -## 8. AI Agent Verification Loop +## 9. AI Agent Verification Loop After writing or modifying code, always run the full verification pipeline: @@ -109,7 +116,7 @@ This runs all checks in sequence: **Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. -## 9. E2E Test Patterns +## 10. E2E Test Patterns E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: diff --git a/apps/web/app/(dashboard)/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/_components/app-sidebar.tsx new file mode 100644 index 00000000..406f4713 --- /dev/null +++ b/apps/web/app/(dashboard)/_components/app-sidebar.tsx @@ -0,0 +1,295 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { + Inbox, + ListTodo, + Bot, + BookOpen, + ChevronDown, + Settings, + LogOut, + Plus, + Check, +} from "lucide-react"; +import { MulticaIcon } from "@multica/ui/components/multica-icon"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@multica/ui/components/ui/sidebar"; +import { useAuth } from "../../../lib/auth-context"; +import { useTabStore } from "../../../lib/tab-store"; + +const navItems = [ + { href: "/inbox", label: "Inbox", icon: Inbox, iconKey: "inbox" }, + { href: "/agents", label: "Agents", icon: Bot, iconKey: "agents" }, + { href: "/issues", label: "Issues", icon: ListTodo, iconKey: "issues" }, + { + href: "/knowledge-base", + label: "Knowledge Base", + icon: BookOpen, + iconKey: "knowledge-base", + }, +]; + +export function AppSidebar() { + const pathname = usePathname(); + const { + user, + workspace, + workspaces, + logout, + switchWorkspace, + createWorkspace, + } = useAuth(); + const { openTab } = useTabStore(); + + const [showMenu, setShowMenu] = useState(false); + const [showCreateDialog, setShowCreateDialog] = useState(false); + const [newName, setNewName] = useState(""); + const [newSlug, setNewSlug] = useState(""); + const [creating, setCreating] = useState(false); + + const handleNameChange = (value: string) => { + setNewName(value); + setNewSlug( + value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + ); + }; + + const handleCreateWorkspace = async () => { + if (!newName.trim() || !newSlug.trim()) return; + setCreating(true); + try { + const ws = await createWorkspace({ + name: newName.trim(), + slug: newSlug.trim(), + }); + setShowCreateDialog(false); + setNewName(""); + setNewSlug(""); + await switchWorkspace(ws.id); + } catch (err) { + console.error("Failed to create workspace:", err); + } finally { + setCreating(false); + } + }; + + return ( + <> + + {/* Workspace Switcher */} + + + + setShowMenu(!showMenu)}> + + + {workspace?.name ?? "Multica"} + + + + + + + {showMenu && ( + <> +
setShowMenu(false)} + /> +
+
+ {user?.email} +
+
+
+ Workspaces +
+ {workspaces.map((ws) => ( + + ))} + +
+ setShowMenu(false)} + className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent" + > + + Settings + + +
+ + )} + + + {/* Navigation */} + + + + + {navItems.map((item) => { + const isActive = + pathname === item.href || + pathname.startsWith(item.href + "/"); + return ( + + } + onClick={() => + openTab(item.href, item.label, { + replace: true, + iconKey: item.iconKey, + }) + } + > + + {item.label} + + + ); + })} + + + + + + {/* User */} + + {user && ( + + + +
+ {user.name + .split(" ") + .map((w) => w[0]) + .join("") + .toUpperCase() + .slice(0, 2)} +
+ {user.name} +
+
+
+ )} +
+ + + {/* Create Workspace Dialog */} + {showCreateDialog && ( + <> +
setShowCreateDialog(false)} + /> +
+
+

+ Create workspace +

+

+ Create a new workspace for your team. +

+
+
+
+ + handleNameChange(e.target.value)} + placeholder="My Workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setNewSlug(e.target.value)} + placeholder="my-workspace" + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+
+ + +
+
+ + )} + + ); +} diff --git a/apps/web/app/(dashboard)/_components/tab-bar.tsx b/apps/web/app/(dashboard)/_components/tab-bar.tsx new file mode 100644 index 00000000..7b5d5327 --- /dev/null +++ b/apps/web/app/(dashboard)/_components/tab-bar.tsx @@ -0,0 +1,271 @@ +"use client"; + +import { useCallback, useState, useEffect, useRef } from "react"; +import { + DndContext, + PointerSensor, + useSensor, + useSensors, + closestCenter, + type DragEndEvent, +} from "@dnd-kit/core"; +import { + SortableContext, + horizontalListSortingStrategy, + useSortable, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + Plus, + X, + Inbox, + Bot, + ListTodo, + BookOpen, + Settings, + FileText, +} from "lucide-react"; +import { useTabStore, type Tab } from "../../../lib/tab-store"; + +// --------------------------------------------------------------------------- +// Icon lookup +// --------------------------------------------------------------------------- + +const TAB_ICONS: Record = { + inbox: Inbox, + agents: Bot, + issues: ListTodo, + "knowledge-base": BookOpen, + settings: Settings, +}; + +function TabIcon({ iconKey }: { iconKey?: string }) { + const Icon = iconKey ? TAB_ICONS[iconKey] : undefined; + if (!Icon) return ; + return ; +} + +// --------------------------------------------------------------------------- +// Context Menu +// --------------------------------------------------------------------------- + +function TabContextMenu({ + x, + y, + tabId, + onClose, +}: { + x: number; + y: number; + tabId: string; + onClose: () => void; +}) { + const { tabs, closeTab } = useTabStore(); + const menuRef = useRef(null); + const canClose = tabs.length > 1; + + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + const handleEsc = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", handleClick); + document.addEventListener("keydown", handleEsc); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleEsc); + }; + }, [onClose]); + + const handleClose = () => { + if (canClose) closeTab(tabId); + onClose(); + }; + + const handleCloseOthers = () => { + tabs.forEach((t) => { + if (t.id !== tabId && tabs.length > 1) closeTab(t.id); + }); + onClose(); + }; + + return ( +
+ + +
+ ); +} + +// --------------------------------------------------------------------------- +// SortableTab +// --------------------------------------------------------------------------- + +function SortableTab({ + tab, + isActive, + canClose, + onContextMenu, +}: { + tab: Tab; + isActive: boolean; + canClose: boolean; + onContextMenu: (e: React.MouseEvent, tabId: string) => void; +}) { + const { activateTab, closeTab } = useTabStore(); + + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: tab.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + + const handleClick = () => { + if (!isDragging) { + activateTab(tab.id); + } + }; + + const handleClose = (e: React.MouseEvent) => { + e.stopPropagation(); + closeTab(tab.id); + }; + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// TabBar +// --------------------------------------------------------------------------- + +export function TabBar() { + const { tabs, activeTabId, reorderTabs, openTab } = useTabStore(); + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + tabId: string; + } | null>(null); + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 5 }, + }) + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (!over || active.id === over.id) return; + const oldIndex = tabs.findIndex((t) => t.id === active.id); + const newIndex = tabs.findIndex((t) => t.id === over.id); + if (oldIndex !== -1 && newIndex !== -1) { + reorderTabs(oldIndex, newIndex); + } + }, + [tabs, reorderTabs] + ); + + const handleNewTab = () => { + openTab("/issues", "All Issues", { replace: false, iconKey: "issues" }); + }; + + const handleContextMenu = (e: React.MouseEvent, tabId: string) => { + e.preventDefault(); + setContextMenu({ x: e.clientX, y: e.clientY, tabId }); + }; + + return ( +
+ + t.id)} + strategy={horizontalListSortingStrategy} + > + {tabs.map((tab) => ( + 1} + onContextMenu={handleContextMenu} + /> + ))} + + + + + {contextMenu && ( + setContextMenu(null)} + /> + )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/_components/tab-link.tsx b/apps/web/app/(dashboard)/_components/tab-link.tsx new file mode 100644 index 00000000..5f8a50fa --- /dev/null +++ b/apps/web/app/(dashboard)/_components/tab-link.tsx @@ -0,0 +1,30 @@ +"use client"; + +import Link from "next/link"; +import { useTabStore } from "../../../lib/tab-store"; + +export function TabLink({ + href, + title, + iconKey, + children, + ...props +}: { + href: string; + title: string; + iconKey?: string; + children: React.ReactNode; +} & Omit, "onClick" | "href">) { + const { openTab } = useTabStore(); + + const handleClick = (e: React.MouseEvent) => { + e.preventDefault(); + openTab(href, title, { replace: false, iconKey }); + }; + + return ( + + {children} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx index e2c13db9..7344ff45 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx @@ -50,16 +50,42 @@ vi.mock("../../../../lib/auth-context", () => ({ }), })); +// Mock ws-context +vi.mock("../../../../lib/ws-context", () => ({ + useWSEvent: () => {}, +})); + +// Mock @multica/ui calendar (react-day-picker needs browser APIs) +vi.mock("@multica/ui/components/ui/calendar", () => ({ + Calendar: () => null, +})); + +// Mock tab-store +vi.mock("../../../../lib/tab-store", () => ({ + useTabStore: () => ({ + updateTabTitle: vi.fn(), + activeTabId: "tab-1", + }), +})); + // Mock api const mockGetIssue = vi.hoisted(() => vi.fn()); const mockListComments = vi.hoisted(() => vi.fn()); const mockCreateComment = vi.hoisted(() => vi.fn()); +const mockUpdateComment = vi.hoisted(() => vi.fn()); +const mockDeleteComment = vi.hoisted(() => vi.fn()); +const mockDeleteIssue = vi.hoisted(() => vi.fn()); +const mockUpdateIssue = vi.hoisted(() => vi.fn()); vi.mock("../../../../lib/api", () => ({ api: { getIssue: (...args: any[]) => mockGetIssue(...args), listComments: (...args: any[]) => mockListComments(...args), createComment: (...args: any[]) => mockCreateComment(...args), + updateComment: (...args: any[]) => mockUpdateComment(...args), + deleteComment: (...args: any[]) => mockDeleteComment(...args), + deleteIssue: (...args: any[]) => mockDeleteIssue(...args), + updateIssue: (...args: any[]) => mockUpdateIssue(...args), }, })); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index a10f1267..c6789ed1 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -1,19 +1,43 @@ "use client"; -import { use, useState, useEffect, useRef } from "react"; +import { use, useState, useEffect, useCallback } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { Bot, ChevronRight, + GitBranch, + Link2, + Pencil, Send, - UserCircle, + Trash2, X, } from "lucide-react"; -import type { Issue, Comment, IssueAssigneeType } from "@multica/types"; -import { STATUS_CONFIG, PRIORITY_CONFIG } from "../_data/config"; -import { StatusIcon, PriorityIcon } from "../page"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@multica/ui/components/ui/alert-dialog"; +import { Calendar } from "@multica/ui/components/ui/calendar"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; +import type { Issue, Comment, UpdateIssueRequest } from "@multica/types"; +import { StatusPicker, PriorityPicker, AssigneePicker } from "../_components"; import { api } from "../../../../lib/api"; import { useAuth } from "../../../../lib/auth-context"; +import { useWSEvent } from "../../../../lib/ws-context"; +import { useTabStore } from "../../../../lib/tab-store"; +import type { CommentCreatedPayload, CommentUpdatedPayload, CommentDeletedPayload } from "@multica/types"; // --------------------------------------------------------------------------- // Helpers @@ -81,19 +105,12 @@ function ActorAvatar({ function PropRow({ label, children, - onClick, }: { label: string; children: React.ReactNode; - onClick?: () => void; }) { return ( -
+
{label}
{children} @@ -103,150 +120,285 @@ function PropRow({ } // --------------------------------------------------------------------------- -// Assignee Picker +// Due Date Picker // --------------------------------------------------------------------------- -function AssigneePicker({ - issue, - onSelect, - onClose, +function DueDatePicker({ + dueDate, + onUpdate, }: { - issue: Issue; - onSelect: (type: IssueAssigneeType | null, id: string | null) => void; - onClose: () => void; + dueDate: string | null; + onUpdate: (updates: Partial) => void; }) { - const { members, agents } = useAuth(); - const [search, setSearch] = useState(""); - const ref = useRef(null); - const inputRef = useRef(null); - - useEffect(() => { - inputRef.current?.focus(); - }, []); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) { - onClose(); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onClose]); - - const q = search.toLowerCase(); - const filteredMembers = members.filter((m) => - m.name.toLowerCase().includes(q) || m.email.toLowerCase().includes(q), - ); - const filteredAgents = agents.filter((a) => - a.name.toLowerCase().includes(q), - ); - - const isSelected = (type: string, id: string) => - issue.assignee_type === type && issue.assignee_id === id; + const [open, setOpen] = useState(false); + const date = dueDate ? new Date(dueDate) : undefined; + const isOverdue = date ? date < new Date() : false; return ( -
-
- setSearch(e.target.value)} - placeholder="Search..." - className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs focus:outline-none focus:ring-2 focus:ring-ring" + + + {date ? ( + + {date.toLocaleDateString("en-US", { month: "short", day: "numeric" })} + + ) : ( + None + )} + + + { + onUpdate({ due_date: d ? d.toISOString() : null }); + setOpen(false); + }} /> -
- -
- {/* Unassign option */} - {issue.assignee_id && ( - <> + {date && ( +
-
- - )} - - {/* Members */} - {filteredMembers.length > 0 && ( - <> -
- Members -
- {filteredMembers.map((m) => ( - - ))} - - )} - - {/* Agents */} - {filteredAgents.length > 0 && ( - <> -
- Agents -
- {filteredAgents.map((a) => ( - - ))} - - )} - - {filteredMembers.length === 0 && filteredAgents.length === 0 && ( -
- No results found
)} + + + ); +} + +// --------------------------------------------------------------------------- +// Acceptance Criteria Editor +// --------------------------------------------------------------------------- + +function AcceptanceCriteriaEditor({ + criteria, + onUpdate, +}: { + criteria: string[]; + onUpdate: (updates: Partial) => void; +}) { + const [newItem, setNewItem] = useState(""); + + const addItem = () => { + if (!newItem.trim()) return; + onUpdate({ acceptance_criteria: [...criteria, newItem.trim()] }); + setNewItem(""); + }; + + const removeItem = (index: number) => { + onUpdate({ acceptance_criteria: criteria.filter((_, i) => i !== index) }); + }; + + if (criteria.length === 0 && !newItem) { + return null; + } + + return ( +
+

Acceptance Criteria

+
+ {criteria.map((item, i) => ( +
+ + {item} + +
+ ))}
+
{ e.preventDefault(); addItem(); }} + className="flex items-center gap-2" + > + setNewItem(e.target.value)} + placeholder="Add criteria..." + className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" + /> +
); } +// --------------------------------------------------------------------------- +// Context Refs Editor +// --------------------------------------------------------------------------- + +function ContextRefsEditor({ + refs, + onUpdate, +}: { + refs: string[]; + onUpdate: (updates: Partial) => void; +}) { + const [newRef, setNewRef] = useState(""); + + const addRef = () => { + if (!newRef.trim()) return; + onUpdate({ context_refs: [...refs, newRef.trim()] }); + setNewRef(""); + }; + + const removeRef = (index: number) => { + onUpdate({ context_refs: refs.filter((_, i) => i !== index) }); + }; + + if (refs.length === 0 && !newRef) { + return null; + } + + const isUrl = (s: string) => s.startsWith("http://") || s.startsWith("https://"); + + return ( +
+

Context References

+
+ {refs.map((ref, i) => ( +
+ + {isUrl(ref) ? ( + + {ref} + + ) : ( + {ref} + )} + +
+ ))} +
+
{ e.preventDefault(); addRef(); }} + className="flex items-center gap-2" + > + setNewRef(e.target.value)} + placeholder="Add reference URL..." + className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground" + /> +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Repository Editor +// --------------------------------------------------------------------------- + +function RepositoryEditor({ + repository, + onUpdate, +}: { + repository: { url: string; branch?: string; path?: string } | null; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [branch, setBranch] = useState(""); + const [path, setPath] = useState(""); + + const handleOpen = (v: boolean) => { + if (v) { + setUrl(repository?.url ?? ""); + setBranch(repository?.branch ?? ""); + setPath(repository?.path ?? ""); + } + setOpen(v); + }; + + const save = () => { + if (!url.trim()) { + onUpdate({ repository: null }); + } else { + onUpdate({ + repository: { + url: url.trim(), + branch: branch.trim() || undefined, + path: path.trim() || undefined, + }, + }); + } + setOpen(false); + }; + + const clear = () => { + onUpdate({ repository: null }); + setOpen(false); + }; + + return ( + + + {repository ? ( + <> + + {repository.branch ?? "main"} + + ) : ( + None + )} + + +
Repository
+
+ setUrl(e.target.value)} + placeholder="https://github.com/org/repo" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + autoFocus + /> + setBranch(e.target.value)} + placeholder="Branch" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + /> + setPath(e.target.value)} + placeholder="Path" + className="w-full rounded-md border bg-background px-2.5 py-1.5 text-xs outline-none focus:ring-1 focus:ring-ring" + /> +
+
+ {repository && ( + + )} + +
+
+
+ ); +} + // --------------------------------------------------------------------------- // Page // --------------------------------------------------------------------------- @@ -257,13 +409,17 @@ export default function IssueDetailPage({ params: Promise<{ id: string }>; }) { const { id } = use(params); - const { getActorName } = useAuth(); + const router = useRouter(); + const { user, getActorName } = useAuth(); + const { updateTabTitle, activeTabId, closeTabByPath } = useTabStore(); const [issue, setIssue] = useState(null); const [comments, setComments] = useState([]); const [loading, setLoading] = useState(true); const [commentText, setCommentText] = useState(""); const [submitting, setSubmitting] = useState(false); - const [showAssigneePicker, setShowAssigneePicker] = useState(false); + const [deleting, setDeleting] = useState(false); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editContent, setEditContent] = useState(""); useEffect(() => { setIssue(null); @@ -278,6 +434,13 @@ export default function IssueDetailPage({ .finally(() => setLoading(false)); }, [id]); + // Sync tab title with loaded issue title + useEffect(() => { + if (issue?.title && activeTabId) { + updateTabTitle(activeTabId, issue.title); + } + }, [issue?.title, activeTabId, updateTabTitle]); + const handleSubmitComment = async (e: React.FormEvent) => { e.preventDefault(); if (!commentText.trim() || submitting) return; @@ -293,31 +456,92 @@ export default function IssueDetailPage({ } }; - const handleAssigneeChange = async ( - type: IssueAssigneeType | null, - assigneeId: string | null, - ) => { - if (!issue) return; - setShowAssigneePicker(false); - // Optimistic update - setIssue({ - ...issue, - assignee_type: type, - assignee_id: assigneeId, - }); - try { - const updated = await api.updateIssue(id, { - assignee_type: type, - assignee_id: assigneeId, + const handleUpdateField = useCallback( + (updates: Partial) => { + if (!issue) return; + const prev = issue; + setIssue((curr) => (curr ? ({ ...curr, ...updates } as Issue) : curr)); + api.updateIssue(id, updates).catch(() => { + setIssue(prev); + toast.error("Failed to update issue"); }); - setIssue(updated); - } catch (err) { - console.error("Failed to update assignee:", err); - // Revert on error - setIssue(issue); + }, + [issue, id], + ); + + const handleDelete = async () => { + setDeleting(true); + try { + await api.deleteIssue(issue!.id); + toast.success("Issue deleted"); + closeTabByPath(`/issues/${id}`); + router.push("/issues"); + } catch { + toast.error("Failed to delete issue"); + setDeleting(false); } }; + const startEditComment = (c: Comment) => { + setEditingCommentId(c.id); + setEditContent(c.content); + }; + + const handleSaveEditComment = async () => { + if (!editingCommentId || !editContent.trim()) return; + try { + const updated = await api.updateComment(editingCommentId, editContent.trim()); + setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c))); + setEditingCommentId(null); + } catch { + toast.error("Failed to update comment"); + } + }; + + const handleDeleteComment = async (commentId: string) => { + try { + await api.deleteComment(commentId); + setComments((prev) => prev.filter((c) => c.id !== commentId)); + } catch { + toast.error("Failed to delete comment"); + } + }; + + // Real-time comment updates + useWSEvent( + "comment:created", + useCallback((payload: unknown) => { + const { comment } = payload as CommentCreatedPayload; + if (comment.issue_id !== id) return; + // Skip own comments — already added locally via API response + if (comment.author_type === "member" && comment.author_id === user?.id) return; + setComments((prev) => { + if (prev.some((c) => c.id === comment.id)) return prev; + return [...prev, comment]; + }); + }, [id, user?.id]), + ); + + useWSEvent( + "comment:updated", + useCallback((payload: unknown) => { + const { comment } = payload as CommentUpdatedPayload; + if (comment.issue_id === id) { + setComments((prev) => prev.map((c) => (c.id === comment.id ? comment : c))); + } + }, [id]), + ); + + useWSEvent( + "comment:deleted", + useCallback((payload: unknown) => { + const { comment_id, issue_id } = payload as CommentDeletedPayload; + if (issue_id === id) { + setComments((prev) => prev.filter((c) => c.id !== comment_id)); + } + }, [id]), + ); + if (loading) { return (
@@ -334,25 +558,47 @@ export default function IssueDetailPage({ ); } - const statusCfg = STATUS_CONFIG[issue.status]; - const priorityCfg = PRIORITY_CONFIG[issue.priority]; - const isOverdue = - issue.due_date && new Date(issue.due_date) < new Date() && issue.status !== "done"; - return (
{/* LEFT: Content area */}
{/* Header bar */} -
- - Issues - - - {issue.id.slice(0, 8)} +
+
+ + Issues + + + {issue.id.slice(0, 8)} +
+ + } + > + + + + + Delete issue + + This will permanently delete this issue and all its comments. This action cannot be undone. + + + + Cancel + + {deleting ? "Deleting..." : "Delete"} + + + +
{/* Content */} @@ -369,6 +615,19 @@ export default function IssueDetailPage({
)} + {(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && ( +
+ + +
+ )} +
{/* Activity / Comments */} @@ -376,26 +635,57 @@ export default function IssueDetailPage({

Activity

- {comments.map((comment) => ( -
-
- - - {getActorName(comment.author_type, comment.author_id)} - - - {timeAgo(comment.created_at)} - + {comments.map((comment) => { + const isOwn = comment.author_type === "member" && comment.author_id === user?.id; + return ( +
+
+ + + {getActorName(comment.author_type, comment.author_id)} + + + {timeAgo(comment.created_at)} + + {isOwn && ( +
+ + +
+ )} +
+ {editingCommentId === comment.id ? ( +
{ e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-[38px]"> + setEditContent(e.target.value)} + className="w-full text-[13px] bg-transparent border-b outline-none" + onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }} + /> +
+ ) : ( +
+ {comment.content} +
+ )}
-
- {comment.content} -
-
- ))} + ); + })}
{/* Comment input */} @@ -430,51 +720,27 @@ export default function IssueDetailPage({
- - {statusCfg.label} + - - {priorityCfg.label} + -
- setShowAssigneePicker(!showAssigneePicker)} - > - {issue.assignee_type && issue.assignee_id ? ( - <> - - {getActorName(issue.assignee_type, issue.assignee_id)} - - ) : ( - Unassigned - )} - - - {showAssigneePicker && ( - setShowAssigneePicker(false)} - /> - )} -
+ + + - {issue.due_date ? ( - - {shortDate(issue.due_date)} - - ) : ( - None - )} + + + + + diff --git a/apps/web/app/(dashboard)/issues/_components/icons/index.ts b/apps/web/app/(dashboard)/issues/_components/icons/index.ts new file mode 100644 index 00000000..03cb734a --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/index.ts @@ -0,0 +1,2 @@ +export { StatusIcon } from "./status-icon"; +export { PriorityIcon } from "./priority-icon"; diff --git a/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx b/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx new file mode 100644 index 00000000..64a24752 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/priority-icon.tsx @@ -0,0 +1,57 @@ +import type { IssuePriority } from "@multica/types"; +import { PRIORITY_CONFIG } from "../../_config"; + +export function PriorityIcon({ + priority, + className = "", +}: { + priority: IssuePriority; + className?: string; +}) { + const cfg = PRIORITY_CONFIG[priority]; + + // "none" — simple horizontal dashes + if (cfg.bars === 0) { + return ( + + + + ); + } + + const isUrgent = priority === "urgent"; + + return ( + + {[0, 1, 2, 3].map((i) => ( + + ))} + {isUrgent && ( + + )} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx b/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx new file mode 100644 index 00000000..630b9d30 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/icons/status-icon.tsx @@ -0,0 +1,169 @@ +import type { IssueStatus } from "@multica/types"; +import { STATUS_CONFIG } from "../../_config"; + +// --------------------------------------------------------------------------- +// Circle geometry constants (viewBox 0 0 16 16, center 8,8, radius 6) +// --------------------------------------------------------------------------- + +const CX = 8; +const CY = 8; +const R = 6; + +// --------------------------------------------------------------------------- +// Per-status SVG renderers — Linear-style icons +// --------------------------------------------------------------------------- + +/** 16 small dots arranged in a ring */ +function BacklogIcon() { + const count = 16; + const dotR = 0.65; + return ( + + {Array.from({ length: count }, (_, i) => { + const angle = (i / count) * Math.PI * 2 - Math.PI / 2; + return ( + + ); + })} + + ); +} + +/** Empty circle, solid outline */ +function TodoIcon() { + return ( + + ); +} + +/** Circle outline + right half filled (D-shape) */ +function InProgressIcon() { + return ( + <> + + + + ); +} + +/** Circle outline + 75% pie fill (bottom-left quarter empty) */ +function InReviewIcon() { + return ( + <> + + + + ); +} + +/** Solid filled circle + white checkmark */ +function DoneIcon() { + return ( + <> + + + + ); +} + +/** Circle outline + X inside */ +function CancelledIcon() { + return ( + <> + + + + ); +} + +// --------------------------------------------------------------------------- +// Renderer map +// --------------------------------------------------------------------------- + +const STATUS_RENDERERS: Record React.ReactNode> = { + backlog: BacklogIcon, + todo: TodoIcon, + in_progress: InProgressIcon, + in_review: InReviewIcon, + done: DoneIcon, + blocked: CancelledIcon, // fallback if backend sends blocked + cancelled: CancelledIcon, +}; + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- + +export function StatusIcon({ + status, + className = "h-4 w-4", +}: { + status: IssueStatus; + className?: string; +}) { + const cfg = STATUS_CONFIG[status]; + const Renderer = STATUS_RENDERERS[status]; + + return ( + + + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/index.ts b/apps/web/app/(dashboard)/issues/_components/index.ts new file mode 100644 index 00000000..8b22c442 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/index.ts @@ -0,0 +1,2 @@ +export * from "./icons"; +export * from "./pickers"; diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx new file mode 100644 index 00000000..dee8b620 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/assignee-picker.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useState } from "react"; +import { Bot, UserMinus } from "lucide-react"; +import type { IssueAssigneeType, UpdateIssueRequest } from "@multica/types"; +import { useAuth } from "../../../../../lib/auth-context"; +import { + PropertyPicker, + PickerItem, + PickerSection, + PickerEmpty, +} from "./property-picker"; + +export function AssigneePicker({ + assigneeType, + assigneeId, + onUpdate, +}: { + assigneeType: IssueAssigneeType | null; + assigneeId: string | null; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const [filter, setFilter] = useState(""); + const { members, agents, getActorName, getActorInitials } = useAuth(); + + const query = filter.toLowerCase(); + const filteredMembers = members.filter((m) => + m.name.toLowerCase().includes(query), + ); + const filteredAgents = agents.filter((a) => + a.name.toLowerCase().includes(query), + ); + + const isSelected = (type: string, id: string) => + assigneeType === type && assigneeId === id; + + const triggerLabel = + assigneeType && assigneeId + ? getActorName(assigneeType, assigneeId) + : "Unassigned"; + + return ( + { + setOpen(v); + if (!v) setFilter(""); + }} + width="w-52" + searchable + searchPlaceholder="Assign to..." + onSearchChange={setFilter} + trigger={ + assigneeType && assigneeId ? ( + <> +
+ {assigneeType === "agent" ? ( + + ) : ( + getActorInitials(assigneeType, assigneeId) + )} +
+ {triggerLabel} + + ) : ( + Unassigned + ) + } + > + {/* Unassigned option */} + { + onUpdate({ assignee_type: null, assignee_id: null }); + setOpen(false); + }} + > + + Unassigned + + + {/* Members */} + {filteredMembers.length > 0 && ( + + {filteredMembers.map((m) => ( + { + onUpdate({ + assignee_type: "member", + assignee_id: m.user_id, + }); + setOpen(false); + }} + > +
+ {getActorInitials("member", m.user_id)} +
+ {m.name} +
+ ))} +
+ )} + + {/* Agents */} + {filteredAgents.length > 0 && ( + + {filteredAgents.map((a) => ( + { + onUpdate({ + assignee_type: "agent", + assignee_id: a.id, + }); + setOpen(false); + }} + > +
+ +
+ {a.name} +
+ ))} +
+ )} + + {filteredMembers.length === 0 && + filteredAgents.length === 0 && + filter && } +
+ ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/index.ts b/apps/web/app/(dashboard)/issues/_components/pickers/index.ts new file mode 100644 index 00000000..4efa06f1 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/index.ts @@ -0,0 +1,4 @@ +export { PropertyPicker, PickerItem, PickerSection, PickerEmpty } from "./property-picker"; +export { StatusPicker } from "./status-picker"; +export { PriorityPicker } from "./priority-picker"; +export { AssigneePicker } from "./assignee-picker"; diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx new file mode 100644 index 00000000..3c66285e --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/priority-picker.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import type { IssuePriority, UpdateIssueRequest } from "@multica/types"; +import { PRIORITY_ORDER, PRIORITY_CONFIG } from "../../_config"; +import { PriorityIcon } from "../icons"; +import { PropertyPicker, PickerItem } from "./property-picker"; + +export function PriorityPicker({ + priority, + onUpdate, +}: { + priority: IssuePriority; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const cfg = PRIORITY_CONFIG[priority]; + + return ( + + + {cfg.label} + + } + > + {PRIORITY_ORDER.map((p) => { + const c = PRIORITY_CONFIG[p]; + return ( + { + onUpdate({ priority: p }); + setOpen(false); + }} + > + + {c.label} + + ); + })} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx new file mode 100644 index 00000000..cbff69aa --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/property-picker.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { Check } from "lucide-react"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; + +// --------------------------------------------------------------------------- +// PropertyPicker — generic Popover shell with optional search +// --------------------------------------------------------------------------- + +export function PropertyPicker({ + open, + onOpenChange, + trigger, + width = "w-48", + align = "end", + searchable = false, + searchPlaceholder = "Filter...", + onSearchChange, + children, +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + trigger: React.ReactNode; + width?: string; + align?: "start" | "center" | "end"; + searchable?: boolean; + searchPlaceholder?: string; + onSearchChange?: (query: string) => void; + children: React.ReactNode; +}) { + const [query, setQuery] = useState(""); + + const handleOpenChange = useCallback( + (v: boolean) => { + onOpenChange(v); + if (!v) { + setQuery(""); + onSearchChange?.(""); + } + }, + [onOpenChange, onSearchChange], + ); + + return ( + + + {trigger} + + + {searchable && ( +
+ { + setQuery(e.target.value); + onSearchChange?.(e.target.value); + }} + placeholder={searchPlaceholder} + className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none" + /> +
+ )} +
{children}
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// PickerItem — single selectable row +// --------------------------------------------------------------------------- + +export function PickerItem({ + selected, + onClick, + hoverClassName, + children, +}: { + selected: boolean; + onClick: () => void; + hoverClassName?: string; + children: React.ReactNode; +}) { + return ( + + ); +} + +// --------------------------------------------------------------------------- +// PickerSection — group header +// --------------------------------------------------------------------------- + +export function PickerSection({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
+ {label} +
+ {children} +
+ ); +} + +// --------------------------------------------------------------------------- +// PickerEmpty — no results state +// --------------------------------------------------------------------------- + +export function PickerEmpty() { + return ( +
+ No results +
+ ); +} diff --git a/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx b/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx new file mode 100644 index 00000000..5354801f --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_components/pickers/status-picker.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState } from "react"; +import type { IssueStatus, UpdateIssueRequest } from "@multica/types"; +import { ALL_STATUSES, STATUS_CONFIG } from "../../_config"; +import { StatusIcon } from "../icons"; +import { PropertyPicker, PickerItem } from "./property-picker"; + +export function StatusPicker({ + status, + onUpdate, +}: { + status: IssueStatus; + onUpdate: (updates: Partial) => void; +}) { + const [open, setOpen] = useState(false); + const cfg = STATUS_CONFIG[status]; + + return ( + + + {cfg.label} + + } + > + {ALL_STATUSES.map((s) => { + const c = STATUS_CONFIG[s]; + return ( + { + onUpdate({ status: s }); + setOpen(false); + }} + > + + {c.label} + + ); + })} + + ); +} diff --git a/apps/web/app/(dashboard)/issues/_config/index.ts b/apps/web/app/(dashboard)/issues/_config/index.ts new file mode 100644 index 00000000..60d97c53 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/index.ts @@ -0,0 +1,2 @@ +export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status"; +export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority"; diff --git a/apps/web/app/(dashboard)/issues/_config/priority.ts b/apps/web/app/(dashboard)/issues/_config/priority.ts new file mode 100644 index 00000000..59345ecd --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/priority.ts @@ -0,0 +1,20 @@ +import type { IssuePriority } from "@multica/types"; + +export const PRIORITY_ORDER: IssuePriority[] = [ + "urgent", + "high", + "medium", + "low", + "none", +]; + +export const PRIORITY_CONFIG: Record< + IssuePriority, + { label: string; bars: number; color: string } +> = { + urgent: { label: "Urgent", bars: 4, color: "text-orange-500" }, + high: { label: "High", bars: 3, color: "text-orange-400" }, + medium: { label: "Medium", bars: 2, color: "text-yellow-500" }, + low: { label: "Low", bars: 1, color: "text-blue-400" }, + none: { label: "No priority", bars: 0, color: "text-muted-foreground" }, +}; diff --git a/apps/web/app/(dashboard)/issues/_config/status.ts b/apps/web/app/(dashboard)/issues/_config/status.ts new file mode 100644 index 00000000..f00a7964 --- /dev/null +++ b/apps/web/app/(dashboard)/issues/_config/status.ts @@ -0,0 +1,32 @@ +import type { IssueStatus } from "@multica/types"; + +export const STATUS_ORDER: IssueStatus[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "cancelled", +]; + +export const ALL_STATUSES: IssueStatus[] = [ + "backlog", + "todo", + "in_progress", + "in_review", + "done", + "cancelled", +]; + +export const STATUS_CONFIG: Record< + IssueStatus, + { label: string; iconColor: string; hoverBg: string } +> = { + backlog: { label: "Backlog", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + todo: { label: "Todo", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, + in_progress: { label: "In Progress", iconColor: "text-yellow-500", hoverBg: "hover:bg-yellow-500/10" }, + in_review: { label: "In Review", iconColor: "text-green-500", hoverBg: "hover:bg-green-500/10" }, + done: { label: "Done", iconColor: "text-blue-500", hoverBg: "hover:bg-blue-500/10" }, + blocked: { label: "Blocked", iconColor: "text-red-500", hoverBg: "hover:bg-red-500/10" }, + cancelled: { label: "Cancelled", iconColor: "text-muted-foreground", hoverBg: "hover:bg-accent" }, +}; diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx index b9ca6aeb..ca753fc9 100644 --- a/apps/web/app/(dashboard)/issues/page.test.tsx +++ b/apps/web/app/(dashboard)/issues/page.test.tsx @@ -49,6 +49,20 @@ vi.mock("../../../lib/ws-context", () => ({ WSProvider: ({ children }: { children: React.ReactNode }) => children, })); +// Mock tab-store +vi.mock("../../../lib/tab-store", () => ({ + useTabStore: () => ({ + updateTabTitle: vi.fn(), + activeTabId: "tab-1", + openTab: vi.fn(), + }), +})); + +// Mock tab-link to avoid TabProvider dependency +vi.mock("../_components/tab-link", () => ({ + TabLink: ({ children, href, ...props }: any) => {children}, +})); + // Mock api const mockListIssues = vi.fn(); const mockCreateIssue = vi.fn(); @@ -160,13 +174,14 @@ describe("IssuesPage", () => { render(); await waitFor(() => { - expect(screen.getByText("Backlog")).toBeInTheDocument(); + // Status labels appear in both filter dropdown and board columns + expect(screen.getAllByText("Backlog").length).toBeGreaterThanOrEqual(1); }); - expect(screen.getByText("Todo")).toBeInTheDocument(); - expect(screen.getByText("In Progress")).toBeInTheDocument(); - expect(screen.getByText("In Review")).toBeInTheDocument(); - expect(screen.getByText("Done")).toBeInTheDocument(); + expect(screen.getAllByText("Todo").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In Progress").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("In Review").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("Done").length).toBeGreaterThanOrEqual(1); }); it("switches to list view", async () => { @@ -191,7 +206,20 @@ describe("IssuesPage", () => { expect(screen.getByText("Design landing page")).toBeInTheDocument(); }); - it("shows 'New Issue' button and opens create form", async () => { + it("shows 'New Issue' button", async () => { + mockListIssues.mockResolvedValueOnce({ + issues: [], + total: 0, + } as ListIssuesResponse); + + render(); + + await waitFor(() => { + expect(screen.getByText("New Issue")).toBeInTheDocument(); + }); + }); + + it("shows create dialog when New Issue is clicked", async () => { mockListIssues.mockResolvedValueOnce({ issues: [], total: 0, @@ -206,15 +234,14 @@ describe("IssuesPage", () => { await user.click(screen.getByText("New Issue")); - // Create form should be visible - expect( - screen.getByPlaceholderText("Issue title..."), - ).toBeInTheDocument(); - expect(screen.getByText("Create")).toBeInTheDocument(); - expect(screen.getByText("Cancel")).toBeInTheDocument(); + // Dialog should open with title input + await waitFor(() => { + expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument(); + }); + expect(screen.getByText("Create Issue")).toBeInTheDocument(); }); - it("creates an issue via the form", async () => { + it("creates an issue via the dialog", async () => { mockListIssues.mockResolvedValueOnce({ issues: [], total: 0, @@ -226,7 +253,7 @@ describe("IssuesPage", () => { workspace_id: "ws-1", title: "New test issue", description: null, - status: "backlog", + status: "todo", priority: "none", assignee_type: null, assignee_id: null, @@ -246,47 +273,21 @@ describe("IssuesPage", () => { }); await user.click(screen.getByText("New Issue")); - await user.type( - screen.getByPlaceholderText("Issue title..."), - "New test issue", - ); - await user.click(screen.getByText("Create")); + + await waitFor(() => { + expect(screen.getByPlaceholderText("Issue title")).toBeInTheDocument(); + }); + + await user.type(screen.getByPlaceholderText("Issue title"), "New test issue"); + await user.click(screen.getByText("Create Issue")); await waitFor(() => { expect(mockCreateIssue).toHaveBeenCalledWith({ title: "New test issue", + status: "todo", + priority: "none", }); }); - - // New issue should appear - await waitFor(() => { - expect(screen.getByText("New test issue")).toBeInTheDocument(); - }); - }); - - it("closes create form on Cancel", async () => { - mockListIssues.mockResolvedValueOnce({ - issues: [], - total: 0, - } as ListIssuesResponse); - - const user = userEvent.setup(); - render(); - - await waitFor(() => { - expect(screen.getByText("New Issue")).toBeInTheDocument(); - }); - - await user.click(screen.getByText("New Issue")); - expect( - screen.getByPlaceholderText("Issue title..."), - ).toBeInTheDocument(); - - await user.click(screen.getByText("Cancel")); - expect( - screen.queryByPlaceholderText("Issue title..."), - ).not.toBeInTheDocument(); - expect(screen.getByText("New Issue")).toBeInTheDocument(); }); it("handles API error gracefully", async () => { diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 4af1b00d..4e3e5cae 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -2,19 +2,13 @@ import { useState, useCallback, useEffect } from "react"; import Link from "next/link"; +import { TabLink } from "../_components/tab-link"; +import { useTabStore } from "../../../lib/tab-store"; import { Columns3, List, Plus, Bot, - Circle, - CircleDashed, - CircleDot, - CircleCheck, - CircleX, - CircleAlert, - Eye, - Minus, } from "lucide-react"; import { DndContext, @@ -30,70 +24,21 @@ import { import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import type { Issue, IssueStatus, IssuePriority } from "@multica/types"; -import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/config"; +import { STATUS_CONFIG, PRIORITY_CONFIG, ALL_STATUSES, PRIORITY_ORDER } from "./_config"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogFooter, + DialogTrigger, +} from "@multica/ui/components/ui/dialog"; +import { StatusIcon, PriorityIcon } from "./_components"; import { api } from "../../../lib/api"; import { useAuth } from "../../../lib/auth-context"; import { useWSEvent } from "../../../lib/ws-context"; import type { IssueCreatedPayload, IssueUpdatedPayload, IssueDeletedPayload } from "@multica/types"; -// --------------------------------------------------------------------------- -// Shared icon components -// --------------------------------------------------------------------------- - -const STATUS_ICONS: Record = { - backlog: CircleDashed, - todo: Circle, - in_progress: CircleDot, - in_review: Eye, - done: CircleCheck, - blocked: CircleAlert, - cancelled: CircleX, -}; - -export function StatusIcon({ - status, - className = "h-4 w-4", -}: { - status: IssueStatus; - className?: string; -}) { - const Icon = STATUS_ICONS[status]; - const cfg = STATUS_CONFIG[status]; - return ; -} - -export function PriorityIcon({ - priority, - className = "", -}: { - priority: IssuePriority; - className?: string; -}) { - const cfg = PRIORITY_CONFIG[priority]; - if (cfg.bars === 0) { - return ; - } - return ( - - {[0, 1, 2, 3].map((i) => ( - - ))} - - ); -} - function AssigneeAvatar({ issue, size = "sm", @@ -186,16 +131,18 @@ function DraggableBoardCard({ issue }: { issue: Issue }) { {...attributes} {...listeners} className={isDragging ? "opacity-30" : ""} + onClickCapture={(e) => { + if (isDragging) e.stopPropagation(); + }} > - { - if (isDragging) e.preventDefault(); - }} + title={issue.title} + iconKey="issues" className="block transition-colors hover:opacity-80" > - +
); } @@ -330,8 +277,10 @@ function BoardView({ function ListRow({ issue }: { issue: Issue }) { return ( - @@ -346,7 +295,7 @@ function ListRow({ issue }: { issue: Issue }) { )} - + ); } @@ -383,65 +332,120 @@ function ListView({ issues }: { issues: Issue[] }) { } // --------------------------------------------------------------------------- -// Create Issue Dialog (simple inline) +// Create Issue Dialog // --------------------------------------------------------------------------- -function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) { +function CreateIssueDialog({ onCreated }: { onCreated: (issue: Issue) => void }) { + const [open, setOpen] = useState(false); const [title, setTitle] = useState(""); - const [isOpen, setIsOpen] = useState(false); + const [description, setDescription] = useState(""); + const [status, setStatus] = useState("todo"); + const [priority, setPriority] = useState("none"); + const [submitting, setSubmitting] = useState(false); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const reset = () => { + setTitle(""); + setDescription(""); + setStatus("todo"); + setPriority("none"); + }; + + const handleSubmit = async () => { if (!title.trim()) return; + setSubmitting(true); try { - const issue = await api.createIssue({ title: title.trim() }); + const issue = await api.createIssue({ + title: title.trim(), + description: description.trim() || undefined, + status, + priority, + }); onCreated(issue); - setTitle(""); - setIsOpen(false); + reset(); + setOpen(false); } catch (err) { console.error("Failed to create issue:", err); + } finally { + setSubmitting(false); } }; - if (!isOpen) { - return ( - - ); - } - return ( -
- setTitle(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") setIsOpen(false); - }} - placeholder="Issue title..." - className="rounded-md border bg-background px-2 py-1 text-xs w-48" + { setOpen(v); if (!v) reset(); }}> + + + New Issue + + } /> - - - + + + New Issue + +
+ setTitle(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + }} + placeholder="Issue title" + className="w-full rounded-md border bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:ring-1 focus:ring-ring" + /> +