180 lines
7.4 KiB
TypeScript
180 lines
7.4 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { TaskIntakeModal } from "@/components/task-intake-modal";
|
|
import type { FleetAgent, TaskRecord, TaskStatus, TaskTemplate } from "@/lib/types";
|
|
|
|
const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
|
|
|
|
function familyVariant(family: string | null) {
|
|
if (family === "zeroclaw") {
|
|
return "success";
|
|
}
|
|
if (family === "direct") {
|
|
return "warning";
|
|
}
|
|
return "default";
|
|
}
|
|
|
|
function dispatchVariant(state: TaskRecord["dispatch_state"]) {
|
|
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
|
|
}
|
|
|
|
export function TasksClient({
|
|
initialTasks,
|
|
agents,
|
|
templates,
|
|
}: {
|
|
initialTasks: TaskRecord[];
|
|
agents: FleetAgent[];
|
|
templates: TaskTemplate[];
|
|
}) {
|
|
const [tasks, setTasks] = useState(initialTasks);
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
|
|
async function refreshData() {
|
|
const taskResponse = await fetch("/api/tasks");
|
|
setTasks((await taskResponse.json()) as TaskRecord[]);
|
|
}
|
|
|
|
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
|
|
await fetch(`/api/tasks/${taskId}`, {
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
await refreshData();
|
|
}
|
|
|
|
async function dispatchTask(taskId: number) {
|
|
await fetch(`/api/tasks/${taskId}/dispatch`, { method: "POST" });
|
|
await refreshData();
|
|
}
|
|
|
|
async function acknowledgeTask(taskId: number) {
|
|
await fetch(`/api/tasks/${taskId}/ack`, { method: "POST" });
|
|
await refreshData();
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<Card className="border-white/10 bg-slate-950/35">
|
|
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
|
<div>
|
|
<CardTitle>Taskboard</CardTitle>
|
|
<CardDescription>
|
|
The board is the primary workspace. Task intake opens as a modal so the board keeps its full visual width.
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Badge variant="outline">{tasks.length} total</Badge>
|
|
<Badge variant="secondary">{tasks.filter((task) => task.status === "In Progress").length} active</Badge>
|
|
<Button onClick={() => setIsModalOpen(true)}>New Task</Button>
|
|
</div>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-white">Task Board</h2>
|
|
<p className="text-sm text-slate-400">
|
|
Columns keep a readable width and scroll horizontally when the viewport is narrower than the full board.
|
|
</p>
|
|
</div>
|
|
<Badge variant="outline">{tasks.length} total tasks</Badge>
|
|
</div>
|
|
|
|
<div className="grid gap-4 xl:grid-cols-5">
|
|
{COLUMNS.map((column) => {
|
|
const columnTasks = tasks.filter((task) => task.status === column);
|
|
return (
|
|
<Card className="flex min-h-[560px] min-w-0 flex-col border-white/10 bg-slate-950/35" key={column}>
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="flex items-center justify-between text-base">
|
|
<span>{column}</span>
|
|
<Badge variant="secondary">{columnTasks.length}</Badge>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
|
|
{columnTasks.map((task) => (
|
|
<div className="min-w-0 rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<h3 className="min-w-0 break-words font-medium text-white">{task.title}</h3>
|
|
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>
|
|
{task.priority}
|
|
</Badge>
|
|
</div>
|
|
<p className="mt-2 break-words text-sm leading-6 text-slate-300">
|
|
{task.description || "No description"}
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">
|
|
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
|
|
<Badge variant="secondary">{task.assignee || "Unassigned"}</Badge>
|
|
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
|
|
{task.tags.map((tag) => (
|
|
<Badge key={tag} variant="outline">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
<dl className="mt-3 grid gap-1 text-xs text-slate-400">
|
|
<div className="flex justify-between gap-2">
|
|
<dt>Host</dt>
|
|
<dd className="break-words text-right">{task.target_host || "n/a"}</dd>
|
|
</div>
|
|
<div className="flex justify-between gap-2">
|
|
<dt>Channel</dt>
|
|
<dd className="break-all text-right">{task.target_channel || "n/a"}</dd>
|
|
</div>
|
|
</dl>
|
|
{task.result_summary ? (
|
|
<div className="mt-3 rounded-lg border border-emerald-400/20 bg-emerald-400/5 p-3">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-emerald-300/80">Latest Result</p>
|
|
<p className="mt-1 text-sm text-slate-200">{task.result_summary}</p>
|
|
{task.result_detail ? (
|
|
<p className="mt-1 break-words text-xs leading-5 text-slate-400">{task.result_detail}</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<div className="mt-4 space-y-2">
|
|
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
|
|
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>
|
|
Dispatch
|
|
</Button>
|
|
) : null}
|
|
{task.dispatch_state === "dispatched" ? (
|
|
<Button className="w-full" size="sm" variant="outline" onClick={() => acknowledgeTask(task.id)}>
|
|
Mark Acknowledged
|
|
</Button>
|
|
) : null}
|
|
{task.status !== "Done" ? (
|
|
<Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
|
|
Mark Done
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<TaskIntakeModal
|
|
agents={agents}
|
|
templates={templates}
|
|
open={isModalOpen}
|
|
onClose={() => setIsModalOpen(false)}
|
|
onCreated={refreshData}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|