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:
co-authored by
Claude Fable 5
parent
4dc5ca7343
commit
1985a692bf
@@ -0,0 +1,214 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import {
|
||||
History, HeartPulse, ShieldAlert, Bell, Moon, Radar, Inbox, Loader2,
|
||||
Archive, RotateCcw, AlarmClock, MessageCircle, Sparkles, type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { api, type ChronikItem, type Snapshot } from "@/lib/api"
|
||||
import { useChronik, useZeitmaschine, useQueryClient, qk } from "@/lib/queries"
|
||||
import { useDialog } from "@/lib/useDialog"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Chronik = die lesbare Geschichte dessen, was die Box von allein getan und gemeldet hat
|
||||
// (Quelle: Melde-Briefkasten). Daneben die Zeitmaschine: Snapshots ansehen und per Klick
|
||||
// zu einem Stand zurückkehren — mit hartem Bestätigungsdialog und Pre-Restore-Sicherung.
|
||||
|
||||
const SOURCE_UI: Record<string, { icon: LucideIcon; label: string; cls: string }> = {
|
||||
sentry: { icon: HeartPulse, label: "Health-Wächter", cls: "text-red-300 bg-red-500/10 border-red-500/25" },
|
||||
alarm: { icon: ShieldAlert, label: "Alarm", cls: "text-red-200 bg-red-500/15 border-red-500/40" },
|
||||
notify: { icon: Bell, label: "Meldung", cls: "text-sky-300 bg-sky-500/10 border-sky-500/25" },
|
||||
"chef-gutachter": { icon: Moon, label: "Morgenlage", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
|
||||
radar: { icon: Radar, label: "Radar", cls: "text-teal-300 bg-teal-500/10 border-teal-500/25" },
|
||||
auftragsbuch: { icon: Inbox, label: "Auftragsbuch", cls: "text-primary bg-primary/10 border-primary/25" },
|
||||
zeitmaschine: { icon: RotateCcw, label: "Zeitmaschine", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
|
||||
reminder: { icon: AlarmClock, label: "Erinnerung", cls: "text-amber-300 bg-amber-500/10 border-amber-500/25" },
|
||||
traum: { icon: Sparkles, label: "Traum", cls: "text-violet-300 bg-violet-500/10 border-violet-500/25" },
|
||||
}
|
||||
const SOURCE_FALLBACK = { icon: MessageCircle, label: "Box", cls: "text-muted-foreground bg-background/40 border-border/50" }
|
||||
|
||||
function dayKey(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleDateString("de-DE", { weekday: "long", day: "2-digit", month: "long" })
|
||||
}
|
||||
|
||||
export function ChronikView() {
|
||||
const { data: items = [], isLoading } = useChronik()
|
||||
const [limitPerDay, setLimitPerDay] = useState(true)
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const g: { day: string; items: ChronikItem[] }[] = []
|
||||
for (const it of items) {
|
||||
const day = dayKey(it.ts)
|
||||
const last = g[g.length - 1]
|
||||
if (last && last.day === day) last.items.push(it)
|
||||
else g.push({ day, items: [it] })
|
||||
}
|
||||
return g
|
||||
}, [items])
|
||||
|
||||
return (
|
||||
<div className="space-y-7">
|
||||
<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">
|
||||
Chronik
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Was deine Box von allein getan und gemeldet hat — und die Zeitmaschine, falls du zu einem früheren Stand zurück willst.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Zeitmaschine />
|
||||
|
||||
<section className="space-y-4">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<History className="h-3.5 w-3.5" /> Verlauf
|
||||
</p>
|
||||
|
||||
{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" /> Chronik wird geladen …
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && items.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-8 text-center text-xs text-muted-foreground">
|
||||
Noch keine Einträge. Sobald die Box etwas von allein tut oder meldet (Updates, Wächter, Träume), steht es hier.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(({ day, items: dayItems }) => (
|
||||
<div key={day}>
|
||||
<p className="mb-2 text-xs font-bold text-foreground/70">{day}</p>
|
||||
<div className="space-y-2 border-l-2 border-border/40 pl-4">
|
||||
{(limitPerDay ? dayItems.slice(0, 8) : dayItems).map((it) => {
|
||||
const ui = SOURCE_UI[it.source] ?? SOURCE_FALLBACK
|
||||
const Icon = ui.icon
|
||||
return (
|
||||
<div key={it.id} className="relative rounded-xl border border-border/50 bg-card/40 p-3 backdrop-blur-sm">
|
||||
<span className="absolute -left-[23px] top-4 h-2.5 w-2.5 rounded-full bg-border ring-4 ring-background/60" />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn("flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-[10px] font-bold", ui.cls)}>
|
||||
<Icon className="h-3 w-3" /> {ui.label}
|
||||
</span>
|
||||
{it.subject && <span className="text-[10px] font-semibold text-muted-foreground">{it.subject}</span>}
|
||||
<span className="ml-auto font-mono text-[10px] text-muted-foreground/60">
|
||||
{new Date(it.ts * 1000).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 whitespace-pre-wrap text-xs leading-relaxed text-foreground/85">{it.text}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{limitPerDay && dayItems.length > 8 && (
|
||||
<button onClick={() => setLimitPerDay(false)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
|
||||
{dayItems.length - 8} weitere anzeigen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Zeitmaschine: Snapshots + Ein-Klick-Restore ───────────────────────────────
|
||||
function Zeitmaschine() {
|
||||
const { data } = useZeitmaschine()
|
||||
const qc = useQueryClient()
|
||||
const { showAlert, showConfirm, dialogElement } = useDialog()
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [backupBusy, setBackupBusy] = useState(false)
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
const backups = data?.backups ?? []
|
||||
const shown = showAll ? backups : backups.slice(0, 4)
|
||||
|
||||
function fmtSnap(s: Snapshot): string {
|
||||
// Zeitstempel-Format aus backup.sh: 2026-07-08T03-30-01 o. ä. → lesbar machen.
|
||||
const m = s.snapshot.match(/^(\d{4})-(\d{2})-(\d{2})[T_-](\d{2})[-:](\d{2})/)
|
||||
if (!m) return s.snapshot
|
||||
return `${m[3]}.${m[2]}.${m[1]}, ${m[4]}:${m[5]} Uhr`
|
||||
}
|
||||
|
||||
async function restore(s: Snapshot) {
|
||||
setBusy(s.file)
|
||||
try {
|
||||
await api("/api/zeitmaschine/restore", { method: "POST", body: JSON.stringify({ file: s.file }) })
|
||||
showAlert("Zeitmaschine läuft",
|
||||
"Die Wiederherstellung wurde gestartet. Die Dienste starten gleich neu — die Zentrale ist kurz weg und meldet sich von selbst zurück. Vorher wurde automatisch ein Sicherheits-Backup des jetzigen Zustands gemacht.")
|
||||
} catch (e: any) {
|
||||
showAlert("Start fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function backupNow() {
|
||||
setBackupBusy(true)
|
||||
try {
|
||||
const r = await api<{ ok: boolean; snapshot?: string; error?: string }>("/api/system/backup", { method: "POST" })
|
||||
if (r.ok) showAlert("Gesichert", `Snapshot ${r.snapshot} wurde angelegt.`)
|
||||
else showAlert("Backup fehlgeschlagen", r.error || "Unbekannter Fehler")
|
||||
qc.invalidateQueries({ queryKey: qk.zeitmaschine })
|
||||
} catch (e: any) {
|
||||
showAlert("Backup fehlgeschlagen", String(e?.message || e))
|
||||
} finally {
|
||||
setBackupBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<Archive className="h-3.5 w-3.5" /> Zeitmaschine — gesicherte Stände
|
||||
</p>
|
||||
<button onClick={backupNow} disabled={backupBusy || !data?.available}
|
||||
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-card/45 px-2.5 text-[11px] font-semibold text-muted-foreground hover:text-foreground hover:border-primary/40 transition-all cursor-pointer disabled:opacity-50">
|
||||
{backupBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Archive className="h-3.5 w-3.5" />} Jetzt sichern
|
||||
</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">
|
||||
Die Snapshots liegen auf der Box — im lokalen Dev-Modus nicht verfügbar.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data?.available && backups.length === 0 && (
|
||||
<div className="rounded-2xl border border-dashed border-border/60 bg-card/20 p-5 text-center text-xs text-muted-foreground">
|
||||
Noch keine Snapshots. Das nächtliche Backup (03:30) legt sie automatisch an — oder „Jetzt sichern".
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shown.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{shown.map((s, idx) => (
|
||||
<div key={s.file} className="flex items-center justify-between gap-3 rounded-xl border border-border/50 bg-card/40 p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{fmtSnap(s)} {idx === 0 && <span className="ml-1 rounded bg-emerald-500/10 px-1.5 py-0.5 text-[9px] font-bold text-emerald-300">neuester</span>}
|
||||
</p>
|
||||
<p className="font-mono text-[10px] text-muted-foreground/70 truncate" title={s.file}>{s.size_mb.toLocaleString("de-DE")} MB · {s.file}</p>
|
||||
</div>
|
||||
<button
|
||||
disabled={busy !== null}
|
||||
onClick={() =>
|
||||
showConfirm("Wirklich zu diesem Stand zurück?",
|
||||
`Die Box wird auf den Snapshot vom ${fmtSnap(s)} zurückgesetzt (Gedächtnis, Hermes-Config, Modell-Router). Der JETZIGE Zustand wird vorher automatisch gesichert — der Schritt ist also umkehrbar. Die Dienste starten neu, die Zentrale ist kurz weg.`,
|
||||
() => restore(s))}
|
||||
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2.5 text-[10px] font-bold uppercase tracking-wide text-amber-300 transition-all hover:bg-amber-500/20 cursor-pointer disabled:opacity-50">
|
||||
{busy === s.file ? <Loader2 className="h-3 w-3 animate-spin" /> : <RotateCcw className="h-3 w-3" />} Zurück
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{backups.length > 4 && !showAll && (
|
||||
<button onClick={() => setShowAll(true)} className="text-[11px] text-muted-foreground underline hover:text-foreground">
|
||||
Alle {backups.length} Snapshots anzeigen
|
||||
</button>
|
||||
)}
|
||||
{dialogElement}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user