- {/* Description */}
{issue.description && (
- {/* Activity */}
+ {/* Activity / Comments */}
- {/* ================================================================
- RIGHT: Properties sidebar
- ================================================================ */}
+ {/* RIGHT: Properties sidebar */}
@@ -256,10 +257,14 @@ export default function IssueDetailPage({
- {issue.assignee ? (
+ {issue.assignee_type && issue.assignee_id ? (
<>
-
- {issue.assignee.name}
+
+ {getActorName(issue.assignee_type, issue.assignee_id)}
>
) : (
Unassigned
@@ -267,9 +272,9 @@ export default function IssueDetailPage({
- {issue.dueDate ? (
+ {issue.due_date ? (
- {shortDate(issue.dueDate)}
+ {shortDate(issue.due_date)}
) : (
None
@@ -277,17 +282,21 @@ export default function IssueDetailPage({
-
- {issue.creator.name}
+
+ {getActorName(issue.creator_type, issue.creator_id)}
- {shortDate(issue.createdAt)}
+ {shortDate(issue.created_at)}
- {shortDate(issue.updatedAt)}
+ {shortDate(issue.updated_at)}
diff --git a/apps/web/app/(dashboard)/issues/_data/mock.ts b/apps/web/app/(dashboard)/issues/_data/mock.ts
index d746aee6..082a0ea0 100644
--- a/apps/web/app/(dashboard)/issues/_data/mock.ts
+++ b/apps/web/app/(dashboard)/issues/_data/mock.ts
@@ -95,7 +95,12 @@ export const PRIORITY_CONFIG: Record<
// Mock Issues
// ---------------------------------------------------------------------------
-const { jiayuan, bohan, yuzhen, claude1, codex1, reviewBot } = PEOPLE;
+const jiayuan = PEOPLE["jiayuan"]!;
+const bohan = PEOPLE["bohan"]!;
+const yuzhen = PEOPLE["yuzhen"]!;
+const claude1 = PEOPLE["claude1"]!;
+const codex1 = PEOPLE["codex1"]!;
+const reviewBot = PEOPLE["reviewBot"]!;
export const MOCK_ISSUES: MockIssue[] = [
// ---- Backlog ----
diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx
index a033d6f1..127b606a 100644
--- a/apps/web/app/(dashboard)/issues/page.tsx
+++ b/apps/web/app/(dashboard)/issues/page.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useCallback } from "react";
+import { useState, useCallback, useEffect } from "react";
import Link from "next/link";
import {
Columns3,
@@ -15,7 +15,6 @@ import {
CircleAlert,
Eye,
Minus,
- MessageSquare,
} from "lucide-react";
import {
DndContext,
@@ -30,14 +29,12 @@ import {
} from "@dnd-kit/core";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import type { IssueStatus, IssuePriority } from "@multica/types";
-import {
- MOCK_ISSUES,
- STATUS_CONFIG,
- PRIORITY_CONFIG,
- type MockIssue,
- type MockAssignee,
-} from "./_data/mock";
+import type { Issue, IssueStatus, IssuePriority } from "@multica/types";
+import { STATUS_CONFIG, PRIORITY_CONFIG } from "./_data/mock";
+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
@@ -98,27 +95,30 @@ export function PriorityIcon({
}
function AssigneeAvatar({
- assignee,
+ issue,
size = "sm",
}: {
- assignee: MockAssignee | null;
+ issue: Issue;
size?: "sm" | "md";
}) {
- if (!assignee) return null;
+ const { getActorName, getActorInitials } = useAuth();
+ if (!issue.assignee_type || !issue.assignee_id) return null;
+ const name = getActorName(issue.assignee_type, issue.assignee_id);
+ const initials = getActorInitials(issue.assignee_type, issue.assignee_id);
const sizeClass = size === "sm" ? "h-5 w-5 text-[10px]" : "h-6 w-6 text-xs";
return (
- {assignee.type === "agent" ? (
+ {issue.assignee_type === "agent" ? (
) : (
- assignee.avatar.charAt(0)
+ initials
)}
);
@@ -132,30 +132,24 @@ function formatDate(date: string): string {
}
// ---------------------------------------------------------------------------
-// Board View — Card (static, used in both draggable wrapper and overlay)
+// Board View — Card
// ---------------------------------------------------------------------------
-function BoardCardContent({ issue }: { issue: MockIssue }) {
+function BoardCardContent({ issue }: { issue: Issue }) {
return (
-
{issue.key}
+
{issue.id.slice(0, 8)}
{issue.title}
-
- {issue.comments.length > 0 && (
-
-
- {issue.comments.length}
-
- )}
+
- {issue.dueDate && (
+ {issue.due_date && (
- {formatDate(issue.dueDate)}
+ {formatDate(issue.due_date)}
)}
@@ -167,7 +161,7 @@ function BoardCardContent({ issue }: { issue: MockIssue }) {
// Draggable card wrapper
// ---------------------------------------------------------------------------
-function DraggableBoardCard({ issue }: { issue: MockIssue }) {
+function DraggableBoardCard({ issue }: { issue: Issue }) {
const {
attributes,
listeners,
@@ -196,7 +190,6 @@ function DraggableBoardCard({ issue }: { issue: MockIssue }) {
{
- // Prevent navigation when dragging
if (isDragging) e.preventDefault();
}}
className="block transition-colors hover:opacity-80"
@@ -216,7 +209,7 @@ function DroppableColumn({
issues,
}: {
status: IssueStatus;
- issues: MockIssue[];
+ issues: Issue[];
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
@@ -250,10 +243,10 @@ function BoardView({
issues,
onMoveIssue,
}: {
- issues: MockIssue[];
+ issues: Issue[];
onMoveIssue: (issueId: string, newStatus: IssueStatus) => void;
}) {
- const [activeIssue, setActiveIssue] = useState
(null);
+ const [activeIssue, setActiveIssue] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, {
@@ -284,14 +277,11 @@ function BoardView({
if (!over) return;
const issueId = active.id as string;
- // `over.id` is the column's droppable id (a status string)
- // or another card's sortable id
let targetStatus: IssueStatus | undefined;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
} else {
- // Dropped on a card — find which column that card is in
const targetIssue = issues.find((i) => i.id === over.id);
if (targetIssue) targetStatus = targetIssue.status;
}
@@ -338,27 +328,29 @@ function BoardView({
// List View
// ---------------------------------------------------------------------------
-function ListRow({ issue }: { issue: MockIssue }) {
+function ListRow({ issue }: { issue: Issue }) {
return (
- {issue.key}
+
+ {issue.id.slice(0, 8)}
+
{issue.title}
- {issue.dueDate && (
+ {issue.due_date && (
- {formatDate(issue.dueDate)}
+ {formatDate(issue.due_date)}
)}
-
+
);
}
-function ListView({ issues }: { issues: MockIssue[] }) {
+function ListView({ issues }: { issues: Issue[] }) {
const groupOrder: IssueStatus[] = [
"in_review",
"in_progress",
@@ -390,6 +382,69 @@ function ListView({ issues }: { issues: MockIssue[] }) {
);
}
+// ---------------------------------------------------------------------------
+// Create Issue Dialog (simple inline)
+// ---------------------------------------------------------------------------
+
+function CreateIssueForm({ onCreated }: { onCreated: (issue: Issue) => void }) {
+ const [title, setTitle] = useState("");
+ const [isOpen, setIsOpen] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim()) return;
+ try {
+ const issue = await api.createIssue({ title: title.trim() });
+ onCreated(issue);
+ setTitle("");
+ setIsOpen(false);
+ } catch (err) {
+ console.error("Failed to create issue:", err);
+ }
+ };
+
+ if (!isOpen) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
+
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
@@ -398,21 +453,78 @@ type ViewMode = "board" | "list";
export default function IssuesPage() {
const [view, setView] = useState("board");
- const [issues, setIssues] = useState(MOCK_ISSUES);
+ const [issues, setIssues] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ api
+ .listIssues({ limit: 200 })
+ .then((res) => {
+ setIssues(res.issues);
+ })
+ .catch(console.error)
+ .finally(() => setLoading(false));
+ }, []);
+
+ // Real-time updates
+ useWSEvent(
+ "issue:created",
+ useCallback((payload: unknown) => {
+ const { issue } = payload as IssueCreatedPayload;
+ setIssues((prev) => {
+ if (prev.some((i) => i.id === issue.id)) return prev;
+ return [...prev, issue];
+ });
+ }, []),
+ );
+
+ useWSEvent(
+ "issue:updated",
+ useCallback((payload: unknown) => {
+ const { issue } = payload as IssueUpdatedPayload;
+ setIssues((prev) => prev.map((i) => (i.id === issue.id ? issue : i)));
+ }, []),
+ );
+
+ useWSEvent(
+ "issue:deleted",
+ useCallback((payload: unknown) => {
+ const { issue_id } = payload as IssueDeletedPayload;
+ setIssues((prev) => prev.filter((i) => i.id !== issue_id));
+ }, []),
+ );
const handleMoveIssue = useCallback(
(issueId: string, newStatus: IssueStatus) => {
+ // Optimistic update
setIssues((prev) =>
prev.map((issue) =>
- issue.id === issueId
- ? { ...issue, status: newStatus, updatedAt: new Date().toISOString() }
- : issue
+ issue.id === issueId ? { ...issue, status: newStatus } : issue
)
);
+
+ // Persist to API
+ api.updateIssue(issueId, { status: newStatus }).catch((err) => {
+ console.error("Failed to update issue:", err);
+ // Revert on error
+ api.listIssues({ limit: 200 }).then((res) => setIssues(res.issues));
+ });
},
[]
);
+ const handleIssueCreated = useCallback((issue: Issue) => {
+ setIssues((prev) => [...prev, issue]);
+ }, []);
+
+ if (loading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
return (
{/* Toolbar */}
@@ -444,10 +556,7 @@ export default function IssuesPage() {
-
+
diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx
index e20cf58b..cd03bcf4 100644
--- a/apps/web/app/(dashboard)/layout.tsx
+++ b/apps/web/app/(dashboard)/layout.tsx
@@ -1,15 +1,22 @@
"use client";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+import { usePathname, useRouter } from "next/navigation";
+import { useEffect, useState, useCallback } from "react";
import {
Inbox,
ListTodo,
Bot,
BookOpen,
ChevronDown,
+ Settings,
+ LogOut,
+ Plus,
} from "lucide-react";
import { MulticaIcon } from "@multica/ui/components/multica-icon";
+import { useAuth } from "../../lib/auth-context";
+import type { Workspace } from "@multica/types";
+import { api } from "../../lib/api";
const navItems = [
{ href: "/inbox", label: "Inbox", icon: Inbox },
@@ -24,18 +31,75 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const pathname = usePathname();
+ const router = useRouter();
+ const { user, workspace, isLoading, logout } = useAuth();
+ const [showMenu, setShowMenu] = useState(false);
+
+ useEffect(() => {
+ if (!isLoading && !user) {
+ router.push("/login");
+ }
+ }, [user, isLoading, router]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!user) return null;
return (
- {/* Sidebar — sits on the canvas layer */}
+ {/* Sidebar */}