feat: add boards and tasks management endpoints
This commit is contained in:
@@ -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}}
|
||||
@@ -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 <span className={styles.kbd}>:8000</span> API
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.main}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
627
frontend/src/app/agents/page.tsx
Normal file
627
frontend/src/app/agents/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
frontend/src/app/boards/[boardId]/page.tsx
Normal file
310
frontend/src/app/boards/[boardId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
frontend/src/app/boards/new/page.tsx
Normal file
135
frontend/src/app/boards/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
frontend/src/app/boards/page.tsx
Normal file
202
frontend/src/app/boards/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
frontend/src/app/dashboard/page.tsx
Normal file
50
frontend/src/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. No‑auth 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user