feat: add boards and tasks management endpoints

This commit is contained in:
Abhimanyu Saharan
2026-02-04 02:28:51 +05:30
parent 23faa0865b
commit 1abc8f68f3
170 changed files with 6860 additions and 10706 deletions

View File

@@ -1,24 +0,0 @@
.shell{min-height:100vh;display:grid;grid-template-columns:260px 1fr;background:var(--mc-bg)}
.sidebar{border-right:1px solid var(--mc-border);padding:20px 16px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column;gap:16px;background:linear-gradient(180deg,var(--mc-surface) 0%, color-mix(in oklab,var(--mc-surface), var(--mc-bg) 40%) 100%)}
.brand{display:flex;flex-direction:column;gap:6px}
.brandTitle{font-family:var(--mc-font-serif);font-size:18px;letter-spacing:-0.2px}
.brandSub{font-size:12px;color:var(--mc-muted)}
.nav{display:flex;flex-direction:column;gap:6px}
.nav a{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;color:var(--mc-text);text-decoration:none;border:1px solid transparent}
.nav a:hover{background:color-mix(in oklab,var(--mc-accent), transparent 92%);border-color:color-mix(in oklab,var(--mc-accent), transparent 80%)}
.active{background:color-mix(in oklab,var(--mc-accent), transparent 88%);border-color:color-mix(in oklab,var(--mc-accent), transparent 70%)}
.main{padding:28px 28px 48px}
.topbar{display:flex;justify-content:space-between;align-items:flex-start;gap:18px;margin-bottom:18px}
.h1{font-family:var(--mc-font-serif);font-size:30px;line-height:1.1;letter-spacing:-0.6px;margin:0}
.p{margin:8px 0 0;color:var(--mc-muted);max-width:72ch}
.btn{border:1px solid var(--mc-border);background:var(--mc-surface);padding:10px 12px;border-radius:12px;cursor:pointer}
.btnPrimary{border-color:color-mix(in oklab,var(--mc-accent), black 10%);background:var(--mc-accent);color:white}
.grid2{display:grid;grid-template-columns:1.4fr 1fr;gap:16px}
.card{background:var(--mc-surface);border:1px solid var(--mc-border);border-radius:16px;padding:14px}
.cardTitle{margin:0 0 10px;font-size:13px;color:var(--mc-muted);letter-spacing:0.06em;text-transform:uppercase}
.list{display:flex;flex-direction:column;gap:10px}
.item{border:1px solid var(--mc-border);border-radius:14px;padding:12px;background:color-mix(in oklab,var(--mc-surface), white 20%)}
.mono{font-family:var(--mc-font-mono);font-size:12px;color:var(--mc-muted)}
.badge{display:inline-flex;align-items:center;padding:4px 8px;border-radius:999px;font-size:12px;border:1px solid var(--mc-border);background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%)}
.kbd{font-family:var(--mc-font-mono);font-size:12px;background:color-mix(in oklab,var(--mc-bg), var(--mc-surface) 40%);border:1px solid var(--mc-border);border-bottom-width:2px;padding:2px 6px;border-radius:8px}
@media (max-width: 980px){.shell{grid-template-columns:1fr}.sidebar{position:relative;height:auto}.grid2{grid-template-columns:1fr}.main{padding:18px}}

View File

@@ -1,72 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useState } from "react";
import styles from "./Shell.module.css";
const NAV = [
{ href: "/", label: "Mission Control" },
{ href: "/projects", label: "Projects" },
{ href: "/kanban", label: "Kanban" },
{ href: "/departments", label: "Departments" },
{ href: "/teams", label: "Teams" },
{ href: "/people", label: "People" },
];
export function Shell({ children }: { children: React.ReactNode }) {
const path = usePathname();
const [actorId, setActorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<div className={styles.brand}>
<div className={styles.brandTitle}>OpenClaw Agency</div>
<div className={styles.brandSub}>Company Mission Control (no-auth v1)</div>
</div>
<nav className={styles.nav}>
{NAV.map((n) => (
<Link
key={n.href}
href={n.href}
className={path === n.href ? styles.active : undefined}
>
{n.label}
</Link>
))}
</nav>
<div className={styles.mono} style={{ marginTop: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 6 }}>Actor ID</div>
<input
value={actorId}
onChange={(e) => {
const v = e.target.value;
setActorId(v);
try {
if (v) window.localStorage.setItem("actor_employee_id", v);
else window.localStorage.removeItem("actor_employee_id");
} catch {
// ignore
}
}}
placeholder="e.g. 1"
style={{ width: "100%", padding: "6px 8px", borderRadius: 6, border: "1px solid #333", background: "transparent" }}
/>
</div>
<div className={styles.mono} style={{ marginTop: "auto" }}>
Tip: use your machine IP + ports<br />
<span className={styles.kbd}>:3000</span> UI &nbsp; <span className={styles.kbd}>:8000</span> API
</div>
</aside>
<div className={styles.main}>{children}</div>
</div>
);
}

View File

