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
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
// components/NavBar.tsx
|
|
// Top-of-page navigation. Logo lockup on the left, primary nav
|
|
// center, secondary status on the right. Sticky on scroll with the
|
|
// signature hairline border-bottom.
|
|
//
|
|
// Mobile (<md): a fixed bottom tab bar with inline-SVG icons (Overview
|
|
// · Directors · Companies · Onboarding). The second-row horizontal
|
|
// scroller from earlier turns has been replaced because the tab bar
|
|
// is the standard mobile-app pattern and reclaims vertical screen
|
|
// real estate.
|
|
//
|
|
// Icons use stroke-currentColor at 1.6px per the design system
|
|
// (anti-slop rule: emoji and gradient icons are forbidden). Brand
|
|
// mark "Y" pill in the top-left stays on all viewports.
|
|
|
|
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useTheme } from "next-themes";
|
|
import { useEffect, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
|
|
const LINKS = [
|
|
{ href: "/", label: "Overview", icon: IconHome },
|
|
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
|
|
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
|
|
{ href: "/tasks", label: "Tasks", icon: IconClipboard },
|
|
{ href: "/directors", label: "Directors", icon: IconUsers },
|
|
{ href: "/companies", label: "Companies", icon: IconBuilding },
|
|
{ href: "/groups", label: "Groups", icon: IconTag },
|
|
{ href: "/resolutions", label: "Resolutions", icon: IconDocument },
|
|
] as const;
|
|
|
|
export default function NavBar() {
|
|
const pathname = usePathname() ?? "/";
|
|
|
|
return (
|
|
<>
|
|
<header
|
|
data-od-id="navbar"
|
|
className="sticky top-0 z-30 bg-surface border-b border-border"
|
|
>
|
|
<div className="container-x flex items-center justify-between h-20">
|
|
<Link
|
|
href="/"
|
|
data-od-id="brand-mark"
|
|
className="flex items-center gap-2 no-underline"
|
|
>
|
|
<span
|
|
aria-hidden="true"
|
|
className="inline-flex items-center justify-center w-8 h-8 rounded-pill bg-accent text-accent-on font-bold text-sm"
|
|
>
|
|
Y
|
|
</span>
|
|
<span className="text-lg font-bold text-fg tracking-tight">
|
|
Workflow
|
|
</span>
|
|
</Link>
|
|
|
|
<nav
|
|
className="hidden md:flex items-center gap-2"
|
|
aria-label="Primary"
|
|
>
|
|
{LINKS.map((link) => {
|
|
const isActive = isActiveLink(pathname, link.href);
|
|
return (
|
|
<Link
|
|
key={link.href}
|
|
href={link.href}
|
|
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
|
|
className={
|
|
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
|
|
(isActive
|
|
? "bg-surface-warm text-fg"
|
|
: "text-fg hover:bg-surface-warm")
|
|
}
|
|
>
|
|
<span>{link.label}</span>
|
|
{"badge" in link && link.badge ? (
|
|
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
|
{link.badge}
|
|
</span>
|
|
) : null}
|
|
</Link>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
{/* Right-side desktop cluster: label + theme toggle. Hidden
|
|
on mobile — the bottom tab bar carries the brand context. */}
|
|
<div className="hidden md:flex items-center gap-3">
|
|
<span className="text-xs text-muted">Registry Browser</span>
|
|
<ThemeToggle />
|
|
</div>
|
|
|
|
{/* Spacer for mobile so the brand lockup stays left-aligned
|
|
and the bottom-bar height is the only vertical change. */}
|
|
<div className="md:hidden" aria-hidden="true" />
|
|
</div>
|
|
</header>
|
|
|
|
{/* Mobile bottom tab bar */}
|
|
<nav
|
|
className="md:hidden fixed inset-x-0 bottom-0 z-30 bg-surface border-t border-border pb-[env(safe-area-inset-bottom)]"
|
|
aria-label="Primary"
|
|
>
|
|
<ul className="container-x flex items-stretch justify-between h-16 px-2">
|
|
{LINKS.map((link) => {
|
|
const isActive = isActiveLink(pathname, link.href);
|
|
const Icon = link.icon;
|
|
return (
|
|
<li key={link.href} className="flex-1">
|
|
<Link
|
|
href={link.href}
|
|
aria-current={isActive ? "page" : undefined}
|
|
aria-label={link.label}
|
|
className={
|
|
"group flex flex-col items-center justify-center gap-1 h-full no-underline transition-colors " +
|
|
(isActive
|
|
? "text-accent"
|
|
: "text-muted hover:text-fg")
|
|
}
|
|
>
|
|
<Icon
|
|
className={
|
|
"w-5 h-5 transition-transform " +
|
|
(isActive ? "scale-110" : "scale-100")
|
|
}
|
|
active={isActive}
|
|
/>
|
|
<span className="text-[11px] font-semibold tracking-wide">
|
|
{link.label}
|
|
</span>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function isActiveLink(pathname: string, href: string): boolean {
|
|
if (href === "/") return pathname === "/";
|
|
return pathname === href || pathname.startsWith(href + "/");
|
|
}
|
|
|
|
/* ─── Icon set (inline SVG, stroke-currentColor, 1.6px stroke) ──── */
|
|
|
|
interface IconProps {
|
|
className?: string;
|
|
/** Active state hints — used to swap fill behavior on the home icon
|
|
* and to thicken the stroke on outlined icons. Always defaults to
|
|
* the brand "muted" rendering since the active state is owned by
|
|
* the parent (text color + scale already encodes it). */
|
|
active?: boolean;
|
|
}
|
|
|
|
function IconHome({ className, active }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill={active ? "currentColor" : "none"}
|
|
stroke="currentColor"
|
|
strokeWidth={1.6}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M3 11.5L12 4l9 7.5" />
|
|
<path d="M5 10.5V20a1 1 0 0 0 1 1h4v-6h4v6h4a1 1 0 0 0 1-1v-9.5" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconUsers({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1.6}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="9" cy="9" r="3.2" />
|
|
<path d="M3.5 19c.7-3 3-4.6 5.5-4.6S13.8 16 14.5 19" />
|
|
<circle cx="16.5" cy="9" r="2.6" />
|
|
<path d="M15.2 14.4c2 0 4 1.4 4.6 4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconBuilding({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth={1.6}
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="4" y="3" width="16" height="18" rx="1.5" />
|
|
<path d="M8 7h2M8 11h2M8 15h2M14 7h2M14 11h2M14 15h2" />
|
|
<path d="M10 21v-3h4v3" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconDocument({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
<polyline points="14,2 14,8 20,8" />
|
|
<line x1="9" y1="13" x2="15" y2="13" />
|
|
<line x1="9" y1="17" x2="13" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconClipboard({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<rect x="8" y="2" width="8" height="4" rx="1" />
|
|
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
|
|
<line x1="9" y1="13" x2="15" y2="13" />
|
|
<line x1="9" y1="17" x2="12" y2="17" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSearch({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="11" cy="11" r="7" />
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconTag({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/* ─── Theme toggle ────────────────────────────────────────────────── */
|
|
|
|
/**
|
|
* Sun/moon button. Uses next-themes' `resolvedTheme` so system
|
|
* preference is honoured. The icon shown represents the mode you'd
|
|
* switch TO (moon in light, sun in dark).
|
|
*
|
|
* `mounted` guard avoids SSR/CSR mismatch — `useTheme` returns
|
|
* undefined on the first render. Until mounted, we render a neutral
|
|
* placeholder so the button's box doesn't pop in late.
|
|
*/
|
|
function ThemeToggle() {
|
|
const [mounted, setMounted] = useState(false);
|
|
const { resolvedTheme, setTheme } = useTheme();
|
|
useEffect(() => setMounted(true), []);
|
|
|
|
const isDark = mounted && resolvedTheme === "dark";
|
|
const next = isDark ? "light" : "dark";
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setTheme(next)}
|
|
aria-label={mounted ? `Switch to ${next} mode` : "Toggle theme"}
|
|
title={`Switch to ${next} mode`}
|
|
data-od-id="theme-toggle"
|
|
className="inline-flex items-center justify-center w-9 h-9 rounded-md text-fg hover:bg-surface-warm transition-colors"
|
|
>
|
|
{mounted ? (
|
|
isDark ? (
|
|
<IconSun className="w-5 h-5" />
|
|
) : (
|
|
<IconMoon className="w-5 h-5" />
|
|
)
|
|
) : (
|
|
// Neutral placeholder slot — same dimensions so layout doesn't shift.
|
|
<span className="w-5 h-5 block" aria-hidden="true" />
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function IconSun({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="12" cy="12" r="4" />
|
|
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconMoon({ className }: IconProps): ReactNode {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.6"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
className={className}
|
|
aria-hidden="true"
|
|
>
|
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
|
</svg>
|
|
);
|
|
}
|