Merge pull request #240 from multica-ai/feature/issue-ui-ux
feat: issue UI/UX enhancements with comment CRUD and tab navigation
This commit is contained in:
commit
5d993531d1
35 changed files with 2959 additions and 1428 deletions
17
CLAUDE.md
17
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:
|
||||
|
||||
|
|
|
|||
295
apps/web/app/(dashboard)/_components/app-sidebar.tsx
Normal file
295
apps/web/app/(dashboard)/_components/app-sidebar.tsx
Normal file
|
|
@ -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 (
|
||||
<>
|
||||
<Sidebar variant="inset">
|
||||
{/* Workspace Switcher */}
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" onClick={() => setShowMenu(!showMenu)}>
|
||||
<MulticaIcon className="size-4" noSpin />
|
||||
<span className="flex-1 truncate font-semibold">
|
||||
{workspace?.name ?? "Multica"}
|
||||
</span>
|
||||
<ChevronDown className="size-4" />
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-2 top-14 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{user?.email}
|
||||
</div>
|
||||
<div className="my-1 border-t" />
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Workspaces
|
||||
</div>
|
||||
{workspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
if (ws.id !== workspace?.id) {
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">
|
||||
{ws.name}
|
||||
</span>
|
||||
{ws.id === workspace?.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
setShowCreateDialog(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</button>
|
||||
<div className="my-1 border-t" />
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
{/* Navigation */}
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<SidebarMenuItem key={item.href}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
render={<Link href={item.href} />}
|
||||
onClick={() =>
|
||||
openTab(item.href, item.label, {
|
||||
replace: true,
|
||||
iconKey: item.iconKey,
|
||||
})
|
||||
}
|
||||
>
|
||||
<item.icon />
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
|
||||
{/* User */}
|
||||
<SidebarFooter>
|
||||
{user && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="sm">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[9px] font-medium">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<span className="truncate">{user.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
{/* Create Workspace Dialog */}
|
||||
{showCreateDialog && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
<div className="fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-lg font-semibold leading-none">
|
||||
Create workspace
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new workspace for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
Slug
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateWorkspace}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
271
apps/web/app/(dashboard)/_components/tab-bar.tsx
Normal file
271
apps/web/app/(dashboard)/_components/tab-bar.tsx
Normal file
|
|
@ -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<string, typeof Inbox> = {
|
||||
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 <FileText className="h-3.5 w-3.5 shrink-0" />;
|
||||
return <Icon className="h-3.5 w-3.5 shrink-0" />;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function TabContextMenu({
|
||||
x,
|
||||
y,
|
||||
tabId,
|
||||
onClose,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
tabId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { tabs, closeTab } = useTabStore();
|
||||
const menuRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-50 min-w-[140px] rounded-md border bg-popover p-1 shadow-md"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
disabled={!canClose}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCloseOthers}
|
||||
disabled={tabs.length <= 1}
|
||||
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
Close others
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<button
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
onContextMenu={(e) => onContextMenu(e, tab.id)}
|
||||
className={`group flex h-7 max-w-[200px] items-center gap-1.5 rounded-lg px-2.5 text-[13px] transition-all select-none ${
|
||||
isDragging ? "opacity-30" : ""
|
||||
} ${
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm ring-1 ring-border/60"
|
||||
: "bg-background/50 text-foreground ring-1 ring-border/30 opacity-60 hover:opacity-85"
|
||||
}`}
|
||||
>
|
||||
<TabIcon iconKey={tab.iconKey} />
|
||||
<span className="truncate">{tab.title}</span>
|
||||
{canClose && (
|
||||
<span
|
||||
onClick={handleClose}
|
||||
className="ml-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-sm opacity-0 transition-opacity hover:bg-foreground/10 group-hover:opacity-100"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 (
|
||||
<div className="flex h-10 shrink-0 items-center gap-1 bg-sidebar px-2">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={tabs.map((t) => t.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<SortableTab
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
canClose={tab.closeable && tabs.length > 1}
|
||||
onContextMenu={handleContextMenu}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<button
|
||||
onClick={handleNewTab}
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-background/60 hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{contextMenu && (
|
||||
<TabContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
tabId={contextMenu.tabId}
|
||||
onClose={() => setContextMenu(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
apps/web/app/(dashboard)/_components/tab-link.tsx
Normal file
30
apps/web/app/(dashboard)/_components/tab-link.tsx
Normal file
|
|
@ -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<React.ComponentProps<typeof Link>, "onClick" | "href">) {
|
||||
const { openTab } = useTabStore();
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
openTab(href, title, { replace: false, iconKey });
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={href} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors ${
|
||||
onClick ? "cursor-pointer" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-h-[32px] items-center gap-3 rounded-md px-2 -mx-2 hover:bg-accent/50 transition-colors">
|
||||
<span className="w-20 shrink-0 text-[13px] text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-1.5 text-[13px]">
|
||||
{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<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const { members, agents } = useAuth();
|
||||
const [search, setSearch] = useState("");
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div
|
||||
ref={ref}
|
||||
className="absolute right-0 top-full z-50 mt-1 w-64 rounded-lg border bg-popover shadow-md"
|
||||
>
|
||||
<div className="p-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => 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"
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
|
||||
{date ? (
|
||||
<span className={isOverdue ? "text-red-500" : ""}>
|
||||
{date.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d: Date | undefined) => {
|
||||
onUpdate({ due_date: d ? d.toISOString() : null });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-64 overflow-y-auto px-1 pb-1">
|
||||
{/* Unassign option */}
|
||||
{issue.assignee_id && (
|
||||
<>
|
||||
{date && (
|
||||
<div className="border-t px-3 py-2">
|
||||
<button
|
||||
onClick={() => onSelect(null, null)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs hover:bg-accent"
|
||||
onClick={() => {
|
||||
onUpdate({ due_date: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassign</span>
|
||||
Clear date
|
||||
</button>
|
||||
<div className="my-1 border-t mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Members
|
||||
</div>
|
||||
{filteredMembers.map((m) => (
|
||||
<button
|
||||
key={m.user_id}
|
||||
onClick={() => onSelect("member", m.user_id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("member", m.user_id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
||||
{m.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{m.name}</div>
|
||||
</div>
|
||||
{isSelected("member", m.user_id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<>
|
||||
<div className="px-2 py-1 mt-1 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Agents
|
||||
</div>
|
||||
{filteredAgents.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
onClick={() => onSelect("agent", a.id)}
|
||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-xs transition-colors ${
|
||||
isSelected("agent", a.id) ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate font-medium">{a.name}</div>
|
||||
</div>
|
||||
{isSelected("agent", a.id) && (
|
||||
<span className="text-primary text-[10px] font-medium">Assigned</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 && filteredAgents.length === 0 && (
|
||||
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
|
||||
No results found
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Acceptance Criteria Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function AcceptanceCriteriaEditor({
|
||||
criteria,
|
||||
onUpdate,
|
||||
}: {
|
||||
criteria: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => 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 (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Acceptance Criteria</h3>
|
||||
<div className="space-y-1">
|
||||
{criteria.map((item, i) => (
|
||||
<div key={i} className="group flex items-start gap-2 text-sm">
|
||||
<span className="mt-0.5 text-muted-foreground">•</span>
|
||||
<span className="flex-1">{item}</span>
|
||||
<button
|
||||
onClick={() => removeItem(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addItem(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
placeholder="Add criteria..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context Refs Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ContextRefsEditor({
|
||||
refs,
|
||||
onUpdate,
|
||||
}: {
|
||||
refs: string[];
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => 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 (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Context References</h3>
|
||||
<div className="space-y-1">
|
||||
{refs.map((ref, i) => (
|
||||
<div key={i} className="group flex items-center gap-2 text-sm">
|
||||
<Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
{isUrl(ref) ? (
|
||||
<a href={ref} target="_blank" rel="noopener noreferrer" className="flex-1 text-blue-500 hover:underline truncate">
|
||||
{ref}
|
||||
</a>
|
||||
) : (
|
||||
<span className="flex-1 truncate">{ref}</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => removeRef(i)}
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
onSubmit={(e) => { e.preventDefault(); addRef(); }}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
value={newRef}
|
||||
onChange={(e) => setNewRef(e.target.value)}
|
||||
placeholder="Add reference URL..."
|
||||
className="flex-1 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository Editor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RepositoryEditor({
|
||||
repository,
|
||||
onUpdate,
|
||||
}: {
|
||||
repository: { url: string; branch?: string; path?: string } | null;
|
||||
onUpdate: (updates: Partial<UpdateIssueRequest>) => 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 (
|
||||
<Popover open={open} onOpenChange={handleOpen}>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
|
||||
{repository ? (
|
||||
<>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate text-xs">{repository.branch ?? "main"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">None</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-auto min-w-48 p-3 space-y-2.5">
|
||||
<div className="text-xs font-medium">Repository</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
value={url}
|
||||
onChange={(e) => 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
|
||||
/>
|
||||
<input
|
||||
value={branch}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<input
|
||||
value={path}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
{repository && (
|
||||
<button
|
||||
onClick={clear}
|
||||
className="text-xs text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={save}
|
||||
className="ml-auto rounded-md bg-primary px-3 py-1 text-xs text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<Issue | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
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<string | null>(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<UpdateIssueRequest>) => {
|
||||
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 (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
|
|
@ -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 (
|
||||
<div className="flex h-full">
|
||||
{/* LEFT: Content area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Header bar */}
|
||||
<div className="sticky top-0 z-10 flex h-11 items-center gap-1.5 border-b bg-background px-6 text-[13px]">
|
||||
<Link
|
||||
href="/issues"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Issues
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
|
||||
<div className="sticky top-0 z-10 flex h-11 items-center justify-between border-b bg-background px-6 text-[13px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Link
|
||||
href="/issues"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Issues
|
||||
</Link>
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground/50" />
|
||||
<span className="truncate text-muted-foreground">{issue.id.slice(0, 8)}</span>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger
|
||||
render={<button className="rounded-md p-1.5 text-muted-foreground hover:bg-destructive/10 hover:text-destructive transition-colors" />}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete issue</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this issue and all its comments. This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
|
|
@ -369,6 +615,19 @@ export default function IssueDetailPage({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{(issue.acceptance_criteria.length > 0 || issue.context_refs.length > 0) && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<AcceptanceCriteriaEditor
|
||||
criteria={issue.acceptance_criteria}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
<ContextRefsEditor
|
||||
refs={issue.context_refs}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="my-8 border-t" />
|
||||
|
||||
{/* Activity / Comments */}
|
||||
|
|
@ -376,26 +635,57 @@ export default function IssueDetailPage({
|
|||
<h2 className="text-[13px] font-medium">Activity</h2>
|
||||
|
||||
<div className="mt-4">
|
||||
{comments.map((comment) => (
|
||||
<div key={comment.id} className="relative py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={comment.author_type}
|
||||
actorId={comment.author_id}
|
||||
size={28}
|
||||
/>
|
||||
<span className="text-[13px] font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
{comments.map((comment) => {
|
||||
const isOwn = comment.author_type === "member" && comment.author_id === user?.id;
|
||||
return (
|
||||
<div key={comment.id} className="group relative py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<ActorAvatar
|
||||
actorType={comment.author_type}
|
||||
actorId={comment.author_id}
|
||||
size={28}
|
||||
/>
|
||||
<span className="text-[13px] font-medium">
|
||||
{getActorName(comment.author_type, comment.author_id)}
|
||||
</span>
|
||||
<span className="text-[12px] text-muted-foreground">
|
||||
{timeAgo(comment.created_at)}
|
||||
</span>
|
||||
{isOwn && (
|
||||
<div className="ml-auto flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => startEditComment(comment)}
|
||||
className="p-1 text-muted-foreground hover:text-foreground rounded"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
className="p-1 text-muted-foreground hover:text-destructive rounded"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editingCommentId === comment.id ? (
|
||||
<form onSubmit={(e) => { e.preventDefault(); handleSaveEditComment(); }} className="mt-2 pl-[38px]">
|
||||
<input
|
||||
autoFocus
|
||||
value={editContent}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
className="w-full text-[13px] bg-transparent border-b outline-none"
|
||||
onKeyDown={(e) => { if (e.key === "Escape") setEditingCommentId(null); }}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 pl-[38px] text-[13px] leading-[1.6] text-foreground/85 whitespace-pre-wrap">
|
||||
{comment.content}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Comment input */}
|
||||
|
|
@ -430,51 +720,27 @@ export default function IssueDetailPage({
|
|||
|
||||
<div className="space-y-0.5">
|
||||
<PropRow label="Status">
|
||||
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
|
||||
<span className={statusCfg.iconColor}>{statusCfg.label}</span>
|
||||
<StatusPicker status={issue.status} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Priority">
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
<span>{priorityCfg.label}</span>
|
||||
<PriorityPicker priority={issue.priority} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<div className="relative">
|
||||
<PropRow
|
||||
label="Assignee"
|
||||
onClick={() => setShowAssigneePicker(!showAssigneePicker)}
|
||||
>
|
||||
{issue.assignee_type && issue.assignee_id ? (
|
||||
<>
|
||||
<ActorAvatar
|
||||
actorType={issue.assignee_type}
|
||||
actorId={issue.assignee_id}
|
||||
size={18}
|
||||
/>
|
||||
<span>{getActorName(issue.assignee_type, issue.assignee_id)}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)}
|
||||
</PropRow>
|
||||
|
||||
{showAssigneePicker && (
|
||||
<AssigneePicker
|
||||
issue={issue}
|
||||
onSelect={handleAssigneeChange}
|
||||
onClose={() => setShowAssigneePicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<PropRow label="Assignee">
|
||||
<AssigneePicker
|
||||
assigneeType={issue.assignee_type}
|
||||
assigneeId={issue.assignee_id}
|
||||
onUpdate={handleUpdateField}
|
||||
/>
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Due date">
|
||||
{issue.due_date ? (
|
||||
<span className={isOverdue ? "text-red-500" : ""}>
|
||||
{shortDate(issue.due_date)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">None</span>
|
||||
)}
|
||||
<DueDatePicker dueDate={issue.due_date} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Repository">
|
||||
<RepositoryEditor repository={issue.repository} onUpdate={handleUpdateField} />
|
||||
</PropRow>
|
||||
|
||||
<PropRow label="Created by">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export { StatusIcon } from "./status-icon";
|
||||
export { PriorityIcon } from "./priority-icon";
|
||||
|
|
@ -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 (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 text-muted-foreground shrink-0 ${className}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<line x1="3" y1="8" x2="13" y2="8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const isUrgent = priority === "urgent";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 ${cfg.color} shrink-0 ${className}`}
|
||||
fill="currentColor"
|
||||
style={isUrgent ? { animation: "priority-pulse 2s ease-in-out infinite" } : undefined}
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 4}
|
||||
width="3"
|
||||
rx="0.5"
|
||||
style={{
|
||||
y: 12 - (i + 1) * 3,
|
||||
height: (i + 1) * 3,
|
||||
opacity: i < cfg.bars ? 1 : 0.2,
|
||||
transition: "y 0.2s ease, height 0.2s ease, opacity 0.2s ease",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{isUrgent && (
|
||||
<style>{`@keyframes priority-pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.08)}}`}</style>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<g>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const angle = (i / count) * Math.PI * 2 - Math.PI / 2;
|
||||
return (
|
||||
<circle
|
||||
key={i}
|
||||
cx={CX + R * Math.cos(angle)}
|
||||
cy={CY + R * Math.sin(angle)}
|
||||
r={dotR}
|
||||
fill="currentColor"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/** Empty circle, solid outline */
|
||||
function TodoIcon() {
|
||||
return (
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + right half filled (D-shape) */
|
||||
function InProgressIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + 75% pie fill (bottom-left quarter empty) */
|
||||
function InReviewIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d={`M${CX},${CY} L${CX},${CY - R} A${R},${R} 0 1,1 ${CX - R},${CY} Z`}
|
||||
fill="currentColor"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Solid filled circle + white checkmark */
|
||||
function DoneIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle cx={CX} cy={CY} r={R} fill="currentColor" />
|
||||
<path
|
||||
d="M5.5 8.2 L7.2 9.8 L10.5 6.2"
|
||||
fill="none"
|
||||
stroke="white"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Circle outline + X inside */
|
||||
function CancelledIcon() {
|
||||
return (
|
||||
<>
|
||||
<circle
|
||||
cx={CX}
|
||||
cy={CY}
|
||||
r={R}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M5.75 5.75 L10.25 10.25 M10.25 5.75 L5.75 10.25"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Renderer map
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const STATUS_RENDERERS: Record<IssueStatus, () => 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 (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
className={`${className} ${cfg.iconColor} shrink-0`}
|
||||
>
|
||||
<Renderer />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
2
apps/web/app/(dashboard)/issues/_components/index.ts
Normal file
2
apps/web/app/(dashboard)/issues/_components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./icons";
|
||||
export * from "./pickers";
|
||||
|
|
@ -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<UpdateIssueRequest>) => 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 (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={(v: boolean) => {
|
||||
setOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-52"
|
||||
searchable
|
||||
searchPlaceholder="Assign to..."
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
assigneeType && assigneeId ? (
|
||||
<>
|
||||
<div
|
||||
className={`inline-flex shrink-0 items-center justify-center rounded-full font-medium text-[8px] ${
|
||||
assigneeType === "agent"
|
||||
? "bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
style={{ width: 18, height: 18 }}
|
||||
>
|
||||
{assigneeType === "agent" ? (
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
) : (
|
||||
getActorInitials(assigneeType, assigneeId)
|
||||
)}
|
||||
</div>
|
||||
<span>{triggerLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
)
|
||||
}
|
||||
>
|
||||
{/* Unassigned option */}
|
||||
<PickerItem
|
||||
selected={!assigneeType && !assigneeId}
|
||||
onClick={() => {
|
||||
onUpdate({ assignee_type: null, assignee_id: null });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<UserMinus className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</PickerItem>
|
||||
|
||||
{/* Members */}
|
||||
{filteredMembers.length > 0 && (
|
||||
<PickerSection label="Members">
|
||||
{filteredMembers.map((m) => (
|
||||
<PickerItem
|
||||
key={m.user_id}
|
||||
selected={isSelected("member", m.user_id)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
assignee_type: "member",
|
||||
assignee_id: m.user_id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-muted text-[8px] font-medium text-muted-foreground">
|
||||
{getActorInitials("member", m.user_id)}
|
||||
</div>
|
||||
<span>{m.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
|
||||
{/* Agents */}
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label="Agents">
|
||||
{filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={isSelected("agent", a.id)}
|
||||
onClick={() => {
|
||||
onUpdate({
|
||||
assignee_type: "agent",
|
||||
assignee_id: a.id,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-full bg-purple-100 text-purple-700 dark:bg-purple-950 dark:text-purple-300">
|
||||
<Bot style={{ width: 10, height: 10 }} />
|
||||
</div>
|
||||
<span>{a.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
|
||||
{filteredMembers.length === 0 &&
|
||||
filteredAgents.length === 0 &&
|
||||
filter && <PickerEmpty />}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-44"
|
||||
trigger={
|
||||
<>
|
||||
<PriorityIcon priority={priority} />
|
||||
<span>{cfg.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{PRIORITY_ORDER.map((p) => {
|
||||
const c = PRIORITY_CONFIG[p];
|
||||
return (
|
||||
<PickerItem
|
||||
key={p}
|
||||
selected={p === priority}
|
||||
onClick={() => {
|
||||
onUpdate({ priority: p });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<PriorityIcon priority={p} />
|
||||
<span>{c.label}</span>
|
||||
</PickerItem>
|
||||
);
|
||||
})}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger className="flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors">
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} className={`${width} gap-0 p-0`}>
|
||||
{searchable && (
|
||||
<div className="px-2 py-1.5 border-b">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
onSearchChange?.(e.target.value);
|
||||
}}
|
||||
placeholder={searchPlaceholder}
|
||||
className="w-full bg-transparent text-[13px] placeholder:text-muted-foreground outline-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-1 max-h-60 overflow-y-auto">{children}</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerItem — single selectable row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerItem({
|
||||
selected,
|
||||
onClick,
|
||||
hoverClassName,
|
||||
children,
|
||||
}: {
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
hoverClassName?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-[13px] ${hoverClassName ?? "hover:bg-accent"} transition-colors`}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-2">{children}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerSection — group header
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerSection({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-2 pt-2 pb-1 text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PickerEmpty — no results state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function PickerEmpty() {
|
||||
return (
|
||||
<div className="px-2 py-3 text-center text-[13px] text-muted-foreground">
|
||||
No results
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<UpdateIssueRequest>) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
width="w-44"
|
||||
trigger={
|
||||
<>
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<span>{cfg.label}</span>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{ALL_STATUSES.map((s) => {
|
||||
const c = STATUS_CONFIG[s];
|
||||
return (
|
||||
<PickerItem
|
||||
key={s}
|
||||
selected={s === status}
|
||||
hoverClassName={c.hoverBg}
|
||||
onClick={() => {
|
||||
onUpdate({ status: s });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<StatusIcon status={s} className="h-3.5 w-3.5" />
|
||||
<span>{c.label}</span>
|
||||
</PickerItem>
|
||||
);
|
||||
})}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
2
apps/web/app/(dashboard)/issues/_config/index.ts
Normal file
2
apps/web/app/(dashboard)/issues/_config/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { STATUS_ORDER, ALL_STATUSES, STATUS_CONFIG } from "./status";
|
||||
export { PRIORITY_ORDER, PRIORITY_CONFIG } from "./priority";
|
||||
20
apps/web/app/(dashboard)/issues/_config/priority.ts
Normal file
20
apps/web/app/(dashboard)/issues/_config/priority.ts
Normal file
|
|
@ -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" },
|
||||
};
|
||||
32
apps/web/app/(dashboard)/issues/_config/status.ts
Normal file
32
apps/web/app/(dashboard)/issues/_config/status.ts
Normal file
|
|
@ -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" },
|
||||
};
|
||||
|
|
@ -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) => <a href={href} {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
// Mock api
|
||||
const mockListIssues = vi.fn();
|
||||
const mockCreateIssue = vi.fn();
|
||||
|
|
@ -160,13 +174,14 @@ describe("IssuesPage", () => {
|
|||
render(<IssuesPage />);
|
||||
|
||||
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(<IssuesPage />);
|
||||
|
||||
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(<IssuesPage />);
|
||||
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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<IssueStatus, typeof Circle> = {
|
||||
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 <Icon className={`${className} ${cfg.iconColor}`} />;
|
||||
}
|
||||
|
||||
export function PriorityIcon({
|
||||
priority,
|
||||
className = "",
|
||||
}: {
|
||||
priority: IssuePriority;
|
||||
className?: string;
|
||||
}) {
|
||||
const cfg = PRIORITY_CONFIG[priority];
|
||||
if (cfg.bars === 0) {
|
||||
return <Minus className={`h-3.5 w-3.5 text-muted-foreground ${className}`} />;
|
||||
}
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
className={`h-3.5 w-3.5 ${cfg.color} ${className}`}
|
||||
fill="currentColor"
|
||||
>
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<rect
|
||||
key={i}
|
||||
x={1 + i * 4}
|
||||
y={12 - (i + 1) * 3}
|
||||
width="3"
|
||||
height={(i + 1) * 3}
|
||||
rx="0.5"
|
||||
opacity={i < cfg.bars ? 1 : 0.2}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
<TabLink
|
||||
href={`/issues/${issue.id}`}
|
||||
onClick={(e) => {
|
||||
if (isDragging) e.preventDefault();
|
||||
}}
|
||||
title={issue.title}
|
||||
iconKey="issues"
|
||||
className="block transition-colors hover:opacity-80"
|
||||
>
|
||||
<BoardCardContent issue={issue} />
|
||||
</Link>
|
||||
</TabLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -330,8 +277,10 @@ function BoardView({
|
|||
|
||||
function ListRow({ issue }: { issue: Issue }) {
|
||||
return (
|
||||
<Link
|
||||
<TabLink
|
||||
href={`/issues/${issue.id}`}
|
||||
title={issue.title}
|
||||
iconKey="issues"
|
||||
className="flex h-9 items-center gap-2 px-4 text-[13px] transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<PriorityIcon priority={issue.priority} />
|
||||
|
|
@ -346,7 +295,7 @@ function ListRow({ issue }: { issue: Issue }) {
|
|||
</span>
|
||||
)}
|
||||
<AssigneeAvatar issue={issue} />
|
||||
</Link>
|
||||
</TabLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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<IssueStatus>("todo");
|
||||
const [priority, setPriority] = useState<IssuePriority>("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 (
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => 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"
|
||||
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<button className="flex items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs text-primary-foreground transition-colors hover:bg-primary/90">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New Issue
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Issue</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Add description..."
|
||||
rows={3}
|
||||
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 resize-none"
|
||||
/>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Status selector */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<StatusIcon status={status} className="h-3.5 w-3.5" />
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as IssueStatus)}
|
||||
className="bg-transparent text-xs outline-none cursor-pointer"
|
||||
>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{/* Priority selector */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<PriorityIcon priority={priority} />
|
||||
<select
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(e.target.value as IssuePriority)}
|
||||
className="bg-transparent text-xs outline-none cursor-pointer"
|
||||
>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!title.trim() || submitting}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -452,19 +456,27 @@ function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) {
|
|||
type ViewMode = "board" | "list";
|
||||
|
||||
export default function IssuesPage() {
|
||||
const { closeTabByPath } = useTabStore();
|
||||
const [view, setView] = useState<ViewMode>("board");
|
||||
const [issues, setIssues] = useState<Issue[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filterStatus, setFilterStatus] = useState<IssueStatus | "">("");
|
||||
const [filterPriority, setFilterPriority] = useState<IssuePriority | "">("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.listIssues({ limit: 200 })
|
||||
.listIssues({
|
||||
limit: 200,
|
||||
...(filterStatus ? { status: filterStatus } : {}),
|
||||
...(filterPriority ? { priority: filterPriority } : {}),
|
||||
})
|
||||
.then((res) => {
|
||||
setIssues(res.issues);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [filterStatus, filterPriority]);
|
||||
|
||||
// Real-time updates
|
||||
useWSEvent(
|
||||
|
|
@ -491,7 +503,8 @@ export default function IssuesPage() {
|
|||
useCallback((payload: unknown) => {
|
||||
const { issue_id } = payload as IssueDeletedPayload;
|
||||
setIssues((prev) => prev.filter((i) => i.id !== issue_id));
|
||||
}, []),
|
||||
closeTabByPath(`/issues/${issue_id}`);
|
||||
}, [closeTabByPath]),
|
||||
);
|
||||
|
||||
const handleMoveIssue = useCallback(
|
||||
|
|
@ -558,8 +571,30 @@ export default function IssuesPage() {
|
|||
List
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value as IssueStatus | "")}
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
|
||||
>
|
||||
<option value="">All Status</option>
|
||||
{ALL_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>{STATUS_CONFIG[s].label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={filterPriority}
|
||||
onChange={(e) => setFilterPriority(e.target.value as IssuePriority | "")}
|
||||
className="rounded-md border bg-background px-2 py-1 text-xs outline-none"
|
||||
>
|
||||
<option value="">All Priority</option>
|
||||
{PRIORITY_ORDER.map((p) => (
|
||||
<option key={p} value={p}>{PRIORITY_CONFIG[p].label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<CreateIssueForm onCreated={handleIssueCreated} />
|
||||
<CreateIssueDialog onCreated={handleIssueCreated} />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
|
|
|
|||
|
|
@ -1,69 +1,21 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Inbox,
|
||||
ListTodo,
|
||||
Bot,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { MulticaIcon } from "@multica/ui/components/multica-icon";
|
||||
import { SidebarProvider } from "@multica/ui/components/ui/sidebar";
|
||||
import { useAuth } from "../../lib/auth-context";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/inbox", label: "Inbox", icon: Inbox },
|
||||
{ href: "/agents", label: "Agents", icon: Bot },
|
||||
{ href: "/issues", label: "Issues", icon: ListTodo },
|
||||
{ href: "/knowledge-base", label: "Knowledge Base", icon: BookOpen },
|
||||
];
|
||||
import { TabProvider } from "../../lib/tab-store";
|
||||
import { AppSidebar } from "./_components/app-sidebar";
|
||||
import { TabBar } from "./_components/tab-bar";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { user, workspace, workspaces, isLoading, logout, switchWorkspace, createWorkspace } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [newSlug, setNewSlug] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && user && workspaces.length === 0) {
|
||||
setShowCreateDialog(true);
|
||||
}
|
||||
}, [isLoading, user, workspaces.length]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
const { user, workspace, isLoading } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) {
|
||||
|
|
@ -79,289 +31,19 @@ export default function DashboardLayout({
|
|||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
if (!workspace) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-screen items-center justify-center bg-canvas p-6">
|
||||
<div className="w-full max-w-md rounded-2xl border bg-background p-8 shadow-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-11 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<MulticaIcon className="size-5" noSpin />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Create your first workspace</h1>
|
||||
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-muted-foreground">
|
||||
You need a workspace before you can manage issues, agents, and inbox items.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(true)}
|
||||
className="rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Create workspace
|
||||
</button>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md border px-3 py-2 text-sm hover:bg-accent"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateDialog && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new workspace for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateWorkspace}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (!user || !workspace) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-canvas">
|
||||
{/* Sidebar */}
|
||||
<aside className="flex w-56 shrink-0 flex-col">
|
||||
{/* Workspace Switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowMenu(!showMenu)}
|
||||
className="flex h-12 w-full items-center gap-2 px-3 hover:bg-sidebar-accent/50 transition-colors"
|
||||
>
|
||||
<MulticaIcon className="size-4" noSpin />
|
||||
<span className="flex-1 truncate text-left text-sm font-semibold">
|
||||
{workspace?.name ?? "Multica"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
|
||||
{showMenu && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => setShowMenu(false)}
|
||||
/>
|
||||
<div className="absolute left-2 top-12 z-50 w-52 rounded-lg border bg-popover p-1 shadow-md">
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="my-1 border-t" />
|
||||
<div className="px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
Workspaces
|
||||
</div>
|
||||
{workspaces.map((ws) => (
|
||||
<button
|
||||
key={ws.id}
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
if (ws.id !== workspace?.id) {
|
||||
switchWorkspace(ws.id);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-muted text-[10px] font-semibold">
|
||||
{ws.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-left">{ws.name}</span>
|
||||
{ws.id === workspace?.id && (
|
||||
<Check className="h-3.5 w-3.5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
setShowCreateDialog(true);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create workspace
|
||||
</button>
|
||||
<div className="my-1 border-t" />
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
Settings
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowMenu(false);
|
||||
logout();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-red-500 hover:bg-accent"
|
||||
>
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TabProvider workspaceId={workspace.id}>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<div className="relative flex w-full flex-1 flex-col overflow-hidden">
|
||||
<TabBar />
|
||||
<main className="flex-1 overflow-auto rounded-xl bg-background shadow-sm md:mr-2 md:mb-2">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-0.5 px-2">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
|
||||
: "text-sidebar-foreground/60 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground"
|
||||
}`}
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User info at bottom */}
|
||||
<div className="border-t px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted text-[10px] font-medium">
|
||||
{user.name
|
||||
.split(" ")
|
||||
.map((w) => w[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{user.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 pt-1.5 pr-1.5 pb-1.5">
|
||||
<main className="h-full overflow-auto rounded-xl bg-background shadow-sm">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Create Workspace Dialog */}
|
||||
{showCreateDialog && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/10 backdrop-blur-xs"
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
/>
|
||||
<div className="fixed top-1/2 left-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl bg-background p-6 shadow-lg ring-1 ring-foreground/10">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<h2 className="text-lg font-semibold leading-none">Create workspace</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create a new workspace for your team.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Name</label>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-muted-foreground">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newSlug}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateDialog(false)}
|
||||
className="rounded-md px-3 py-1.5 text-sm hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateWorkspace}
|
||||
disabled={creating || !newName.trim() || !newSlug.trim()}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{creating ? "Creating..." : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</TabProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import { ThemeProvider } from "@multica/ui/components/theme-provider";
|
||||
import { Toaster } from "@multica/ui/components/ui/sonner";
|
||||
import { AuthProvider } from "../lib/auth-context";
|
||||
import { WSProvider } from "../lib/ws-context";
|
||||
import "./globals.css";
|
||||
|
|
@ -26,6 +27,7 @@ export default function RootLayout({
|
|||
<AuthProvider>
|
||||
<WSProvider>{children}</WSProvider>
|
||||
</AuthProvider>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
357
apps/web/lib/tab-store.tsx
Normal file
357
apps/web/lib/tab-store.tsx
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
path: string;
|
||||
title: string;
|
||||
iconKey?: string;
|
||||
closeable: boolean;
|
||||
}
|
||||
|
||||
interface TabStoreValue {
|
||||
tabs: Tab[];
|
||||
activeTabId: string;
|
||||
openTab: (
|
||||
path: string,
|
||||
title: string,
|
||||
opts?: { replace?: boolean; iconKey?: string }
|
||||
) => void;
|
||||
activateTab: (tabId: string) => void;
|
||||
closeTab: (tabId: string) => void;
|
||||
closeTabByPath: (path: string) => void;
|
||||
updateTabTitle: (tabId: string, title: string) => void;
|
||||
reorderTabs: (oldIndex: number, newIndex: number) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Route title mapping (for hydration / fallback)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ROUTE_TITLES: Record<string, string> = {
|
||||
"/inbox": "Inbox",
|
||||
"/agents": "Agents",
|
||||
"/issues": "All Issues",
|
||||
"/knowledge-base": "Knowledge Base",
|
||||
"/settings": "Settings",
|
||||
};
|
||||
|
||||
const ROUTE_ICON_KEYS: Record<string, string> = {
|
||||
"/inbox": "inbox",
|
||||
"/agents": "agents",
|
||||
"/issues": "issues",
|
||||
"/knowledge-base": "knowledge-base",
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
function getTitleForPath(path: string): string {
|
||||
if (ROUTE_TITLES[path]) return ROUTE_TITLES[path];
|
||||
if (path.startsWith("/issues/")) return path.split("/")[2]?.slice(0, 8) ?? "Issue";
|
||||
if (path.startsWith("/agents/")) return "Agent";
|
||||
return "Tab";
|
||||
}
|
||||
|
||||
function getIconKeyForPath(path: string): string | undefined {
|
||||
if (ROUTE_ICON_KEYS[path]) return ROUTE_ICON_KEYS[path];
|
||||
// Sub-paths inherit parent icon
|
||||
for (const [route, key] of Object.entries(ROUTE_ICON_KEYS)) {
|
||||
if (path.startsWith(route + "/")) return key;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function storageKey(workspaceId: string): string {
|
||||
return `multica-tabs-${workspaceId}`;
|
||||
}
|
||||
|
||||
function loadTabs(workspaceId: string): { tabs: Tab[]; activeTabId: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(workspaceId));
|
||||
if (!raw) return null;
|
||||
const data = JSON.parse(raw) as { tabs: Tab[]; activeTabId: string };
|
||||
if (Array.isArray(data.tabs) && data.tabs.length > 0 && data.activeTabId) {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveTabs(workspaceId: string, tabs: Tab[], activeTabId: string): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
storageKey(workspaceId),
|
||||
JSON.stringify({ tabs, activeTabId })
|
||||
);
|
||||
} catch {
|
||||
// localStorage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TabStoreContext = createContext<TabStoreValue | null>(null);
|
||||
|
||||
export function useTabStore(): TabStoreValue {
|
||||
const ctx = useContext(TabStoreContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useTabStore must be used within a TabProvider.");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TabProvider({
|
||||
workspaceId,
|
||||
children,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// Suppress URL-sync effect when we are the ones triggering navigation
|
||||
const navigatingRef = useRef(false);
|
||||
|
||||
// Initialize tabs: hydrate from localStorage or create default
|
||||
const [tabs, setTabs] = useState<Tab[]>(() => {
|
||||
const saved = loadTabs(workspaceId);
|
||||
if (saved) return saved.tabs;
|
||||
return [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
path: pathname,
|
||||
title: getTitleForPath(pathname),
|
||||
iconKey: getIconKeyForPath(pathname),
|
||||
closeable: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const [activeTabId, setActiveTabId] = useState<string>(() => {
|
||||
const saved = loadTabs(workspaceId);
|
||||
if (saved) {
|
||||
// If saved active tab still exists, use it
|
||||
const exists = saved.tabs.find((t) => t.id === saved.activeTabId);
|
||||
if (exists) return saved.activeTabId;
|
||||
}
|
||||
return tabs[0]?.id ?? "";
|
||||
});
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
saveTabs(workspaceId, tabs, activeTabId);
|
||||
}, [workspaceId, tabs, activeTabId]);
|
||||
|
||||
// Sync active tab with initial pathname on mount
|
||||
const initialSyncDone = useRef(false);
|
||||
useEffect(() => {
|
||||
if (initialSyncDone.current) return;
|
||||
initialSyncDone.current = true;
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab && activeTab.path === pathname) return;
|
||||
|
||||
// Try to find a tab matching the current URL
|
||||
const match = tabs.find((t) => t.path === pathname);
|
||||
if (match) {
|
||||
setActiveTabId(match.id);
|
||||
} else if (activeTab) {
|
||||
// Replace the active tab with current URL
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === activeTabId
|
||||
? {
|
||||
...t,
|
||||
path: pathname,
|
||||
title: getTitleForPath(pathname),
|
||||
iconKey: getIconKeyForPath(pathname),
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// URL sync: when pathname changes externally (back/forward, direct URL)
|
||||
useEffect(() => {
|
||||
if (navigatingRef.current) {
|
||||
navigatingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const activeTab = tabs.find((t) => t.id === activeTabId);
|
||||
if (activeTab?.path === pathname) return;
|
||||
|
||||
// Find existing tab with this path
|
||||
const match = tabs.find((t) => t.path === pathname);
|
||||
if (match) {
|
||||
setActiveTabId(match.id);
|
||||
} else {
|
||||
// Replace current tab
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === activeTabId
|
||||
? {
|
||||
...t,
|
||||
path: pathname,
|
||||
title: getTitleForPath(pathname),
|
||||
iconKey: getIconKeyForPath(pathname),
|
||||
}
|
||||
: t
|
||||
)
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pathname]);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Actions
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
const openTab = useCallback(
|
||||
(
|
||||
path: string,
|
||||
title: string,
|
||||
opts?: { replace?: boolean; iconKey?: string }
|
||||
) => {
|
||||
const replace = opts?.replace ?? false;
|
||||
const iconKey = opts?.iconKey ?? getIconKeyForPath(path);
|
||||
|
||||
if (replace) {
|
||||
// Sidebar nav click: find existing tab with same path or replace current
|
||||
const existing = tabs.find((t) => t.path === path);
|
||||
if (existing) {
|
||||
setActiveTabId(existing.id);
|
||||
navigatingRef.current = true;
|
||||
router.push(path);
|
||||
return;
|
||||
}
|
||||
// Replace current active tab
|
||||
setTabs((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === activeTabId
|
||||
? { ...t, path, title, iconKey, closeable: false }
|
||||
: t
|
||||
)
|
||||
);
|
||||
setActiveTabId(activeTabId); // stays the same
|
||||
navigatingRef.current = true;
|
||||
router.push(path);
|
||||
} else {
|
||||
// Open new tab (e.g., clicking an issue)
|
||||
const newTab: Tab = {
|
||||
id: crypto.randomUUID(),
|
||||
path,
|
||||
title,
|
||||
iconKey,
|
||||
closeable: true,
|
||||
};
|
||||
setTabs((prev) => {
|
||||
const idx = prev.findIndex((t) => t.id === activeTabId);
|
||||
const next = [...prev];
|
||||
next.splice(idx + 1, 0, newTab);
|
||||
return next;
|
||||
});
|
||||
setActiveTabId(newTab.id);
|
||||
navigatingRef.current = true;
|
||||
router.push(path);
|
||||
}
|
||||
},
|
||||
[tabs, activeTabId, router]
|
||||
);
|
||||
|
||||
const activateTab = useCallback(
|
||||
(tabId: string) => {
|
||||
const tab = tabs.find((t) => t.id === tabId);
|
||||
if (!tab) return;
|
||||
setActiveTabId(tabId);
|
||||
navigatingRef.current = true;
|
||||
router.push(tab.path);
|
||||
},
|
||||
[tabs, router]
|
||||
);
|
||||
|
||||
const closeTab = useCallback(
|
||||
(tabId: string) => {
|
||||
if (tabs.length <= 1) return;
|
||||
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return;
|
||||
|
||||
const next = tabs.filter((t) => t.id !== tabId);
|
||||
setTabs(next);
|
||||
|
||||
if (tabId === activeTabId) {
|
||||
// Activate neighbor: prefer left, fallback to first
|
||||
const newActive = next[Math.max(0, idx - 1)];
|
||||
if (newActive) {
|
||||
setActiveTabId(newActive.id);
|
||||
navigatingRef.current = true;
|
||||
router.push(newActive.path);
|
||||
}
|
||||
}
|
||||
},
|
||||
[tabs, activeTabId, router]
|
||||
);
|
||||
|
||||
const closeTabByPath = useCallback(
|
||||
(path: string) => {
|
||||
const tab = tabs.find((t) => t.path === path);
|
||||
if (tab) closeTab(tab.id);
|
||||
},
|
||||
[tabs, closeTab]
|
||||
);
|
||||
|
||||
const updateTabTitle = useCallback((tabId: string, title: string) => {
|
||||
setTabs((prev) =>
|
||||
prev.map((t) => (t.id === tabId ? { ...t, title } : t))
|
||||
);
|
||||
}, []);
|
||||
|
||||
const reorderTabs = useCallback((oldIndex: number, newIndex: number) => {
|
||||
setTabs((prev) => arrayMove(prev, oldIndex, newIndex));
|
||||
}, []);
|
||||
|
||||
const value: TabStoreValue = {
|
||||
tabs,
|
||||
activeTabId,
|
||||
openTab,
|
||||
activateTab,
|
||||
closeTab,
|
||||
closeTabByPath,
|
||||
updateTabTitle,
|
||||
reorderTabs,
|
||||
};
|
||||
|
||||
return (
|
||||
<TabStoreContext.Provider value={value}>{children}</TabStoreContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import type {
|
|||
UpdateMeRequest,
|
||||
CreateMemberRequest,
|
||||
UpdateMemberRequest,
|
||||
ListIssuesParams,
|
||||
Agent,
|
||||
CreateAgentRequest,
|
||||
UpdateAgentRequest,
|
||||
|
|
@ -97,12 +98,15 @@ export class ApiClient {
|
|||
}
|
||||
|
||||
// Issues
|
||||
async listIssues(params?: { limit?: number; offset?: number; workspace_id?: string }): Promise<ListIssuesResponse> {
|
||||
async listIssues(params?: ListIssuesParams): Promise<ListIssuesResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params?.limit) search.set("limit", String(params.limit));
|
||||
if (params?.offset) search.set("offset", String(params.offset));
|
||||
const wsId = params?.workspace_id ?? this.workspaceId;
|
||||
if (wsId) search.set("workspace_id", wsId);
|
||||
if (params?.status) search.set("status", params.status);
|
||||
if (params?.priority) search.set("priority", params.priority);
|
||||
if (params?.assignee_id) search.set("assignee_id", params.assignee_id);
|
||||
return this.fetch(`/api/issues?${search}`);
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +146,17 @@ export class ApiClient {
|
|||
});
|
||||
}
|
||||
|
||||
async updateComment(commentId: string, content: string): Promise<Comment> {
|
||||
return this.fetch(`/api/comments/${commentId}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(commentId: string): Promise<void> {
|
||||
await this.fetch(`/api/comments/${commentId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
// Agents
|
||||
async listAgents(params?: { workspace_id?: string }): Promise<Agent[]> {
|
||||
const search = new URLSearchParams();
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ export interface UpdateIssueRequest {
|
|||
assignee_type?: IssueAssigneeType | null;
|
||||
assignee_id?: string | null;
|
||||
position?: number;
|
||||
due_date?: string | null;
|
||||
acceptance_criteria?: string[];
|
||||
context_refs?: string[];
|
||||
repository?: { url: string; branch?: string; path?: string } | null;
|
||||
}
|
||||
|
||||
export interface ListIssuesParams {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
workspace_id?: string;
|
||||
status?: IssueStatus;
|
||||
priority?: IssuePriority;
|
||||
assignee_id?: string;
|
||||
}
|
||||
|
||||
export interface ListIssuesResponse {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import type { Issue } from "./issue.js";
|
||||
import type { Agent } from "./agent.js";
|
||||
import type { InboxItem } from "./inbox.js";
|
||||
import type { Comment } from "./comment.js";
|
||||
|
||||
// WebSocket event types (matching Go server protocol/events.go)
|
||||
export type WSEventType =
|
||||
| "issue:created"
|
||||
| "issue:updated"
|
||||
| "issue:deleted"
|
||||
| "comment:created"
|
||||
| "comment:updated"
|
||||
| "comment:deleted"
|
||||
| "agent:status"
|
||||
| "task:dispatch"
|
||||
| "task:progress"
|
||||
|
|
@ -40,3 +44,16 @@ export interface AgentStatusPayload {
|
|||
export interface InboxNewPayload {
|
||||
item: InboxItem;
|
||||
}
|
||||
|
||||
export interface CommentCreatedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentUpdatedPayload {
|
||||
comment: Comment;
|
||||
}
|
||||
|
||||
export interface CommentDeletedPayload {
|
||||
comment_id: string;
|
||||
issue_id: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,26 +13,15 @@
|
|||
"./hooks/*": "./src/hooks/*.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.7",
|
||||
"@radix-ui/react-dialog": "^1.1.7",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
||||
"@radix-ui/react-hover-card": "^1.1.7",
|
||||
"@radix-ui/react-label": "^2.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.7",
|
||||
"@radix-ui/react-select": "^2.1.7",
|
||||
"@radix-ui/react-separator": "^1.1.4",
|
||||
"@radix-ui/react-slot": "^1.2.0",
|
||||
"@radix-ui/react-switch": "^1.1.5",
|
||||
"@radix-ui/react-tabs": "^1.1.7",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"class-variance-authority": "catalog:",
|
||||
"clsx": "catalog:",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "catalog:",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "catalog:",
|
||||
"react-markdown": "^10.1.0",
|
||||
"shiki": "^3.21.0",
|
||||
|
|
|
|||
221
packages/ui/src/components/ui/calendar.tsx
Normal file
221
packages/ui/src/components/ui/calendar.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@multica/ui/lib/utils"
|
||||
import { Button, buttonVariants } from "@multica/ui/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-(--cell-radius)",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
|
|
@ -263,6 +263,11 @@
|
|||
@apply w-full max-w-4xl mx-auto;
|
||||
}
|
||||
|
||||
/* Shadcn sidebar: remove default padding from inset container */
|
||||
[data-slot="sidebar-container"] {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
|
|
|||
751
pnpm-lock.yaml
generated
751
pnpm-lock.yaml
generated
|
|
@ -179,7 +179,7 @@ importers:
|
|||
version: link:../types
|
||||
zustand:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.3)
|
||||
version: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3))
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: 'catalog:'
|
||||
|
|
@ -193,48 +193,9 @@ importers:
|
|||
|
||||
packages/ui:
|
||||
dependencies:
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-checkbox':
|
||||
specifier: ^1.3.2
|
||||
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.7
|
||||
version: 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-label':
|
||||
specifier: ^2.1.4
|
||||
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-popover':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.1.7
|
||||
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.4
|
||||
version: 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.0
|
||||
version: 1.2.4(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-switch':
|
||||
specifier: ^1.1.5
|
||||
version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-tabs':
|
||||
specifier: ^1.1.7
|
||||
version: 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.1.8
|
||||
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@base-ui/react':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
class-variance-authority:
|
||||
specifier: 'catalog:'
|
||||
version: 0.7.1
|
||||
|
|
@ -244,6 +205,9 @@ importers:
|
|||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
date-fns:
|
||||
specifier: ^4.1.0
|
||||
version: 4.1.0
|
||||
lucide-react:
|
||||
specifier: 'catalog:'
|
||||
version: 0.511.0(react@19.2.3)
|
||||
|
|
@ -253,6 +217,9 @@ importers:
|
|||
react:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3
|
||||
react-day-picker:
|
||||
specifier: ^9.14.0
|
||||
version: 9.14.0(react@19.2.3)
|
||||
react-dom:
|
||||
specifier: 'catalog:'
|
||||
version: 19.2.3(react@19.2.3)
|
||||
|
|
@ -447,6 +414,27 @@ packages:
|
|||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@base-ui/react@1.3.0':
|
||||
resolution: {integrity: sha512-FwpKqZbPz14AITp1CVgf4AjhKPe1OeeVKSBMdgD10zbFlj3QSWelmtCMLi2+/PFZZcIm3l87G7rwtCZJwHyXWA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@base-ui/utils@0.2.6':
|
||||
resolution: {integrity: sha512-yQ+qeuqohwhsNpoYDqqXaLllYAkPCP4vYdDrVo8FQXaAPfHWm1pG/Vm+jmGTA5JFS0BAIjookyapuJFY8F9PIw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^17 || ^18 || ^19
|
||||
react-dom: ^17 || ^18 || ^19
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
|
@ -487,6 +475,9 @@ packages:
|
|||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@date-fns/tz@1.4.1':
|
||||
resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
|
|
@ -875,77 +866,9 @@ packages:
|
|||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@radix-ui/number@1.1.1':
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
|
||||
'@radix-ui/primitive@1.1.3':
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7':
|
||||
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.3':
|
||||
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12':
|
||||
resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-collection@1.1.7':
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2':
|
||||
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
|
||||
peerDependencies:
|
||||
|
|
@ -977,15 +900,6 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-direction@1.1.1':
|
||||
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11':
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
|
|
@ -999,19 +913,6 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16':
|
||||
resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3':
|
||||
resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1034,19 +935,6 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.15':
|
||||
resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-id@1.1.1':
|
||||
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
|
||||
peerDependencies:
|
||||
|
|
@ -1056,58 +944,6 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-label@2.1.8':
|
||||
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-menu@2.1.16':
|
||||
resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popover@1.1.15':
|
||||
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-popper@1.2.8':
|
||||
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-portal@1.1.9':
|
||||
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1160,45 +996,6 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11':
|
||||
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-select@2.2.6':
|
||||
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.8':
|
||||
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.3':
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
|
|
@ -1217,45 +1014,6 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-switch@1.2.6':
|
||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13':
|
||||
resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8':
|
||||
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1':
|
||||
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
|
||||
peerDependencies:
|
||||
|
|
@ -1301,49 +1059,6 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1':
|
||||
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1':
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1':
|
||||
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3':
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
'@types/react': ^19.2.0
|
||||
'@types/react-dom': ^19.2.0
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/rect@1.1.1':
|
||||
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.10':
|
||||
resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
|
|
@ -1479,6 +1194,10 @@ packages:
|
|||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5':
|
||||
resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
resolution: {integrity: sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==}
|
||||
|
||||
|
|
@ -1982,6 +1701,12 @@ packages:
|
|||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
date-fns-jalali@4.1.0-0:
|
||||
resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==}
|
||||
|
||||
date-fns@4.1.0:
|
||||
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
|
|
@ -2997,6 +2722,12 @@ packages:
|
|||
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
react-day-picker@9.14.0:
|
||||
resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
react-dom@19.2.3:
|
||||
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
|
||||
peerDependencies:
|
||||
|
|
@ -3076,6 +2807,9 @@ packages:
|
|||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.1.1:
|
||||
resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
|
|
@ -3280,6 +3014,9 @@ packages:
|
|||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tabbable@6.4.0:
|
||||
resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -3437,6 +3174,11 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
use-sync-external-store@1.6.0:
|
||||
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
|
|
@ -3853,6 +3595,30 @@ snapshots:
|
|||
'@babel/helper-string-parser': 7.27.1
|
||||
'@babel/helper-validator-identifier': 7.28.5
|
||||
|
||||
'@base-ui/react@1.3.0(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@base-ui/utils': 0.2.6(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@floating-ui/utils': 0.2.11
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
tabbable: 6.4.0
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@base-ui/utils@0.2.6(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@floating-ui/utils': 0.2.11
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
reselect: 5.1.1
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
|
@ -3881,6 +3647,8 @@ snapshots:
|
|||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@date-fns/tz@1.4.1': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -4206,77 +3974,8 @@ snapshots:
|
|||
dependencies:
|
||||
playwright: 1.58.2
|
||||
|
||||
'@radix-ui/number@1.1.1': {}
|
||||
|
||||
'@radix-ui/primitive@1.1.3': {}
|
||||
|
||||
'@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -4311,12 +4010,6 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -4330,21 +4023,6 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -4362,23 +4040,6 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
|
|
@ -4386,82 +4047,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@floating-ui/react-dom': 2.1.8(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
|
|
@ -4500,61 +4085,6 @@ snapshots:
|
|||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
aria-hidden: 1.2.6
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-separator@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
|
|
@ -4569,57 +4099,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.3)
|
||||
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -4654,37 +4133,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/rect': 1.1.1
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.3)
|
||||
react: 19.2.3
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.14)
|
||||
|
||||
'@radix-ui/rect@1.1.1': {}
|
||||
|
||||
'@rolldown/binding-android-arm64@1.0.0-rc.10':
|
||||
optional: true
|
||||
|
||||
|
|
@ -4779,6 +4227,8 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@tabby_ai/hijri-converter@1.0.5': {}
|
||||
|
||||
'@tailwindcss/node@4.2.2':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
|
|
@ -5229,6 +4679,10 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
date-fns-jalali@4.1.0-0: {}
|
||||
|
||||
date-fns@4.1.0: {}
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
|
@ -6318,6 +5772,14 @@ snapshots:
|
|||
iconv-lite: 0.7.2
|
||||
unpipe: 1.0.0
|
||||
|
||||
react-day-picker@9.14.0(react@19.2.3):
|
||||
dependencies:
|
||||
'@date-fns/tz': 1.4.1
|
||||
'@tabby_ai/hijri-converter': 1.0.5
|
||||
date-fns: 4.1.0
|
||||
date-fns-jalali: 4.1.0-0
|
||||
react: 19.2.3
|
||||
|
||||
react-dom@19.2.3(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
|
@ -6416,6 +5878,8 @@ snapshots:
|
|||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.1.1: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
restore-cursor@5.1.0:
|
||||
|
|
@ -6709,6 +6173,8 @@ snapshots:
|
|||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tabbable@6.4.0: {}
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
tailwind-merge@3.5.0: {}
|
||||
|
|
@ -6856,6 +6322,10 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
|
||||
use-sync-external-store@1.6.0(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
validate-npm-package-name@7.0.2: {}
|
||||
|
|
@ -6992,9 +6462,10 @@ snapshots:
|
|||
|
||||
zod@3.25.76: {}
|
||||
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3):
|
||||
zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)):
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.14
|
||||
react: 19.2.3
|
||||
use-sync-external-store: 1.6.0(react@19.2.3)
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub) chi.Router {
|
|||
})
|
||||
})
|
||||
|
||||
// Comments
|
||||
r.Route("/api/comments/{commentId}", func(r chi.Router) {
|
||||
r.Put("/", h.UpdateComment)
|
||||
r.Delete("/", h.DeleteComment)
|
||||
})
|
||||
|
||||
// Agents
|
||||
r.Route("/api/agents", func(r chi.Router) {
|
||||
r.Get("/", h.ListAgents)
|
||||
|
|
|
|||
|
|
@ -96,5 +96,58 @@ func (h *Handler) CreateComment(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusCreated, commentToResponse(comment))
|
||||
resp := commentToResponse(comment)
|
||||
h.broadcast("comment:created", map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if req.Content == "" {
|
||||
writeError(w, http.StatusBadRequest, "content is required")
|
||||
return
|
||||
}
|
||||
|
||||
comment, err := h.Queries.UpdateComment(r.Context(), db.UpdateCommentParams{
|
||||
ID: parseUUID(commentId),
|
||||
Content: req.Content,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to update comment")
|
||||
return
|
||||
}
|
||||
|
||||
resp := commentToResponse(comment)
|
||||
h.broadcast("comment:updated", map[string]any{"comment": resp})
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteComment(w http.ResponseWriter, r *http.Request) {
|
||||
commentId := chi.URLParam(r, "commentId")
|
||||
|
||||
// Get the comment first to know the issue_id for the broadcast
|
||||
comment, err := h.Queries.GetComment(r.Context(), parseUUID(commentId))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "comment not found")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Queries.DeleteComment(r.Context(), parseUUID(commentId)); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to delete comment")
|
||||
return
|
||||
}
|
||||
|
||||
h.broadcast("comment:deleted", map[string]any{
|
||||
"comment_id": commentId,
|
||||
"issue_id": uuidToString(comment.IssueID),
|
||||
})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@ package handler
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
|
@ -97,10 +99,27 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Parse optional filter params
|
||||
var statusFilter pgtype.Text
|
||||
if s := r.URL.Query().Get("status"); s != "" {
|
||||
statusFilter = pgtype.Text{String: s, Valid: true}
|
||||
}
|
||||
var priorityFilter pgtype.Text
|
||||
if p := r.URL.Query().Get("priority"); p != "" {
|
||||
priorityFilter = pgtype.Text{String: p, Valid: true}
|
||||
}
|
||||
var assigneeFilter pgtype.UUID
|
||||
if a := r.URL.Query().Get("assignee_id"); a != "" {
|
||||
assigneeFilter = parseUUID(a)
|
||||
}
|
||||
|
||||
issues, err := h.Queries.ListIssues(ctx, db.ListIssuesParams{
|
||||
WorkspaceID: parseUUID(workspaceID),
|
||||
Limit: int32(limit),
|
||||
Offset: int32(offset),
|
||||
Status: statusFilter,
|
||||
Priority: priorityFilter,
|
||||
AssigneeID: assigneeFilter,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "failed to list issues")
|
||||
|
|
@ -249,31 +268,52 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
type UpdateIssueRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
Priority *string `json:"priority"`
|
||||
AssigneeType *string `json:"assignee_type"`
|
||||
AssigneeID *string `json:"assignee_id"`
|
||||
Position *float64 `json:"position"`
|
||||
DueDate *string `json:"due_date"`
|
||||
AcceptanceCriteria *[]any `json:"acceptance_criteria"`
|
||||
ContextRefs *[]any `json:"context_refs"`
|
||||
Repository *any `json:"repository"`
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if _, ok := h.loadIssueForUser(w, r, id); !ok {
|
||||
current, ok := h.loadIssueForUser(w, r, id)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Read body as raw bytes so we can detect which fields were explicitly sent
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "failed to read request body")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateIssueRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
if err := json.Unmarshal(bodyBytes, &req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Track which fields were explicitly present in JSON (even if null)
|
||||
var rawFields map[string]json.RawMessage
|
||||
json.Unmarshal(bodyBytes, &rawFields)
|
||||
|
||||
// Pre-fill nullable fields (bare sqlc.narg) with current values
|
||||
params := db.UpdateIssueParams{
|
||||
ID: parseUUID(id),
|
||||
ID: current.ID,
|
||||
AssigneeType: current.AssigneeType,
|
||||
AssigneeID: current.AssigneeID,
|
||||
DueDate: current.DueDate,
|
||||
}
|
||||
|
||||
// COALESCE fields — only set when explicitly provided
|
||||
if req.Title != nil {
|
||||
params.Title = pgtype.Text{String: *req.Title, Valid: true}
|
||||
}
|
||||
|
|
@ -286,15 +326,49 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
|
|||
if req.Priority != nil {
|
||||
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
|
||||
}
|
||||
if req.AssigneeType != nil {
|
||||
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
}
|
||||
if req.AssigneeID != nil {
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
}
|
||||
if req.Position != nil {
|
||||
params.Position = pgtype.Float8{Float64: *req.Position, Valid: true}
|
||||
}
|
||||
if req.AcceptanceCriteria != nil {
|
||||
ac, _ := json.Marshal(*req.AcceptanceCriteria)
|
||||
params.AcceptanceCriteria = ac
|
||||
}
|
||||
if req.ContextRefs != nil {
|
||||
cr, _ := json.Marshal(*req.ContextRefs)
|
||||
params.ContextRefs = cr
|
||||
}
|
||||
if req.Repository != nil {
|
||||
repo, _ := json.Marshal(*req.Repository)
|
||||
params.Repository = repo
|
||||
}
|
||||
|
||||
// Nullable fields — only override when explicitly present in JSON
|
||||
if _, ok := rawFields["assignee_type"]; ok {
|
||||
if req.AssigneeType != nil {
|
||||
params.AssigneeType = pgtype.Text{String: *req.AssigneeType, Valid: true}
|
||||
} else {
|
||||
params.AssigneeType = pgtype.Text{Valid: false} // explicit null = unassign
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["assignee_id"]; ok {
|
||||
if req.AssigneeID != nil {
|
||||
params.AssigneeID = parseUUID(*req.AssigneeID)
|
||||
} else {
|
||||
params.AssigneeID = pgtype.UUID{Valid: false} // explicit null = unassign
|
||||
}
|
||||
}
|
||||
if _, ok := rawFields["due_date"]; ok {
|
||||
if req.DueDate != nil && *req.DueDate != "" {
|
||||
t, err := time.Parse(time.RFC3339, *req.DueDate)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
|
||||
return
|
||||
}
|
||||
params.DueDate = pgtype.Timestamptz{Time: t, Valid: true}
|
||||
} else {
|
||||
params.DueDate = pgtype.Timestamptz{Valid: false} // explicit null = clear date
|
||||
}
|
||||
}
|
||||
|
||||
issue, err := h.Queries.UpdateIssue(r.Context(), params)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -123,6 +123,9 @@ func (q *Queries) GetIssue(ctx context.Context, id pgtype.UUID) (Issue, error) {
|
|||
const listIssues = `-- name: ListIssues :many
|
||||
SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND ($4::text IS NULL OR status = $4)
|
||||
AND ($5::text IS NULL OR priority = $5)
|
||||
AND ($6::uuid IS NULL OR assignee_id = $6)
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`
|
||||
|
|
@ -131,10 +134,20 @@ type ListIssuesParams struct {
|
|||
WorkspaceID pgtype.UUID `json:"workspace_id"`
|
||||
Limit int32 `json:"limit"`
|
||||
Offset int32 `json:"offset"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
}
|
||||
|
||||
func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue, error) {
|
||||
rows, err := q.db.Query(ctx, listIssues, arg.WorkspaceID, arg.Limit, arg.Offset)
|
||||
rows, err := q.db.Query(ctx, listIssues,
|
||||
arg.WorkspaceID,
|
||||
arg.Limit,
|
||||
arg.Offset,
|
||||
arg.Status,
|
||||
arg.Priority,
|
||||
arg.AssigneeID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -181,20 +194,28 @@ UPDATE issue SET
|
|||
assignee_type = $6,
|
||||
assignee_id = $7,
|
||||
position = COALESCE($8, position),
|
||||
due_date = $9,
|
||||
acceptance_criteria = COALESCE($10, acceptance_criteria),
|
||||
context_refs = COALESCE($11, context_refs),
|
||||
repository = COALESCE($12, repository),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, repository, position, due_date, created_at, updated_at
|
||||
`
|
||||
|
||||
type UpdateIssueParams struct {
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
ID pgtype.UUID `json:"id"`
|
||||
Title pgtype.Text `json:"title"`
|
||||
Description pgtype.Text `json:"description"`
|
||||
Status pgtype.Text `json:"status"`
|
||||
Priority pgtype.Text `json:"priority"`
|
||||
AssigneeType pgtype.Text `json:"assignee_type"`
|
||||
AssigneeID pgtype.UUID `json:"assignee_id"`
|
||||
Position pgtype.Float8 `json:"position"`
|
||||
DueDate pgtype.Timestamptz `json:"due_date"`
|
||||
AcceptanceCriteria []byte `json:"acceptance_criteria"`
|
||||
ContextRefs []byte `json:"context_refs"`
|
||||
Repository []byte `json:"repository"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue, error) {
|
||||
|
|
@ -207,6 +228,10 @@ func (q *Queries) UpdateIssue(ctx context.Context, arg UpdateIssueParams) (Issue
|
|||
arg.AssigneeType,
|
||||
arg.AssigneeID,
|
||||
arg.Position,
|
||||
arg.DueDate,
|
||||
arg.AcceptanceCriteria,
|
||||
arg.ContextRefs,
|
||||
arg.Repository,
|
||||
)
|
||||
var i Issue
|
||||
err := row.Scan(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
-- name: ListIssues :many
|
||||
SELECT * FROM issue
|
||||
WHERE workspace_id = $1
|
||||
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
|
||||
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
|
||||
AND (sqlc.narg('assignee_id')::uuid IS NULL OR assignee_id = sqlc.narg('assignee_id'))
|
||||
ORDER BY position ASC, created_at DESC
|
||||
LIMIT $2 OFFSET $3;
|
||||
|
||||
|
|
@ -27,6 +30,10 @@ UPDATE issue SET
|
|||
assignee_type = sqlc.narg('assignee_type'),
|
||||
assignee_id = sqlc.narg('assignee_id'),
|
||||
position = COALESCE(sqlc.narg('position'), position),
|
||||
due_date = sqlc.narg('due_date'),
|
||||
acceptance_criteria = COALESCE(sqlc.narg('acceptance_criteria'), acceptance_criteria),
|
||||
context_refs = COALESCE(sqlc.narg('context_refs'), context_refs),
|
||||
repository = COALESCE(sqlc.narg('repository'), repository),
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue