Auftragsbuch: Ketten-Sicht (Projekt-Gruppen) + Telegram-Ketten-Digest
Queue-UI gruppiert verkettete Karten zu Projekten: Fortschritt, laufende Karte mit Heartbeat-Notiz, 'gleich dran', Rest eingeklappt mit 'wartet auf'. Backend reichert die CLI-Sicht read-only aus kanban.db an (task_links + Heartbeats). Neuer Digest-Waechter meldet per Telegram: Fragen sofort, Projekt-Abschluss sofort, sonst 60-min-Puls (User-Wahl 20.07.). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import {
|
||||
Inbox, Check, X, GitBranch, FileDiff, Loader2, AlertTriangle, Sparkles,
|
||||
ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Lightbulb, Send, HelpCircle,
|
||||
ChevronDown, ChevronRight, Clock, ClipboardCheck, Hammer, RefreshCw, Layers, Lightbulb, Send, HelpCircle,
|
||||
} from "lucide-react"
|
||||
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type SkillKandidat } from "@/lib/api"
|
||||
import { api, type AuftragItem, type IdeenErgebnis, type IdeenItem, type IdeenProjekt, type SkillKandidat } from "@/lib/api"
|
||||
import { useAuftragsbuch, useIdeen, useIdeenLog, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -143,7 +143,7 @@ const IDEEN_STATE: Record<string, { label: string; cls: string; spin?: boolean }
|
||||
triage: { label: "wird ausgearbeitet …", cls: "text-violet-300 border-violet-500/40 bg-violet-500/10", spin: true },
|
||||
todo: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
scheduled: { label: "geplant", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
ready: { label: "wartet", cls: "text-muted-foreground border-border/60 bg-background/30" },
|
||||
ready: { label: "gleich dran", cls: "text-sky-300/80 border-sky-500/30 bg-sky-500/5" },
|
||||
running: { label: "in Arbeit …", cls: "text-sky-300 border-sky-500/40 bg-sky-500/10", spin: true },
|
||||
review: { label: "im Review", cls: "text-amber-300 border-amber-500/40 bg-amber-500/10" },
|
||||
blocked: { label: "hängt — braucht dich", cls: "text-red-300 border-red-500/40 bg-red-500/10" },
|
||||
@@ -221,6 +221,108 @@ function IdeenSection() {
|
||||
}
|
||||
}
|
||||
|
||||
// EINE Zeilen-Darstellung für alle Sichten (Projekt-Gruppen + Einzelkarten) — damit
|
||||
// Antwortfeld, Live-Log und Ergebnis-Panel überall identisch funktionieren.
|
||||
const zeile = (it: IdeenItem) => {
|
||||
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
|
||||
const isOpen = openLog === it.id
|
||||
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
|
||||
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
|
||||
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
|
||||
const blocked = it.status === "blocked"
|
||||
const answering = !!busy["ans:" + it.id]
|
||||
return (
|
||||
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : "border-border/40")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||||
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{ui.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
|
||||
{(it.status === "done" || blocked) && (
|
||||
<button
|
||||
disabled={!!busy[it.id]}
|
||||
onClick={() => archive(it.id)}
|
||||
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||||
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{canOpen && (
|
||||
<button
|
||||
onClick={() => setOpenLog(isOpen ? null : it.id)}
|
||||
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
|
||||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{it.status === "running" && it.notiz && (
|
||||
<p className="-mt-1 ml-1 text-[11px] italic leading-relaxed text-sky-200/80" title="Jüngste Zwischenmeldung des Workers (Heartbeat)">
|
||||
„{it.notiz}“
|
||||
</p>
|
||||
)}
|
||||
{(it.status === "todo" || it.status === "scheduled" || it.status === "ready") && (it.wartet_auf?.length ?? 0) > 0 && (
|
||||
<p className="-mt-1 ml-1 text-[10px] text-muted-foreground/70">
|
||||
→ wartet auf: {it.wartet_auf!.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{blocked && (
|
||||
<div className="space-y-2 border-t border-red-500/20 pt-2">
|
||||
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
|
||||
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
|
||||
<span>
|
||||
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
|
||||
{it.frage
|
||||
? <span className="text-foreground/90">{it.frage}</span>
|
||||
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={antworten[it.id] || ""}
|
||||
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
|
||||
placeholder="Deine Antwort — die Box macht damit weiter"
|
||||
maxLength={2000}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => answer(it.id)}
|
||||
disabled={answering || (antworten[it.id] || "").trim().length < 1}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||||
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten & weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && canOpen && it.status !== "done" && (
|
||||
<div className="ml-2 -mt-1">
|
||||
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
|
||||
{logEnabled && logData.isLoading ? (
|
||||
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
|
||||
) : logData.error ? (
|
||||
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
|
||||
) : logData.data?.available && logData.data.lines.length > 0 ? (
|
||||
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
|
||||
{logData.data.lines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Windows-Dev / Queue nicht verfügbar → Sektion still weglassen (wie der Rest der Seite).
|
||||
if (data && !data.available) return null
|
||||
|
||||
@@ -259,96 +361,17 @@ function IdeenSection() {
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((it: IdeenItem) => {
|
||||
const ui = IDEEN_STATE[it.status] ?? { label: it.status, cls: "text-muted-foreground border-border/60 bg-background/30" }
|
||||
const isOpen = openLog === it.id
|
||||
// Aufklappbar: laufende Karten zeigen das Live-Log, FERTIGE das Ergebnis
|
||||
// (User-Wunsch 15.07.: „Dinge werden getestet, ich sehe das Ergebnis nicht").
|
||||
const canOpen = it.status === "triage" || it.status === "running" || it.status === "done"
|
||||
const blocked = it.status === "blocked"
|
||||
const answering = !!busy["ans:" + it.id]
|
||||
return (
|
||||
<li key={it.id} className={cn("flex flex-col gap-2 rounded-xl border bg-background/30 px-3 py-2", blocked ? "border-red-500/30" : "border-border/40")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex shrink-0 items-center gap-1.5 rounded-lg border px-2 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||||
{ui.spin && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{ui.label}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-foreground" title={it.body || it.titel}>{it.titel}</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-[10px] text-muted-foreground/70"><Clock className="h-3 w-3" />{fmtWhen(it.erstellt)}</span>
|
||||
{(it.status === "done" || blocked) && (
|
||||
<button
|
||||
disabled={!!busy[it.id]}
|
||||
onClick={() => archive(it.id)}
|
||||
title={blocked ? "Aufgeben und aus der Liste räumen (wandert ins Kanban-Archiv)" : "Aus der Liste räumen (wandert ins Kanban-Archiv)"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer disabled:opacity-50">
|
||||
{busy[it.id] ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
{canOpen && (
|
||||
<button
|
||||
onClick={() => setOpenLog(isOpen ? null : it.id)}
|
||||
title={it.status === "done" ? "Ergebnis anzeigen" : "Worker-Log anzeigen"}
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground/60 transition-colors hover:text-foreground cursor-pointer">
|
||||
{isOpen ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{blocked && (
|
||||
<div className="space-y-2 border-t border-red-500/20 pt-2">
|
||||
<p className="flex items-start gap-1.5 text-[11px] leading-relaxed text-red-200/90">
|
||||
<HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-red-300" />
|
||||
<span>
|
||||
<b>{FRAGE_HINT[it.frage_kind || ""] ?? "Die Aufgabe hängt und wartet auf dich:"}</b>{" "}
|
||||
{it.frage
|
||||
? <span className="text-foreground/90">{it.frage}</span>
|
||||
: <span className="text-muted-foreground italic">kein genauer Grund hinterlegt — antworte einfach mit einer Entscheidung, dann läuft sie weiter.</span>}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={antworten[it.id] || ""}
|
||||
onChange={(e) => setAntworten((a) => ({ ...a, [it.id]: e.target.value }))}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") answer(it.id) }}
|
||||
placeholder="Deine Antwort — die Box macht damit weiter"
|
||||
maxLength={2000}
|
||||
className="h-8 min-w-0 flex-1 rounded-lg border border-border/60 bg-background/60 px-2.5 text-xs text-foreground placeholder:text-muted-foreground/50 focus:border-red-400/50 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => answer(it.id)}
|
||||
disabled={answering || (antworten[it.id] || "").trim().length < 1}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 text-[10px] font-bold uppercase tracking-wide text-emerald-300 transition-all hover:bg-emerald-500/20 cursor-pointer disabled:opacity-50">
|
||||
{answering ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />} Antworten & weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && canOpen && it.status !== "done" && (
|
||||
<div className="ml-2 -mt-1">
|
||||
<div className="rounded-lg border border-border/50 bg-background/50 p-2">
|
||||
{logEnabled && logData.isLoading ? (
|
||||
<p className="text-[10px] text-muted-foreground animate-pulse">Log wird geladen …</p>
|
||||
) : logData.error ? (
|
||||
<p className="text-[10px] text-red-400">Log nicht ladbar</p>
|
||||
) : logData.data?.available && logData.data.lines.length > 0 ? (
|
||||
<pre className="max-h-64 overflow-auto font-mono text-[10px] leading-relaxed text-foreground/80 whitespace-pre-wrap">
|
||||
{logData.data.lines.join("\n")}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-[10px] text-muted-foreground">Kein Log verfügbar</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isOpen && it.status === "done" && <ErgebnisPanel id={it.id} />}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div className="space-y-3">
|
||||
{(data?.projekte ?? []).map((p) => (
|
||||
<ProjektGruppe key={p.key} projekt={p} karten={items.filter((i) => i.projekt === p.key)} zeile={zeile} />
|
||||
))}
|
||||
{(() => {
|
||||
// Einzelkarten (keine Kette) — die klassische flache Liste wie bisher.
|
||||
const keys = new Set((data?.projekte ?? []).map((p) => p.key))
|
||||
const einzeln = items.filter((i) => !i.projekt || !keys.has(i.projekt))
|
||||
return einzeln.length > 0 ? <ul className="space-y-1.5">{einzeln.map((it) => zeile(it))}</ul> : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{data?.available && !isLoading && items.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground/70">Queue ist leer — wirf der Box eine Idee zu.</p>
|
||||
@@ -359,6 +382,65 @@ function IdeenSection() {
|
||||
)
|
||||
}
|
||||
|
||||
// Karten-Familie (verkettete Karten = ein Projekt) als Block: Fortschritt oben, dann die
|
||||
// AKTIVEN Karten in Ausführungs-Reihenfolge, Wartende und Fertige zusammengeklappt.
|
||||
// Ersetzt für Ketten die flache Erstellzeit-Liste (User: „Reihenfolge komplett durcheinander").
|
||||
const AKTIV_RANG: Record<string, number> = { blocked: 0, running: 1, review: 2, ready: 3, triage: 4 }
|
||||
|
||||
function ProjektGruppe({ projekt, karten, zeile }: {
|
||||
projekt: IdeenProjekt
|
||||
karten: IdeenItem[]
|
||||
zeile: (it: IdeenItem) => React.ReactNode
|
||||
}) {
|
||||
const [zeigWartende, setZeigWartende] = useState(false)
|
||||
const [zeigFertige, setZeigFertige] = useState(false)
|
||||
const aktiv = karten
|
||||
.filter((i) => AKTIV_RANG[i.status] !== undefined)
|
||||
.sort((a, b) => (AKTIV_RANG[a.status] - AKTIV_RANG[b.status]) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||||
const wartend = karten
|
||||
.filter((i) => i.status === "todo" || i.status === "scheduled")
|
||||
.sort((a, b) => ((a.wartet_auf?.length ? 1 : 0) - (b.wartet_auf?.length ? 1 : 0)) || ((a.erstellt ?? 0) - (b.erstellt ?? 0)))
|
||||
const fertig = karten
|
||||
.filter((i) => i.status === "done")
|
||||
.sort((a, b) => (b.fertig ?? 0) - (a.fertig ?? 0))
|
||||
const anteil = projekt.gesamt > 0 ? Math.round((projekt.fertig / projekt.gesamt) * 100) : 0
|
||||
return (
|
||||
<div className="space-y-2 rounded-xl border border-border/50 bg-background/20 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layers className="h-4 w-4 shrink-0 text-primary/70" />
|
||||
<p className="min-w-0 flex-1 truncate text-xs font-semibold text-foreground" title={projekt.titel}>{projekt.titel}</p>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{projekt.fertig} von {projekt.gesamt} fertig</span>
|
||||
</div>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-background/60" title={`${anteil}%`}>
|
||||
<div className="h-1 rounded-full bg-primary/70 transition-all" style={{ width: `${anteil}%` }} />
|
||||
</div>
|
||||
{aktiv.length > 0 && <ul className="space-y-1.5">{aktiv.map((it) => zeile(it))}</ul>}
|
||||
{wartend.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setZeigWartende((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground transition-colors cursor-pointer">
|
||||
{zeigWartende ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{wartend.length} Karte{wartend.length === 1 ? "" : "n"} in der Kette — laufen automatisch der Reihe nach
|
||||
</button>
|
||||
{zeigWartende && <ul className="mt-1.5 space-y-1.5">{wartend.map((it) => zeile(it))}</ul>}
|
||||
</div>
|
||||
)}
|
||||
{fertig.length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setZeigFertige((v) => !v)}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold text-emerald-300/70 hover:text-emerald-300 transition-colors cursor-pointer">
|
||||
{zeigFertige ? <ChevronDown className="h-3.5 w-3.5" /> : <ChevronRight className="h-3.5 w-3.5" />}
|
||||
{fertig.length} fertig
|
||||
</button>
|
||||
{zeigFertige && <ul className="mt-1.5 space-y-1.5">{fertig.map((it) => zeile(it))}</ul>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Ergebnis einer FERTIGEN Queue-Aufgabe: die Abschluss-Zusammenfassung des Workers
|
||||
// (kanban_complete). Lazy — wird erst beim Aufklappen geladen, ändert sich nie mehr.
|
||||
function ErgebnisPanel({ id }: { id: string }) {
|
||||
|
||||
Reference in New Issue
Block a user