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:
co-authored by
Claude Opus 5
parent
12243f177d
commit
f7ccd9a908
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user