WORKFLOW-APP/app/tasks/TaskCard.tsx
Dave c349998fa1 feat: Task Kanban UI + Python task worker
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
2026-08-17 18:57:02 +02:00

99 lines
3.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}