UI: Auftragsbuch, Chronik+Zeitmaschine, Wissens-Wiki, Morgenlage, Erinnerungen
Neue Views: Auftragsbuch (Karten mit Diff/Kritiker-Kontext, Annehmen/Ablehnen mit Fangnetz-Dialog), Chronik (Tages-Timeline aus dem Briefkasten + Zeitmaschine mit Ein-Klick-Restore), Wissen (Vault als klickbares Wiki, [[Links]] springen). Cockpit: Auftragsbuch-Kachel + Braucht-dich-Eintrag, Morgenlage-Karte (juengstes Chef-Verdikt), Anstehendes (Erinnerungen anlegen/loeschen), Wissen/Chronik-Kacheln. Gedaechtnis: Dubletten-Dialog zeigt jetzt konkret, was bleibt und was wegfaellt. dist mitgebaut (tsc+vite gruen). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
|
||||
ChevronDown, ChevronRight, Clock, Hammer, RefreshCw,
|
||||
} from "lucide-react"
|
||||
import { api, type AuftragItem, type SkillKandidat } from "@/lib/api"
|
||||
import { useAuftragsbuch, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Auftragsbuch = die Vorschlags-Inbox der Box. Werkstatt/Orchestrator liefern propose-only-
|
||||
// Branches, der Traum liefert Skill-Kandidaten — hier fällt die Entscheidung per Klick.
|
||||
// Annehmen (Branch) = Merge→Push→Deploy→Health mit Auto-Rollback; alles detached auf der Box.
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
wartung: "Werkstatt",
|
||||
orchestrator: "Orchestrator",
|
||||
doku: "Doku",
|
||||
feature: "Feature",
|
||||
}
|
||||
|
||||
const STATE_UI: Record<string, { label: string; cls: string; spin?: boolean }> = {
|
||||
laeuft: { label: "wird eingespielt …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
|
||||
rollback: { label: "Rollback läuft …", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10", spin: true },
|
||||
eingespielt: { label: "eingespielt ✓", cls: "text-emerald-300 border-emerald-500/40 bg-emerald-500/10" },
|
||||
fehlgeschlagen: { label: "fehlgeschlagen", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
|
||||
zurueckgerollt: { label: "zurückgerollt", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
|
||||
kritisch: { label: "KRITISCH — Box prüfen", cls: "text-red-200 border-red-500/60 bg-red-500/20" },
|
||||
abgelehnt: { label: "abgelehnt", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
}
|
||||
|
||||
function fmtWhen(ts: number | null | undefined): string {
|
||||
if (!ts) return "—"
|
||||
return new Date(ts * 1000).toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
export function AuftragsbuchView() {
|
||||
const { data, isLoading, refetch, isFetching } = useAuftragsbuch()
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({})
|
||||
|
||||
const reload = () => qc.invalidateQueries({ queryKey: qk.auftragsbuch })
|
||||
|
||||
async function act(key: string, path: string, body: object, done?: string) {
|
||||
setBusy((b) => ({ ...b, [key]: true }))
|
||||
try {
|
||||
await api(path, { method: "POST", body: JSON.stringify(body) })
|
||||
if (done) showAlert("Erledigt", done)
|
||||
reload()
|
||||
} catch (e: any) {
|
||||
showAlert("Das hat nicht geklappt", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy((b) => ({ ...b, [key]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const items = data?.items ?? []
|
||||
const kandidaten = data?.skill_kandidaten ?? []
|
||||
const empty = items.length === 0 && kandidaten.length === 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
|
||||
Auftragsbuch
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Was die Box gebaut oder sich ausgedacht hat — du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => refetch()}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-3 text-xs font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer">
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", isFetching && "animate-spin")} /> Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data && !data.available && (
|
||||
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-4 text-xs text-amber-300">
|
||||
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 rounded-2xl border border-border/60 bg-card/30 p-6 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" /> Vorschläge werden geladen …
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.available && empty && (
|
||||
<div className="flex items-center gap-3 rounded-2xl border border-emerald-500/25 bg-emerald-500/5 p-5">
|
||||
<Check className="h-5 w-5 shrink-0 text-emerald-400" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">Nichts zu entscheiden</p>
|
||||
<p className="text-xs text-muted-foreground">Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={GitBranch}>Fertige Patches ({items.length})</SectionLabel>
|
||||
{items.map((it) => (
|
||||
<ProposalCard key={it.branch} item={it} busy={busy} onAct={act} showConfirm={showConfirm} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{kandidaten.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<SectionLabel icon={Sparkles}>Skill-Kandidaten aus dem Traum ({kandidaten.length})</SectionLabel>
|
||||
{kandidaten.map((k) => (
|
||||
<KandidatCard key={k.file} k={k} busy={busy} onAct={act} showConfirm={showConfirm} />
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{dialogElement}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ icon: Icon, children }: { icon: any; children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<Icon className="h-3.5 w-3.5" /> {children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function StateBadge({ item }: { item: AuftragItem }) {
|
||||
const st = item.status
|
||||
if (!st) return null
|
||||
const ui = STATE_UI[st.state]
|
||||
if (!ui) return null
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)} title={st.detail}>
|
||||
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{ui.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function ProposalCard({
|
||||
item, busy, onAct, showConfirm,
|
||||
}: {
|
||||
item: AuftragItem
|
||||
busy: Record<string, boolean>
|
||||
onAct: (key: string, path: string, body: object, done?: string) => void
|
||||
showConfirm: (title: string, msg: string, ok: () => void) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [diff, setDiff] = useState<string | null>(null)
|
||||
const [diffLoading, setDiffLoading] = useState(false)
|
||||
const running = item.status?.state === "laeuft" || item.status?.state === "rollback"
|
||||
const decided = item.status?.state === "eingespielt"
|
||||
|
||||
async function loadDiff() {
|
||||
const next = !open
|
||||
setOpen(next)
|
||||
if (next && diff === null) {
|
||||
setDiffLoading(true)
|
||||
try {
|
||||
const r = await api<{ diff: string; truncated: boolean }>(`/api/auftragsbuch/diff?branch=${encodeURIComponent(item.branch)}`)
|
||||
setDiff(r.diff + (r.truncated ? "\n… (gekürzt)" : ""))
|
||||
} catch (e: any) {
|
||||
setDiff(`(Diff nicht ladbar: ${e?.message || e})`)
|
||||
} finally {
|
||||
setDiffLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-primary">
|
||||
{KIND_LABEL[item.kind] ?? item.kind}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate" title={item.branch}>{item.branch}</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(item.ts)}</span>
|
||||
<StateBadge item={item} />
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm font-semibold text-foreground">{item.subject || "(ohne Titel)"}</p>
|
||||
{item.body && <p className="mt-1 whitespace-pre-wrap text-xs leading-relaxed text-muted-foreground line-clamp-6">{item.body}</p>}
|
||||
</div>
|
||||
|
||||
{!running && !decided && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
disabled={!!busy[item.branch]}
|
||||
onClick={() =>
|
||||
showConfirm("Vorschlag annehmen?",
|
||||
`'${item.branch}' wird auf main gemergt und live eingespielt. Die Zentrale startet dabei kurz neu. Geht der Health-Check danach rot, rollt die Box automatisch zurück.${item.frontend_ohne_build ? "\n\nAchtung: Enthält Frontend-Quelltext ohne gebautes Bundle — die Oberfläche bleibt alt, bis am PC gebaut wird." : ""}`,
|
||||
() => onAct(item.branch, "/api/auftragsbuch/annehmen", { branch: item.branch }))}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy[item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />} Annehmen
|
||||
</button>
|
||||
<button
|
||||
disabled={!!busy["ab:" + item.branch]}
|
||||
onClick={() =>
|
||||
showConfirm("Vorschlag ablehnen?",
|
||||
`Der Branch '${item.branch}' wird auf Gitea gelöscht. Die Arbeit bleibt in der Historie, aber die Karte verschwindet.`,
|
||||
() => onAct("ab:" + item.branch, "/api/auftragsbuch/ablehnen", { branch: item.branch }))}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
|
||||
{busy["ab:" + item.branch] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.status && (running || !decided) && item.status.detail && (
|
||||
<p className="text-[11px] text-muted-foreground">{item.status.detail}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.ahead} Commit{item.ahead === 1 ? "" : "s"}</span>
|
||||
{item.shortstat && <span className="rounded bg-background/40 px-1.5 py-0.5 font-mono">{item.shortstat.trim()}</span>}
|
||||
{item.behind > 0 && (
|
||||
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="main ist seit diesem Vorschlag weitergelaufen">
|
||||
<AlertTriangle className="h-3 w-3" /> {item.behind} hinter main
|
||||
</span>
|
||||
)}
|
||||
{item.frontend_ohne_build && (
|
||||
<span className="flex items-center gap-1 rounded bg-amber-500/10 px-1.5 py-0.5 text-amber-300" title="Die Box kann kein Frontend bauen (kein Node) — nach dem Annehmen bleibt die Oberfläche alt, bis am PC gebaut wird.">
|
||||
<AlertTriangle className="h-3 w-3" /> Frontend ohne Build
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button onClick={loadDiff}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<FileDiff className="h-3.5 w-3.5" /> {item.files.length} Datei{item.files.length === 1 ? "" : "en"} — Änderungen ansehen
|
||||
</button>
|
||||
{open && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{item.files.map((f) => (
|
||||
<span key={f.path} className="rounded bg-background/40 px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<b className={cn(f.status.startsWith("A") && "text-emerald-400", f.status.startsWith("D") && "text-red-400", f.status.startsWith("M") && "text-sky-400")}>{f.status}</b> {f.path}
|
||||
</span>
|
||||
))}
|
||||
{item.files_truncated && <span className="text-[10px] text-muted-foreground/60">…</span>}
|
||||
</div>
|
||||
<div className="max-h-96 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3">
|
||||
{diffLoading
|
||||
? <span className="text-[11px] text-muted-foreground">Diff wird geladen …</span>
|
||||
: <pre className="text-[10px] leading-relaxed text-foreground/80 whitespace-pre">{
|
||||
(diff ?? "").split("\n").map((ln, i) => (
|
||||
<span key={i} className={cn("block", ln.startsWith("+") && !ln.startsWith("+++") && "text-emerald-300/90 bg-emerald-500/5", ln.startsWith("-") && !ln.startsWith("---") && "text-red-300/90 bg-red-500/5", (ln.startsWith("@@") || ln.startsWith("diff ")) && "text-sky-300/90")}>{ln || " "}</span>
|
||||
))
|
||||
}</pre>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KandidatCard({
|
||||
k, busy, onAct, showConfirm,
|
||||
}: {
|
||||
k: SkillKandidat
|
||||
busy: Record<string, boolean>
|
||||
onAct: (key: string, path: string, body: object, done?: string) => void
|
||||
showConfirm: (title: string, msg: string, ok: () => void) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<div className="rounded-2xl border border-violet-500/25 bg-violet-500/[0.04] p-4 shadow-lg shadow-black/15 backdrop-blur-md space-y-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="rounded-md border border-violet-500/30 bg-violet-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-violet-300">Traum-Idee</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground truncate">{k.file}</span>
|
||||
<span className="flex items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(k.mtime)}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm font-semibold text-foreground">{k.title}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<button
|
||||
disabled={!!busy["sk:" + k.file]}
|
||||
onClick={() =>
|
||||
showConfirm("Werkstatt beauftragen?",
|
||||
`Der Kandidat '${k.title}' geht als Auftrag an die Werkstatt (Orchestrator, propose-only). Das Ergebnis erscheint später als neuer Patch-Vorschlag hier im Auftragsbuch — nichts geht ungefragt live.`,
|
||||
() => onAct("sk:" + k.file, "/api/auftragsbuch/skill/annehmen", { file: k.file },
|
||||
"Die Werkstatt arbeitet. Das Ergebnis taucht als neuer Vorschlag im Auftragsbuch auf (das kann einige Minuten dauern)."))}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-violet-500/40 bg-violet-500/10 px-3 text-[11px] font-bold uppercase tracking-wide text-violet-300 transition-all hover:bg-violet-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy["sk:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Hammer className="h-3.5 w-3.5" />} Beauftragen
|
||||
</button>
|
||||
<button
|
||||
disabled={!!busy["skab:" + k.file]}
|
||||
onClick={() =>
|
||||
showConfirm("Kandidat verwerfen?",
|
||||
`'${k.title}' wandert ins Archiv des Wissens-Vaults (verworfen/). Der Traum schlägt ihn nicht erneut vor.`,
|
||||
() => onAct("skab:" + k.file, "/api/auftragsbuch/skill/ablehnen", { file: k.file }))}
|
||||
className="flex h-9 items-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 px-3 text-[11px] font-bold uppercase tracking-wide text-red-300/90 transition-all hover:bg-red-500/15 cursor-pointer disabled:opacity-50">
|
||||
{busy["skab:" + k.file] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />} Verwerfen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
{open ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
<Inbox className="h-3.5 w-3.5" /> Vorschlag lesen
|
||||
</button>
|
||||
{open && (
|
||||
<pre className="max-h-72 overflow-auto rounded-xl border border-border/50 bg-background/50 p-3 text-[11px] leading-relaxed text-foreground/85 whitespace-pre-wrap">{k.preview}</pre>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user