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/_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" + /> +