feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -14,6 +14,7 @@ const REITER = [
|
||||
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
|
||||
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
|
||||
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
|
||||
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
|
||||
/*
|
||||
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD,
|
||||
* aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { ProtokollFilter, type ProtokollFilterfelder } from "@/components/protokoll/protokoll-filter";
|
||||
import { ProtokollListe } from "@/components/protokoll/protokoll-liste";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { PRO_SEITE, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||
|
||||
/**
|
||||
* Das Protokoll ansehen (#023).
|
||||
*
|
||||
* Geschrieben wurde es seit #006 — Rollenänderungen, veröffentlichte Vorlagen,
|
||||
* archivierte und zurückgeholte Läufe, der Moduswechsel. Ansehen konnte es
|
||||
* niemand: wer wissen wollte, wer wann eine Rolle geändert hat, brauchte `psql`.
|
||||
*
|
||||
* `verwaltung` und nicht `lesen`: das Protokoll sagt, wer was getan hat, und ist
|
||||
* damit selbst eine Auskunft über Menschen.
|
||||
*
|
||||
* **Die Seite prüft selbst.** Das Admin-Layout darüber ist das zweite Netz und
|
||||
* nicht das erste (siehe CLAUDE.md).
|
||||
*/
|
||||
|
||||
export default async function ProtokollSeiteAnsicht({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/protokoll");
|
||||
const parameter = await searchParams;
|
||||
const einzeln = (name: string): string =>
|
||||
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
|
||||
|
||||
const felder: ProtokollFilterfelder = {
|
||||
suche: einzeln("suche"),
|
||||
urheber: einzeln("urheber"),
|
||||
von: einzeln("von"),
|
||||
bis: einzeln("bis"),
|
||||
};
|
||||
const gewuenschteSeite = Number.parseInt(einzeln("seite"), 10);
|
||||
|
||||
const [ergebnis, urheber, kundenEbene] = await Promise.all([
|
||||
protokollSeite(
|
||||
{
|
||||
suche: felder.suche,
|
||||
...(felder.urheber ? { urheberId: felder.urheber } : {}),
|
||||
...(felder.von ? { vonDatum: felder.von } : {}),
|
||||
...(felder.bis ? { bisDatum: felder.bis } : {}),
|
||||
seite: Number.isNaN(gewuenschteSeite) ? 1 : gewuenschteSeite,
|
||||
},
|
||||
benutzer,
|
||||
),
|
||||
protokollUrheber(benutzer),
|
||||
kundenEbeneSichtbar(),
|
||||
]);
|
||||
|
||||
/** Dieselben Filter mitnehmen, nur die Seite wechseln. */
|
||||
function blaetternZu(seite: number): string {
|
||||
const suchteil = new URLSearchParams();
|
||||
if (felder.suche) suchteil.set("suche", felder.suche);
|
||||
if (felder.urheber) suchteil.set("urheber", felder.urheber);
|
||||
if (felder.von) suchteil.set("von", felder.von);
|
||||
if (felder.bis) suchteil.set("bis", felder.bis);
|
||||
suchteil.set("seite", String(seite));
|
||||
return `/admin/protokoll?${suchteil.toString()}`;
|
||||
}
|
||||
|
||||
const erster = (ergebnis.seite - 1) * PRO_SEITE + 1;
|
||||
const letzter = Math.min(ergebnis.seite * PRO_SEITE, ergebnis.gesamt);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||||
<ScrollText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Protokoll
|
||||
</h1>
|
||||
<p className="mt-2 max-w-prose text-nebentext">
|
||||
Wer hat was wann getan. Festgehalten wird, was nachvollziehbar bleiben muss — Rollenänderungen,
|
||||
veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, der Moduswechsel.
|
||||
Einträge werden nie geändert und nie gelöscht.
|
||||
</p>
|
||||
|
||||
<ProtokollFilter felder={felder} urheber={urheber} />
|
||||
|
||||
<p className="mt-4 text-sm text-nebentext">
|
||||
{ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`}
|
||||
</p>
|
||||
|
||||
<ProtokollListe zeilen={ergebnis.zeilen} kundenEbene={kundenEbene} />
|
||||
|
||||
{ergebnis.seiten > 1 ? (
|
||||
<nav aria-label="Seiten" className="mt-6 flex flex-wrap items-center gap-3">
|
||||
{ergebnis.seite > 1 ? (
|
||||
<Link
|
||||
href={blaetternZu(ergebnis.seite - 1)}
|
||||
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
zurück
|
||||
</Link>
|
||||
) : null}
|
||||
<span className="text-sm text-nebentext">
|
||||
Seite {ergebnis.seite} von {ergebnis.seiten}
|
||||
</span>
|
||||
{ergebnis.seite < ergebnis.seiten ? (
|
||||
<Link
|
||||
href={blaetternZu(ergebnis.seite + 1)}
|
||||
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
weiter
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react";
|
||||
import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react";
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle";
|
||||
@@ -19,6 +19,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert";
|
||||
import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { protokollDesLaufs } from "@/server/queries/protokoll";
|
||||
import {
|
||||
kommentareDesLaufs,
|
||||
laufZumAbarbeiten,
|
||||
@@ -293,6 +294,9 @@ export default async function LaufSeite({
|
||||
if (!daten) notFound();
|
||||
|
||||
const kommentare = await kommentareDesLaufs(id, benutzer);
|
||||
// Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die
|
||||
// Einträge dieses Laufs (#023).
|
||||
const laufProtokoll = await protokollDesLaufs(id);
|
||||
/*
|
||||
* Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
|
||||
* jedem Seitenaufbau eine Abfrage, die niemand braucht.
|
||||
@@ -416,6 +420,41 @@ export default async function LaufSeite({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/*
|
||||
* Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und
|
||||
* nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das
|
||||
* zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt,
|
||||
* weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte
|
||||
* sonst aus dem Bild schieben.
|
||||
*
|
||||
* Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm
|
||||
* geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt.
|
||||
*/}
|
||||
{laufProtokoll.length > 0 ? (
|
||||
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
|
||||
<details>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
|
||||
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
Protokoll dieses Laufs
|
||||
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
|
||||
</summary>
|
||||
<ol className="mt-3 flex flex-col gap-2">
|
||||
{laufProtokoll.map((eintrag) => (
|
||||
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
|
||||
<span className="mono shrink-0 text-xs text-nebentext">
|
||||
{ZEITPUNKT.format(eintrag.erstelltAm)}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{eintrag.aktion}</span>
|
||||
<span className="text-xs text-nebentext">
|
||||
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import Link from "next/link";
|
||||
import { Search } from "lucide-react";
|
||||
import { FELD } from "@/lib/formular";
|
||||
|
||||
/**
|
||||
* Filter des Protokolls (#023).
|
||||
*
|
||||
* Ein GET-Formular, kein React-State: eine gesetzte Suche ist verlinkbar, der
|
||||
* Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine Server-Komponente.
|
||||
* Dieselbe Entscheidung wie beim Archiv (#015).
|
||||
*/
|
||||
|
||||
export type ProtokollFilterfelder = {
|
||||
readonly suche: string;
|
||||
readonly urheber: string;
|
||||
readonly von: string;
|
||||
readonly bis: string;
|
||||
};
|
||||
|
||||
export function ProtokollFilter({
|
||||
felder,
|
||||
urheber,
|
||||
}: {
|
||||
felder: ProtokollFilterfelder;
|
||||
urheber: readonly { readonly id: string; readonly name: string }[];
|
||||
}) {
|
||||
const gesetzt = Boolean(felder.suche || felder.urheber || felder.von || felder.bis);
|
||||
|
||||
return (
|
||||
<form method="get" className="mt-6 flex flex-col gap-3">
|
||||
{/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins
|
||||
Leere, wenn die neue Auswahl kuerzer ist. */}
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Aktion oder Lauf</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="size-4 shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||||
<input
|
||||
type="search"
|
||||
name="suche"
|
||||
defaultValue={felder.suche}
|
||||
placeholder="Rolle geändert, archiviert …"
|
||||
className={`${FELD} w-full`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-56 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">Urheber</span>
|
||||
<select name="urheber" defaultValue={felder.urheber} className={`${FELD} w-full`}>
|
||||
<option value="">alle</option>
|
||||
{urheber.map((eintrag) => (
|
||||
<option key={eintrag.id} value={eintrag.id}>
|
||||
{eintrag.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">von</span>
|
||||
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
|
||||
</label>
|
||||
|
||||
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
|
||||
<span className="text-nebentext">bis</span>
|
||||
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="min-h-11 shrink-0 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
|
||||
>
|
||||
Filtern
|
||||
</button>
|
||||
|
||||
{gesetzt ? (
|
||||
<Link
|
||||
href="/admin/protokoll"
|
||||
className="flex min-h-11 shrink-0 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
|
||||
>
|
||||
zurücksetzen
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import Link from "next/link";
|
||||
import { Bot } from "lucide-react";
|
||||
import type { ProtokollZeile } from "@/server/queries/protokoll";
|
||||
|
||||
/**
|
||||
* Die Einträge des Protokolls (#023).
|
||||
*
|
||||
* Keine Tabelle, sondern eine Liste aus Karten. Eine Tabelle mit sieben Spalten
|
||||
* ist bei 390 px nicht darstellbar, ohne horizontal zu scrollen — und genau das
|
||||
* ist ausgeschlossen (CLAUDE.md).
|
||||
*
|
||||
* `vorher`/`nachher` stehen in einem `details`: sie sind Json und koennen lang
|
||||
* werden. Zugeklappt bleibt die Liste lesbar, aufgeklappt steht alles da.
|
||||
*/
|
||||
|
||||
const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
/** Json lesbar, ohne Anführungszeichen-Wald für den häufigen Fall eines Objekts. */
|
||||
function alsText(wert: unknown): string {
|
||||
if (wert === null || wert === undefined) return "";
|
||||
if (typeof wert === "object" && !Array.isArray(wert)) {
|
||||
const paare = Object.entries(wert as Record<string, unknown>).map(
|
||||
([schluessel, inhalt]) =>
|
||||
`${schluessel}: ${typeof inhalt === "object" ? JSON.stringify(inhalt) : String(inhalt)}`,
|
||||
);
|
||||
return paare.join(" · ");
|
||||
}
|
||||
return JSON.stringify(wert);
|
||||
}
|
||||
|
||||
export function ProtokollListe({
|
||||
zeilen,
|
||||
kundenEbene,
|
||||
}: {
|
||||
zeilen: readonly ProtokollZeile[];
|
||||
kundenEbene: boolean;
|
||||
}) {
|
||||
if (zeilen.length === 0) {
|
||||
return (
|
||||
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
|
||||
Kein Eintrag passt zu diesen Filtern.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="mt-4 flex flex-col gap-2">
|
||||
{zeilen.map((zeile) => {
|
||||
const vorher = alsText(zeile.vorher);
|
||||
const nachher = alsText(zeile.nachher);
|
||||
return (
|
||||
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="mono shrink-0 text-xs text-nebentext">
|
||||
{ZEITPUNKT.format(zeile.erstelltAm)}
|
||||
</span>
|
||||
<span className="min-w-0 font-semibold break-words">{zeile.aktion}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-nebentext">
|
||||
{zeile.urheber ? (
|
||||
<span>{zeile.urheber.name}</span>
|
||||
) : (
|
||||
/* Ein Systemlauf ist keine Lücke – der Zeitplan des
|
||||
Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Bot className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||||
Systemlauf
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs">{zeile.entitaet}</span>
|
||||
{zeile.laufId && zeile.laufTitel ? (
|
||||
<Link
|
||||
href={`/laeufe/${zeile.laufId}`}
|
||||
className="min-w-0 break-words text-akzent underline underline-offset-2"
|
||||
>
|
||||
{zeile.laufTitel}
|
||||
</Link>
|
||||
) : null}
|
||||
{kundenEbene && zeile.kundeName ? <span>· {zeile.kundeName}</span> : null}
|
||||
</div>
|
||||
|
||||
{vorher || nachher ? (
|
||||
<details className="mt-2">
|
||||
<summary className="inline-flex min-h-11 cursor-pointer items-center text-sm text-nebentext hover:text-text md:min-h-0">
|
||||
Werte
|
||||
</summary>
|
||||
<dl className="mt-1 flex flex-col gap-1 text-sm">
|
||||
{vorher ? (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="shrink-0 text-nebentext">vorher</dt>
|
||||
<dd className="mono min-w-0 break-words">{vorher}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{nachher ? (
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="shrink-0 text-nebentext">nachher</dt>
|
||||
<dd className="mono min-w-0 break-words">{nachher}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Lesemodell des Protokolls (#023).
|
||||
*
|
||||
* Geschrieben wurde es seit #006, ansehen konnte es niemand — wer wissen wollte,
|
||||
* wer wann eine Rolle geaendert hat, brauchte `psql`.
|
||||
*
|
||||
* Die Mandantensicht wird hier selbst angewandt, obwohl die Seite `verwaltung`
|
||||
* verlangt und ein ADMIN heute ohnehin alle Kunden sieht. Das ist Absicht: ein
|
||||
* Lesemodell traegt seine Sicht selbst (CLAUDE.md), und wenn das Recht je
|
||||
* weiter gefasst wird, haelt die Grenze trotzdem.
|
||||
*/
|
||||
|
||||
export type ProtokollZeile = {
|
||||
readonly id: string;
|
||||
readonly erstelltAm: Date;
|
||||
readonly aktion: string;
|
||||
readonly entitaet: string;
|
||||
readonly entitaetId: string;
|
||||
readonly laufId: string | null;
|
||||
readonly laufTitel: string | null;
|
||||
readonly kundeName: string | null;
|
||||
/** Leer bei einem Systemlauf – etwa dem Zeitplan des Verzeichnisabgleichs. */
|
||||
readonly urheber: { readonly id: string; readonly name: string } | null;
|
||||
readonly vorher: unknown;
|
||||
readonly nachher: unknown;
|
||||
};
|
||||
|
||||
export type ProtokollSeite = {
|
||||
readonly zeilen: readonly ProtokollZeile[];
|
||||
readonly gesamt: number;
|
||||
readonly seite: number;
|
||||
readonly seiten: number;
|
||||
};
|
||||
|
||||
export type ProtokollFilter = {
|
||||
readonly suche?: string;
|
||||
readonly urheberId?: string;
|
||||
readonly vonDatum?: string;
|
||||
readonly bisDatum?: string;
|
||||
readonly seite?: number;
|
||||
};
|
||||
|
||||
export const PRO_SEITE = 50;
|
||||
|
||||
/**
|
||||
* Eintraege ohne `kundeId` und ohne Lauf, deren Entitaet wirklich global ist.
|
||||
*
|
||||
* Alles andere mit leerer `kundeId` ist **nicht zuordenbar** — etwa ein Eintrag
|
||||
* zu einem inzwischen geloeschten Geraet. Einem auf Kunden beschraenkten
|
||||
* Benutzer wird das nicht gezeigt: `vorher`/`nachher` koennen einen Hostnamen
|
||||
* enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht.
|
||||
*/
|
||||
const GLOBALE_ENTITAETEN = ["Einstellung", "Benutzer", "GeraetetypVorlage", "Vorlage", "VorlagenVersion"];
|
||||
|
||||
async function sichtBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.ProtokollWhereInput[]> {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
if (sichtbar === undefined) return [];
|
||||
return [
|
||||
{
|
||||
OR: [
|
||||
// Eintraege mit eigener Zuordnung.
|
||||
{ kundeId: { in: [...sichtbar] } },
|
||||
// Laufbezogene Eintraege: der Lauf ist die bessere Quelle, weil er die
|
||||
// Zuordnung nicht verlieren kann.
|
||||
{ lauf: { kundeId: { in: [...sichtbar] } } },
|
||||
// Wirklich globale Eintraege.
|
||||
{ kundeId: null, laufId: null, entitaet: { in: GLOBALE_ENTITAETEN } },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function datumBedingung(filter: ProtokollFilter): Prisma.ProtokollWhereInput[] {
|
||||
const von = filter.vonDatum ? new Date(`${filter.vonDatum}T00:00:00`) : undefined;
|
||||
// Bis **einschliesslich** des gewaehlten Tages: ein Filter „bis 4.10." soll
|
||||
// den 4. enthalten, sonst sucht man den Eintrag von heute vergeblich.
|
||||
const bis = filter.bisDatum ? new Date(`${filter.bisDatum}T23:59:59.999`) : undefined;
|
||||
if (!von && !bis) return [];
|
||||
return [
|
||||
{
|
||||
erstelltAm: {
|
||||
...(von && !Number.isNaN(von.getTime()) ? { gte: von } : {}),
|
||||
...(bis && !Number.isNaN(bis.getTime()) ? { lte: bis } : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Seite des Protokolls, neueste zuerst.
|
||||
*
|
||||
* Alle Teilbedingungen liegen in einem `AND`, wo keine die andere erreichen
|
||||
* kann — dieselbe Vorsicht wie in den anderen Lesemodellen: im Review von #009
|
||||
* hatte ein Suchfilter mit eigenem `OR` die Kundenbedingung ueberschrieben.
|
||||
*/
|
||||
export async function protokollSeite(
|
||||
filter: ProtokollFilter,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<ProtokollSeite> {
|
||||
const suche = filter.suche?.trim() ?? "";
|
||||
const bedingungen: Prisma.ProtokollWhereInput[] = [
|
||||
...(await sichtBedingung(benutzer)),
|
||||
...datumBedingung(filter),
|
||||
...(suche.length > 0
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ aktion: { contains: suche, mode: "insensitive" as const } },
|
||||
{ entitaet: { contains: suche, mode: "insensitive" as const } },
|
||||
{ lauf: { titel: { contains: suche, mode: "insensitive" as const } } },
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(filter.urheberId ? [{ benutzerId: filter.urheberId }] : []),
|
||||
];
|
||||
|
||||
const where: Prisma.ProtokollWhereInput = { AND: bedingungen };
|
||||
const seite = Math.max(1, Math.trunc(filter.seite ?? 1));
|
||||
|
||||
const [gesamt, zeilen] = await Promise.all([
|
||||
prisma.protokoll.count({ where }),
|
||||
prisma.protokoll.findMany({
|
||||
where,
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
skip: (seite - 1) * PRO_SEITE,
|
||||
take: PRO_SEITE,
|
||||
select: {
|
||||
id: true,
|
||||
erstelltAm: true,
|
||||
aktion: true,
|
||||
entitaet: true,
|
||||
entitaetId: true,
|
||||
laufId: true,
|
||||
vorher: true,
|
||||
nachher: true,
|
||||
lauf: { select: { titel: true, kunde: { select: { name: true } } } },
|
||||
kunde: { select: { name: true } },
|
||||
benutzer: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
zeilen: zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
aktion: zeile.aktion,
|
||||
entitaet: zeile.entitaet,
|
||||
entitaetId: zeile.entitaetId,
|
||||
laufId: zeile.laufId,
|
||||
laufTitel: zeile.lauf?.titel ?? null,
|
||||
kundeName: zeile.kunde?.name ?? zeile.lauf?.kunde.name ?? null,
|
||||
urheber: zeile.benutzer
|
||||
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
|
||||
: null,
|
||||
vorher: zeile.vorher,
|
||||
nachher: zeile.nachher,
|
||||
})),
|
||||
gesamt,
|
||||
seite,
|
||||
seiten: Math.max(1, Math.ceil(gesamt / PRO_SEITE)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Die Urheber, die im Protokoll vorkommen – für den Filter. */
|
||||
export async function protokollUrheber(
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<readonly { readonly id: string; readonly name: string }[]> {
|
||||
const bedingungen = await sichtBedingung(benutzer);
|
||||
const zeilen = await prisma.protokoll.findMany({
|
||||
where: { AND: [...bedingungen, { benutzerId: { not: null } }] },
|
||||
distinct: ["benutzerId"],
|
||||
select: { benutzer: { select: { id: true, name: true, email: true } } },
|
||||
});
|
||||
return zeilen
|
||||
.flatMap((zeile) =>
|
||||
zeile.benutzer === null
|
||||
? []
|
||||
: [{ id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }],
|
||||
)
|
||||
.sort((links, rechts) => links.name.localeCompare(rechts.name, "de"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Einträge eines Laufs, für seine Abarbeitungsansicht.
|
||||
*
|
||||
* Eigene Funktion und nicht `protokollSeite` mit Filter: hier gilt die Sicht des
|
||||
* Laufs, den der Aufrufer schon geprüft hat, und es gibt keine Blätterung —
|
||||
* ein Lauf hat Dutzende Einträge, nicht Tausende.
|
||||
*/
|
||||
export async function protokollDesLaufs(laufId: string): Promise<readonly ProtokollZeile[]> {
|
||||
const zeilen = await prisma.protokoll.findMany({
|
||||
where: { laufId },
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
erstelltAm: true,
|
||||
aktion: true,
|
||||
entitaet: true,
|
||||
entitaetId: true,
|
||||
laufId: true,
|
||||
vorher: true,
|
||||
nachher: true,
|
||||
benutzer: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
erstelltAm: zeile.erstelltAm,
|
||||
aktion: zeile.aktion,
|
||||
entitaet: zeile.entitaet,
|
||||
entitaetId: zeile.entitaetId,
|
||||
laufId: zeile.laufId,
|
||||
laufTitel: null,
|
||||
kundeName: null,
|
||||
urheber: zeile.benutzer
|
||||
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
|
||||
: null,
|
||||
vorher: zeile.vorher,
|
||||
nachher: zeile.nachher,
|
||||
}));
|
||||
}
|
||||
@@ -568,7 +568,7 @@ async function abschlussSchreiben(
|
||||
uebersprungen: number,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.verzeichnisquelle.update({
|
||||
const quelle = await tx.verzeichnisquelle.update({
|
||||
where: { id: quelleId },
|
||||
data: {
|
||||
letzterAbgleichAm: new Date(),
|
||||
@@ -578,6 +578,9 @@ async function abschlussSchreiben(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
// Aus der Aktualisierung, nicht aus einer zweiten Abfrage: der Kunde
|
||||
// steht in derselben Zeile, die gerade geschrieben wurde (#023).
|
||||
kundeId: quelle.kundeId,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: quelleId,
|
||||
aktion:
|
||||
|
||||
@@ -100,6 +100,7 @@ export async function abteilungAnlegen(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: abteilung.kundeId,
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilung.id,
|
||||
aktion: "Abteilung angelegt",
|
||||
@@ -169,6 +170,7 @@ export async function abteilungBearbeiten(
|
||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilungId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
@@ -202,6 +204,7 @@ export async function abteilungArchivieren(
|
||||
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Abteilung",
|
||||
entitaetId: abteilungId,
|
||||
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
|
||||
|
||||
@@ -81,6 +81,7 @@ export async function einstellungenSpeichern(
|
||||
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.eigeneOrganisation.id,
|
||||
entitaet: "Kunde",
|
||||
entitaetId: vorher.eigeneOrganisation.id,
|
||||
aktion: "Name der eigenen Organisation geändert",
|
||||
|
||||
@@ -197,6 +197,7 @@ async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: geraet.kundeId,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraet.id,
|
||||
aktion: auftrag.aktion,
|
||||
@@ -314,6 +315,7 @@ export async function geraetBearbeiten(
|
||||
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
@@ -371,6 +373,7 @@ export async function geraetArchivieren(
|
||||
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: geraet.kundeId,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
|
||||
@@ -418,6 +421,7 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut
|
||||
await tx.geraet.delete({ where: { id: geraetId } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: geraet.kundeId,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: geraetId,
|
||||
aktion: "Gerät gelöscht",
|
||||
|
||||
@@ -114,6 +114,7 @@ export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string)
|
||||
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: kunde.id,
|
||||
entitaet: "Kunde",
|
||||
entitaetId: kunde.id,
|
||||
aktion: "Kunde angelegt",
|
||||
@@ -181,6 +182,7 @@ export async function kundeBearbeiten(
|
||||
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: id,
|
||||
entitaet: "Kunde",
|
||||
entitaetId: id,
|
||||
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`,
|
||||
@@ -215,6 +217,7 @@ export async function kundeArchivieren(
|
||||
await prisma.kunde.update({ where: { id }, data: { archiviert } });
|
||||
await prisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: id,
|
||||
entitaet: "Kunde",
|
||||
entitaetId: id,
|
||||
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
|
||||
|
||||
@@ -131,6 +131,7 @@ export async function mitarbeiterAnlegen(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: mitarbeiter.kundeId,
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiter.id,
|
||||
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
|
||||
@@ -200,6 +201,7 @@ export async function mitarbeiterBearbeiten(
|
||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiterId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
@@ -260,6 +262,7 @@ export async function vorgesetztenSetzen(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiterId,
|
||||
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`,
|
||||
@@ -321,6 +324,7 @@ export async function mitarbeiterAktivSetzen(
|
||||
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiterId,
|
||||
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
|
||||
|
||||
@@ -210,6 +210,7 @@ export async function quelleAnlegen(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: angelegt.kundeId,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: angelegt.id,
|
||||
aktion: "Verzeichnisquelle angelegt",
|
||||
@@ -316,6 +317,7 @@ export async function quelleBearbeiten(
|
||||
const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorher.kundeId,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: id,
|
||||
aktion: `${geaendert.join(", ")} geändert`,
|
||||
@@ -354,6 +356,7 @@ export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer):
|
||||
await tx.verzeichnisquelle.delete({ where: { id } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: quelle.kundeId,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: id,
|
||||
aktion: "Verzeichnisquelle gelöscht",
|
||||
|
||||
@@ -177,6 +177,7 @@ export async function vorlageImportieren(
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage importiert",
|
||||
|
||||
@@ -61,6 +61,7 @@ export async function vorlageAnlegen(
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage angelegt",
|
||||
@@ -147,6 +148,7 @@ export async function vorlageBearbeiten(
|
||||
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: nachher.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
@@ -263,6 +265,12 @@ async function entwurfKopieren(
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Der Kunde fuer den Protokolleintrag; ein indizierter Lesevorgang auf dem
|
||||
// Primaerschluessel (#023).
|
||||
const vorlage = await tx.vorlage.findUniqueOrThrow({
|
||||
where: { id: vorlageId },
|
||||
select: { kundeId: true },
|
||||
});
|
||||
const entwurf = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
@@ -304,6 +312,7 @@ async function entwurfKopieren(
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: entwurf.id,
|
||||
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
||||
@@ -485,7 +494,7 @@ export async function versionVeroeffentlichen(
|
||||
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: versionId },
|
||||
select: {
|
||||
vorlage: { select: { archiviert: true, name: true } },
|
||||
vorlage: { select: { archiviert: true, name: true, kundeId: true } },
|
||||
_count: { select: { abschnitte: true } },
|
||||
},
|
||||
});
|
||||
@@ -518,6 +527,7 @@ export async function versionVeroeffentlichen(
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: kopf.vorlage.kundeId,
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Version veröffentlicht",
|
||||
@@ -545,7 +555,13 @@ export async function versionVeroeffentlichen(
|
||||
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
|
||||
const version = await prisma.vorlagenVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { id: true, status: true, version: true, vorlageId: true },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
version: true,
|
||||
vorlageId: true,
|
||||
vorlage: { select: { kundeId: true } },
|
||||
},
|
||||
});
|
||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
versionMussEntwurfSein(version);
|
||||
@@ -563,6 +579,7 @@ export async function entwurfVerwerfen(versionId: string, durchBenutzerId: strin
|
||||
await tx.vorlagenVersion.delete({ where: { id: versionId } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: version.vorlage.kundeId,
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Entwurf verworfen",
|
||||
@@ -661,6 +678,7 @@ export async function vorlageDuplizieren(
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: kopie.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: kopie.id,
|
||||
aktion: "Vorlage dupliziert",
|
||||
@@ -693,6 +711,7 @@ export async function vorlageArchivieren(
|
||||
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
|
||||
|
||||
Reference in New Issue
Block a user