Phase 8: Lokale KI

Wiki mit semantischer Suche ueber pgvector, Diktat ueber faster-whisper,
Berichtsassistent und Belegerfassung — alles gegen selbst betriebene
Modelle. Dazu zwei Protokollansichten: was die Maschine getan hat und was
Menschen getan haben.

Nichts, was ein Modell erzeugt, gelangt ungeprueft in einen Nachweis. Jede
Ausgabe ist zuerst ein eigener Datensatz, gekennzeichnet, mit zwei gleich
grossen Knoepfen davor. Die Datenbank haelt gegen: Eingabe, Ausgabe und
Modell sind unveraenderlich, geloescht wird nicht, entschieden wird einmal.

Kein Rueckfall auf einen Anbieter: fehlen die Container, fehlt die Funktion.
Dafuer bleibt alles andere ohne Modell vollstaendig benutzbar — geprueft von
vierzehn E2E-Tests, die ohne laufendes Modell durchlaufen.

Postgres bringt jetzt PostGIS und pgvector mit (eigenes Image), Ollama und
faster-whisper stehen im Compose-Profil "ki".

73 neue Unit-Tests, 14 neue E2E-Tests, Version 0.9.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 11:06:03 +02:00
co-authored by Claude Opus 5
parent 12243f177d
commit f7ccd9a908
134 changed files with 8624 additions and 72 deletions
+329
View File
@@ -0,0 +1,329 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
import { KI_STAND_TEXT, KI_STAND_VARIANTE } from "@/lib/ki/texte";
import { alsDatum, alsEuro } from "@/lib/utils";
/**
* Die Belegliste.
*
* Der ganze Ablauf in einer Ansicht: fotografieren, prüfen, gegenzeichnen.
*
* Was das Modell gelesen hat, steht in **änderbaren Feldern** — genau dafür
* ist es ein Entwurf. Wer den Betrag berichtigt und dann bestätigt, hat
* gegengezeichnet, was danach dasteht, nicht was die Maschine gelesen hat.
* Beides bleibt nachvollziehbar: der KI-Vorgang daneben hält die ursprüngliche
* Antwort fest.
*
* Felder, die das Modell nicht erkannt hat, sind hervorgehoben. Ein leeres
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
const werkzeuge = trpc.useUtils();
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const dateiRef = React.useRef<HTMLInputElement>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const belege = trpc.assistent.belege.useQuery({ anzahl: 50 });
const erfassen = trpc.assistent.belegErfassen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(ergebnis.hinweis);
void werkzeuge.assistent.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
onSettled: () => setLaedtHoch(false),
});
async function fotoWaehlen(datei: File) {
setMeldung(null);
setHinweis(null);
setLaedtHoch(true);
try {
const abgelegt = await dateiHochladen(datei, "Belegentwurf", null, "beleg");
erfassen.mutate({ fotoDateiId: abgelegt.id });
} catch (fehler) {
setMeldung(fehler instanceof Error ? fehler.message : String(fehler));
setLaedtHoch(false);
}
}
const offen = belege.data?.filter((beleg) => beleg.stand === KiStand.ENTWURF) ?? [];
const erledigt = belege.data?.filter((beleg) => beleg.stand !== KiStand.ENTWURF) ?? [];
return (
<div className="space-y-6">
{!moeglichkeiten.data?.bild && !moeglichkeiten.isLoading && (
<KiFehltHinweis
was="Es ist kein Modell eingerichtet, das Bilder lesen kann (LLM_VISION_MODELL)."
ohneGeht="Belege lassen sich trotzdem fotografieren und ablegen — die Angaben werden dann von Hand eingetragen."
/>
)}
{meldung && <Fehlermeldung meldung={meldung} kennung="belegfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<input
ref={dateiRef}
type="file"
accept="image/*"
// capture öffnet auf dem Handy direkt die Kamera — genau das will
// jemand, der mit einer Quittung in der Hand dasteht.
capture="environment"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void fotoWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button onClick={() => dateiRef.current?.click()} disabled={laedtHoch}>
<Camera aria-hidden />
{laedtHoch ? "Wird gelesen …" : "Beleg fotografieren"}
</Button>
<span className="text-sm text-[var(--muted-foreground)]">
{offen.length} {offen.length === 1 ? "Beleg wartet" : "Belege warten"} auf
Gegenzeichnung.
</span>
</div>
{offen.length === 0 && erledigt.length === 0 && !belege.isLoading && (
<NichtsGefunden
titel="Noch keine Belege"
hinweis="Fotografieren Sie eine Quittung — die Angaben werden ausgelesen und zum Prüfen vorgelegt."
/>
)}
{offen.length > 0 && (
<section className="space-y-4">
<h2 className="text-lg font-semibold">Zu prüfen</h2>
{offen.map((beleg) => (
<BelegKarte key={beleg.id} beleg={beleg} fehler={setMeldung} />
))}
</section>
)}
{erledigt.length > 0 && (
<section className="space-y-3">
<h2 className="text-lg font-semibold">Erledigt</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="py-2 pr-4">Datum</th>
<th className="py-2 pr-4">Händler</th>
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
</tr>
</thead>
<tbody>
{erledigt.map((beleg) => (
<tr key={beleg.id} className="border-b border-[var(--border)]">
<td className="py-2 pr-4">{alsDatum(beleg.belegdatum)}</td>
<td className="py-2 pr-4">{beleg.haendler ?? "—"}</td>
<td className="py-2 pr-4">{beleg.kategorie ?? "—"}</td>
<td className="py-2 pr-4 text-right tabular-nums">
{beleg.betrag === null ? "—" : alsEuro(Number(beleg.betrag))}
</td>
<td className="py-2 pr-4">
<Badge variant={KI_STAND_VARIANTE[beleg.stand]}>
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
);
}
/** Ein Beleg, wie der Router ihn liefert — direkt von dort abgeleitet. */
type Beleg = inferRouterOutputs<AppRouter>["assistent"]["belege"][number];
/**
* Ein Beleg zum Gegenzeichnen.
*
* Foto links, Felder rechts. Das Foto ist nicht Zierde: es ist das, wogegen
* geprüft wird, und muss beim Prüfen sichtbar sein — nicht hinter einem Klick.
*/
function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: string) => void }) {
const werkzeuge = trpc.useUtils();
const [betrag, setBetrag] = React.useState(beleg.betrag === null ? "" : String(beleg.betrag));
const [datum, setDatum] = React.useState(
beleg.belegdatum ? new Date(beleg.belegdatum).toISOString().slice(0, 10) : "",
);
const [haendler, setHaendler] = React.useState(beleg.haendler ?? "");
const [kategorie, setKategorie] = React.useState(beleg.kategorie ?? "");
const bestaetigen = trpc.assistent.belegBestaetigen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
const verwerfen = trpc.assistent.belegVerwerfen.useMutation({
onSuccess: () => void werkzeuge.assistent.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
/** Ein Feld, das das Modell nicht gefüllt hat, wird hervorgehoben. */
const lueckeKlasse = (wert: string) =>
wert.trim() ? "" : "border-[var(--pruefung-faellig)] bg-[var(--pruefung-faellig)]/5";
return (
<Card>
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<CardTitle className="text-base">
{beleg.haendler ?? "Beleg ohne erkannten Händler"}
</CardTitle>
{beleg.kiVorgangId && <KiAbzeichen />}
</div>
<CardDescription>
Erfasst am {alsDatum(beleg.erstelltAm)}
{beleg.erfasstVon && ` von ${beleg.erfasstVon.vorname} ${beleg.erfasstVon.nachname}`}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-[240px_1fr]">
<a
href={`/api/dateien/${beleg.fotoDatei.id}`}
target="_blank"
rel="noreferrer"
className="block"
>
<Image
src={`/api/dateien/${beleg.fotoDatei.id}`}
alt="Belegfoto"
width={240}
height={320}
unoptimized
className="h-auto w-full rounded-md border border-[var(--border)] object-contain"
/>
</a>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor={`betrag-${beleg.id}`}>Betrag</Label>
<Input
id={`betrag-${beleg.id}`}
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
className={lueckeKlasse(betrag)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`datum-${beleg.id}`}>Belegdatum</Label>
<Input
id={`datum-${beleg.id}`}
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
className={lueckeKlasse(datum)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`haendler-${beleg.id}`}>Händler</Label>
<Input
id={`haendler-${beleg.id}`}
value={haendler}
onChange={(ereignis) => setHaendler(ereignis.target.value)}
className={lueckeKlasse(haendler)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor={`kategorie-${beleg.id}`}>Kategorie</Label>
<Input
id={`kategorie-${beleg.id}`}
value={kategorie}
onChange={(ereignis) => setKategorie(ereignis.target.value)}
placeholder="Kraftstoff, Verpflegung, Material"
className={lueckeKlasse(kategorie)}
/>
</div>
</div>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Gelb hinterlegte Felder hat die Maschine nicht erkannt — bitte gegen das Foto prüfen
und eintragen. Mit dem Gegenzeichnen übernehmen Sie, was hier steht.
</p>
<div className="flex flex-wrap gap-2 border-t border-[var(--border)] pt-3">
<Button
size="sm"
onClick={() =>
bestaetigen.mutate({
id: beleg.id,
betrag: betrag ? Number(betrag) : null,
waehrung: beleg.waehrung,
belegdatum: datum ? new Date(datum) : null,
haendler: haendler || null,
kategorie: kategorie || null,
steuersatz: beleg.steuersatz === null ? null : Number(beleg.steuersatz),
})
}
disabled={!betrag || bestaetigen.isPending}
>
<Check aria-hidden />
Gegenzeichnen
</Button>
<Button
size="sm"
variant="outline"
onClick={() => verwerfen.mutate({ id: beleg.id })}
disabled={verwerfen.isPending}
>
<Trash2 aria-hidden />
Verwerfen
</Button>
</div>
</CardContent>
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BelegListe } from "@/app/(app)/belege/beleg-liste";
export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "beleg.erfassen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
</p>
</div>
<BelegListe />
</div>
);
}
@@ -39,6 +39,7 @@ import { FUNKTION_TEXT, QUELLE_TEXT } from "@/lib/einsatz/texte";
import { ZEITSTEMPEL, ZEITSTEMPEL_TEXT, type Zeitstempel } from "@/lib/einsatz/zeiten";
import { alsDatumZeit, alsUhrzeit } from "@/lib/utils";
import { taktischeEinheit } from "@/lib/einsatz/staerke";
import { Berichtsassistent } from "@/components/ki/berichtsassistent";
/**
* Einsatzdetail — die Arbeitsfläche während und nach dem Einsatz.
@@ -678,6 +679,23 @@ export function EinsatzDetail({
>
Bericht speichern
</Button>
{/* Phase 8: Diktat und Entwurf. Was hier herauskommt, landet im
Feld darüber — gespeichert wird es erst, wenn jemand
speichert. Zwei Entscheidungen, nicht eine. */}
<div className="border-t border-[var(--border)] pt-4">
<Berichtsassistent
einsatzId={einsatzId}
onUebernehmen={(text) =>
setBericht((bisher) => {
const vorhanden = bisher ?? einsatz.bericht ?? "";
return vorhanden.trim() ? `${vorhanden.trim()}
${text}` : text;
})
}
/>
</div>
</>
) : (
<p className="whitespace-pre-line text-sm">
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KiProtokoll } from "@/app/(app)/ki-protokoll/protokoll";
export const metadata = { title: "KI-Protokoll" };
/**
* Was die Maschine getan hat.
*
* Die Antwort auf die Frage, die irgendwann jemand stellt — in der
* Jahreshauptversammlung, beim Datenschutzbeauftragten der Gemeinde, oder
* wenn ein Bericht angezweifelt wird: **Was macht das Ding eigentlich?**
*
* Hier steht es. Jede Befragung, jedes Modell, jede Entscheidung, mit Namen
* und Zeitpunkt.
*/
export default async function KiProtokollSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "ki.protokoll")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">KI-Protokoll</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Jede Befragung eines Modells, was dabei herauskam und wer darüber entschieden hat.
</p>
</div>
<KiProtokoll />
</div>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import * as React from "react";
import { KiArt, KiStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KI_ART_TEXT, KI_STAND_TEXT, KI_STAND_VARIANTE } from "@/lib/ki/texte";
import { alsDatumZeit } from "@/lib/utils";
/**
* Das KI-Protokoll.
*
* Aufgebaut wie ein Kassenbuch und nicht wie ein Logfile: oben die Summen,
* darunter die Einzelposten. Wer hierherkommt, will meistens erst wissen, wie
* viel überhaupt läuft — und danach eine bestimmte Zeile.
*
* Die Zeile mit den offenen Vorschlägen ist die wichtigste: sie zeigt, wie
* viele Ausgaben erzeugt und nie angesehen wurden. Steht dort eine große Zahl,
* benutzt jemand den Assistenten und liest nicht gegen.
*/
export function KiProtokoll() {
const [art, setArt] = React.useState<KiArt | "alle">("alle");
const [stand, setStand] = React.useState<KiStand | "alle">("alle");
const [aufgeklappt, setAufgeklappt] = React.useState<string | null>(null);
const kennzahlen = trpc.assistent.kennzahlen.useQuery();
const vorgaenge = trpc.assistent.protokoll.useQuery({
art: art === "alle" ? undefined : art,
stand: stand === "alle" ? undefined : stand,
anzahl: 100,
});
return (
<div className="space-y-6">
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Kennzahl titel="Vorgänge insgesamt" wert={kennzahlen.data?.gesamt} />
<Kennzahl
titel="Offen"
wert={kennzahlen.data?.offen}
hinweis="erzeugt, aber nicht entschieden"
hervorheben={(kennzahlen.data?.offen ?? 0) > 0}
/>
<Kennzahl titel="Übernommen" wert={kennzahlen.data?.uebernommen} />
<Kennzahl titel="Verworfen" wert={kennzahlen.data?.verworfen} />
<Kennzahl titel="Fehlgeschlagen" wert={kennzahlen.data?.fehlgeschlagen} />
</div>
<div className="flex flex-wrap gap-3">
<Select value={art} onValueChange={(wert) => setArt(wert as KiArt | "alle")}>
<SelectTrigger className="w-64">
<SelectValue placeholder="Alle Arten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Arten</SelectItem>
{Object.values(KiArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KI_ART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as KiStand | "alle")}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Alle Zustände" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Zustände</SelectItem>
{Object.values(KiStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{KI_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!vorgaenge.isLoading && (vorgaenge.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch nichts"
hinweis="Sobald jemand den Assistenten benutzt, steht hier, was geschehen ist."
/>
)}
<ul className="space-y-2">
{vorgaenge.data?.map((vorgang) => (
<li key={vorgang.id} className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
<button
type="button"
onClick={() => setAufgeklappt(aufgeklappt === vorgang.id ? null : vorgang.id)}
className="flex w-full flex-wrap items-center gap-3 p-4 text-left hover:bg-[var(--accent)]"
aria-expanded={aufgeklappt === vorgang.id}
>
<Badge variant={KI_STAND_VARIANTE[vorgang.stand]}>
{KI_STAND_TEXT[vorgang.stand]}
</Badge>
<span className="font-medium">{KI_ART_TEXT[vorgang.art]}</span>
<span className="text-sm text-[var(--muted-foreground)]">
{alsDatumZeit(vorgang.erstelltAm)} · {vorgang.modell}
{vorgang.dauerMs !== null && ` · ${(vorgang.dauerMs / 1000).toFixed(1)} s`}
</span>
<span className="ml-auto text-sm text-[var(--muted-foreground)]">
{vorgang.erzeugtVon
? `${vorgang.erzeugtVon.vorname} ${vorgang.erzeugtVon.nachname}`
: "Hintergrundprozess"}
{vorgang.entschiedenVon &&
` → ${vorgang.entschiedenVon.vorname} ${vorgang.entschiedenVon.nachname}`}
</span>
</button>
{aufgeklappt === vorgang.id && (
<div className="space-y-3 border-t border-[var(--border)] p-4 text-sm">
<div>
<p className="font-medium">Eingabe</p>
<p className="mt-1 whitespace-pre-wrap text-[var(--muted-foreground)]">
{vorgang.eingabe}
</p>
</div>
{vorgang.ausgabe && (
<div>
<p className="font-medium">Ausgabe des Modells</p>
<p className="mt-1 whitespace-pre-wrap text-[var(--muted-foreground)]">
{vorgang.ausgabe}
</p>
</div>
)}
{vorgang.fehler && (
<div>
<p className="font-medium text-[var(--destructive)]">Fehler</p>
<p className="mt-1 text-[var(--destructive)]">{vorgang.fehler}</p>
</div>
)}
<dl className="grid gap-2 text-xs text-[var(--muted-foreground)] sm:grid-cols-3">
<div>
<dt>Betrifft</dt>
<dd>{vorgang.entitaet ?? "—"}</dd>
</div>
<div>
<dt>Token (Ein/Aus)</dt>
<dd>
{vorgang.tokenEingabe ?? "—"} / {vorgang.tokenAusgabe ?? "—"}
</dd>
</div>
<div>
<dt>Entschieden</dt>
<dd>
{vorgang.entschiedenAm ? alsDatumZeit(vorgang.entschiedenAm) : "noch offen"}
</dd>
</div>
</dl>
</div>
)}
</li>
))}
</ul>
<p className="text-xs text-[var(--muted-foreground)]">
Eingabe, Ausgabe und Modell lassen sich nachträglich nicht ändern — die Datenbank hält
dagegen. Ein Vorgang lässt sich auch nicht löschen. Das ist der Zweck dieser Liste: sie
soll belegen, was war, nicht was jemand gern hätte.
</p>
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: number | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">{wert ?? "—"}</p>
{hinweis && <CardDescription className="mt-1">{hinweis}</CardDescription>}
</CardContent>
</Card>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import * as React from "react";
import { AuditAktion } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsDatumZeit } from "@/lib/utils";
/**
* Das Audit-Log lesen.
*
* Eine Zeile je Handlung, aufklappbar auf Vorher/Nachher. Mehr braucht es
* nicht: das Protokoll wird nicht durchgearbeitet, sondern befragt — „wer hat
* dieses Gerät ausgesondert", „wann wurde die PIN geändert".
*
* Deshalb ist der Filter nach Entität wichtiger als eine Volltextsuche. Wer
* eine Frage an das Protokoll hat, kennt fast immer die Art des Datensatzes.
*/
const AKTION_TEXT: Record<AuditAktion, string> = {
ERSTELLT: "angelegt",
GEAENDERT: "geändert",
GELOESCHT: "gelöscht",
WIEDERHERGESTELLT: "wiederhergestellt",
ANGEMELDET: "angemeldet",
ABGEMELDET: "abgemeldet",
ANMELDUNG_FEHLGESCHLAGEN: "Anmeldung fehlgeschlagen",
EXPORTIERT: "exportiert",
DATEI_HOCHGELADEN: "Datei hochgeladen",
DATEI_ABGERUFEN: "Datei abgerufen",
BERECHTIGUNG_GEAENDERT: "Berechtigung geändert",
};
const AKTION_VARIANTE: Record<AuditAktion, "einsatzbereit" | "gesperrt" | "secondary" | "outline"> =
{
ERSTELLT: "einsatzbereit",
GEAENDERT: "outline",
GELOESCHT: "gesperrt",
WIEDERHERGESTELLT: "einsatzbereit",
ANGEMELDET: "secondary",
ABGEMELDET: "secondary",
ANMELDUNG_FEHLGESCHLAGEN: "gesperrt",
EXPORTIERT: "outline",
DATEI_HOCHGELADEN: "outline",
DATEI_ABGERUFEN: "secondary",
BERECHTIGUNG_GEAENDERT: "gesperrt",
};
export function Auditansicht() {
const [entitaet, setEntitaet] = React.useState("");
const [anzahl, setAnzahl] = React.useState(50);
const [aufgeklappt, setAufgeklappt] = React.useState<string | null>(null);
const eintraege = trpc.audit.letzte.useQuery({
anzahl,
entitaet: entitaet.trim() || undefined,
});
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<Input
value={entitaet}
onChange={(ereignis) => setEntitaet(ereignis.target.value)}
placeholder="Nach Art filtern: Geraet, Person, Einsatz, KiVorgang …"
className="min-w-64 flex-1"
aria-label="Nach Entität filtern"
/>
<Select value={String(anzahl)} onValueChange={(wert) => setAnzahl(Number(wert))}>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{[50, 100, 200].map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert} Einträge
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{!eintraege.isLoading && (eintraege.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Keine Einträge"
hinweis={
entitaet
? `Zu „${entitaet}" ist nichts protokolliert. Die Schreibweise muss genau stimmen — etwa „Geraet" statt „Gerät".`
: "Das Protokoll ist leer."
}
/>
)}
<ul className="space-y-2">
{eintraege.data?.map((eintrag) => (
<li key={eintrag.id} className="rounded-lg border border-[var(--border)] bg-[var(--card)]">
<button
type="button"
onClick={() => setAufgeklappt(aufgeklappt === eintrag.id ? null : eintrag.id)}
className="flex w-full flex-wrap items-center gap-3 p-3 text-left hover:bg-[var(--accent)]"
aria-expanded={aufgeklappt === eintrag.id}
>
<Badge variant={AKTION_VARIANTE[eintrag.aktion]}>
{AKTION_TEXT[eintrag.aktion]}
</Badge>
<span className="font-medium">{eintrag.entitaet}</span>
<span className="min-w-0 flex-1 truncate text-sm text-[var(--muted-foreground)]">
{eintrag.beschreibung ?? "—"}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{alsDatumZeit(eintrag.zeitpunkt)}
</span>
<span className="text-sm text-[var(--muted-foreground)]">
{eintrag.akteur
? `${eintrag.akteur.vorname} ${eintrag.akteur.nachname}`
: (eintrag.akteurName ?? eintrag.akteurTyp)}
</span>
</button>
{aufgeklappt === eintrag.id && (
<div className="grid gap-3 border-t border-[var(--border)] p-3 text-xs md:grid-cols-2">
<div>
<p className="font-medium">Vorher</p>
<pre className="mt-1 overflow-x-auto rounded bg-[var(--muted)] p-2">
{eintrag.vorher ? JSON.stringify(eintrag.vorher, null, 2) : "—"}
</pre>
</div>
<div>
<p className="font-medium">Nachher</p>
<pre className="mt-1 overflow-x-auto rounded bg-[var(--muted)] p-2">
{eintrag.nachher ? JSON.stringify(eintrag.nachher, null, 2) : "—"}
</pre>
</div>
</div>
)}
</li>
))}
</ul>
<p className="text-xs text-[var(--muted-foreground)]">
Das Protokoll ist append-only: Ändern ist dauerhaft gesperrt, Löschen nur im
Aufbewahrungslauf des Hintergrundprozesses. Ohne diese Ausnahme wäre das Löschkonzept
nach DSGVO nicht umsetzbar.
</p>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Auditansicht } from "@/app/(app)/protokoll/auditansicht";
export const metadata = { title: "Protokoll" };
/**
* Das Audit-Log.
*
* Geschrieben wird es seit Phase 0, angesehen werden konnte es bisher nur in
* der Datenbank. Mit dem KI-Protokoll daneben wäre das schief geworden: die
* Wehr könnte nachlesen, was die Maschine getan hat, aber nicht, was Menschen
* getan haben. Deshalb hier die Ansicht dazu.
*
* Bewusst nur lesend. Das Protokoll ist append-only — die Datenbank lässt
* weder Ändern noch Löschen zu, außer im Aufbewahrungslauf des Workers.
*/
export default async function ProtokollSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "system.auditlog")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wer hat wann was geändert. Jede schreibende Handlung in FLORIAN steht hier.
</p>
</div>
<Auditansicht />
</div>
);
}
+449
View File
@@ -0,0 +1,449 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, History, Pencil, RefreshCw, Trash2 } from "lucide-react";
import { WikiKategorie } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { Markdown } from "@/components/ki/markdown";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
import { alsDatumZeit } from "@/lib/utils";
/**
* Eine Wiki-Seite.
*
* Der Text steht im Vordergrund, alles andere daneben. Wer eine
* Dienstvorschrift aufschlägt, will sie lesen und nicht erst an einer
* Werkzeugleiste vorbei.
*
* Zwei Hinweise, die selten auffallen und trotzdem wichtig sind: dass eine
* Seite ein Entwurf ist (dann wird sie nicht beantwortet), und dass ihr
* Suchstand veraltet ist (dann findet die semantische Suche noch den alten
* Text). Beides steht sichtbar über dem Inhalt.
*/
export function WikiSeitenansicht({
slug,
darfBearbeiten,
}: {
slug: string;
darfBearbeiten: boolean;
}) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [bearbeiten, setBearbeiten] = React.useState(false);
const [verlaufOffen, setVerlaufOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const seite = trpc.wiki.einzeln.useQuery({ slug });
const neuEinbetten = trpc.wiki.neuEinbetten.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.wiki.loeschen.useMutation({
onSuccess: () => {
void werkzeuge.wiki.invalidate();
router.push("/wiki");
},
onError: (fehler) => setMeldung(fehler.message),
});
if (seite.isLoading) {
return <div className="h-64 animate-pulse rounded-lg bg-[var(--muted)]" aria-hidden />;
}
if (seite.error || !seite.data) {
return <Fehlermeldung meldung={seite.error?.message ?? "Seite nicht gefunden."} />;
}
const daten = seite.data;
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="nicht-drucken">
<Link href="/wiki">
<ArrowLeft aria-hidden />
Zurück zum Wissen
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
<Badge variant="outline">{WIKI_KATEGORIE_TEXT[daten.art]}</Badge>
{!daten.veroeffentlicht && <Badge variant="secondary">Entwurf</Badge>}
</div>
{daten.quelle && (
<p className="text-sm text-[var(--muted-foreground)]">Fundstelle: {daten.quelle}</p>
)}
<p className="text-xs text-[var(--muted-foreground)]">
Zuletzt geändert am {alsDatumZeit(daten.geaendertAm)}
{daten.autor && ` von ${daten.autor.vorname} ${daten.autor.nachname}`}
{daten.versionen.length > 0 && ` · ${daten.versionen.length} frühere Fassungen`}
</p>
</div>
<div className="flex flex-wrap gap-2 nicht-drucken">
{daten.versionen.length > 0 && (
<Button variant="outline" size="sm" onClick={() => setVerlaufOffen(true)}>
<History aria-hidden />
Verlauf
</Button>
)}
{darfBearbeiten && (
<>
<Button variant="outline" size="sm" onClick={() => setBearbeiten(true)}>
<Pencil aria-hidden />
Bearbeiten
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => loeschen.mutate({ id: daten.id })}
aria-label="Seite löschen"
>
<Trash2 aria-hidden />
</Button>
</>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="wikifehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{!daten.veroeffentlicht && (
<div
role="note"
className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm"
>
Diese Seite ist ein <strong>Entwurf</strong>. Sie taucht nicht in der Suche auf und wird
bei Fragen nicht herangezogen.
</div>
)}
{daten.veroeffentlicht && daten.suchstandVeraltet && (
<div
role="note"
className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm"
>
<span className="flex-1">
Der Suchstand ist älter als der Text. Bis die Seite neu eingebettet ist, findet die
Frage-Funktion noch die vorherige Fassung — gelesen wird hier natürlich die aktuelle.
</span>
{darfBearbeiten && (
<Button
size="sm"
variant="outline"
onClick={() => neuEinbetten.mutate({ id: daten.id })}
disabled={neuEinbetten.isPending}
>
<RefreshCw aria-hidden />
Jetzt nachziehen
</Button>
)}
</div>
)}
<Card>
<CardContent className="pt-6">
<Markdown text={daten.inhalt} />
</CardContent>
</Card>
{daten.schlagworte.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{daten.schlagworte.map((wort) => (
<Badge key={wort} variant="secondary">
{wort}
</Badge>
))}
</div>
)}
<p className="text-xs text-[var(--muted-foreground)]">
{daten.abschnitte} {daten.abschnitte === 1 ? "Abschnitt" : "Abschnitte"} für die Suche
{daten.eingebettetAm && ` · zuletzt eingebettet am ${alsDatumZeit(daten.eingebettetAm)}`}
</p>
{darfBearbeiten && (
<SeiteBearbeiten
offen={bearbeiten}
seite={daten}
schliessen={() => setBearbeiten(false)}
fehler={setMeldung}
fertig={() => {
setBearbeiten(false);
void werkzeuge.wiki.invalidate();
}}
/>
)}
<VerlaufDialog
offen={verlaufOffen}
schliessen={() => setVerlaufOffen(false)}
versionen={daten.versionen}
/>
</div>
);
}
/** Die Seite, wie der Router sie liefert — direkt von dort abgeleitet. */
type Seite = inferRouterOutputs<AppRouter>["wiki"]["einzeln"];
function SeiteBearbeiten({
offen,
seite,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
seite: Seite;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [titel, setTitel] = React.useState(seite.titel);
const [art, setArt] = React.useState<WikiKategorie>(seite.art);
const [quelle, setQuelle] = React.useState(seite.quelle ?? "");
const [inhalt, setInhalt] = React.useState(seite.inhalt);
const [veroeffentlicht, setVeroeffentlicht] = React.useState(seite.veroeffentlicht);
const [bemerkung, setBemerkung] = React.useState("");
// Beim Öffnen den aktuellen Stand übernehmen — sonst steht dort der Text von
// vor dem letzten Speichern.
React.useEffect(() => {
if (!offen) return;
setTitel(seite.titel);
setArt(seite.art);
setQuelle(seite.quelle ?? "");
setInhalt(seite.inhalt);
setVeroeffentlicht(seite.veroeffentlicht);
setBemerkung("");
}, [offen, seite]);
const vorschau = trpc.wiki.vorschauAbschnitte.useQuery({ inhalt }, { enabled: offen });
const aendern = trpc.wiki.aendern.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Seite bearbeiten</DialogTitle>
<DialogDescription>
Die bisherige Fassung wird aufgehoben. Eine Dienstvorschrift, die sich unbemerkt
ändert, ist schlimmer als keine.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bearbeiten-titel">Titel</Label>
<Input
id="bearbeiten-titel"
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
/>
<p className="text-xs text-[var(--muted-foreground)]">
Die Adresse der Seite (<code>/wiki/{seite.slug}</code>) bleibt gleich, damit
Verweise und Lesezeichen weiter stimmen.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bearbeiten-art">Bereich</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie)}>
<SelectTrigger id="bearbeiten-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-quelle">Fundstelle</Label>
<Input
id="bearbeiten-quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-inhalt">Inhalt</Label>
<Textarea
id="bearbeiten-inhalt"
rows={18}
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="font-mono text-sm"
/>
{vorschau.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Wird für die Suche in {vorschau.data.anzahl}{" "}
{vorschau.data.anzahl === 1 ? "Abschnitt" : "Abschnitte"} zerlegt — an den
Überschriften.
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bearbeiten-bemerkung">Was wurde geändert? (freiwillig)</Label>
<Input
id="bearbeiten-bemerkung"
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Prüffrist nach neuer DGUV-Information angepasst"
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={veroeffentlicht}
onCheckedChange={(wert) => setVeroeffentlicht(wert === true)}
/>
Veröffentlicht
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
aendern.mutate({
id: seite.id,
titel,
art,
inhalt,
quelle: quelle || null,
veroeffentlicht,
bemerkung: bemerkung || null,
})
}
disabled={!titel.trim() || aendern.isPending}
>
{aendern.isPending ? "Wird gespeichert …" : "Speichern"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Die früheren Fassungen im Wortlaut. */
function VerlaufDialog({
offen,
schliessen,
versionen,
}: {
offen: boolean;
schliessen: () => void;
versionen: Seite["versionen"];
}) {
const [gewaehlt, setGewaehlt] = React.useState<string | null>(null);
const fassung = trpc.wiki.fassung.useQuery(
{ id: gewaehlt ?? "" },
{ enabled: offen && Boolean(gewaehlt) },
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Frühere Fassungen</DialogTitle>
<DialogDescription>
Bei jeder Änderung wird der vorherige Text vollständig aufgehoben.
</DialogDescription>
</DialogHeader>
<ul className="space-y-2">
{versionen.map((version) => (
<li key={version.id}>
<button
type="button"
onClick={() => setGewaehlt(gewaehlt === version.id ? null : version.id)}
className="w-full rounded-md border border-[var(--border)] p-3 text-left text-sm hover:bg-[var(--accent)]"
>
<span className="font-medium">Fassung {version.nummer}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsDatumZeit(version.erstelltAm)}
{version.autor && ` · ${version.autor.nachname}`}
</span>
{version.bemerkung && (
<span className="block text-[var(--muted-foreground)]">{version.bemerkung}</span>
)}
</button>
{gewaehlt === version.id && fassung.data && (
<div className="mt-2 rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
<p className="mb-2 text-sm font-medium">{fassung.data.titel}</p>
<Markdown text={fassung.data.inhalt} />
</div>
)}
</li>
))}
</ul>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Schließen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { WikiSeitenansicht } from "@/app/(app)/wiki/[slug]/ansicht";
export const metadata = { title: "Wiki-Seite" };
export default async function WikiDetailSeite({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { slug } = await params;
return <WikiSeitenansicht slug={slug} darfBearbeiten={hatRecht(sitzung, "wiki.bearbeiten")} />;
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { WikiUebersicht } from "@/app/(app)/wiki/uebersicht";
export const metadata = { title: "Wissen" };
/**
* Das Wiki der Wehr.
*
* Dienstvorschriften, Ausbildungsunterlagen, Merkblätter — das, was sonst als
* PDF im Ordner steht und im Einsatz niemand findet.
*/
export default async function WikiSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wissen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Dienstvorschriften, Ausbildungsunterlagen und Merkblätter der Wehr — durchsuchbar.
</p>
</div>
<WikiUebersicht darfBearbeiten={hatRecht(sitzung, "wiki.bearbeiten")} />
</div>
);
}
+492
View File
@@ -0,0 +1,492 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { BookPlus, Search, Sparkles } from "lucide-react";
import { WikiKategorie } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiFehltHinweis, KiVorschlag } from "@/components/ki/kennzeichnung";
import { WIKI_KATEGORIE_TEXT } from "@/lib/ki/texte";
import { alsDatum } from "@/lib/utils";
/**
* Wiki-Übersicht mit beiden Suchen.
*
* Oben die Frage, darunter die Liste. Die Reihenfolge ist Absicht: wer
* hierherkommt, hat meistens eine Frage und nicht den Wunsch, eine Liste zu
* lesen.
*
* Die Wortsuche läuft immer und sofort mit. Die Frage ans Modell kostet ein
* paar Sekunden und wird deshalb ausdrücklich abgeschickt — und liefert immer
* beides: die Antwort und die Auszüge, aus denen sie gebaut wurde.
*/
export function WikiUebersicht({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [begriff, setBegriff] = React.useState("");
const [art, setArt] = React.useState<WikiKategorie | "alle">("alle");
const [mitEntwuerfen, setMitEntwuerfen] = React.useState(false);
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const moeglichkeiten = trpc.assistent.moeglichkeiten.useQuery();
const seiten = trpc.wiki.liste.useQuery({
suche: begriff.trim() || undefined,
art: art === "alle" ? undefined : art,
mitEntwuerfen,
});
const treffer = trpc.wiki.suchen.useQuery(
{ begriff: begriff.trim() },
{ enabled: begriff.trim().length >= 2 },
);
return (
<div className="space-y-6">
<Frage verfuegbar={moeglichkeiten.data?.text ?? false} />
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-64 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={begriff}
onChange={(ereignis) => setBegriff(ereignis.target.value)}
placeholder="Wortsuche: FwDV 7, Ablegereife, Schlauchpflege …"
className="pl-9"
aria-label="Wortsuche im Wiki"
/>
</div>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie | "alle")}>
<SelectTrigger className="w-56">
<SelectValue placeholder="Alle Bereiche" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Bereiche</SelectItem>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={mitEntwuerfen}
onCheckedChange={(wert) => setMitEntwuerfen(wert === true)}
/>
Entwürfe zeigen
</label>
<Button onClick={() => setAnlegenOffen(true)}>
<BookPlus aria-hidden />
Seite anlegen
</Button>
</>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="wikifehler" />}
{/* Wortsuche: Textstellen statt bloßer Seitennamen. */}
{begriff.trim().length >= 2 && treffer.data && treffer.data.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">
Wortsuche: {treffer.data.length} {treffer.data.length === 1 ? "Stelle" : "Stellen"}
</CardTitle>
<CardDescription>Was wörtlich in den Unterlagen steht.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{treffer.data.map((eintrag) => (
<div key={eintrag.id} className="border-b border-[var(--border)] pb-3 last:border-0">
<Link
href={`/wiki/${eintrag.slug}`}
className="font-medium underline-offset-4 hover:underline"
>
{eintrag.titel}
</Link>
<Badge variant="outline" className="ml-2">
{WIKI_KATEGORIE_TEXT[eintrag.art]}
</Badge>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{eintrag.ausschnitt}</p>
</div>
))}
</CardContent>
</Card>
)}
{seiten.isLoading && (
<ul className="space-y-3" aria-hidden>
{Array.from({ length: 4 }).map((_, zeile) => (
<li
key={zeile}
className="h-20 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
</ul>
)}
{!seiten.isLoading && (seiten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Seiten"
hinweis={
darfBearbeiten
? "Legen Sie an, was die Wehr nachschlagen soll: Dienstvorschriften, Merkblätter, Ausbildungsunterlagen."
: "In diesem Bereich ist noch nichts hinterlegt."
}
/>
)}
<ul className="grid gap-3 md:grid-cols-2">
{seiten.data?.map((seite) => (
<li
key={seite.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] p-4"
>
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/wiki/${seite.slug}`}
className="font-medium underline-offset-4 hover:underline"
>
{seite.titel}
</Link>
<Badge variant="outline">{WIKI_KATEGORIE_TEXT[seite.art]}</Badge>
{!seite.veroeffentlicht && <Badge variant="secondary">Entwurf</Badge>}
</div>
{seite.quelle && (
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{seite.quelle}</p>
)}
<p className="mt-1 text-xs text-[var(--muted-foreground)]">
Zuletzt geändert am {alsDatum(seite.geaendertAm)}
{seite.autor && ` von ${seite.autor.nachname}`}
</p>
{seite.schlagworte.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{seite.schlagworte.slice(0, 6).map((wort) => (
<Badge key={wort} variant="secondary" className="text-xs">
{wort}
</Badge>
))}
</div>
)}
</li>
))}
</ul>
{darfBearbeiten && (
<SeiteAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(slug) => {
setAnlegenOffen(false);
void werkzeuge.wiki.invalidate();
router.push(`/wiki/${slug}`);
}}
/>
)}
</div>
);
}
/**
* Die Frage ans Wiki.
*
* Zeigt immer beides: die Antwort und die Auszüge. Die Auszüge sind der
* eigentliche Wert — sie sind aus den Unterlagen der Wehr und damit belegt,
* während die Antwort eine Zusammenfassung ist, die stimmen kann oder nicht.
*/
function Frage({ verfuegbar }: { verfuegbar: boolean }) {
const [frage, setFrage] = React.useState("");
const [ergebnis, setErgebnis] = React.useState<
| {
antwort: string | null;
auszuege: Array<{ abschnittId: string; fundstelle: string; text: string; slug: string; quelle: string | null }>;
vorgangId: string | null;
fehler: string | null;
}
| null
>(null);
const fragen = trpc.wiki.fragen.useMutation({
onSuccess: (antwort) =>
setErgebnis({
antwort: antwort.antwort,
auszuege: antwort.auszuege,
vorgangId: antwort.vorgangId,
fehler: antwort.fehler,
}),
onError: (fehler) =>
setErgebnis({ antwort: null, auszuege: [], vorgangId: null, fehler: fehler.message }),
});
const entscheiden = trpc.assistent.entscheiden.useMutation();
if (!verfuegbar) {
return (
<KiFehltHinweis
was="Es ist kein Sprachmodell eingerichtet."
ohneGeht="Die Wortsuche unten funktioniert trotzdem — sie findet, was wörtlich in den Unterlagen steht."
/>
);
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Sparkles className="size-4" aria-hidden />
Frage an die Unterlagen
</CardTitle>
<CardDescription>
Beantwortet wird ausschließlich aus den hinterlegten Seiten. Steht die Antwort dort
nicht, sagt FLORIAN das — statt etwas zu erfinden.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
<Input
value={frage}
onChange={(ereignis) => setFrage(ereignis.target.value)}
onKeyDown={(ereignis) => {
if (ereignis.key === "Enter" && frage.trim().length >= 3) {
fragen.mutate({ frage });
}
}}
placeholder="Wie oft muss ein Schlauch geprüft werden?"
className="min-w-64 flex-1"
aria-label="Frage an das Wiki"
/>
<Button
onClick={() => fragen.mutate({ frage })}
disabled={frage.trim().length < 3 || fragen.isPending}
>
{fragen.isPending ? "Sucht …" : "Fragen"}
</Button>
</div>
{ergebnis?.fehler && !ergebnis.antwort && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{ergebnis.fehler}
</div>
)}
{ergebnis?.antwort && (
<KiVorschlag
titel="Antwort aus den Unterlagen"
text={ergebnis.antwort}
uebernehmenText="Hat geholfen"
laeuft={entscheiden.isPending}
onUebernehmen={() => {
if (ergebnis.vorgangId) {
entscheiden.mutate({ vorgangId: ergebnis.vorgangId, uebernommen: true });
}
setErgebnis(null);
}}
onVerwerfen={() => {
if (ergebnis.vorgangId) {
entscheiden.mutate({ vorgangId: ergebnis.vorgangId, uebernommen: false });
}
setErgebnis(null);
}}
fussnote={
<div className="space-y-1">
<p className="font-medium">Fundstellen:</p>
<ol className="list-decimal space-y-1 pl-5">
{ergebnis.auszuege.map((auszug) => (
<li key={auszug.abschnittId}>
<Link
href={`/wiki/${auszug.slug}`}
className="underline underline-offset-4"
>
{auszug.fundstelle}
</Link>
{auszug.quelle && ` (${auszug.quelle})`}
</li>
))}
</ol>
</div>
}
/>
)}
{/* Die Auszüge auch dann, wenn das Modell nicht geantwortet hat: sie
sind für sich brauchbar und stammen unverändert aus den Unterlagen. */}
{ergebnis && !ergebnis.antwort && ergebnis.auszuege.length > 0 && (
<div className="space-y-3">
<p className="text-sm font-medium">Passende Stellen aus den Unterlagen:</p>
{ergebnis.auszuege.map((auszug) => (
<div key={auszug.abschnittId} className="rounded-md border border-[var(--border)] p-3">
<Link href={`/wiki/${auszug.slug}`} className="text-sm font-medium underline-offset-4 hover:underline">
{auszug.fundstelle}
</Link>
<p className="mt-1 text-sm text-[var(--muted-foreground)]">{auszug.text}</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
/** Eine neue Seite anlegen. */
function SeiteAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (slug: string) => void;
fehler: (nachricht: string) => void;
}) {
const [titel, setTitel] = React.useState("");
const [art, setArt] = React.useState<WikiKategorie>(WikiKategorie.SONSTIGES);
const [quelle, setQuelle] = React.useState("");
const [inhalt, setInhalt] = React.useState("");
const [veroeffentlicht, setVeroeffentlicht] = React.useState(true);
const anlegen = trpc.wiki.anlegen.useMutation({
onSuccess: (seite) => {
setTitel("");
setInhalt("");
setQuelle("");
fertig(seite.slug);
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Seite anlegen</DialogTitle>
<DialogDescription>
Der Text ist Markdown — Überschriften mit #, Aufzählungen mit -. An den Überschriften
wird die Seite für die Suche zerlegt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="wiki-titel">Titel</Label>
<Input
id="wiki-titel"
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
placeholder="Schlauchpflege und Prüffristen"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="wiki-art">Bereich</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as WikiKategorie)}>
<SelectTrigger id="wiki-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(WikiKategorie).map((wert) => (
<SelectItem key={wert} value={wert}>
{WIKI_KATEGORIE_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="wiki-quelle">Fundstelle (freiwillig)</Label>
<Input
id="wiki-quelle"
value={quelle}
onChange={(ereignis) => setQuelle(ereignis.target.value)}
placeholder="DGUV Information 205-021"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="wiki-inhalt">Inhalt</Label>
<Textarea
id="wiki-inhalt"
rows={14}
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="font-mono text-sm"
placeholder={"## Prüffristen\n\nSchläuche werden ...\n"}
/>
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={veroeffentlicht}
onCheckedChange={(wert) => setVeroeffentlicht(wert === true)}
/>
Veröffentlichen — ein Entwurf wird nicht durchsucht und nicht beantwortet.
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
titel,
art,
inhalt,
quelle: quelle || null,
schlagworte: [],
veroeffentlicht,
})
}
disabled={!titel.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}