Adds the visible front-end and the worker back-end for the agent
task system. The PocketBase schema, API routes, and ToastProvider
were already in the previous commit; this commit completes the
picture.
UI
- app/tasks/{page,KanbanBoard,TaskCard,CardModal,TaskDetail,api,columns}
Full Kanban with six columns (Backlog, Ready, In Progress,
Blocked, Done, Failed), drag-and-drop between columns, quick-add
chips, type/priority filters, task detail panel with retry/cancel/
delete, 5s polling, transition toasts.
- components/NavBar.tsx: 'Tasks' link in primary nav.
- app/bizportal-2/BizPortal2Explorer.tsx: 'Send to Kanban' button
on the result view — dispatches the current search to the agent
queue as a 'ready' task.
Back-end
- scripts/task_worker.py: PocketBase polling worker with two
lanes. Deterministic handles bizportal_search/reserve, pdf_generate,
email_send. Custom lane writes to /tmp/agent_inbox/ for the
LLM agent to pick up. Atomic claim via PB filter, auto-retry up
to 3x, structured log on each task.
- scripts/bizportal_service.py: bugfix — _is_logged_in was using
a non-CSS selector so session-recovery was a no-op. Also made
the login flow re-runnable mid-session (handles BizPortal's
~20-minute session timeout transparently).
- scripts/init_pb_schema.py: duration_ms no longer capped at 0.
Verified end-to-end:
- CK search via worker: 1 company, 3 directors, ~10s
- ID search via worker: 4 companies, 7 total directors, ~34s
- Auto-retry + status transitions + toast fires
99 lines
3.2 KiB
TypeScript
99 lines
3.2 KiB
TypeScript
// app/tasks/TaskCard.tsx
|
||
// Compact card for a single task. Used inside the Kanban columns.
|
||
|
||
"use client";
|
||
|
||
import type { Task } from "./api";
|
||
import {
|
||
TASK_TYPE_META,
|
||
PRIORITY_COLORS,
|
||
PRIORITY_LABEL,
|
||
DONE_COLLAPSE_DAYS,
|
||
} from "./columns";
|
||
|
||
interface TaskCardProps {
|
||
task: Task;
|
||
onClick?: () => void;
|
||
onDragStart?: (e: React.DragEvent) => void;
|
||
onDragEnd?: (e: React.DragEvent) => void;
|
||
isDragging?: boolean;
|
||
}
|
||
|
||
export default function TaskCard({
|
||
task,
|
||
onClick,
|
||
onDragStart,
|
||
onDragEnd,
|
||
isDragging,
|
||
}: TaskCardProps) {
|
||
const meta = TASK_TYPE_META[task.task_type];
|
||
const priorityClass = PRIORITY_COLORS[task.priority];
|
||
const isDone = task.status === "done";
|
||
const isOld = isDone && isOlderThanDays(task.completed_at, DONE_COLLAPSE_DAYS);
|
||
|
||
return (
|
||
<div
|
||
draggable
|
||
onDragStart={onDragStart}
|
||
onDragEnd={onDragEnd}
|
||
onClick={onClick}
|
||
data-od-id={`task-card-${task.id}`}
|
||
className={`group rounded-md border border-border bg-surface p-3 cursor-grab active:cursor-grabbing transition-all ${
|
||
isDragging ? "opacity-50 scale-95" : "hover:border-accent hover:shadow-sm"
|
||
} ${isOld ? "opacity-60" : ""}`}
|
||
>
|
||
<div className="flex items-start justify-between gap-2 mb-2">
|
||
<h4 className="text-sm font-semibold text-fg leading-tight line-clamp-2">{task.title}</h4>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.tone}`}>
|
||
{meta.icon} {meta.label}
|
||
</span>
|
||
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${priorityClass}`}>
|
||
{PRIORITY_LABEL[task.priority]}
|
||
</span>
|
||
{task.retry_count > 0 ? (
|
||
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-orange-50 text-orange-800 border-orange-300">
|
||
retry ×{task.retry_count}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between text-[11px] text-muted">
|
||
<span>{relativeTime(task.created)}</span>
|
||
{task.duration_ms != null ? (
|
||
<span className="font-mono">{(task.duration_ms / 1000).toFixed(1)}s</span>
|
||
) : null}
|
||
</div>
|
||
|
||
{task.error ? (
|
||
<p className="mt-2 text-[11px] text-red-700 line-clamp-2" title={task.error}>
|
||
❌ {task.error}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function relativeTime(iso: string | null): string {
|
||
if (!iso) return "—";
|
||
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z").getTime();
|
||
if (isNaN(t)) return "—";
|
||
const diff = Date.now() - t;
|
||
const sec = Math.floor(diff / 1000);
|
||
if (sec < 60) return `${sec}s ago`;
|
||
const min = Math.floor(sec / 60);
|
||
if (min < 60) return `${min}m ago`;
|
||
const hr = Math.floor(min / 60);
|
||
if (hr < 24) return `${hr}h ago`;
|
||
const day = Math.floor(hr / 24);
|
||
return `${day}d ago`;
|
||
}
|
||
|
||
function isOlderThanDays(iso: string | null, days: number): boolean {
|
||
if (!iso) return false;
|
||
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z").getTime();
|
||
if (isNaN(t)) return false;
|
||
return Date.now() - t > days * 24 * 60 * 60 * 1000;
|
||
}
|