import { useState } from "react" import { Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles, ChevronDown, ChevronRight, Clock, Hammer, RefreshCw, Lightbulb, ArrowRight, } from "lucide-react" import { api, type AuftragItem, type SkillKandidat } from "@/lib/api" import { useAuftragsbuch, useIdeen, useQueryClient, qk } from "@/lib/queries" import { useDialog } from "@/lib/useDialog" import { cn } from "@/lib/utils" import { fmtWhen } from "@/lib/format" import { SectionLabel } from "@/components/ui/SectionLabel" // 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 = { wartung: "Werkstatt", orchestrator: "Orchestrator", doku: "Doku", feature: "Feature", } const STATE_UI: Record = { 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" }, } export function AuftragsbuchView() { const { data, isLoading, refetch, isFetching } = useAuftragsbuch() // Geteilter Cache mit der IdeenSection — hier nur fürs ehrliche "Nichts zu entscheiden"- // Banner (eine hängende Karte direkt darunter widerspräche dem grünen Häkchen). const { data: ideenData } = useIdeen() const qc = useQueryClient() const { showAlert, showConfirm, dialogElement } = useDialog() const [busy, setBusy] = useState>({}) 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 ?? [] // Hängende Ideen leben seit 25.07. auf der eigenen Ideen-Seite. Das grüne „Nichts zu // entscheiden" darf trotzdem nicht behaupten, alles sei erledigt — es zeigt jetzt einen // Querverweis statt die Karte selbst (vorher stand sie direkt darunter). const blockedIdeen = ideenData?.available ? ideenData.items.filter((i) => i.status === "blocked").length : 0 const empty = items.length === 0 && kandidaten.length === 0 return (

Auftragsbuch

Was die Box gebaut oder sich ausgedacht hat — du entscheidest. Annehmen spielt es mit Fangnetz ein (Health-Check, automatischer Rollback).

{data && !data.available && (
Das Auftragsbuch lebt auf der Box (liest die Gitea-Branches dort). Im lokalen Dev-Modus ist hier nichts zu sehen.
)} {isLoading && (
Vorschläge werden geladen …
)} {data?.available && empty && (

Nichts zu entscheiden

Keine offenen Vorschläge. Werkstatt, Orchestrator und Traum melden sich hier, sobald es etwas gibt.

)} {blockedIdeen > 0 && ( )} {items.length > 0 && (
Patch-Vorschläge ({items.length}) {items.map((it) => ( ))}
)} {kandidaten.length > 0 && (
Skill-Kandidaten aus dem Traum ({kandidaten.length}) {kandidaten.map((k) => ( ))}
)} {dialogElement}
) } function StateBadge({ item }: { item: AuftragItem }) { const st = item.status if (!st) return null const ui = STATE_UI[st.state] if (!ui) return null return ( {ui.spin && } {ui.label} ) } function ProposalCard({ item, busy, onAct, showConfirm, }: { item: AuftragItem busy: Record 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(null) const [diffLoading, setDiffLoading] = useState(false) // Ablehnen mit Grund: der Grund ist das Lern-Gedächtnis des Kreislaufs (Radar/Specifier // bekommen ihn als „nie wieder vorschlagen"-Material) — deshalb ein kleines Inline-Feld. const [rejecting, setRejecting] = useState(false) const [grund, setGrund] = useState("") const running = item.status?.state === "laeuft" || item.status?.state === "rollback" const decided = item.status?.state === "eingespielt" // Lucy-Karten (Desktop-App): Annehmen baut am PC (dist) statt auf der Box zu deployen. const repo = item.repo ?? "mc2" const isLucy = repo === "lucy" const k = `${repo}:${item.branch}` 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)}&repo=${repo}`) setDiff(r.diff + (r.truncated ? "\n… (gekürzt)" : "")) } catch (e: any) { setDiff(`(Diff nicht ladbar: ${e?.message || e})`) } finally { setDiffLoading(false) } } } return (
{KIND_LABEL[item.kind] ?? item.kind} {isLucy && ( Lucy )} {item.branch} {fmtWhen(item.ts)}

{item.subject || "(ohne Titel)"}

{item.body &&

{item.body}

} {item.empfehlung && (

Empfehlung: {item.empfehlung.empfehlung === "ANNEHMEN" ? "Annehmen" : item.empfehlung.empfehlung === "ABLEHNEN" ? "Ablehnen" : "Unklar"}. {item.empfehlung.satz}

)}
{!running && !decided && (
{!item.verwaist && ( )}
)}
{rejecting && !running && !decided && (

Der Branch '{item.branch}' wird auf Gitea gelöscht (die Arbeit bleibt in der Historie). Sag der Box kurz warum — dann schlägt sie dir so etwas nicht wieder vor.

setGrund(e.target.value)} placeholder={"Grund (optional) — z. B. „gibt es schon“ oder „will ich nicht“"} maxLength={400} className="w-full rounded-lg border border-border/60 bg-background/60 px-2.5 py-1.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none" />
)} {item.status && (running || !decided) && item.status.detail && (

{item.status.detail}

)} {item.verwaist && (

Kaputt aufgesetzt: Dieser Branch hat keinen gemeinsamen Ursprung mit main (der Werkstatt-Worker hat nicht richtig geklont) — Annehmen ist technisch unmöglich, deshalb fehlt der Knopf. Am besten ablehnen mit Grund; die Idee kann danach frisch in die Queue.

)}
{item.ahead} Commit{item.ahead === 1 ? "" : "s"} {item.shortstat && {item.shortstat.trim()}} {item.verwaist && ( kein gemeinsamer Ursprung )} {item.leer && ( Diff leer — bringt nichts )} {item.behind > 0 && !item.verwaist && ( {item.behind} hinter main )} {item.frontend_ohne_build && ( Frontend ohne Build )}
{open && (
{item.files.map((f) => ( {f.status} {f.path} ))} {item.files_truncated && }
{diffLoading ? Diff wird geladen … :
{
                    (diff ?? "").split("\n").map((ln, i) => (
                      {ln || " "}
                    ))
                  }
}
)}
) } function KandidatCard({ k, busy, onAct, showConfirm, }: { k: SkillKandidat busy: Record onAct: (key: string, path: string, body: object, done?: string) => void showConfirm: (title: string, msg: string, ok: () => void) => void }) { const [open, setOpen] = useState(false) return (
Traum-Idee {k.file} {fmtWhen(k.mtime)}

{k.title}

{open && (
{k.preview}
)}
) }