@@ -0,0 +1,627 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { StatusPill } from "@/components/atoms/StatusPill";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
type Agent = {
id: string;
name: string;
status: string;
last_seen_at: string;
};
type ActivityEvent = {
id: string;
event_type: string;
message?: string | null;
created_at: string;
};
type GatewayStatus = {
connected: boolean;
gateway_url: string;
sessions_count?: number;
sessions?: Record<string, unknown>[];
error?: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const statusOptions = [
{ value: "online", label: "Online" },
{ value: "busy", label: "Busy" },
{ value: "offline", label: "Offline" },
];
const formatTimestamp = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const formatRelative = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "—";
const diff = Date.now() - date.getTime();
const minutes = Math.round(diff / 60000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
return `${days}d ago`;
};
const getSessionKey = (
session: Record<string, unknown>,
index: number
) => {
const key = session.key;
if (typeof key === "string" && key.length > 0) {
return key;
}
const sessionId = session.sessionId;
if (typeof sessionId === "string" && sessionId.length > 0) {
return sessionId;
}
return `session-${index}`;
};
export default function AgentsPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const [agents, setAgents] = useState<Agent[]>([]);
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [gatewayStatus, setGatewayStatus] = useState<GatewayStatus | null>(null);
const [gatewaySessions, setGatewaySessions] = useState<
Record<string, unknown>[]
>([]);
const [gatewayError, setGatewayError] = useState<string | null>(null);
const [selectedSession, setSelectedSession] = useState<
Record<string, unknown> | null
>(null);
const [sessionHistory, setSessionHistory] = useState<unknown[]>([]);
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [name, setName] = useState("");
const [status, setStatus] = useState("online");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const sortedAgents = useMemo(
() => [...agents].sort((a, b) => a.name.localeCompare(b.name)),
[agents],
);
const loadData = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [agentsResponse, activityResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/agents`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
fetch(`${apiBase}/api/v1/activity`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
}),
]);
if (!agentsResponse.ok || !activityResponse.ok) {
throw new Error("Unable to load operational data.");
}
const agentsData = (await agentsResponse.json()) as Agent[];
const eventsData = (await activityResponse.json()) as ActivityEvent[];
setAgents(agentsData);
setEvents(eventsData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
const loadGateway = async () => {
if (!isSignedIn) return;
setGatewayError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/gateway/status`, {
headers: { Authorization: token ? `Bearer ${token}` : "" },
});
if (!response.ok) {
throw new Error("Unable to load gateway status.");
}
const statusData = (await response.json()) as GatewayStatus;
setGatewayStatus(statusData);
setGatewaySessions(statusData.sessions ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
const loadSessionHistory = async (sessionId: string) => {
if (!isSignedIn) return;
try {
const token = await getToken();
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/history`,
{
headers: { Authorization: token ? `Bearer ${token}` : "" },
}
);
if (!response.ok) {
throw new Error("Unable to load session history.");
}
const data = (await response.json()) as { history?: unknown[] };
setSessionHistory(data.history ?? []);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
}
};
useEffect(() => {
loadData();
loadGateway();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const resetForm = () => {
setName("");
setStatus("online");
setCreateError(null);
};
const handleCreate = async () => {
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) {
setCreateError("Agent name is required.");
return;
}
setIsCreating(true);
setCreateError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/agents`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, status }),
});
if (!response.ok) {
throw new Error("Unable to create agent.");
}
const created = (await response.json()) as Agent;
setAgents((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCreating(false);
}
};
const handleSendMessage = async () => {
if (!isSignedIn || !selectedSession) return;
const content = message.trim();
if (!content) return;
setIsSending(true);
setGatewayError(null);
try {
const token = await getToken();
const sessionId = selectedSession.key as string | undefined;
if (!sessionId) {
throw new Error("Missing session id.");
}
const response = await fetch(
`${apiBase}/api/v1/gateway/sessions/${sessionId}/message`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ content }),
}
);
if (!response.ok) {
throw new Error("Unable to send message.");
}
setMessage("");
loadSessionHistory(sessionId);
} catch (err) {
setGatewayError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsSending(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Sign in to view operational status.
</p>
<SignInButton
mode="modal"
afterSignInUrl="/agents"
afterSignUpUrl="/agents"
forceRedirectUrl="/agents"
signUpForceRedirectUrl="/agents"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
Operations
</p>
<h1 className="text-2xl font-semibold text-gray-900">
Agents
</h1>
<p className="text-sm text-gray-600">
Live status and heartbeat activity across all agents.
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => loadData()}
disabled={isLoading}
>
Refresh
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => setIsDialogOpen(true)}
>
New agent
</Button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
) : null}
<div className="grid gap-6 lg:grid-cols-[1.1fr_0.9fr]">
<div className="overflow-hidden rounded-xl border border-gray-200">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Agents
</p>
<p className="text-xs text-gray-500">
{sortedAgents.length} total
</p>
</div>
<div className="divide-y divide-gray-200 text-sm">
{sortedAgents.length === 0 && !isLoading ? (
<div className="p-6 text-sm text-gray-500">
No agents yet. Add one or wait for a heartbeat.
</div>
) : (
sortedAgents.map((agent) => (
<div
key={agent.id}
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"
>
<div>
<p className="font-medium text-gray-900">
{agent.name}
</p>
<p className="text-xs text-gray-500">
Last seen {formatRelative(agent.last_seen_at)}
</p>
</div>
<div className="flex items-center gap-3">
<StatusPill status={agent.status} />
<Button
variant="outline"
className="border-2 border-gray-200 text-xs text-gray-700"
onClick={() => router.push(`/boards`)}
>
View work
</Button>
</div>
</div>
))
)}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 p-5">
<Tabs defaultValue="activity">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList>
<TabsTrigger value="activity">Activity</TabsTrigger>
<TabsTrigger value="gateway">Gateway</TabsTrigger>
</TabsList>
</div>
<TabsContent value="activity">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Activity feed
</p>
<p className="text-xs text-gray-500">
{events.length} events
</p>
</div>
<div className="space-y-3">
{events.length === 0 && !isLoading ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-4 text-sm text-gray-500">
No activity yet.
</div>
) : (
events.map((event) => (
<div
key={event.id}
className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700"
>
<p className="font-medium text-gray-900">
{event.message ?? event.event_type}
</p>
<p className="mt-1 text-xs text-gray-500">
{formatTimestamp(event.created_at)}
</p>
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="gateway">
<div className="mb-4 flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
OpenClaw Gateway
</p>
<Button
variant="outline"
className="border-2 border-gray-200 text-xs text-gray-700"
onClick={() => loadGateway()}
>
Refresh
</Button>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700">
<div className="flex items-center justify-between">
<p className="font-medium text-gray-900">
{gatewayStatus?.connected ? "Connected" : "Not connected"}
</p>
<StatusPill
status={gatewayStatus?.connected ? "online" : "offline"}
/>
</div>
<p className="mt-1 text-xs text-gray-500">
{gatewayStatus?.gateway_url ?? "Gateway URL not set"}
</p>
{gatewayStatus?.error ? (
<p className="mt-2 text-xs text-red-500">
{gatewayStatus.error}
</p>
) : null}
</div>
<div className="rounded-lg border border-gray-200 bg-white">
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
<span>Sessions</span>
<span>{gatewaySessions.length}</span>
</div>
<div className="max-h-56 divide-y divide-gray-200 overflow-y-auto text-sm">
{gatewaySessions.length === 0 ? (
<div className="p-4 text-sm text-gray-500">
No sessions found.
</div>
) : (
gatewaySessions.map((session, index) => {
const sessionId = session.key as string | undefined;
const display =
(session.displayName as string | undefined) ??
(session.label as string | undefined) ??
sessionId ??
"Session";
return (
<button
key={getSessionKey(session, index)}
type="button"
className="flex w-full items-center justify-between px-4 py-3 text-left text-sm hover:bg-gray-50"
onClick={() => {
setSelectedSession(session);
if (sessionId) {
loadSessionHistory(sessionId);
}
}}
>
<div>
<p className="font-medium text-gray-900">{display}</p>
<p className="text-xs text-gray-500">
{session.status ?? "active"}
</p>
</div>
<span className="text-xs text-gray-400">Open</span>
</button>
);
})
)}
</div>
</div>
{selectedSession ? (
<div className="rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-700">
<div className="mb-3 space-y-1">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Session details
</p>
<p className="font-medium text-gray-900">
{selectedSession.displayName ??
selectedSession.label ??
selectedSession.key ??
"Session"}
</p>
</div>
<div className="mb-4 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{sessionHistory.length === 0 ? (
<p>No history loaded.</p>
) : (
sessionHistory.map((item, index) => (
<pre key={index} className="whitespace-pre-wrap">
{JSON.stringify(item, null, 2)}
</pre>
))
)}
</div>
<div className="space-y-2">
<label className="text-xs font-semibold uppercase tracking-[0.2em] text-gray-500">
Send message
</label>
<Input
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Type a message to the session"
className="h-10 rounded-lg border-2 border-gray-200 bg-white"
/>
<Button
className="w-full border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleSendMessage}
disabled={isSending}
>
{isSending ? "Sending…" : "Send to session"}
</Button>
</div>
</div>
) : null}
{gatewayError ? (
<div className="rounded-lg border border-gray-200 bg-white p-3 text-xs text-red-500">
{gatewayError}
</div>
) : null}
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
</SignedIn>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
}
}}
>
<DialogContent aria-label="New agent">
<DialogHeader>
<DialogTitle>New agent</DialogTitle>
<DialogDescription>
Add a manual agent entry for tracking and monitoring.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Agent name
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Deployment bot"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Status
</label>
<Select value={status} onValueChange={setStatus}>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{createError}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleCreate}
disabled={isCreating}
>
{isCreating ? "Creating…" : "Create agent"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -0,0 +1,310 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useParams, useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { TaskBoard } from "@/components/organisms/TaskBoard";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
type Board = {
id: string;
name: string;
slug: string;
};
type Task = {
id: string;
title: string;
description?: string | null;
status: string;
priority: string;
due_at?: string | null;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const priorities = [
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
];
export default function BoardDetailPage() {
const router = useRouter();
const params = useParams();
const boardIdParam = params?.boardId;
const boardId = Array.isArray(boardIdParam) ? boardIdParam[0] : boardIdParam;
const { getToken, isSignedIn } = useAuth();
const [board, setBoard] = useState<Board | null>(null);
const [tasks, setTasks] = useState<Task[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [priority, setPriority] = useState("medium");
const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const titleLabel = useMemo(
() => (board ? `${board.name} board` : "Board"),
[board],
);
const loadBoard = async () => {
if (!isSignedIn || !boardId) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const [boardResponse, tasksResponse] = await Promise.all([
fetch(`${apiBase}/api/v1/boards/${boardId}`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
}),
]);
if (!boardResponse.ok) {
throw new Error("Unable to load board.");
}
if (!tasksResponse.ok) {
throw new Error("Unable to load tasks.");
}
const boardData = (await boardResponse.json()) as Board;
const taskData = (await tasksResponse.json()) as Task[];
setBoard(boardData);
setTasks(taskData);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBoard();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boardId, isSignedIn]);
const resetForm = () => {
setTitle("");
setDescription("");
setPriority("medium");
setCreateError(null);
};
const handleCreateTask = async () => {
if (!isSignedIn || !boardId) return;
const trimmed = title.trim();
if (!trimmed) {
setCreateError("Add a task title to continue.");
return;
}
setIsCreating(true);
setCreateError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards/${boardId}/tasks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({
title: trimmed,
description: description.trim() || null,
status: "inbox",
priority,
}),
});
if (!response.ok) {
throw new Error("Unable to create task.");
}
const created = (await response.json()) as Task;
setTasks((prev) => [created, ...prev]);
setIsDialogOpen(false);
resetForm();
} catch (err) {
setCreateError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsCreating(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">Sign in to view boards.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-6 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
{board?.slug ?? "board"}
</p>
<h1 className="text-2xl font-semibold text-gray-900">
{board?.name ?? "Board"}
</h1>
<p className="text-sm text-gray-600">
Keep tasks moving through your workflow.
</p>
</div>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div>
{error && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
)}
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-gray-500">
Loading {titleLabel}
</div>
) : (
<TaskBoard
tasks={tasks}
onCreateTask={() => setIsDialogOpen(true)}
isCreateDisabled={isCreating}
/>
)}
</div>
</SignedIn>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
setIsDialogOpen(nextOpen);
if (!nextOpen) {
resetForm();
}
}}
>
<DialogContent aria-label={titleLabel}>
<DialogHeader>
<DialogTitle>New task</DialogTitle>
<DialogDescription>
Add a task to the inbox and triage it when you are ready.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">Title</label>
<Input
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="e.g. Prepare launch notes"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Description
</label>
<Textarea
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="Optional details"
className="min-h-[120px] rounded-lg border-2 border-gray-200 bg-white"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Priority
</label>
<Select value={priority} onValueChange={setPriority}>
<SelectTrigger>
<SelectValue placeholder="Select priority" />
</SelectTrigger>
<SelectContent>
{priorities.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{createError ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{createError}
</div>
) : null}
</div>
<DialogFooter>
<Button
variant="outline"
className="border-2 border-gray-200 text-gray-700"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={handleCreateTask}
disabled={isCreating}
>
{isCreating ? "Creating…" : "Create task"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DashboardShell>
);
}

