feat: projects with context - schema, API, frontend page, task assignment (HQ-17, HQ-21)
This commit is contained in:
@@ -38,6 +38,29 @@ export interface ProgressNote {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Projects ───
|
||||||
|
|
||||||
|
export interface ProjectLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projects = pgTable("projects", {
|
||||||
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
context: text("context"), // Architecture notes, how-to, credentials references
|
||||||
|
repos: jsonb("repos").$type<string[]>().default([]), // Git repo URLs
|
||||||
|
links: jsonb("links").$type<ProjectLink[]>().default([]), // Related URLs (docs, domains, dashboards)
|
||||||
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type Project = typeof projects.$inferSelect;
|
||||||
|
export type NewProject = typeof projects.$inferInsert;
|
||||||
|
|
||||||
|
// ─── Tasks ───
|
||||||
|
|
||||||
export const tasks = pgTable("tasks", {
|
export const tasks = pgTable("tasks", {
|
||||||
id: uuid("id").defaultRandom().primaryKey(),
|
id: uuid("id").defaultRandom().primaryKey(),
|
||||||
taskNumber: integer("task_number"),
|
taskNumber: integer("task_number"),
|
||||||
@@ -49,6 +72,7 @@ export const tasks = pgTable("tasks", {
|
|||||||
position: integer("position").notNull().default(0),
|
position: integer("position").notNull().default(0),
|
||||||
assigneeId: text("assignee_id"),
|
assigneeId: text("assignee_id"),
|
||||||
assigneeName: text("assignee_name"),
|
assigneeName: text("assignee_name"),
|
||||||
|
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||||
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
progressNotes: jsonb("progress_notes").$type<ProgressNote[]>().default([]),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
|
|||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
import { taskRoutes } from "./routes/tasks";
|
import { taskRoutes } from "./routes/tasks";
|
||||||
import { adminRoutes } from "./routes/admin";
|
import { adminRoutes } from "./routes/admin";
|
||||||
|
import { projectRoutes } from "./routes/projects";
|
||||||
import { auth } from "./lib/auth";
|
import { auth } from "./lib/auth";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import { tasks, users } from "./db/schema";
|
import { tasks, users } from "./db/schema";
|
||||||
@@ -113,6 +114,7 @@ const app = new Elysia()
|
|||||||
})
|
})
|
||||||
|
|
||||||
.use(taskRoutes)
|
.use(taskRoutes)
|
||||||
|
.use(projectRoutes)
|
||||||
.use(adminRoutes)
|
.use(adminRoutes)
|
||||||
|
|
||||||
// Current user info (role, etc.)
|
// Current user info (role, etc.)
|
||||||
|
|||||||
153
backend/src/routes/projects.ts
Normal file
153
backend/src/routes/projects.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Elysia, t } from "elysia";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { projects, tasks } from "../db/schema";
|
||||||
|
import { eq, asc, desc } from "drizzle-orm";
|
||||||
|
import { auth } from "../lib/auth";
|
||||||
|
|
||||||
|
const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token";
|
||||||
|
|
||||||
|
async function requireSessionOrBearer(
|
||||||
|
request: Request,
|
||||||
|
headers: Record<string, string | undefined>
|
||||||
|
) {
|
||||||
|
const authHeader = headers["authorization"];
|
||||||
|
if (authHeader === `Bearer ${BEARER_TOKEN}`) return;
|
||||||
|
try {
|
||||||
|
const session = await auth.api.getSession({ headers: request.headers });
|
||||||
|
if (session) return;
|
||||||
|
} catch {}
|
||||||
|
throw new Error("Unauthorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const projectRoutes = new Elysia({ prefix: "/api/projects" })
|
||||||
|
.onError(({ error, set }) => {
|
||||||
|
const msg = error?.message || String(error);
|
||||||
|
if (msg === "Unauthorized") {
|
||||||
|
set.status = 401;
|
||||||
|
return { error: "Unauthorized" };
|
||||||
|
}
|
||||||
|
if (msg === "Project not found") {
|
||||||
|
set.status = 404;
|
||||||
|
return { error: "Project not found" };
|
||||||
|
}
|
||||||
|
console.error("Project route error:", msg);
|
||||||
|
set.status = 500;
|
||||||
|
return { error: "Internal server error" };
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET all projects
|
||||||
|
.get("/", async ({ request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const allProjects = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.orderBy(asc(projects.name));
|
||||||
|
return allProjects;
|
||||||
|
})
|
||||||
|
|
||||||
|
// POST create project
|
||||||
|
.post(
|
||||||
|
"/",
|
||||||
|
async ({ body, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const newProject = await db
|
||||||
|
.insert(projects)
|
||||||
|
.values({
|
||||||
|
name: body.name,
|
||||||
|
description: body.description,
|
||||||
|
context: body.context,
|
||||||
|
repos: body.repos || [],
|
||||||
|
links: body.links || [],
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
return newProject[0];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
body: t.Object({
|
||||||
|
name: t.String(),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
context: t.Optional(t.String()),
|
||||||
|
repos: t.Optional(t.Array(t.String())),
|
||||||
|
links: t.Optional(
|
||||||
|
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// GET single project with its tasks
|
||||||
|
.get(
|
||||||
|
"/:id",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const project = await db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, params.id));
|
||||||
|
if (!project.length) throw new Error("Project not found");
|
||||||
|
|
||||||
|
const projectTasks = await db
|
||||||
|
.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.projectId, params.id))
|
||||||
|
.orderBy(asc(tasks.position), desc(tasks.createdAt));
|
||||||
|
|
||||||
|
return { ...project[0], tasks: projectTasks };
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// PATCH update project
|
||||||
|
.patch(
|
||||||
|
"/:id",
|
||||||
|
async ({ params, body, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
const updates: Record<string, any> = { updatedAt: new Date() };
|
||||||
|
if (body.name !== undefined) updates.name = body.name;
|
||||||
|
if (body.description !== undefined) updates.description = body.description;
|
||||||
|
if (body.context !== undefined) updates.context = body.context;
|
||||||
|
if (body.repos !== undefined) updates.repos = body.repos;
|
||||||
|
if (body.links !== undefined) updates.links = body.links;
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.update(projects)
|
||||||
|
.set(updates)
|
||||||
|
.where(eq(projects.id, params.id))
|
||||||
|
.returning();
|
||||||
|
if (!updated.length) throw new Error("Project not found");
|
||||||
|
return updated[0];
|
||||||
|
},
|
||||||
|
{
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
name: t.Optional(t.String()),
|
||||||
|
description: t.Optional(t.String()),
|
||||||
|
context: t.Optional(t.String()),
|
||||||
|
repos: t.Optional(t.Array(t.String())),
|
||||||
|
links: t.Optional(
|
||||||
|
t.Array(t.Object({ label: t.String(), url: t.String() }))
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// DELETE project (unlinks tasks, doesn't delete them)
|
||||||
|
.delete(
|
||||||
|
"/:id",
|
||||||
|
async ({ params, request, headers }) => {
|
||||||
|
await requireSessionOrBearer(request, headers);
|
||||||
|
// Unlink tasks first
|
||||||
|
await db
|
||||||
|
.update(tasks)
|
||||||
|
.set({ projectId: null, updatedAt: new Date() })
|
||||||
|
.where(eq(tasks.projectId, params.id));
|
||||||
|
// Delete project
|
||||||
|
const deleted = await db
|
||||||
|
.delete(projects)
|
||||||
|
.where(eq(projects.id, params.id))
|
||||||
|
.returning();
|
||||||
|
if (!deleted.length) throw new Error("Project not found");
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
{ params: t.Object({ id: t.String() }) }
|
||||||
|
);
|
||||||
@@ -152,6 +152,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
priority: body.priority || "medium",
|
priority: body.priority || "medium",
|
||||||
position: (maxPos[0]?.max ?? 0) + 1,
|
position: (maxPos[0]?.max ?? 0) + 1,
|
||||||
taskNumber: nextNumber,
|
taskNumber: nextNumber,
|
||||||
|
projectId: body.projectId || null,
|
||||||
progressNotes: [],
|
progressNotes: [],
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -188,6 +189,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
t.Literal("low"),
|
t.Literal("low"),
|
||||||
])
|
])
|
||||||
),
|
),
|
||||||
|
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -233,6 +235,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
if (body.position !== undefined) updates.position = body.position;
|
if (body.position !== undefined) updates.position = body.position;
|
||||||
if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId;
|
if (body.assigneeId !== undefined) updates.assigneeId = body.assigneeId;
|
||||||
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
|
if (body.assigneeName !== undefined) updates.assigneeName = body.assigneeName;
|
||||||
|
if (body.projectId !== undefined) updates.projectId = body.projectId;
|
||||||
|
|
||||||
const updated = await db
|
const updated = await db
|
||||||
.update(tasks)
|
.update(tasks)
|
||||||
@@ -259,6 +262,7 @@ export const taskRoutes = new Elysia({ prefix: "/api/tasks" })
|
|||||||
position: t.Optional(t.Number()),
|
position: t.Optional(t.Number()),
|
||||||
assigneeId: t.Optional(t.Union([t.String(), t.Null()])),
|
assigneeId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
|
assigneeName: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
|
projectId: t.Optional(t.Union([t.String(), t.Null()])),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
|||||||
import { DashboardLayout } from "./components/DashboardLayout";
|
import { DashboardLayout } from "./components/DashboardLayout";
|
||||||
import { QueuePage } from "./pages/QueuePage";
|
import { QueuePage } from "./pages/QueuePage";
|
||||||
import { ChatPage } from "./pages/ChatPage";
|
import { ChatPage } from "./pages/ChatPage";
|
||||||
|
import { ProjectsPage } from "./pages/ProjectsPage";
|
||||||
import { AdminPage } from "./components/AdminPage";
|
import { AdminPage } from "./components/AdminPage";
|
||||||
import { LoginPage } from "./components/LoginPage";
|
import { LoginPage } from "./components/LoginPage";
|
||||||
import { useSession } from "./lib/auth-client";
|
import { useSession } from "./lib/auth-client";
|
||||||
@@ -12,6 +13,7 @@ function AuthenticatedApp() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<DashboardLayout />}>
|
<Route element={<DashboardLayout />}>
|
||||||
<Route path="/queue" element={<QueuePage />} />
|
<Route path="/queue" element={<QueuePage />} />
|
||||||
|
<Route path="/projects" element={<ProjectsPage />} />
|
||||||
<Route path="/chat" element={<ChatPage />} />
|
<Route path="/chat" element={<ChatPage />} />
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
<Route path="/admin" element={<AdminPage />} />
|
||||||
<Route path="*" element={<Navigate to="/queue" replace />} />
|
<Route path="*" element={<Navigate to="/queue" replace />} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { signOut } from "../lib/auth-client";
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/queue", label: "Queue", icon: "📋" },
|
{ to: "/queue", label: "Queue", icon: "📋" },
|
||||||
|
{ to: "/projects", label: "Projects", icon: "📁" },
|
||||||
{ to: "/chat", label: "Chat", icon: "💬" },
|
{ to: "/chat", label: "Chat", icon: "💬" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import type { Task, TaskStatus, TaskPriority, TaskSource } from "../lib/types";
|
import type { Task, TaskStatus, TaskPriority, TaskSource, Project } from "../lib/types";
|
||||||
import { updateTask } from "../lib/api";
|
import { updateTask, fetchProjects } from "../lib/api";
|
||||||
|
|
||||||
const priorityColors: Record<TaskPriority, string> = {
|
const priorityColors: Record<TaskPriority, string> = {
|
||||||
critical: "bg-red-500 text-white",
|
critical: "bg-red-500 text-white",
|
||||||
@@ -250,6 +250,13 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
const [draftDescription, setDraftDescription] = useState(task.description || "");
|
const [draftDescription, setDraftDescription] = useState(task.description || "");
|
||||||
const [draftPriority, setDraftPriority] = useState(task.priority);
|
const [draftPriority, setDraftPriority] = useState(task.priority);
|
||||||
const [draftSource, setDraftSource] = useState(task.source);
|
const [draftSource, setDraftSource] = useState(task.source);
|
||||||
|
const [draftProjectId, setDraftProjectId] = useState(task.projectId || "");
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
|
||||||
|
// Fetch projects for the selector
|
||||||
|
useEffect(() => {
|
||||||
|
fetchProjects().then(setProjects).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Reset drafts when task changes
|
// Reset drafts when task changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -257,31 +264,35 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
setDraftDescription(task.description || "");
|
setDraftDescription(task.description || "");
|
||||||
setDraftPriority(task.priority);
|
setDraftPriority(task.priority);
|
||||||
setDraftSource(task.source);
|
setDraftSource(task.source);
|
||||||
}, [task.id, task.title, task.description, task.priority, task.source]);
|
setDraftProjectId(task.projectId || "");
|
||||||
|
}, [task.id, task.title, task.description, task.priority, task.source, task.projectId]);
|
||||||
|
|
||||||
// Detect if any field has been modified
|
// Detect if any field has been modified
|
||||||
const isDirty =
|
const isDirty =
|
||||||
draftTitle !== task.title ||
|
draftTitle !== task.title ||
|
||||||
draftDescription !== (task.description || "") ||
|
draftDescription !== (task.description || "") ||
|
||||||
draftPriority !== task.priority ||
|
draftPriority !== task.priority ||
|
||||||
draftSource !== task.source;
|
draftSource !== task.source ||
|
||||||
|
draftProjectId !== (task.projectId || "");
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setDraftTitle(task.title);
|
setDraftTitle(task.title);
|
||||||
setDraftDescription(task.description || "");
|
setDraftDescription(task.description || "");
|
||||||
setDraftPriority(task.priority);
|
setDraftPriority(task.priority);
|
||||||
setDraftSource(task.source);
|
setDraftSource(task.source);
|
||||||
|
setDraftProjectId(task.projectId || "");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!hasToken || !isDirty) return;
|
if (!hasToken || !isDirty) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const updates: Record<string, string> = {};
|
const updates: Record<string, string | null> = {};
|
||||||
if (draftTitle !== task.title) updates.title = draftTitle.trim();
|
if (draftTitle !== task.title) updates.title = draftTitle.trim();
|
||||||
if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim();
|
if (draftDescription !== (task.description || "")) updates.description = draftDescription.trim();
|
||||||
if (draftPriority !== task.priority) updates.priority = draftPriority;
|
if (draftPriority !== task.priority) updates.priority = draftPriority;
|
||||||
if (draftSource !== task.source) updates.source = draftSource;
|
if (draftSource !== task.source) updates.source = draftSource;
|
||||||
|
if (draftProjectId !== (task.projectId || "")) updates.projectId = draftProjectId || null;
|
||||||
await updateTask(task.id, updates, token);
|
await updateTask(task.id, updates, token);
|
||||||
onTaskUpdated();
|
onTaskUpdated();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -426,6 +437,29 @@ export function TaskDetailPanel({ task, onClose, onStatusChange, onTaskUpdated,
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Project */}
|
||||||
|
{(hasToken || task.projectId) && (
|
||||||
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Project</h3>
|
||||||
|
{hasToken ? (
|
||||||
|
<select
|
||||||
|
value={draftProjectId}
|
||||||
|
onChange={(e) => setDraftProjectId(e.target.value)}
|
||||||
|
className="text-sm border border-gray-200 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-amber-200 bg-white w-full max-w-xs"
|
||||||
|
>
|
||||||
|
<option value="">No project</option>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>{p.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{projects.find((p) => p.id === task.projectId)?.name || task.projectId || "None"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
<div className="px-4 sm:px-6 py-4 border-b border-gray-100">
|
||||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
|
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">Description</h3>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Task } from "./types";
|
import type { Task, Project, ProjectWithTasks } from "./types";
|
||||||
|
|
||||||
const BASE = "/api/tasks";
|
const BASE = "/api/tasks";
|
||||||
|
|
||||||
@@ -64,6 +64,57 @@ export async function deleteTask(id: string, token?: string): Promise<void> {
|
|||||||
if (!res.ok) throw new Error("Failed to delete task");
|
if (!res.ok) throw new Error("Failed to delete task");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Projects API ───
|
||||||
|
|
||||||
|
const PROJECTS_BASE = "/api/projects";
|
||||||
|
|
||||||
|
export async function fetchProjects(): Promise<Project[]> {
|
||||||
|
const res = await fetch(PROJECTS_BASE, { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error(res.status === 401 ? "Unauthorized" : "Failed to fetch projects");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProject(id: string): Promise<ProjectWithTasks> {
|
||||||
|
const res = await fetch(`${PROJECTS_BASE}/${id}`, { credentials: "include" });
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch project");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(
|
||||||
|
project: { name: string; description?: string; context?: string; repos?: string[]; links?: { label: string; url: string }[] }
|
||||||
|
): Promise<Project> {
|
||||||
|
const res = await fetch(PROJECTS_BASE, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(project),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create project");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProject(
|
||||||
|
id: string,
|
||||||
|
updates: Record<string, any>
|
||||||
|
): Promise<Project> {
|
||||||
|
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(updates),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update project");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProject(id: string): Promise<void> {
|
||||||
|
const res = await fetch(`${PROJECTS_BASE}/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete project");
|
||||||
|
}
|
||||||
|
|
||||||
// Admin API
|
// Admin API
|
||||||
export async function fetchUsers(): Promise<any[]> {
|
export async function fetchUsers(): Promise<any[]> {
|
||||||
const res = await fetch("/api/admin/users", { credentials: "include" });
|
const res = await fetch("/api/admin/users", { credentials: "include" });
|
||||||
|
|||||||
@@ -7,6 +7,26 @@ export interface ProgressNote {
|
|||||||
note: string;
|
note: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProjectLink {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
context: string | null;
|
||||||
|
repos: string[];
|
||||||
|
links: ProjectLink[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectWithTasks extends Project {
|
||||||
|
tasks: Task[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
taskNumber: number;
|
taskNumber: number;
|
||||||
@@ -16,6 +36,7 @@ export interface Task {
|
|||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
priority: TaskPriority;
|
priority: TaskPriority;
|
||||||
position: number;
|
position: number;
|
||||||
|
projectId: string | null;
|
||||||
progressNotes: ProgressNote[];
|
progressNotes: ProgressNote[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
665
frontend/src/pages/ProjectsPage.tsx
Normal file
665
frontend/src/pages/ProjectsPage.tsx
Normal file
@@ -0,0 +1,665 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import type { Project, ProjectWithTasks, Task } from "../lib/types";
|
||||||
|
import {
|
||||||
|
fetchProjects,
|
||||||
|
fetchProject,
|
||||||
|
createProject,
|
||||||
|
updateProject,
|
||||||
|
updateTask,
|
||||||
|
} from "../lib/api";
|
||||||
|
|
||||||
|
// ─── Status/priority helpers ───
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: "bg-green-100 text-green-700",
|
||||||
|
queued: "bg-blue-100 text-blue-700",
|
||||||
|
blocked: "bg-red-100 text-red-700",
|
||||||
|
completed: "bg-gray-100 text-gray-500",
|
||||||
|
cancelled: "bg-gray-100 text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase ${statusColors[status] || "bg-gray-100 text-gray-500"}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Create Project Modal ───
|
||||||
|
function CreateProjectModal({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: (p: Project) => void;
|
||||||
|
}) {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [context, setContext] = useState("");
|
||||||
|
const [repos, setRepos] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!name.trim()) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const repoList = repos
|
||||||
|
.split("\n")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
const project = await createProject({
|
||||||
|
name: name.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
context: context.trim() || undefined,
|
||||||
|
repos: repoList.length ? repoList : undefined,
|
||||||
|
});
|
||||||
|
onCreated(project);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">New Project</h2>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200"
|
||||||
|
placeholder="e.g. Hammer Dashboard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
placeholder="Brief description of the project"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Context{" "}
|
||||||
|
<span className="text-gray-400 font-normal">
|
||||||
|
(architecture, credentials refs, how-to)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={context}
|
||||||
|
onChange={(e) => setContext(e.target.value)}
|
||||||
|
rows={5}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
placeholder={`Repo: https://gitea.donovankelly.xyz/...\nDomain: dash.donovankelly.xyz\nDokploy Compose ID: ...\nStack: React + Vite, Elysia + Bun, Postgres + Drizzle`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Repos{" "}
|
||||||
|
<span className="text-gray-400 font-normal">(one per line)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={repos}
|
||||||
|
onChange={(e) => setRepos(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
placeholder="https://gitea.donovankelly.xyz/donovan/hammer-queue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-3 border-t border-gray-100 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!name.trim() || saving}
|
||||||
|
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 transition font-medium"
|
||||||
|
>
|
||||||
|
{saving ? "Creating..." : "Create Project"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Detail View ───
|
||||||
|
function ProjectDetail({
|
||||||
|
projectId,
|
||||||
|
onBack,
|
||||||
|
allTasks,
|
||||||
|
onTasksChanged,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
onBack: () => void;
|
||||||
|
allTasks: Task[];
|
||||||
|
onTasksChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const [project, setProject] = useState<ProjectWithTasks | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
const [editDesc, setEditDesc] = useState("");
|
||||||
|
const [editContext, setEditContext] = useState("");
|
||||||
|
const [editRepos, setEditRepos] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showAssign, setShowAssign] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await fetchProject(projectId);
|
||||||
|
setProject(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const startEdit = () => {
|
||||||
|
if (!project) return;
|
||||||
|
setEditName(project.name);
|
||||||
|
setEditDesc(project.description || "");
|
||||||
|
setEditContext(project.context || "");
|
||||||
|
setEditRepos((project.repos || []).join("\n"));
|
||||||
|
setEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!project) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const repoList = editRepos
|
||||||
|
.split("\n")
|
||||||
|
.map((r) => r.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
await updateProject(project.id, {
|
||||||
|
name: editName.trim(),
|
||||||
|
description: editDesc.trim() || null,
|
||||||
|
context: editContext.trim() || null,
|
||||||
|
repos: repoList,
|
||||||
|
});
|
||||||
|
setEditing(false);
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const assignTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
await updateTask(taskId, { projectId });
|
||||||
|
load();
|
||||||
|
onTasksChanged();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const unassignTask = async (taskId: string) => {
|
||||||
|
try {
|
||||||
|
await updateTask(taskId, { projectId: null });
|
||||||
|
load();
|
||||||
|
onTasksChanged();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">Loading project...</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">Project not found</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unassigned tasks (not in this project)
|
||||||
|
const unassignedTasks = allTasks.filter(
|
||||||
|
(t) =>
|
||||||
|
!t.projectId &&
|
||||||
|
t.status !== "completed" &&
|
||||||
|
t.status !== "cancelled"
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 mb-6">
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-gray-100 transition text-gray-400"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1">
|
||||||
|
{editing ? (
|
||||||
|
<input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="text-2xl font-bold text-gray-900 border-b-2 border-amber-400 focus:outline-none w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{project.name}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!editing && (
|
||||||
|
<button
|
||||||
|
onClick={startEdit}
|
||||||
|
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-3 py-1.5 rounded-lg hover:bg-amber-50 transition"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit mode */}
|
||||||
|
{editing ? (
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editDesc}
|
||||||
|
onChange={(e) => setEditDesc(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Context
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editContext}
|
||||||
|
onChange={(e) => setEditContext(e.target.value)}
|
||||||
|
rows={8}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Repos (one per line)
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={editRepos}
|
||||||
|
onChange={(e) => setEditRepos(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-amber-200 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(false)}
|
||||||
|
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveEdit}
|
||||||
|
disabled={saving || !editName.trim()}
|
||||||
|
className="px-4 py-2 text-sm bg-amber-500 text-white rounded-lg hover:bg-amber-600 disabled:opacity-50 font-medium"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Description */}
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-gray-600 mb-4">{project.description}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Context card */}
|
||||||
|
{project.context && (
|
||||||
|
<div className="bg-gray-50 rounded-xl border border-gray-200 p-5 mb-6">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
📋 Context
|
||||||
|
</h3>
|
||||||
|
<pre className="text-sm text-gray-700 whitespace-pre-wrap font-mono leading-relaxed">
|
||||||
|
{project.context}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Repos */}
|
||||||
|
{project.repos && project.repos.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
📦 Repos
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{project.repos.map((repo, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={repo}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block text-sm text-blue-600 hover:text-blue-800 font-mono truncate"
|
||||||
|
>
|
||||||
|
{repo}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
{project.links && project.links.length > 0 && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
🔗 Links
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{project.links.map((link, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={link.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="block text-sm text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
{link.label}{" "}
|
||||||
|
<span className="text-gray-400 font-mono text-xs">
|
||||||
|
({link.url})
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tasks section */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
📝 Tasks ({project.tasks?.length || 0})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAssign(!showAssign)}
|
||||||
|
className="text-xs text-amber-600 hover:text-amber-700 font-medium px-2 py-1 rounded hover:bg-amber-50 transition"
|
||||||
|
>
|
||||||
|
{showAssign ? "Done" : "+ Assign Task"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign task dropdown */}
|
||||||
|
{showAssign && unassignedTasks.length > 0 && (
|
||||||
|
<div className="bg-white rounded-xl border border-amber-200 p-3 mb-4 max-h-48 overflow-y-auto">
|
||||||
|
<p className="text-xs text-gray-400 mb-2">
|
||||||
|
Select a task to assign to this project:
|
||||||
|
</p>
|
||||||
|
{unassignedTasks.map((task) => (
|
||||||
|
<button
|
||||||
|
key={task.id}
|
||||||
|
onClick={() => assignTask(task.id)}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-amber-50 rounded-lg transition flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-gray-400 text-xs font-mono">
|
||||||
|
HQ-{task.taskNumber}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{task.title}</span>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showAssign && unassignedTasks.length === 0 && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 mb-4 text-xs text-gray-400 text-center">
|
||||||
|
All tasks are already assigned to projects
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Task list */}
|
||||||
|
{project.tasks && project.tasks.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{project.tasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
className="bg-white rounded-lg border border-gray-200 px-4 py-3 flex items-center gap-3"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 font-mono shrink-0">
|
||||||
|
HQ-{task.taskNumber}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-gray-800 flex-1 truncate">
|
||||||
|
{task.title}
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={task.status} />
|
||||||
|
<button
|
||||||
|
onClick={() => unassignTask(task.id)}
|
||||||
|
className="text-gray-300 hover:text-red-400 transition shrink-0"
|
||||||
|
title="Remove from project"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-400 text-center py-6 bg-gray-50 rounded-lg">
|
||||||
|
No tasks assigned to this project yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project Card ───
|
||||||
|
function ProjectCard({
|
||||||
|
project,
|
||||||
|
taskCount,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
project: Project;
|
||||||
|
taskCount: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-full text-left bg-white rounded-xl border border-gray-200 p-5 hover:border-amber-300 hover:shadow-sm transition group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<h3 className="text-base font-semibold text-gray-900 group-hover:text-amber-700 transition">
|
||||||
|
{project.name}
|
||||||
|
</h3>
|
||||||
|
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full shrink-0 ml-2">
|
||||||
|
{taskCount} task{taskCount !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{project.description && (
|
||||||
|
<p className="text-sm text-gray-500 line-clamp-2 mb-3">
|
||||||
|
{project.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{project.repos && project.repos.length > 0 && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
📦 {project.repos.length} repo
|
||||||
|
{project.repos.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{project.context && (
|
||||||
|
<span className="text-xs text-gray-400">📋 Has context</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Page ───
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const [projects, setProjects] = useState<Project[]>([]);
|
||||||
|
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const [projectsData, tasksRes] = await Promise.all([
|
||||||
|
fetchProjects(),
|
||||||
|
fetch("/api/tasks", { credentials: "include" }).then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
setProjects(projectsData);
|
||||||
|
setAllTasks(tasksRes);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadAll();
|
||||||
|
}, [loadAll]);
|
||||||
|
|
||||||
|
// Count tasks per project
|
||||||
|
const taskCountMap: Record<string, number> = {};
|
||||||
|
for (const task of allTasks) {
|
||||||
|
if (task.projectId) {
|
||||||
|
taskCountMap[task.projectId] = (taskCountMap[task.projectId] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedProject) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
<ProjectDetail
|
||||||
|
projectId={selectedProject}
|
||||||
|
onBack={() => {
|
||||||
|
setSelectedProject(null);
|
||||||
|
loadAll();
|
||||||
|
}}
|
||||||
|
allTasks={allTasks}
|
||||||
|
onTasksChanged={loadAll}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 sm:p-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">
|
||||||
|
Projects
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Organize tasks by project with context for autonomous work
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
|
||||||
|
>
|
||||||
|
+ New Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center text-gray-400 py-12">
|
||||||
|
Loading projects...
|
||||||
|
</div>
|
||||||
|
) : projects.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<span className="text-5xl block mb-4">📁</span>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-600 mb-2">
|
||||||
|
No projects yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-400 mb-4">
|
||||||
|
Create a project to group tasks and add context
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(true)}
|
||||||
|
className="px-4 py-2 bg-amber-500 text-white rounded-lg text-sm font-medium hover:bg-amber-600 transition"
|
||||||
|
>
|
||||||
|
Create First Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ProjectCard
|
||||||
|
key={project.id}
|
||||||
|
project={project}
|
||||||
|
taskCount={taskCountMap[project.id] || 0}
|
||||||
|
onClick={() => setSelectedProject(project.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<CreateProjectModal
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onCreated={(p) => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setProjects((prev) => [...prev, p]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user