Phase 5: secondary screens (diagnostics, skills, training)

Port the remaining read views from the vanilla UI to Next.js:

- /diagnostics — system health snapshot (DB connected, table counts,
  active tasks, failed and stuck documents). Uses the existing
  /api/system/diagnostics payload with a 10s refetchInterval so the
  page self-updates while the user watches.
- /skills — Paperclip skill inventory with sync status (DB-only,
  disk-only, synced, not-synced) as a card grid driven by
  /api/admin/skills.
- /training — Dafna's style portrait as three tabs on one page:
  * Report: corpus KPIs + CSS conic-gradient subject donut
    (SubjectDonut ported from index.html renderHero) + horizontal
    anatomy bars + top-12 signature phrases.
  * Corpus: TanStack Table of style_corpus rows with an inline
    delete mutation (useDeleteCorpusEntry invalidates both the
    corpus list and the style report so KPIs update).
  * Compare: two-decision selector backed by /api/training/compare,
    side-by-side panels plus shared / only-A / only-B pattern
    lists.

New API modules: lib/api/system.ts, lib/api/skills.ts,
lib/api/training.ts. All three use TanStack Query with staleTime
profiles tuned per endpoint (10s for diagnostics, 30s for skills,
60s for training reports).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-11 17:33:33 +00:00
parent ac0a5ee30b
commit fb1f73fa25
11 changed files with 1228 additions and 6 deletions

View File

@@ -0,0 +1,189 @@
"use client";
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from "@/components/ui/select";
import {
useCorpus, useCompare, type CompareSide, type PatternEntry,
} from "@/lib/api/training";
/*
* Compare two decisions from the style corpus side-by-side. Uses the
* training/compare endpoint which already does the heavy lifting (pattern
* extraction, section stats, shared/unique pattern sets). Our job is
* layout: two columns of metadata + section bars, plus a third "shared"
* section listing patterns that appear in both.
*/
function SideColumn({ side }: { side: CompareSide }) {
return (
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4 space-y-3">
<header>
<h3 className="text-navy text-lg mb-0 tabular-nums">
{side.decision_number || "—"}
</h3>
<p className="text-[0.78rem] text-ink-muted tabular-nums">
{side.decision_date || "—"} · {(side.chars / 1000).toFixed(1)}K תווים
</p>
</header>
{side.subjects.length > 0 && (
<div className="flex flex-wrap gap-1">
{side.subjects.map((s) => (
<Badge
key={s}
variant="outline"
className="text-[0.7rem] bg-gold-wash text-gold-deep border-gold/40"
>
{s}
</Badge>
))}
</div>
)}
{side.sections.length > 0 && (
<div>
<h4 className="text-[0.72rem] uppercase tracking-wider text-gold-deep font-semibold mb-1.5">
חלוקה לחלקים
</h4>
<ul className="space-y-1 text-[0.78rem]">
{side.sections.map((sec) => (
<li key={sec.type} className="flex items-center justify-between gap-2">
<span className="text-ink-soft truncate">{sec.type}</span>
<span className="text-ink-muted tabular-nums shrink-0">
{(sec.chars / 1000).toFixed(1)}K
</span>
</li>
))}
</ul>
</div>
)}
<p className="text-[0.78rem] text-ink-muted">
דפוסי סגנון שנמצאו: <span className="text-navy font-semibold tabular-nums">{side.patterns_count}</span>
</p>
</CardContent>
</Card>
);
}
function PatternList({
title,
items,
tone,
}: {
title: string;
items: PatternEntry[];
tone: "shared" | "a" | "b";
}) {
const toneClass =
tone === "shared"
? "bg-success-bg border-success/40"
: tone === "a"
? "bg-info-bg border-info/40"
: "bg-gold-wash border-gold/40";
const toneHeading =
tone === "shared"
? "text-success"
: tone === "a"
? "text-info"
: "text-gold-deep";
return (
<Card className={`${toneClass} shadow-sm`}>
<CardContent className="px-5 py-4">
<h4 className={`${toneHeading} text-sm font-semibold mb-3 flex items-center gap-2`}>
{title}
<span className="text-[0.7rem] tabular-nums opacity-70">{items.length}</span>
</h4>
{items.length === 0 ? (
<p className="text-ink-muted text-[0.78rem]"></p>
) : (
<ul className="space-y-1.5 text-[0.78rem] max-h-60 overflow-y-auto">
{items.slice(0, 20).map((p) => (
<li key={p.id} className="text-ink leading-relaxed">
{p.text}
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
export function ComparePanel() {
const { data: corpus, isPending } = useCorpus();
const [a, setA] = useState<string | null>(null);
const [b, setB] = useState<string | null>(null);
const cmp = useCompare(a, b);
return (
<div className="space-y-6">
<Card className="bg-surface border-rule shadow-sm">
<CardContent className="px-5 py-4">
<h3 className="text-navy text-base mb-3">בחר שתי החלטות להשוואה</h3>
<div className="grid gap-3 md:grid-cols-2">
{(["a", "b"] as const).map((slot) => (
<div key={slot}>
<label className="block text-[0.78rem] font-medium text-navy mb-1">
{slot === "a" ? "החלטה א" : "החלטה ב"}
</label>
<Select
disabled={isPending}
value={(slot === "a" ? a : b) ?? ""}
onValueChange={(v) => (slot === "a" ? setA(v) : setB(v))}
dir="rtl"
>
<SelectTrigger>
<SelectValue placeholder={isPending ? "טוען…" : "בחר החלטה"} />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{corpus?.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.decision_number || "—"}
{c.decision_date ? ` · ${c.decision_date}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
{a && b && a === b && (
<p className="text-ink-muted text-sm text-center">בחר שתי החלטות שונות</p>
)}
{cmp.error && (
<Card className="bg-danger-bg border-danger/40">
<CardContent className="px-6 py-5 text-danger text-center">
{cmp.error.message}
</CardContent>
</Card>
)}
{cmp.isPending && a && b && a !== b && (
<Skeleton className="h-60 w-full" />
)}
{cmp.data && (
<>
<div className="grid gap-4 md:grid-cols-2">
<SideColumn side={cmp.data.a} />
<SideColumn side={cmp.data.b} />
</div>
<div className="grid gap-4 md:grid-cols-3">
<PatternList title="דפוסים משותפים" items={cmp.data.shared} tone="shared" />
<PatternList title="רק בהחלטה א" items={cmp.data.only_a} tone="a" />
<PatternList title="רק בהחלטה ב" items={cmp.data.only_b} tone="b" />
</div>
</>
)}
</div>
);
}