View File

@@ -0,0 +1,135 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
type Board = {
id: string;
name: string;
slug: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
const slugify = (value: string) =>
value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "") || "board";
export default function NewBoardPage() {
const router = useRouter();
const { getToken, isSignedIn } = useAuth();
const [name, setName] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isSignedIn) return;
const trimmed = name.trim();
if (!trimmed) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ name: trimmed, slug: slugify(trimmed) }),
});
if (!response.ok) {
throw new Error("Unable to create board.");
}
const created = (await response.json()) as Board;
router.push(`/boards/${created.id}`);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush lg:col-span-2">
<p className="text-sm text-gray-600">Sign in to create a board.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards/new"
afterSignUpUrl="/boards/new"
forceRedirectUrl="/boards/new"
signUpForceRedirectUrl="/boards/new"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col justify-center rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="mb-6 space-y-2">
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-gray-500">
New board
</p>
<h1 className="text-2xl font-semibold text-gray-900">
Spin up a board.
</h1>
<p className="text-sm text-gray-600">
Boards are where tasks live and move through your workflow.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-gray-800">
Board name
</label>
<Input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Product ops"
className="h-11 rounded-lg border-2 border-gray-200 bg-white"
disabled={isLoading}
/>
</div>
{error ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
) : null}
<Button
type="submit"
className="w-full border-2 border-gray-900 bg-gray-900 text-white"
disabled={isLoading}
>
{isLoading ? "Creating…" : "Create board"}
</Button>
</form>
<Button
variant="outline"
className="mt-4 border-2 border-gray-200 text-gray-700"
onClick={() => router.push("/boards")}
>
Back to boards
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,202 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
type Board = {
id: string;
name: string;
slug: string;
};
const apiBase =
process.env.NEXT_PUBLIC_API_URL?.replace(/\/+$/, "") ||
"http://localhost:8000";
export default function BoardsPage() {
const { getToken, isSignedIn } = useAuth();
const router = useRouter();
const [boards, setBoards] = useState<Board[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sortedBoards = useMemo(
() => [...boards].sort((a, b) => a.name.localeCompare(b.name)),
[boards]
);
const loadBoards = async () => {
if (!isSignedIn) return;
setIsLoading(true);
setError(null);
try {
const token = await getToken();
const response = await fetch(`${apiBase}/api/v1/boards`, {
headers: {
Authorization: token ? `Bearer ${token}` : "",
},
});
if (!response.ok) {
throw new Error("Unable to load boards.");
}
const data = (await response.json()) as Board[];
setBoards(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
loadBoards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSignedIn]);
const columns = useMemo<ColumnDef<Board>[]>(
() => [
{
accessorKey: "name",
header: "Board",
cell: ({ row }) => (
<div>
<p className="font-medium text-gray-900">{row.original.name}</p>
<p className="text-xs text-gray-500">{row.original.slug}</p>
</div>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => (
<div
className="flex items-center justify-end"
onClick={(event) => event.stopPropagation()}
>
<Link
href={`/boards/${row.original.id}`}
className="inline-flex h-8 items-center justify-center rounded-lg border-2 border-gray-200 px-3 text-xs font-medium text-gray-700"
>
Open
</Link>
</div>
),
},
],
[]
);
const table = useReactTable({
data: sortedBoards,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush lg:col-span-2">
<p className="text-sm text-gray-600">Sign in to view boards.</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col gap-4 rounded-xl border-2 border-gray-200 bg-white p-8 shadow-lush">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-gray-900">Boards</h2>
<p className="text-sm text-gray-500">
{sortedBoards.length} board
{sortedBoards.length === 1 ? "" : "s"} total.
</p>
</div>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => router.push("/boards/new")}
>
New board
</Button>
</div>
{error && (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
{error}
</div>
)}
{sortedBoards.length === 0 && !isLoading ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-gray-200 bg-gray-50 p-6 text-center text-sm text-gray-500">
No boards yet. Create your first board to get started.
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200">
<table className="min-w-full divide-y divide-gray-200 text-sm">
<thead className="bg-gray-50">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-[0.2em] text-gray-500"
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</th>
))}
</tr>
))}
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className="cursor-pointer hover:bg-gray-50"
onClick={() => router.push(`/boards/${row.original.id}`)}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-4 py-3 align-top">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useRouter } from "next/navigation";
import { SignInButton, SignedIn, SignedOut } from "@clerk/nextjs";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button";
export default function DashboardPage() {
const router = useRouter();
return (
<DashboardShell>
<SignedOut>
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Sign in to access your dashboard.
</p>
<SignInButton
mode="modal"
afterSignInUrl="/boards"
afterSignUpUrl="/boards"
forceRedirectUrl="/boards"
signUpForceRedirectUrl="/boards"
>
<Button className="border-2 border-gray-900 bg-gray-900 text-white">
Sign in
</Button>
</SignInButton>
</div>
</SignedOut>
<SignedIn>
<DashboardSidebar />
<div className="flex h-full flex-col items-center justify-center gap-4 rounded-xl border-2 border-gray-200 bg-white p-10 text-center shadow-lush">
<p className="text-sm text-gray-600">
Your work lives in boards. Jump in to manage tasks.
</p>
<Button
className="border-2 border-gray-900 bg-gray-900 text-white"
onClick={() => router.push("/boards")}
>
Go to boards
</Button>
</div>
</SignedIn>
</DashboardShell>
);
}

View File

@@ -1,143 +0,0 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateDepartmentDepartmentsPost,
useListDepartmentsDepartmentsGet,
useUpdateDepartmentDepartmentsDepartmentIdPatch,
} from "@/api/generated/org/org";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
export default function DepartmentsPage() {
const [name, setName] = useState("");
const [headId, setHeadId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const createDepartment = useCreateDepartmentDepartmentsPost({
mutation: {
onSuccess: () => {
setName("");
setHeadId("");
departments.refetch();
},
},
});
const updateDepartment = useUpdateDepartmentDepartmentsDepartmentIdPatch({
mutation: {
onSuccess: () => departments.refetch(),
},
});
const sortedEmployees = employeeList.slice().sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Departments</h1>
<p className="mt-1 text-sm text-muted-foreground">Create departments and assign department heads.</p>
</div>
<Button variant="outline" onClick={() => departments.refetch()} disabled={departments.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create department</CardTitle>
<CardDescription>Optional head</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{employees.isLoading ? <div className="text-sm text-muted-foreground">Loading employees</div> : null}
{employees.error ? <div className="text-sm text-destructive">{(employees.error as Error).message}</div> : null}
<Input placeholder="Department name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={headId} onChange={(e) => setHeadId(e.target.value)}>
<option value="">(no head)</option>
{sortedEmployees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name} ({e.employee_type})
</option>
))}
</Select>
<Button
onClick={() =>
createDepartment.mutate({
data: {
name,
head_employee_id: headId ? Number(headId) : null,
},
})
}
disabled={!name.trim() || createDepartment.isPending || employees.isFetching}
>
Create
</Button>
{createDepartment.error ? (
<div className="text-sm text-destructive">{(createDepartment.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All departments</CardTitle>
<CardDescription>{departmentList.length} total</CardDescription>
</CardHeader>
<CardContent>
{departments.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{departments.error ? (
<div className="text-sm text-destructive">{(departments.error as Error).message}</div>
) : null}
{!departments.isLoading && !departments.error ? (
<ul className="space-y-2">
{departmentList.map((d) => (
<li key={d.id ?? d.name} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{d.name}</div>
<div className="text-xs text-muted-foreground">id: {d.id}</div>
</div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-muted-foreground">Head:</span>
<Select
disabled={d.id == null}
value={d.head_employee_id ? String(d.head_employee_id) : ""}
onBlur={(e) => { if (d.id == null) return; updateDepartment.mutate({ departmentId: Number(d.id), data: { head_employee_id: e.target.value ? Number(e.target.value) : null } }); }}
>
<option value="">(none)</option>
{sortedEmployees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
</div>
</li>
))}
{departmentList.length === 0 ? (
<li className="text-sm text-muted-foreground">No departments yet.</li>
) : null}
</ul>
) : null}
{updateDepartment.error ? (
<div className="mt-3 text-sm text-destructive">{(updateDepartment.error as Error).message}</div>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -3,65 +3,91 @@
@tailwind utilities;
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--radius: 12px;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
}
* {
@apply border-border;
color-scheme: light;
}
body {
@apply bg-background text-foreground;
@apply bg-white text-gray-900 font-body;
}
* {
@apply border-black/10;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes float-slow {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes progress-shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@layer utilities {
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out both;
}
.animate-fade-in {
animation: fade-in 0.4s ease-out both;
}
.animate-float {
animation: float-slow 6s ease-in-out infinite;
}
.animate-progress-shimmer {
animation: progress-shimmer 1.8s linear infinite;
}
.glass-panel {
background: #ffffff;
border: 1px solid #0b0b0b;
box-shadow: 4px 4px 0 #0b0b0b;
}
.shadow-lush {
box-shadow: 6px 6px 0 #0b0b0b;
}
.soft-shadow {
box-shadow: 0 24px 60px rgba(11, 11, 11, 0.12);
}
.soft-shadow-sm {
box-shadow: 0 16px 32px rgba(11, 11, 11, 0.08);
}
.bg-landing-grid {
background-image:
linear-gradient(to right, rgba(11, 11, 11, 0.08) 1px, transparent 1px),
linear-gradient(to bottom, rgba(11, 11, 11, 0.08) 1px, transparent 1px);
background-size: 80px 80px;
}
}
.landing-page {
font-family: var(--font-body), sans-serif;
}

View File

@@ -1,211 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select } from "@/components/ui/select";
import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import { useListTasksTasksGet, useUpdateTaskTasksTaskIdPatch } from "@/api/generated/work/work";
const STATUSES = ["backlog", "ready", "in_progress", "review", "blocked", "done"] as const;
export default function KanbanPage() {
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.data ?? [];
const employees = useListEmployeesEmployeesGet();
const employeeList = useMemo(() => employees.data?.data ?? [], [employees.data]);
const [projectId, setProjectId] = useState<string>("");
const [assigneeId, setAssigneeId] = useState<string>("");
const [live, setLive] = useState(false);
const tasks = useListTasksTasksGet(
{
...(projectId ? { project_id: Number(projectId) } : {}),
},
{
query: {
enabled: true,
refetchInterval: live ? 5000 : false,
refetchIntervalInBackground: false,
},
},
);
const taskList = useMemo(() => (tasks.data?.status === 200 ? tasks.data.data : []), [tasks.data]);
const updateTask = useUpdateTaskTasksTaskIdPatch({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const employeeNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const filtered = useMemo(() => {
return taskList.filter((t) => {
if (assigneeId && String(t.assignee_employee_id ?? "") !== assigneeId) return false;
return true;
});
}, [taskList, assigneeId]);
const tasksByStatus = useMemo(() => {
const map = new Map<(typeof STATUSES)[number], typeof filtered>();
for (const s of STATUSES) map.set(s, []);
for (const t of filtered) {
const s = (t.status ?? "backlog") as (typeof STATUSES)[number];
(map.get(s) ?? map.get("backlog"))?.push(t);
}
// stable sort inside each column
for (const s of STATUSES) {
const arr = map.get(s) ?? [];
arr.sort((a, b) => String(a.id ?? 0).localeCompare(String(b.id ?? 0)));
}
return map;
}, [filtered]);
return (
<main className="mx-auto max-w-screen-2xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kanban</h1>
<p className="mt-1 text-sm text-muted-foreground">Board view for tasks (quick triage + status moves).</p>
</div>
<Button
variant="outline"
onClick={() => {
tasks.refetch();
projects.refetch();
employees.refetch();
}}
disabled={tasks.isFetching || projects.isFetching || employees.isFetching}
>
Refresh
</Button>
</div>
{tasks.error ? (
<div className="mt-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-base">Filters</CardTitle>
<CardDescription>Scope the board.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Select value={projectId} onChange={(e) => setProjectId(e.target.value)}>
<option value="">All projects</option>
{projectList.map((p) => (
<option key={p.id ?? p.name} value={p.id ?? ""}>
{p.name}
</option>
))}
</Select>
<Select value={assigneeId} onChange={(e) => setAssigneeId(e.target.value)}>
<option value="">All assignees</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<div className="flex items-center justify-between gap-2 rounded-md border p-2 text-sm">
<div>
<div className="font-medium">Live updates</div>
<div className="text-xs text-muted-foreground">Auto-refresh tasks every 5s on this page.</div>
</div>
<Button variant="outline" size="sm" onClick={() => setLive((v) => !v)}>
{live ? "On" : "Off"}
</Button>
</div>
<div className="text-xs text-muted-foreground">
Showing {filtered.length} / {taskList.length} tasks
</div>
</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-4" style={{ gridTemplateColumns: `repeat(${STATUSES.length}, minmax(260px, 1fr))` }}>
{STATUSES.map((status) => (
<Card key={status} className="min-w-[260px]">
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">{status.replaceAll("_", " ")}</CardTitle>
<CardDescription>{tasksByStatus.get(status)?.length ?? 0} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(tasksByStatus.get(status) ?? []).map((t) => (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
{t.description ? (
<div className="mt-1 text-xs text-muted-foreground line-clamp-3">{t.description}</div>
) : null}
<div className="mt-2 text-xs text-muted-foreground">
#{t.id} · {t.project_id ? `proj ${t.project_id}` : "no project"}
{t.assignee_employee_id != null ? ` · assignee ${employeeNameById.get(t.assignee_employee_id) ?? t.assignee_employee_id}` : ""}
</div>
<div className="mt-2 flex gap-2">
<Select
value={t.status ?? "backlog"}
onChange={(e) =>
updateTask.mutate({
taskId: Number(t.id),
data: {
status: e.target.value,
},
})
}
disabled={!t.id || updateTask.isPending}
>
{STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</Select>
<Button
variant="outline"
onClick={() => {
// quick move right
const idx = STATUSES.indexOf(status);
const next = STATUSES[Math.min(STATUSES.length - 1, idx + 1)];
if (!t.id) return;
updateTask.mutate({ taskId: Number(t.id), data: { status: next } });
}}
disabled={!t.id || updateTask.isPending}
>
</Button>
</div>
</div>
))}
{(tasksByStatus.get(status) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div>
) : null}
</CardContent>
</Card>
))}
</div>
<div className="mt-4 text-xs text-muted-foreground">
Tip: set Actor ID in the left sidebar so changes are attributed correctly.
</div>
</main>
);
}

View File

@@ -1,23 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
import { Shell } from "./_components/Shell";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import { ClerkProvider } from "@clerk/nextjs";
import { Inter, Space_Grotesk } from "next/font/google";
export const metadata: Metadata = {
title: "OpenClaw Agency — Mission Control",
description: "Company OS for projects, departments, people, and HR.",
title: "OpenClaw Mission Control",
description: "A calm command center for every task.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const bodyFont = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-body",
});
const headingFont = Space_Grotesk({
subsets: ["latin"],
display: "swap",
variable: "--font-heading",
});
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Shell><Providers>{children}</Providers></Shell>
</body>
</html>
<ClerkProvider>
<html lang="en">
<body
className={`${bodyFont.variable} ${headingFont.variable} min-h-screen bg-white text-gray-900 antialiased`}
>
{children}
</body>
</html>
</ClerkProvider>
);
}

View File

@@ -1,141 +0,0 @@
.page {
--background: #fafafa;
--foreground: #fff;
--text-primary: #000;
--text-secondary: #666;
--button-primary-hover: #383838;
--button-secondary-hover: #f2f2f2;
--button-secondary-border: #ebebeb;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
font-family: var(--font-geist-sans);
background-color: var(--background);
}
.main {
display: flex;
min-height: 100vh;
width: 100%;
max-width: 800px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
background-color: var(--foreground);
padding: 120px 60px;
}
.intro {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
gap: 24px;
}
.intro h1 {
max-width: 320px;
font-size: 40px;
font-weight: 600;
line-height: 48px;
letter-spacing: -2.4px;
text-wrap: balance;
color: var(--text-primary);
}
.intro p {
max-width: 440px;
font-size: 18px;
line-height: 32px;
text-wrap: balance;
color: var(--text-secondary);
}
.intro a {
font-weight: 500;
color: var(--text-primary);
}
.ctas {
display: flex;
flex-direction: row;
width: 100%;
max-width: 440px;
gap: 16px;
font-size: 14px;
}
.ctas a {
display: flex;
justify-content: center;
align-items: center;
height: 40px;
padding: 0 16px;
border-radius: 128px;
border: 1px solid transparent;
transition: 0.2s;
cursor: pointer;
width: fit-content;
font-weight: 500;
}
a.primary {
background: var(--text-primary);
color: var(--background);
gap: 8px;
}
a.secondary {
border-color: var(--button-secondary-border);
}
/* Enable hover only on non-touch devices */
@media (hover: hover) and (pointer: fine) {
a.primary:hover {
background: var(--button-primary-hover);
border-color: transparent;
}
a.secondary:hover {
background: var(--button-secondary-hover);
border-color: transparent;
}
}
@media (max-width: 600px) {
.main {
padding: 48px 24px;
}
.intro {
gap: 16px;
}
.intro h1 {
font-size: 32px;
line-height: 40px;
letter-spacing: -1.92px;
}
}
@media (prefers-color-scheme: dark) {
.logo {
filter: invert();
}
.page {
--background: #000;
--foreground: #000;
--text-primary: #ededed;
--text-secondary: #999;
--button-primary-hover: #ccc;
--button-secondary-hover: #1a1a1a;
--button-secondary-border: #1a1a1a;
}
}

View File

@@ -1,170 +1,12 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import styles from "@/app/_components/Shell.module.css";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { normalizeActivities } from "@/lib/normalize";
import { Select } from "@/components/ui/select";
import { useCreateProjectProjectsPost, useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useCreateDepartmentDepartmentsPost, useListDepartmentsDepartmentsGet } from "@/api/generated/org/org";
import { useCreateEmployeeEmployeesPost, useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import { useListActivitiesActivitiesGet } from "@/api/generated/activities/activities";
export default function Home() {
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const departments = useListDepartmentsDepartmentsGet();
const departmentList = departments.data?.status === 200 ? departments.data.data : [];
const employees = useListEmployeesEmployeesGet();
const activities = useListActivitiesActivitiesGet({ limit: 20 });
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const activityList = normalizeActivities(activities.data);
const [projectName, setProjectName] = useState("");
const [deptName, setDeptName] = useState("");
const [personName, setPersonName] = useState("");
const [personType, setPersonType] = useState<"human" | "agent">("human");
const createProject = useCreateProjectProjectsPost({
mutation: { onSuccess: () => { setProjectName(""); projects.refetch(); } },
});
const createDepartment = useCreateDepartmentDepartmentsPost({
mutation: { onSuccess: () => { setDeptName(""); departments.refetch(); } },
});
const createEmployee = useCreateEmployeeEmployeesPost({
mutation: { onSuccess: () => { setPersonName(""); employees.refetch(); } },
});
import { LandingHero } from "@/components/organisms/LandingHero";
import { LandingShell } from "@/components/templates/LandingShell";
export default function Page() {
return (
<main>
<div className={styles.topbar}>
<div>
<h1 className={styles.h1}>Company Mission Control</h1>
<p className={styles.p}>Command center for projects, people, and operations. Noauth v1.</p>
</div>
<Button variant="outline" onClick={() => { projects.refetch(); departments.refetch(); employees.refetch(); activities.refetch(); }} disabled={projects.isFetching || departments.isFetching || employees.isFetching || activities.isFetching}>
Refresh
</Button>
</div>
<div className={styles.grid2}>
<div className={styles.card}>
<div className={styles.cardTitle}>Quick create</div>
<div className={styles.list}>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Project</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Project name" value={projectName} onChange={(e) => setProjectName(e.target.value)} />
<Button onClick={() => createProject.mutate({ data: { name: projectName, status: "active" } })} disabled={!projectName.trim() || createProject.isPending}>Create</Button>
{createProject.error ? <div className={styles.mono}>{(createProject.error as Error).message}</div> : null}
</div>
</div>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Department</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Department name" value={deptName} onChange={(e) => setDeptName(e.target.value)} />
<Button onClick={() => createDepartment.mutate({ data: { name: deptName } })} disabled={!deptName.trim() || createDepartment.isPending}>Create</Button>
{createDepartment.error ? <div className={styles.mono}>{(createDepartment.error as Error).message}</div> : null}
</div>
</div>
<div className={styles.item}>
<div style={{ marginBottom: 8, fontWeight: 600 }}>Person</div>
<div style={{ display: "grid", gap: 8 }}>
<Input placeholder="Name" value={personName} onChange={(e) => setPersonName(e.target.value)} />
<Select value={personType} onChange={(e) => setPersonType(e.target.value === "agent" ? "agent" : "human")}>
<option value="human">human</option>
<option value="agent">agent</option>
</Select>
<Button onClick={() => createEmployee.mutate({ data: { name: personName, employee_type: personType, status: "active" } })} disabled={!personName.trim() || createEmployee.isPending}>Create</Button>
{createEmployee.error ? <div className={styles.mono}>{(createEmployee.error as Error).message}</div> : null}
</div>
</div>
</div>
</div>
<div className={styles.card}>
<div className={styles.cardTitle}>Live activity</div>
<div className={styles.list}>
{activityList.map((a) => (
<div key={String(a.id)} className={styles.item}>
<div style={{ fontWeight: 600 }}>{a.entity_type} · {a.verb}</div>
<div className={styles.mono}>id {a.entity_id ?? "—"}</div>
</div>
))}
{activityList.length === 0 ? (
<div className={styles.mono}>No activity yet.</div>
) : null}
</div>
</div>
</div>
<div style={{ marginTop: 18, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 16 }}>
<Card>
<CardHeader>
<CardTitle>Projects</CardTitle>
<CardDescription>{projectList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{projectList.slice(0, 8).map((p) => (
<div key={p.id ?? p.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div className={styles.mono} style={{ display: "flex", gap: 10, alignItems: "center" }}>
<span>{p.status}</span>
{p.id ? (
<Link href={
"/projects/" + p.id
} className={styles.badge}>Open</Link>
) : null}
</div>
</div>
))}
{projectList.length === 0 ? <div className={styles.mono}>No projects yet.</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Departments</CardTitle>
<CardDescription>{departmentList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{departmentList.slice(0, 8).map((d) => (
<div key={d.id ?? d.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{d.name}</div>
<div className={styles.mono}>id {d.id}</div>
</div>
))}
{departmentList.length === 0 ? <div className={styles.mono}>No departments yet.</div> : null}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>People</CardTitle>
<CardDescription>{employeeList.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{employeeList.slice(0, 8).map((e) => (
<div key={e.id ?? e.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{e.name}</div>
<div className={styles.mono}>{e.employee_type}</div>
</div>
))}
{employeeList.length === 0 ? <div className={styles.mono}>No people yet.</div> : null}
</div>
</CardContent>
</Card>
</div>
</main>
<LandingShell>
<LandingHero />
</LandingShell>
);
}

View File

@@ -1,207 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateEmployeeEmployeesPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost,
useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost,
} from "@/api/generated/org/org";
export default function PeoplePage() {
const [actorId] = useState(() => {
if (typeof window === "undefined") return "";
try {
return window.localStorage.getItem("actor_employee_id") ?? "";
} catch {
return "";
}
});
const [name, setName] = useState("");
const [employeeType, setEmployeeType] = useState<"human" | "agent">("human");
const [title, setTitle] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [teamId, setTeamId] = useState<string>("");
const [managerId, setManagerId] = useState<string>("");
const employees = useListEmployeesEmployeesGet();
const departments = useListDepartmentsDepartmentsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(() => (departments.data?.status === 200 ? departments.data.data : []), [departments.data]);
const employeeList = useMemo(() => (employees.data?.status === 200 ? employees.data.data : []), [employees.data]);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const provisionEmployee = useProvisionEmployeeAgentEmployeesEmployeeIdProvisionPost();
const deprovisionEmployee = useDeprovisionEmployeeAgentEmployeesEmployeeIdDeprovisionPost();
const createEmployee = useCreateEmployeeEmployeesPost({
mutation: {
onSuccess: async (res) => {
setName("");
setTitle("");
setDepartmentId("");
setTeamId("");
setManagerId("");
// If an agent was created but not yet provisioned, provision immediately so it can receive tasks.
try {
const e = (res as any)?.data?.data ?? (res as any)?.data ?? null;
if (e?.employee_type === "agent" && !e.openclaw_session_key) {
await provisionEmployee.mutateAsync({ employeeId: e.id! });
}
} catch {
// ignore; UI will show unprovisioned state
}
employees.refetch();
teams.refetch();
},
},
});
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const teamNameById = useMemo(() => {
const m = new Map<number, string>();
for (const t of teamList) {
if (t.id != null) m.set(t.id, t.name);
}
return m;
}, [teamList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">People</h1>
<p className="mt-1 text-sm text-muted-foreground">Employees and agents share the same table.</p>
</div>
<Button variant="outline" onClick={() => employees.refetch()} disabled={employees.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Add person</CardTitle>
<CardDescription>Create an employee (human) or an agent.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={employeeType} onChange={(e) => setEmployeeType(e.target.value === "agent" ? "agent" : "human")}>
<option value="human">human</option>
<option value="agent">agent</option>
</Select>
<Input placeholder="Title (optional)" value={title} onChange={(e) => setTitle(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">(no department)</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>
{d.name}
</option>
))}
</Select>
<Select value={teamId} onChange={(e) => setTeamId(e.target.value)}>
<option value="">(no team)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ""}>
{t.name}
</option>
))}
</Select>
<Select value={managerId} onChange={(e) => setManagerId(e.target.value)}>
<option value="">(no manager)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createEmployee.mutate({
data: {
name,
employee_type: employeeType,
title: title.trim() ? title : null,
department_id: departmentId ? Number(departmentId) : null,
team_id: teamId ? Number(teamId) : null,
manager_id: managerId ? Number(managerId) : null,
status: "active",
},
})
}
disabled={!name.trim() || createEmployee.isPending}
>
Create
</Button>
{createEmployee.error ? (
<div className="text-sm text-destructive">{(createEmployee.error as Error).message}</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Directory</CardTitle>
<CardDescription>{employeeList.length} total</CardDescription>
</CardHeader>
<CardContent>
{employees.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{employees.error ? (
<div className="text-sm text-destructive">{(employees.error as Error).message}</div>
) : null}
{!employees.isLoading && !employees.error ? (
<ul className="space-y-2">
{employeeList.map((e) => (
<li key={e.id ?? e.name} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{e.name}</div>
<Badge variant={e.employee_type === "agent" ? "secondary" : "outline"}>
{e.employee_type}
</Badge>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{e.title ? <span>{e.title} · </span> : null}
{e.department_id ? <span>{deptNameById.get(e.department_id) ?? `Dept#${e.department_id}`} · </span> : null}
{e.team_id ? <span>Team: {teamNameById.get(e.team_id) ?? `Team#${e.team_id}`} · </span> : null}
{e.manager_id ? <span>Mgr: {empNameById.get(e.manager_id) ?? `Emp#${e.manager_id}`}</span> : <span>No manager</span>}
</div>
</li>
))}
{employeeList.length === 0 ? (
<li className="text-sm text-muted-foreground">No people yet.</li>
) : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,539 +0,0 @@
"use client";
import { useState } from "react";
import { useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useListProjectsProjectsGet } from "@/api/generated/projects/projects";
import { useListEmployeesEmployeesGet } from "@/api/generated/org/org";
import {
useCreateTaskTasksPost,
useDeleteTaskTasksTaskIdDelete,
useDispatchTaskTasksTaskIdDispatchPost,
useListTaskCommentsTaskCommentsGet,
useListTasksTasksGet,
useUpdateTaskTasksTaskIdPatch,
useCreateTaskCommentTaskCommentsPost,
} from "@/api/generated/work/work";
import {
useAddProjectMemberProjectsProjectIdMembersPost,
useListProjectMembersProjectsProjectIdMembersGet,
useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete,
useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch,
} from "@/api/generated/projects/projects";
function getActorEmployeeId(): number | null {
if (typeof window === "undefined") return null;
try {
const v = window.localStorage.getItem("actor_employee_id");
if (!v) return null;
const n = Number(v);
return Number.isFinite(n) ? n : null;
} catch {
return null;
}
}
const STATUSES = ["backlog", "ready", "in_progress", "review", "done", "blocked"] as const;
export default function ProjectDetailPage() {
const params = useParams();
const projectId = Number(params?.id);
const projects = useListProjectsProjectsGet();
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const project = projectList.find((p) => p.id === projectId);
const employees = useListEmployeesEmployeesGet();
const employeeList = employees.data?.status === 200 ? employees.data.data : [];
const eligibleAssignees = employeeList.filter(
(e) => e.employee_type !== "agent" || !!e.openclaw_session_key,
);
const members = useListProjectMembersProjectsProjectIdMembersGet(projectId);
const memberList = members.data?.status === 200 ? members.data.data : [];
const addMember = useAddProjectMemberProjectsProjectIdMembersPost({
mutation: { onSuccess: () => members.refetch() },
});
const removeMember = useRemoveProjectMemberProjectsProjectIdMembersMemberIdDelete({
mutation: { onSuccess: () => members.refetch() },
});
const updateMember = useUpdateProjectMemberProjectsProjectIdMembersMemberIdPatch({
mutation: { onSuccess: () => members.refetch() },
});
const tasks = useListTasksTasksGet({ project_id: projectId });
const taskList = tasks.data?.status === 200 ? tasks.data.data : [];
const createTask = useCreateTaskTasksPost({
mutation: { onSuccess: () => tasks.refetch() },
});
const updateTask = useUpdateTaskTasksTaskIdPatch({
mutation: { onSuccess: () => tasks.refetch() },
});
const deleteTask = useDeleteTaskTasksTaskIdDelete({
mutation: { onSuccess: () => tasks.refetch() },
});
const dispatchTask = useDispatchTaskTasksTaskIdDispatchPost({
mutation: {
onSuccess: () => tasks.refetch(),
},
});
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [assigneeId, setAssigneeId] = useState<string>("");
const [commentTaskId, setCommentTaskId] = useState<number | null>(null);
const [replyToCommentId, setReplyToCommentId] = useState<number | null>(null);
const [commentBody, setCommentBody] = useState("");
const comments = useListTaskCommentsTaskCommentsGet(
{ task_id: commentTaskId ?? 0 },
{ query: { enabled: Boolean(commentTaskId) } },
);
const commentList = comments.data?.status === 200 ? comments.data.data : [];
const addComment = useCreateTaskCommentTaskCommentsPost({
mutation: {
onSuccess: () => {
comments.refetch();
setCommentBody("");
setReplyToCommentId(null);
},
},
});
const tasksByStatus = (() => {
const map = new Map<string, typeof taskList>();
for (const s of STATUSES) map.set(s, []);
for (const t of taskList) {
const status = t.status ?? "backlog";
map.get(status)?.push(t);
}
return map;
})();
const employeeById = new Map<number, (typeof employeeList)[number]>();
for (const e of employeeList) {
if (e.id != null) employeeById.set(Number(e.id), e);
}
const employeeName = (id: number | null | undefined) =>
employeeList.find((e) => e.id === id)?.name ?? "—";
const projectMembers = memberList;
const commentById = new Map<number, (typeof commentList)[number]>();
for (const c of commentList) {
if (c.id != null) commentById.set(Number(c.id), c);
}
return (
<main className="mx-auto max-w-6xl p-6">
{!Number.isFinite(projectId) ? (
<div className="mb-4 text-sm text-destructive">Invalid project id in URL.</div>
) : null}
{projects.isLoading || employees.isLoading || members.isLoading || tasks.isLoading ? (
<div className="mb-4 text-sm text-muted-foreground">Loading</div>
) : null}
{projects.error ? (
<div className="mb-4 text-sm text-destructive">
{(projects.error as Error).message}
</div>
) : null}
{employees.error ? (
<div className="mb-4 text-sm text-destructive">
{(employees.error as Error).message}
</div>
) : null}
{members.error ? (
<div className="mb-4 text-sm text-destructive">{(members.error as Error).message}</div>
) : null}
{tasks.error ? (
<div className="mb-4 text-sm text-destructive">{(tasks.error as Error).message}</div>
) : null}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">
{project?.name ?? `Project #${projectId}`}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Project detail: staffing + tasks.
</p>
</div>
<Button
variant="outline"
onClick={() => {
tasks.refetch();
members.refetch();
}}
disabled={tasks.isFetching || members.isFetching}
>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Create task</CardTitle>
<CardDescription>Project-scoped tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{createTask.error ? (
<div className="text-sm text-destructive">
{(createTask.error as Error).message}
</div>
) : null}
<Input
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
<Textarea
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="grid grid-cols-1 gap-2">
<Select
value={assigneeId}
onChange={(e) => setAssigneeId(e.target.value)}
>
<option value="">Assignee</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
</div>
<Button
onClick={() =>
createTask.mutate({
data: {
project_id: projectId,
title,
description: description.trim() ? description : null,
status: "backlog",
assignee_employee_id: assigneeId ? Number(assigneeId) : null,
},
})
}
disabled={!title.trim() || createTask.isPending}
>
Add task
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Staffing</CardTitle>
<CardDescription>Project members</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Select
onChange={(e) => {
const empId = e.target.value;
if (!empId) return;
addMember.mutate({
projectId,
data: { project_id: projectId, employee_id: Number(empId), role: "member" },
});
e.currentTarget.value = "";
}}
>
<option value="">Add member</option>
{eligibleAssignees.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
{addMember.error ? (
<div className="text-xs text-destructive">
{(addMember.error as Error).message}
</div>
) : null}
<ul className="space-y-2">
{projectMembers.map((m) => (
<li
key={m.id ?? `${m.project_id}-${m.employee_id}`}
className="rounded-md border p-2 text-sm"
>
<div className="flex items-center justify-between gap-2">
<div>{employeeName(m.employee_id)}</div>
<Button
variant="outline"
onClick={() => {
if (m.id == null) return;
removeMember.mutate({ projectId, memberId: Number(m.id) });
}}
>
Remove
</Button>
</div>
<div className="mt-2">
<Input
placeholder="Role (e.g., PM, QA, Dev)"
defaultValue={m.role ?? ""}
onBlur={(e) =>
m.id == null
? undefined
: updateMember.mutate({
projectId,
memberId: Number(m.id),
data: {
project_id: projectId,
employee_id: m.employee_id,
role: e.currentTarget.value || null,
},
})
}
/>
</div>
</li>
))}
{projectMembers.length === 0 ? (
<li className="text-sm text-muted-foreground">No members yet.</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
<div className="mt-6 grid gap-4">
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-6">
{STATUSES.map((s) => (
<Card key={s}>
<CardHeader>
<CardTitle className="text-sm uppercase tracking-wide">
{s.replace("_", " ")}
</CardTitle>
<CardDescription>{tasksByStatus.get(s)?.length ?? 0} tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{(tasksByStatus.get(s) ?? []).map((t) => {
const assignee =
t.assignee_employee_id != null
? employeeById.get(Number(t.assignee_employee_id))
: undefined;
const canTrigger = Boolean(
t.id != null &&
assignee &&
assignee.employee_type === "agent" &&
assignee.openclaw_session_key,
);
const actorId = getActorEmployeeId();
const isReviewer = Boolean(actorId && t.reviewer_employee_id && Number(t.reviewer_employee_id) === actorId);
const canReviewActions = Boolean(t.id != null && isReviewer && (t.status ?? "") === "review");
return (
<div key={t.id ?? t.title} className="rounded-md border p-2 text-sm">
<div className="font-medium">{t.title}</div>
<div className="text-xs text-muted-foreground">
Assignee: {employeeName(t.assignee_employee_id)}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{STATUSES.filter((x) => x !== s).map((x) => (
<Button
key={x}
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: x },
})
}
>
{x}
</Button>
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
>
Comments
</Button>
<Button
variant="outline"
size="sm"
onClick={() => dispatchTask.mutate({ taskId: Number(t.id) })}
disabled={!canTrigger || dispatchTask.isPending}
title={
canTrigger
? "Send a dispatch message to the assigned agent"
: "Only available when the assignee is a provisioned agent"
}
>
Trigger
</Button>
{canReviewActions ? (
<>
<Button
variant="outline"
size="sm"
onClick={() =>
updateTask.mutate({
taskId: Number(t.id),
data: { status: "done" },
})
}
>
Approve
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
setCommentTaskId(Number(t.id));
setReplyToCommentId(null);
}}
title="Leave a comment asking for changes, then move status back to in_progress"
>
Request changes
</Button>
</>
) : null}
<Button
variant="destructive"
size="sm"
onClick={() => deleteTask.mutate({ taskId: Number(t.id) })}
>
Delete
</Button>
</div>
{dispatchTask.error ? (
<div className="mt-2 text-xs text-destructive">
{(dispatchTask.error as Error).message}
</div>
) : null}
</div>
);
})}
{(tasksByStatus.get(s) ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground">No tasks</div>
) : null}
</CardContent>
</Card>
))}
</div>
</div>
<div className="mt-6">
<Card>
<CardHeader>
<CardTitle>Task comments</CardTitle>
<CardDescription>{commentTaskId ? `Task #${commentTaskId}` : "Select a task"}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{addComment.error ? (
<div className="text-sm text-destructive">{(addComment.error as Error).message}</div>
) : null}
{replyToCommentId ? (
<div className="rounded-md border bg-muted/40 p-2 text-sm">
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
Replying to comment #{replyToCommentId}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(null)}
>
Cancel reply
</Button>
</div>
<div className="mt-1 text-xs text-muted-foreground line-clamp-2">
{commentById.get(replyToCommentId)?.body ?? "—"}
</div>
</div>
) : null}
<Textarea
placeholder="Write a comment"
value={commentBody}
onChange={(e) => setCommentBody(e.target.value)}
disabled={!commentTaskId}
/>
<Button
onClick={() =>
addComment.mutate({
data: {
task_id: Number(commentTaskId),
author_employee_id: getActorEmployeeId(),
body: commentBody,
reply_to_comment_id: replyToCommentId,
},
})
}
disabled={!commentTaskId || !commentBody.trim() || addComment.isPending}
>
Add comment
</Button>
<ul className="space-y-2">
{commentList.map((c) => (
<li key={String(c.id)} className="rounded-md border p-2 text-sm">
<div className="flex items-start justify-between gap-2">
<div>
<div className="font-medium">{employeeName(c.author_employee_id)}</div>
<div className="text-xs text-muted-foreground">
{c.created_at ? new Date(c.created_at).toLocaleString() : "—"}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setReplyToCommentId(Number(c.id))}
>
Reply
</Button>
</div>
{c.reply_to_comment_id ? (
<div className="mt-2 rounded-md border bg-muted/40 p-2 text-xs">
<div className="text-muted-foreground">
Replying to #{c.reply_to_comment_id}: {commentById.get(Number(c.reply_to_comment_id))?.body ?? "—"}
</div>
</div>
) : null}
<div className="mt-2">{c.body}</div>
</li>
))}
{commentList.length === 0 ? (
<li className="text-sm text-muted-foreground">No comments yet.</li>
) : null}
</ul>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,103 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import styles from "@/app/_components/Shell.module.css";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
useCreateProjectProjectsPost,
useListProjectsProjectsGet,
} from "@/api/generated/projects/projects";
import { useListTeamsTeamsGet } from "@/api/generated/org/org";
export default function ProjectsPage() {
const [name, setName] = useState("");
const [teamId, setTeamId] = useState<string>("");
const projects = useListProjectsProjectsGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const projectList = projects.data?.status === 200 ? projects.data.data : [];
const teamList = teams.data?.status === 200 ? teams.data.data : [];
const createProject = useCreateProjectProjectsPost({
mutation: {
onSuccess: () => {
setName("");
setTeamId("");
projects.refetch();
},
},
});
const sorted = projectList.slice().sort((a, b) => a.name.localeCompare(b.name));
return (
<main>
<div className={styles.topbar}>
<div>
<h1 className={styles.h1}>Projects</h1>
<p className={styles.p}>Create, view, and drill into projects.</p>
</div>
<Button variant="outline" onClick={() => projects.refetch()} disabled={projects.isFetching}>
Refresh
</Button>
</div>
<div className={styles.grid2}>
<div className={styles.card}>
<div className={styles.cardTitle}>Create project</div>
{projects.isLoading ? <div className={styles.mono}>Loading</div> : null}
{projects.error ? <div className={styles.mono}>{(projects.error as Error).message}</div> : null}
<div className={styles.list}>
<Input placeholder="Project name" value={name} onChange={(e) => setName(e.target.value)} autoFocus />
<div style={{ display: 'flex', gap: 10, alignItems: 'center' }}>
<span style={{ fontSize: 12, opacity: 0.8 }}>Owning team</span>
<select value={teamId} onChange={(e) => setTeamId(e.target.value)} style={{ flex: 1, padding: '6px 8px', borderRadius: 6, border: '1px solid #333', background: 'transparent' }}>
<option value="">(none)</option>
{teamList.map((t) => (
<option key={t.id ?? t.name} value={t.id ?? ''}>{t.name}</option>
))}
</select>
</div>
<Button
onClick={() => createProject.mutate({ data: { name, status: "active", team_id: teamId ? Number(teamId) : null } })}
disabled={!name.trim() || createProject.isPending || projects.isFetching}
>
Create
</Button>
{createProject.error ? (
<div className={styles.mono}>{(createProject.error as Error).message}</div>
) : null}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>All projects</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
<div className={styles.list}>
{sorted.map((p) => (
<div key={p.id ?? p.name} className={styles.item}>
<div style={{ fontWeight: 600 }}>{p.name}</div>
<div className={styles.mono} style={{ display: "flex", gap: 10, alignItems: "center" }}>
<span>{p.status}</span>
{p.id ? (
<Link href={`/projects/${p.id}`} className={styles.badge}>Open</Link>
) : null}
</div>
</div>
))}
{sorted.length === 0 ? <div className={styles.mono}>No projects yet.</div> : null}
</div>
</CardContent>
</Card>
</div>
</main>
);
}

View File

@@ -1,23 +0,0 @@
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
export function Providers({ children }: { children: React.ReactNode }) {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
retry: 1,
// Mission Control is a live ops surface; keeping data fresh on focus/reconnect
// gives a near-realtime feel without aggressive polling.
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
},
})
);
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}

View File

@@ -1,150 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import {
useCreateTeamTeamsPost,
useListDepartmentsDepartmentsGet,
useListEmployeesEmployeesGet,
useListTeamsTeamsGet,
} from "@/api/generated/org/org";
export default function TeamsPage() {
const [name, setName] = useState("");
const [departmentId, setDepartmentId] = useState<string>("");
const [leadEmployeeId, setLeadEmployeeId] = useState<string>("");
const departments = useListDepartmentsDepartmentsGet();
const employees = useListEmployeesEmployeesGet();
const teams = useListTeamsTeamsGet({ department_id: undefined });
const departmentList = useMemo(
() => (departments.data?.status === 200 ? departments.data.data : []),
[departments.data],
);
const employeeList = useMemo(
() => (employees.data?.status === 200 ? employees.data.data : []),
[employees.data],
);
const teamList = useMemo(() => (teams.data?.status === 200 ? teams.data.data : []), [teams.data]);
const deptNameById = useMemo(() => {
const m = new Map<number, string>();
for (const d of departmentList) {
if (d.id != null) m.set(d.id, d.name);
}
return m;
}, [departmentList]);
const empNameById = useMemo(() => {
const m = new Map<number, string>();
for (const e of employeeList) {
if (e.id != null) m.set(e.id, e.name);
}
return m;
}, [employeeList]);
const createTeam = useCreateTeamTeamsPost({
mutation: {
onSuccess: () => {
setName("");
setDepartmentId("");
setLeadEmployeeId("");
teams.refetch();
},
},
});
const sorted = teamList
.slice()
.sort((a, b) => `${deptNameById.get(a.department_id) ?? ""}::${a.name}`.localeCompare(`${deptNameById.get(b.department_id) ?? ""}::${b.name}`));
return (
<main className="mx-auto max-w-5xl p-6">
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Teams</h1>
<p className="mt-1 text-sm text-muted-foreground">Teams live under departments. Projects are owned by teams.</p>
</div>
<Button variant="outline" onClick={() => teams.refetch()} disabled={teams.isFetching}>
Refresh
</Button>
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>Define a team and attach it to a department.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Team name" value={name} onChange={(e) => setName(e.target.value)} />
<Select value={departmentId} onChange={(e) => setDepartmentId(e.target.value)}>
<option value="">(select department)</option>
{departmentList.map((d) => (
<option key={d.id ?? d.name} value={d.id ?? ""}>
{d.name}
</option>
))}
</Select>
<Select value={leadEmployeeId} onChange={(e) => setLeadEmployeeId(e.target.value)}>
<option value="">(no lead)</option>
{employeeList.map((e) => (
<option key={e.id ?? e.name} value={e.id ?? ""}>
{e.name}
</option>
))}
</Select>
<Button
onClick={() =>
createTeam.mutate({
data: {
name: name.trim(),
department_id: Number(departmentId),
lead_employee_id: leadEmployeeId ? Number(leadEmployeeId) : null,
},
})
}
disabled={!name.trim() || !departmentId || createTeam.isPending}
>
Create
</Button>
{createTeam.error ? <div className="text-sm text-destructive">{(createTeam.error as Error).message}</div> : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>All teams</CardTitle>
<CardDescription>{sorted.length} total</CardDescription>
</CardHeader>
<CardContent>
{teams.isLoading ? <div className="text-sm text-muted-foreground">Loading</div> : null}
{teams.error ? <div className="text-sm text-destructive">{(teams.error as Error).message}</div> : null}
{!teams.isLoading && !teams.error ? (
<ul className="space-y-2">
{sorted.map((t) => (
<li key={t.id ?? `${t.department_id}:${t.name}`} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-3">
<div className="font-medium">{t.name}</div>
<div className="text-sm text-muted-foreground">{deptNameById.get(t.department_id) ?? `Dept#${t.department_id}`}</div>
</div>
<div className="mt-2 text-sm text-muted-foreground">
{t.lead_employee_id ? <span>Lead: {empNameById.get(t.lead_employee_id) ?? `Emp#${t.lead_employee_id}`}</span> : <span>No lead</span>}
</div>
</li>
))}
{sorted.length === 0 ? <li className="text-sm text-muted-foreground">No teams yet.</li> : null}
</ul>
) : null}
</CardContent>
</Card>
</div>
</main>
);
}