feat(archiv): Uebersicht, Volltextsuche und Druckansicht
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Der Einstieg am Schreibtisch und das Gedaechtnis der Anwendung. Die Uebersicht
sortiert nach Dringlichkeit — ueberfaellig zuerst, und das ist das Einzige in
Warnfarbe; alles andere auf der Seite ist Auskunft.

Das Archiv sucht ueber Titel, Notizen, erfasste Werte und Kommentare in einer
SQL-Anweisung: `Schrittergebnis.wert` ist ein Json, und nur `("wert")::text`
trifft den Trigramm-Index aus #006. Vor allem aber gehoert die Fundstelle zum
Treffer — ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.

Die Druckansicht ist ein eigenes Lesemodell und kein Schalter an
`laufZumAbarbeiten`: dort entsteht beim Fuellen der Befehle ein Passwort, und auf
einem Blatt, das jemand abheftet, darf keines stehen. Vom Mitarbeiter holt sie
nur Name, UPN und Abteilung — nicht alles und dann gefiltert.

Der unabhaengige Review fand zwei Blocker, beide im Dashboard: „ueberfaellig“
verglich mit dem Augenblick statt mit dem Tagesbeginn, und der Schnellstart
zaehlte Laeufe fremder Kunden mit. Was „ueberfaellig“ heisst, steht seitdem in
`src/lib/fristen.ts` — eine Regel fuer Lesemodell und Anzeige.

Closes #15

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #29.
This commit is contained in:
2026-09-25 20:59:38 +00:00
co-authored by Claude Opus 5
parent e7a2e2d5c7
commit ab5777a57e
32 changed files with 4202 additions and 38 deletions
+548
View File
@@ -0,0 +1,548 @@
import { Prisma } from "@/generated/prisma/client";
import { LaufStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Das Archiv: Volltextsuche und Filter ueber abgelegte Laeufe (#015).
*
* Vier Entscheidungen tragen das Modul:
*
* 1. **Ein gemeinsames Lesemodell fuer drei Tabellen.** Gesucht wird in
* `Lauf.titel`, in `Schrittergebnis.notiz`, im Json `Schrittergebnis.wert`
* und in `Kommentar.rumpf` — mit `ILIKE '%…%'`, wie im Auftrag festgelegt.
* Die `pg_trgm`-GIN-Indizes aus #006 stehen genau fuer diese vier Ausdruecke
* in der Datenbank (`Lauf_titel_trgm_idx`, `Schrittergebnis_notiz_trgm_idx`,
* `Schrittergebnis_wert_trgm_idx` auf `(("wert")::text)`,
* `Kommentar_rumpf_trgm_idx`). Damit sie erreichbar bleiben, lesen **alle
* vier** Zweige aus den Basistabellen und schraenken ueber den Verbund mit
* `basis` ein; ueber die sechsmal gelesene und deshalb materialisierte CTE
* gaebe es keinen Index. Ob der Planer ihn dann waehlt, entscheidet er
* selbst — bei wenigen Zeilen ist ein Durchlauf schneller. Ein `tsvector`
* ist ausdruecklich spaetere Optimierung und steht hier nicht.
*
* **Ohne Suchbegriff** kostet die Anweisung trotzdem einen Durchlauf ueber
* die gefilterten Laeufe: sortiert wird nach
* `COALESCE(archiviertAm, abgeschlossenAm, erstelltAm)`, und dafuer gibt es
* keinen Index. Das ist der Preis dafuer, dass Sortierung und Zeitraumfilter
* denselben Ausdruck benutzen — ein Archiv, das ein Haus in Jahren fuellt,
* vertraegt ihn.
* 2. **Die Suche laeuft als eine Anweisung in SQL**, weil `wert` ein Json ist:
* ueber Prisma laesst sich eine ganze Json-Spalte nicht als Text
* durchsuchen, und nur `("wert")::text` trifft den Index. Derselbe Weg
* liefert gleich mit, **wo** der Begriff steht und mit welchem Ausschnitt —
* ein Treffer ohne sichtbaren Grund ist fuer den Suchenden wertlos.
* 3. **Die Mandantensicht steht zweimal drin**, und das ist Absicht: einmal in
* der handgeschriebenen SQL-Bedingung, einmal beim Nachladen der Anzeigedaten
* ueber Prisma. Die Teilbedingungen liegen dabei alle in **einem** `AND`, wo
* keine die andere ueberschreiben kann — in #009 hat genau das die
* Kundengrenze gekostet: ein zweites `OR` im selben Objekt, und ein Buchstabe
* im Suchfeld genuegte.
* 4. **Auch abgeschlossene Laeufe gehoeren ins Archiv.** „Abgeschlossen ist
* nicht archiviert“ (docs/architektur.md, Abschnitt 4) beschreibt den
* *Schreibschutz*: ein abgeschlossener Lauf darf noch eine nachgetragene
* Notiz bekommen, ein archivierter nicht. Fuer das **Suchen** ist der
* Unterschied bedeutungslos — wer die Seriennummer von letzter Woche sucht,
* weiss nicht, ob jemand den Lauf schon archiviert hat. Das Archiv sucht
* deshalb ueber `ABGESCHLOSSEN` **und** `ARCHIVIERT`; laufende Laeufe kommen
* nur auf ausdruecklichen Wunsch dazu (`auchLaufende`), weil sie in der
* Laufliste stehen, wo man sie auch anfassen kann.
*/
/** So viele Laeufe liefert eine Suche hoechstens; die Seite sagt es, wenn es mehr sind. */
export const ARCHIV_HOECHSTENS = 100;
/**
* Kuerzere Begriffe werden verworfen.
*
* Ein einzelner Buchstabe traefe fast jede Notiz des Hauses und waere damit
* keine Auskunft. Unter drei Zeichen kann der Trigramm-Index ohnehin nicht
* greifen, ein zweistelliger Begriff („AD“, „PC“) ist also schon ein
* vollstaendiger Durchlauf — dafuer aber einer, der eine sinnvolle Frage
* beantwortet.
*/
export const SUCHE_MINDESTZEICHEN = 2;
/** So viele Fundstellen zeigt ein Treffer; die Zeile nennt die Zahl der uebrigen. */
export const FUNDSTELLEN_JE_LAUF = 4;
/** So viele Eintraege stehen je Filterliste zur Auswahl. */
export const AUSWAHL_HOECHSTENS = 500;
/** Zeichen vor dem Fund, die der Ausschnitt mitnimmt. */
const AUSSCHNITT_VORLAUF = 60;
/** Laenge des Ausschnitts, den die Datenbank liefert. */
const AUSSCHNITT_LAENGE = 240;
/** Die Zustaende, die das Archiv immer durchsucht. */
export const ARCHIV_STATUS = [LaufStatus.ABGESCHLOSSEN, LaufStatus.ARCHIVIERT] as const;
/** Nur mit `auchLaufende` dabei. */
const LAUFENDE_STATUS = [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] as const;
/**
* Der Zeitpunkt, an dem ein Lauf zur Ruhe kam.
*
* Archiviert, sonst abgeschlossen, sonst angelegt. Sortierung und
* Zeitraumfilter benutzen **denselben** Ausdruck: sonst stuende ein Lauf
* ausserhalb des Zeitraums, den die Liste an seiner Position anzeigt.
*/
const ABGELEGT = Prisma.sql`COALESCE(l."archiviertAm", l."abgeschlossenAm", l."erstelltAm")`;
export type TrefferFeld = "TITEL" | "NOTIZ" | "WERT" | "KOMMENTAR";
export type Fundstelle = {
readonly feld: TrefferFeld;
/** Der Text um den Fund herum; „…“ steht dort, wo abgeschnitten wurde. */
readonly ausschnitt: string;
};
export type ArchivZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
readonly abteilungName: string | null;
readonly ticket: string | null;
readonly kundeName: string;
/** Siehe `ABGELEGT`: archiviert, sonst abgeschlossen, sonst angelegt. */
readonly abgelegtAm: Date;
readonly abgeschlossenAm: Date | null;
readonly archiviertAm: Date | null;
readonly abgeschlossenVon: string | null;
readonly zugewiesenAn: string | null;
/** Leer, wenn ohne Suchbegriff nur gefiltert wurde. */
readonly fundstellen: readonly Fundstelle[];
/** Weitere Stellen im selben Lauf, die nicht mehr angezeigt werden. */
readonly weitereFundstellen: number;
};
export type ArchivFilter = {
readonly suche?: string;
readonly vorlageId?: string;
readonly mitarbeiterId?: string;
readonly abteilungId?: string;
readonly geraetId?: string;
/**
* Wer an dem Lauf gearbeitet hat: ihm zugewiesen, von ihm abgeschlossen oder
* an einem Schritt beteiligt. Alle drei, weil keines allein genuegt — ein Lauf
* wird oft ohne Zuweisung abgearbeitet, und wer abschliesst, muss nicht der
* sein, der die Arbeit gemacht hat.
*/
readonly bearbeiterId?: string;
/** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */
readonly kundeId?: string;
/** Ab diesem Zeitpunkt abgelegt (einschliesslich). */
readonly von?: Date;
/** An diesem Tag abgelegt oder davor – der Bis-Tag gehoert vollstaendig dazu. */
readonly bis?: Date;
/** Auch offene und laufende Laeufe durchsuchen. */
readonly auchLaufende?: boolean;
};
export type ArchivErgebnis = {
readonly zeilen: readonly ArchivZeile[];
/** Es gibt mehr Treffer als `ARCHIV_HOECHSTENS` – die Seite sagt es. */
readonly gekappt: boolean;
/** Der Begriff war zu kurz und wurde verworfen; gefiltert wurde trotzdem. */
readonly sucheZuKurz: boolean;
};
/**
* `%`, `_` und `\` sind Sonderzeichen in `ILIKE`.
*
* Ohne Maskierung waere die Suche nach „50%“ eine Suche nach „50 und dann
* irgendetwas“ und traefe jede Notiz, in der eine 50 vorkommt.
*/
function musterMaskieren(begriff: string): string {
return begriff.replace(/[\\%_]/g, (zeichen) => `\\${zeichen}`);
}
type RoheFundstelle = {
readonly feld: string;
readonly roh: string;
readonly beginn: number;
readonly gesamt: number;
};
type Rohzeile = {
readonly id: string;
readonly abgelegtAm: Date;
readonly anzahlFundstellen: number;
readonly fundstellen: readonly RoheFundstelle[] | null;
};
const FELDER: readonly TrefferFeld[] = ["TITEL", "NOTIZ", "WERT", "KOMMENTAR"];
function feldPruefen(wert: string): TrefferFeld | undefined {
return FELDER.find((feld) => feld === wert);
}
/**
* Aus dem Fenster, das die Datenbank geliefert hat, wird ein einzeiliger
* Ausschnitt.
*
* Die „…“ stehen nur dort, wo wirklich etwas fehlt: `beginn` und `gesamt`
* kommen aus derselben Abfrage, die den Text zugeschnitten hat.
*/
function ausschnittBauen(stelle: RoheFundstelle): string {
const text = stelle.roh.replace(/\s+/g, " ").trim();
const vorne = stelle.beginn > 1 ? "…" : "";
const hinten = stelle.beginn - 1 + stelle.roh.length < stelle.gesamt ? "…" : "";
return `${vorne}${text}${hinten}`;
}
function fundstellenBauen(zeile: Rohzeile): readonly Fundstelle[] {
const stellen: Fundstelle[] = [];
for (const stelle of zeile.fundstellen ?? []) {
const feld = feldPruefen(stelle.feld);
if (!feld) continue;
stellen.push({ feld, ausschnitt: ausschnittBauen(stelle) });
}
return stellen;
}
const ZEILEN_AUSWAHL = {
id: true,
titel: true,
status: true,
ticket: true,
erstelltAm: true,
abgeschlossenAm: true,
archiviertAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
abteilung: { select: { name: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
} satisfies Prisma.LaufSelect;
type Anzeigezeile = Prisma.LaufGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
/**
* Volltextsuche und Filter ueber das Archiv.
*
* Ohne Suchbegriff wird nur gefiltert; die Trefferliste hat dann keine
* Fundstellen, weil es keine gibt.
*/
export async function archivSuche(
benutzer: HandelnderBenutzer,
filter: ArchivFilter = {},
): Promise<ArchivErgebnis> {
const eingegeben = filter.suche?.trim() ?? "";
const sucheZuKurz = eingegeben.length > 0 && eingegeben.length < SUCHE_MINDESTZEICHEN;
const begriff = sucheZuKurz ? "" : eingegeben;
const muster = begriff ? `%${musterMaskieren(begriff)}%` : null;
const [sichtbar, kundenEbene] = await Promise.all([sichtbareKundenIds(benutzer), kundenEbeneSichtbar()]);
const status: string[] = [...ARCHIV_STATUS, ...(filter.auchLaufende ? LAUFENDE_STATUS : [])];
const bedingungen: Prisma.Sql[] = [Prisma.sql`l."status"::text = ANY(${status}::text[])`];
// Die Mandantengrenze. `undefined` heisst „keine Einschraenkung“, eine leere
// Liste heisst „sieht nichts“ – und `= ANY('{}')` ist genau das.
if (sichtbar !== undefined) {
bedingungen.push(Prisma.sql`l."kundeId" = ANY(${[...sichtbar]}::text[])`);
}
// Im Modus INTERN gibt es keine Kundenwahl, also auch nichts zu entscheiden:
// eine mitgeschickte kundeId wird verworfen, nicht abgelehnt (#008).
if (kundenEbene && filter.kundeId) {
bedingungen.push(Prisma.sql`l."kundeId" = ${filter.kundeId}`);
}
if (filter.vorlageId) {
// Gefiltert wird ueber die Vorlage, nicht ueber eine einzelne Version: der
// Lauf haengt an der Version, mit der er gestartet wurde, und wer im Archiv
// nach „Onboarding“ sucht, meint alle Fassungen davon.
bedingungen.push(
Prisma.sql`EXISTS (
SELECT 1 FROM "VorlagenVersion" v
WHERE v."id" = l."vorlagenVersionId" AND v."vorlageId" = ${filter.vorlageId}
)`,
);
}
if (filter.mitarbeiterId) {
bedingungen.push(Prisma.sql`l."mitarbeiterId" = ${filter.mitarbeiterId}`);
}
if (filter.abteilungId) {
bedingungen.push(Prisma.sql`l."abteilungId" = ${filter.abteilungId}`);
}
if (filter.geraetId) {
bedingungen.push(Prisma.sql`l."geraetId" = ${filter.geraetId}`);
}
if (filter.bearbeiterId) {
bedingungen.push(
Prisma.sql`(
l."zugewiesenAnId" = ${filter.bearbeiterId}
OR l."abgeschlossenVonId" = ${filter.bearbeiterId}
OR EXISTS (
SELECT 1 FROM "Schrittergebnis" e
WHERE e."laufId" = l."id" AND e."aktualisiertVonId" = ${filter.bearbeiterId}
)
)`,
);
}
if (filter.von) {
bedingungen.push(Prisma.sql`${ABGELEGT} >= ${filter.von}`);
}
if (filter.bis) {
// Der Bis-Tag gehoert dazu. Gerechnet wird mit dem Kalendertag und nicht mit
// „plus 24 Stunden“: an den beiden Umstelltagen des Jahres hat ein Tag 23
// oder 25 Stunden.
const grenze = new Date(filter.bis);
grenze.setDate(grenze.getDate() + 1);
bedingungen.push(Prisma.sql`${ABGELEGT} < ${grenze}`);
}
/*
* Eine Anweisung, drei Tabellen. `basis` sind die Laeufe, die die Filter
* bestehen; `treffer` sammelt je Fundstelle eine Zeile mit Feld und Text.
* Ohne Suchbegriff bleibt `treffer` leer (`FALSE`), und die Liste zeigt
* einfach die gefilterten Laeufe.
*
* `rang` ordnet die Fundstellen: Titel vor Notiz vor Wert vor Kommentar. Ohne
* die Ordnung waere bei mehr als `FUNDSTELLEN_JE_LAUF` Stellen zufaellig,
* welche der Suchende sieht.
*/
const suchbar = muster !== null;
const roh = await prisma.$queryRaw<Rohzeile[]>(Prisma.sql`
WITH basis AS (
SELECT l."id", l."titel", ${ABGELEGT} AS "abgelegtAm"
FROM "Lauf" l
WHERE ${Prisma.join(bedingungen, " AND ")}
),
treffer AS (
SELECT b."id" AS "laufId", 0::int AS "rang", 'TITEL'::text AS "feld", b."titel" AS "fundstelle"
FROM basis b
WHERE ${suchbar ? Prisma.sql`b."titel" ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT e."laufId", 1::int, 'NOTIZ'::text, e."notiz"
FROM "Schrittergebnis" e JOIN basis b ON b."id" = e."laufId"
WHERE ${suchbar ? Prisma.sql`e."notiz" ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT e."laufId", 2::int, 'WERT'::text, e."wert"::text
FROM "Schrittergebnis" e JOIN basis b ON b."id" = e."laufId"
WHERE ${suchbar ? Prisma.sql`e."wert"::text ILIKE ${muster}` : Prisma.sql`FALSE`}
UNION ALL
SELECT k."laufId", 3::int, 'KOMMENTAR'::text, k."rumpf"
FROM "Kommentar" k JOIN basis b ON b."id" = k."laufId"
WHERE ${suchbar ? Prisma.sql`k."rumpf" ILIKE ${muster}` : Prisma.sql`FALSE`}
)
SELECT
b."id",
b."abgelegtAm",
(SELECT count(*)::int FROM treffer t WHERE t."laufId" = b."id") AS "anzahlFundstellen",
(
SELECT json_agg(
json_build_object(
'feld', x."feld",
'roh', substr(x."fundstelle", x."beginn", ${AUSSCHNITT_LAENGE}),
'beginn', x."beginn",
'gesamt', length(x."fundstelle")
)
)
FROM (
SELECT
t."feld",
t."fundstelle",
-- strpos gibt 0, wenn die Gross-/Kleinschreibung von ILIKE und lower()
-- einmal auseinandergehen; dann beginnt der Ausschnitt eben vorn.
greatest(strpos(lower(t."fundstelle"), lower(${begriff})) - ${AUSSCHNITT_VORLAUF}, 1) AS "beginn"
FROM treffer t
WHERE t."laufId" = b."id"
ORDER BY t."rang"
LIMIT ${FUNDSTELLEN_JE_LAUF}
) x
) AS "fundstellen"
FROM basis b
${suchbar ? Prisma.sql`WHERE EXISTS (SELECT 1 FROM treffer t WHERE t."laufId" = b."id")` : Prisma.empty}
ORDER BY b."abgelegtAm" DESC, b."id" DESC
LIMIT ${ARCHIV_HOECHSTENS + 1}
`);
const gekappt = roh.length > ARCHIV_HOECHSTENS;
const gefunden = roh.slice(0, ARCHIV_HOECHSTENS);
if (gefunden.length === 0) {
return { zeilen: [], gekappt: false, sucheZuKurz };
}
/*
* Die Anzeigedaten kommen ueber Prisma nach – und die Kundenbedingung steht
* dabei **noch einmal** davor. Die Suche oben ist handgeschriebenes SQL; ein
* Fehler darin darf nicht damit enden, dass der Name eines fremden Kunden
* ausgeliefert wird. Zwei unabhaengige Sperren an derselben Grenze.
*/
const zeilen = await prisma.lauf.findMany({
where: {
AND: [
{ id: { in: gefunden.map((zeile) => zeile.id) } },
...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }]),
],
},
select: ZEILEN_AUSWAHL,
});
const nachId = new Map<string, Anzeigezeile>(zeilen.map((zeile) => [zeile.id, zeile]));
const fertig: ArchivZeile[] = [];
// Die Reihenfolge kommt aus der Suche, nicht aus dem Nachladen.
for (const treffer of gefunden) {
const zeile = nachId.get(treffer.id);
if (!zeile) continue;
const stellen = fundstellenBauen(treffer);
fertig.push({
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
// Entweder eine Person aus dem Verzeichnis oder ein Freitextname – die
// Datenbank schliesst beides zugleich aus.
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
abteilungName: zeile.abteilung?.name ?? null,
ticket: zeile.ticket,
kundeName: zeile.kunde.name,
abgelegtAm: treffer.abgelegtAm,
abgeschlossenAm: zeile.abgeschlossenAm,
archiviertAm: zeile.archiviertAm,
abgeschlossenVon: zeile.abgeschlossenVon?.name ?? null,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
fundstellen: stellen,
weitereFundstellen: Math.max(treffer.anzahlFundstellen - stellen.length, 0),
});
}
return { zeilen: fertig, gekappt, sucheZuKurz };
}
export type ArchivAuswahl = {
readonly vorlagen: readonly { readonly id: string; readonly name: string }[];
readonly mitarbeiter: readonly { readonly id: string; readonly name: string }[];
readonly abteilungen: readonly { readonly id: string; readonly name: string }[];
readonly geraete: readonly { readonly id: string; readonly name: string }[];
readonly bearbeiter: readonly { readonly id: string; readonly name: string }[];
/** Leer im Modus INTERN – dort gibt es keine Kundenebene (#008). */
readonly kunden: readonly { readonly id: string; readonly name: string }[];
/** Mindestens eine Liste ist voll; die Seite sagt es, statt still zu kappen. */
readonly gekappt: boolean;
};
/**
* Was in den Filterlisten des Archivs steht.
*
* **Archiviertes und Deaktiviertes gehoert dazu.** Das Archiv sieht nach hinten:
* ein Lauf von 2024 haengt oft an einer archivierten Vorlage, einem
* ausgemusterten Geraet und einer Person, die das Haus verlassen hat. Wuerden
* diese Eintraege fehlen, waere der Lauf ueber keinen Filter mehr erreichbar.
*
* Gekappt bei `AUSWAHL_HOECHSTENS` je Liste, wie im Start-Dialog (#013). Die
* Listen kommen aus den Stammdaten und nicht aus den Laeufen selbst: dafuer
* muessten alle Laeufe des Archivs gelesen werden, und genau das soll eine
* Auswahlliste nicht kosten. Ein Eintrag ohne Lauf im Archiv fuehrt deshalb zu
* einer leeren Trefferliste — das ist die richtige Antwort, nur keine schoene.
*/
export async function archivFilterwerte(benutzer: HandelnderBenutzer): Promise<ArchivAuswahl> {
const [sichtbar, kundenEbene] = await Promise.all([sichtbareKundenIds(benutzer), kundenEbeneSichtbar()]);
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
const [vorlagen, mitarbeiter, abteilungen, geraete, bearbeiter, kunden] = await Promise.all([
prisma.vorlage.findMany({
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
where: sichtbar === undefined ? {} : { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
prisma.mitarbeiter.findMany({
where: { AND: mandant },
orderBy: { anzeigename: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, anzeigename: true },
}),
prisma.abteilung.findMany({
where: { AND: mandant },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
prisma.geraet.findMany({
where: { AND: mandant },
orderBy: [{ hostname: "asc" }, { bezeichnung: "asc" }],
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, hostname: true, bezeichnung: true, tagCode: true },
}),
/*
* Zur Auswahl steht, wer einen Lauf dieses Kunden bearbeitet haben **kann**.
* Zwei Unterschiede zum Start-Dialog (#013), beide gewollt:
*
* - **Deaktivierte Konten sind dabei.** Ihre Laeufe stehen im Archiv, und
* wer nach „wer hat das damals gemacht“ sucht, sucht oft genau nach
* jemandem, der das Haus verlassen hat.
* - **Kein Rollenfilter.** Der Start-Dialog zeigt nur, wem sich ein Lauf
* *zuweisen* laesst; hier geht es darum, wer in der Vergangenheit einer
* war. Eine Rolle kann sich geaendert haben — die abgelegte Arbeit nicht.
*/
prisma.benutzer.findMany({
where:
mandant.length === 0
? {}
: { OR: [{ kundenbindungen: { none: {} } }, { kundenbindungen: { some: mandant[0] } }] },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
}),
kundenEbene
? // Auch die eigene Organisation: im Modus DIENSTLEISTUNG traegt sie die
// internen Laeufe des Hauses, und die stehen genauso im Archiv.
prisma.kunde.findMany({
where: sichtbar === undefined ? {} : { id: { in: [...sichtbar] } },
orderBy: { name: "asc" },
take: AUSWAHL_HOECHSTENS + 1,
select: { id: true, name: true },
})
: Promise.resolve([]),
]);
const listen = [vorlagen, mitarbeiter, abteilungen, geraete, bearbeiter, kunden];
/** Die eine Zeile ueber der Grenze diente nur dem Zaehlen. */
const zeigen = <Zeile>(liste: readonly Zeile[]): Zeile[] => liste.slice(0, AUSWAHL_HOECHSTENS);
return {
vorlagen: zeigen(vorlagen),
mitarbeiter: zeigen(mitarbeiter).map((person) => ({ id: person.id, name: person.anzeigename })),
abteilungen: zeigen(abteilungen),
geraete: zeigen(geraete).map((geraet) => {
const name = geraet.hostname ?? geraet.bezeichnung;
// Ein vorgedrucktes Tag hat keinen Namen – dann steht der Code einmal da
// und nicht zweimal (Reviewbefund zu #015).
return { id: geraet.id, name: name ? `${name} · ${geraet.tagCode}` : geraet.tagCode };
}),
bearbeiter: zeigen(bearbeiter),
kunden: zeigen(kunden),
/*
* Geholt wird eine Zeile mehr als gezeigt: sonst meldete die Oberflaeche
* „gekappt“ auch dann, wenn es **genau** 500 Eintraege gibt und keiner
* fehlt (Reviewbefund zu #015).
*/
gekappt: listen.some((liste) => liste.length > AUSWAHL_HOECHSTENS),
};
}
/**
* Ein Datum aus dem Formular, oder `undefined`.
*
* Mitternacht **lokal**, nicht in UTC: wer „ab 1. März“ eingibt, meint den
* Tagesbeginn dort, wo er sitzt. Ein vertippter Parameter filtert nicht, statt
* eine Fehlerseite zu zeigen.
*/
export function datumAusText(text: string | undefined): Date | undefined {
if (!text || !/^\d{4}-\d{2}-\d{2}$/.test(text)) return undefined;
const datum = new Date(`${text}T00:00:00`);
return Number.isNaN(datum.getTime()) ? undefined : datum;
}
+312
View File
@@ -0,0 +1,312 @@
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
/**
* Das Lesemodell der Druckansicht (#015).
*
* **Warum das kein zweites `laufZumAbarbeiten` ist** — drei Unterschiede, und
* jeder einzelne waere Grund genug:
*
* 1. **Hier entsteht kein Passwort.** `laufZumAbarbeiten` ruft
* `passwortErzeugen()` und fuellt die Befehle mit `{ art: "befehl" }`. Diese
* Abfrage loest die Vorlagentexte — Schritt-Titel und Befehl — mit
* `{ art: "druck" }` auf; dort steht statt des Passworts `PASSWORT_IM_DRUCK`.
* Ein Schalter an der bestehenden Funktion haette den Zweig, der **nie** ein
* Passwort erzeugen darf, mitten in die Funktion gelegt, die eines erzeugt —
* ein Ausdruck liegt herum und wird abgeheftet, und
* `{{generiertes_passwort}}` ist nirgends gespeichert, also auf dem Papier
* ohnehin nicht nachvollziehbar.
* 2. **Vom Mitarbeiter werden nur drei Felder geholt:** Anzeigename, UPN und
* Abteilung. Nicht „alles lesen und beim Anzeigen weglassen“ — was nicht
* geladen wird, kann auch kein spaeteres Feld in die Vorlage rutschen lassen.
* 3. **Der Ausdruck ist ein Beleg, keine Arbeitsflaeche.** Er traegt keine
* Skripte, keine Ergebnis-Kennungen zum Schreiben, keine Hilfetexte und keine
* Doku-Links — dafuer Zeitpunkte und Namen, die `LaufZeile` nicht fuehrt:
* begonnen am, abgeschlossen von wem, archiviert am und von wem.
*
* Gelesen wird wie ueberall ueber `Lauf.vorlagenVersionId`, also die Version, mit
* der der Lauf gestartet wurde (#009). Die Mandantensicht wendet diese Datei
* selbst an: ein Lauf eines fremden Kunden ist `undefined` — „gibt es nicht“ und
* „darfst du nicht“ sind dieselbe Antwort.
*/
export type DruckPerson = {
readonly name: string;
readonly upn: string | null;
readonly abteilung: string | null;
/**
* Der Name steht nur als Freitext im Lauf; im Verzeichnis gab es die Person
* beim Start noch nicht. Dann gibt es weder UPN noch Abteilung, und der
* Ausdruck sagt das, statt ein leeres Feld zu zeigen.
*/
readonly nurFreitext: boolean;
};
export type DruckKopf = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
/** Hostname, sonst Bezeichnung, sonst der Tag-Code – wie in den Listen. */
readonly geraet: string | null;
readonly tagCode: string | null;
readonly person: DruckPerson | null;
/** Die beim Start gewaehlte Abteilung – nicht die des Verzeichniseintrags. */
readonly abteilungImLauf: string | null;
readonly ticket: string | null;
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN gibt es keine Kundenebene (#008). */
readonly kundeName: string | null;
readonly faelligAm: Date | null;
readonly erstelltAm: Date;
readonly erstelltVon: string | null;
readonly gestartetAm: Date | null;
readonly zugewiesenAn: string | null;
readonly abgeschlossenAm: Date | null;
readonly abgeschlossenVon: string | null;
readonly archiviertAm: Date | null;
readonly archiviertVon: string | null;
};
export type DruckSchritt = {
readonly nummer: number;
readonly titel: string;
readonly pflicht: boolean;
readonly typ: SchrittTyp;
readonly zustand: SchrittZustand;
/** Der erfasste Wert als Text, oder `null`, wenn keiner erfasst wurde. */
readonly wert: string | null;
readonly notiz: string | null;
/** Nur bei `BEFEHL`: der Befehlstext mit eingesetzten Werten, ohne Passwort. */
readonly befehl: string | null;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
readonly kommentare: readonly KommentarZeile[];
};
export type DruckAbschnitt = {
readonly id: string;
readonly titel: string;
/** Zweistellig aus der Position, nie gespeichert (Gestaltungsregel). */
readonly nummer: string;
readonly schritte: readonly DruckSchritt[];
};
export type LaufZumDrucken = {
readonly kopf: DruckKopf;
readonly abschnitte: readonly DruckAbschnitt[];
/** Die Kommentare am Lauf selbst; die an einem Schritt stehen beim Schritt. */
readonly kommentare: readonly KommentarZeile[];
readonly anzahlSchritte: number;
/** Erledigt oder nicht zutreffend – beides ist vom Tisch. */
readonly anzahlVomTisch: number;
};
/**
* Der erfasste Wert als eine Zeile Text.
*
* `wert` ist Json, weil ein Schritt vom Typ `AKTION` (Phase 4) dort auch ein
* Protokoll oder erzeugte Kennungen ablegen soll (CLAUDE.md, „Erweiterbarkeit“).
* Deshalb faellt hier nichts weg, was kein String ist: ein Objekt wird als JSON
* gedruckt. Lesbar genug fuer Papier — und in jedem Fall besser als das
* `[object Object]`, das ein `String(wert)` daraus machte.
*/
function wertAlsText(wert: unknown): string | null {
if (wert === null || wert === undefined) return null;
if (typeof wert === "string") return wert.trim() === "" ? null : wert;
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
return JSON.stringify(wert);
}
function befehlAusKonfig(konfig: unknown): string {
if (typeof konfig !== "object" || konfig === null) return "";
const befehl = (konfig as Record<string, unknown>)["befehl"];
return typeof befehl === "string" ? befehl : "";
}
/**
* Der ganze Lauf als Beleg. `undefined` heisst „gibt es nicht oder darfst du
* nicht sehen“.
*/
export async function laufZumDrucken(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZumDrucken | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...mandant] },
select: {
id: true,
titel: true,
status: true,
ticket: true,
faelligAm: true,
erstelltAm: true,
gestartetAm: true,
abgeschlossenAm: true,
archiviertAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
// Genau drei Felder. Kein Geburtsdatum, kein `dn`, kein `sam`, keine
// Kennung aus dem Verzeichnis: was nicht geladen wird, steht auch auf
// keinem Blatt, das irgendwo liegen bleibt.
mitarbeiter: { select: { anzeigename: true, upn: true, abteilung: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
abteilung: { select: { name: true } },
erstelltVon: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
abgeschlossenVon: { select: { name: true } },
archiviertVon: { select: { name: true } },
vorlagenVersion: {
select: {
version: true,
vorlage: { select: { name: true } },
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
orderBy: { position: "asc" },
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
},
},
},
},
},
ergebnisse: {
select: {
id: true,
vorlagenSchrittId: true,
zustand: true,
wert: true,
notiz: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
},
},
},
});
if (!lauf) return undefined;
const kundenEbene = await kundenEbeneSichtbar();
/*
* Die Platzhalterwerte **ohne** Technikernamen und mit dem Anlegezeitpunkt als
* „heute“:
*
* - `{{techniker}}` bliebe sonst der Name dessen, der gerade druckt — und das
* ist nicht unbedingt der, der gearbeitet hat. Wer welchen Schritt erledigt
* hat, steht am Schritt und muss nicht in einem Titel behauptet werden.
* - `{{datum}}` waere sonst der Tag des Ausdrucks. Ein Lauf von vorletztem
* Jahr trueg dann im Schritt-Titel das heutige Datum, und zwei Ausdrucke
* desselben Laufs waeren verschieden.
*/
const werte = await platzhalterWerteDesLaufs({ laufId, technikerName: null, jetzt: lauf.erstelltAm });
// Die Mandantensicht steckt schon in `kommentareDesLaufs`; ein zweites
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
// kann.
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
let nummer = 0;
let anzahlSchritte = 0;
let anzahlVomTisch = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
anzahlSchritte += 1;
const ergebnis = ergebnisse.get(schritt.id);
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
if (zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND) {
anzahlVomTisch += 1;
}
const roherBefehl = schritt.typ === SchrittTyp.BEFEHL ? befehlAusKonfig(schritt.konfig) : "";
return {
nummer,
titel: platzhalterErsetzen(schritt.titel, werte, { art: "druck" }).text,
pflicht: schritt.pflicht,
typ: schritt.typ,
zustand,
wert: wertAlsText(ergebnis?.wert),
notiz: ergebnis?.notiz ?? null,
/*
* Der Befehl **unmaskiert**: `{ art: "druck" }` maskiert nicht, und das
* ist hier richtig. Der Beleg haelt fest, was zu tun war; Maskierzeichen
* waeren auf Papier Rauschen, und kopieren kann man von dort ohnehin
* nichts. Wer den Befehl **ausfuehren** will, nimmt die Laufseite — dort
* ist er fuer die gewaehlte Shell maskiert (#012). Das Passwort steht
* hier als `PASSWORT_IM_DRUCK`; erzeugt wird keines.
*/
befehl: roherBefehl === "" ? null : platzhalterErsetzen(roherBefehl, werte, { art: "druck" }).text,
/*
* „Wer wann“ nur, wenn der Schritt nicht mehr offen ist: die
* Ergebniszeilen entstehen beim Anlegen des Laufs, `aktualisiertAm`
* traegt also auch an einem unberuehrten Schritt einen Zeitstempel, der
* nichts sagt. Am Zustand und nicht am Urheber, weil `aktualisiertVonId`
* leer bleiben darf — dieselbe Regel wie in `laufZumAbarbeiten` (#013).
*/
bearbeitetVon:
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
};
});
return {
id: abschnitt.id,
titel: abschnitt.titel,
nummer: String(index + 1).padStart(2, "0"),
schritte,
};
});
const person: DruckPerson | null = lauf.mitarbeiter
? {
name: lauf.mitarbeiter.anzeigename,
upn: lauf.mitarbeiter.upn,
abteilung: lauf.mitarbeiter.abteilung,
nurFreitext: false,
}
: lauf.mitarbeiterName
? { name: lauf.mitarbeiterName, upn: null, abteilung: null, nurFreitext: true }
: null;
return {
kopf: {
id: lauf.id,
titel: lauf.titel,
status: lauf.status,
vorlagenName: lauf.vorlagenVersion.vorlage.name,
version: lauf.vorlagenVersion.version,
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
tagCode: lauf.geraet?.tagCode ?? null,
person,
abteilungImLauf: lauf.abteilung?.name ?? null,
ticket: lauf.ticket,
kundeName: kundenEbene ? lauf.kunde.name : null,
faelligAm: lauf.faelligAm,
erstelltAm: lauf.erstelltAm,
erstelltVon: lauf.erstelltVon?.name ?? null,
gestartetAm: lauf.gestartetAm,
zugewiesenAn: lauf.zugewiesenAn?.name ?? null,
abgeschlossenAm: lauf.abgeschlossenAm,
abgeschlossenVon: lauf.abgeschlossenVon?.name ?? null,
archiviertAm: lauf.archiviertAm,
archiviertVon: lauf.archiviertVon?.name ?? null,
},
abschnitte,
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === null),
anzahlSchritte,
anzahlVomTisch,
};
}
+234
View File
@@ -0,0 +1,234 @@
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { tagesbeginn } from "@/lib/fristen";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Die Uebersicht: was heute ansteht (#015).
*
* Das ist die Seite, die ein Techniker morgens aufschlaegt — und die einzige,
* die ihn **anspricht** statt nur Daten zu zeigen. Sie beantwortet vier Fragen,
* in dieser Reihenfolge:
*
* 1. Was ist ueberfaellig? (Das Einzige, was sofort etwas bedeutet.)
* 2. Was liegt bei mir?
* 3. Woran war ich zuletzt?
* 4. Was faengt man haeufig an?
*
* Alle vier Abfragen laufen nebeneinander und tragen die Mandantensicht selbst.
* Sie sind bewusst **gekappt**: eine Uebersicht, die alles zeigt, ist keine.
*/
/** So viele Zeilen zeigt jeder Block hoechstens. */
export const UEBERSICHT_JE_BLOCK = 5;
export type UebersichtsLauf = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
readonly faelligAm: Date | null;
readonly zugewiesenAn: string | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
/** Wann zuletzt an diesem Lauf etwas geschah – fuer „zuletzt bearbeitet“. */
readonly zuletztAm: Date;
};
export type Uebersicht = {
readonly ueberfaellig: readonly UebersichtsLauf[];
readonly meine: readonly UebersichtsLauf[];
readonly zuletzt: readonly UebersichtsLauf[];
/** Mehr, als der Block zeigt? Dann fuehrt die Seite in die Liste. */
readonly mehrUeberfaellig: number;
readonly mehrMeine: number;
};
const ZEILE = {
id: true,
titel: true,
status: true,
faelligAm: true,
geaendertAm: true,
zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
mitarbeiterName: true,
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILE }>;
function zeileBauen(zeile: Rohzeile): UebersichtsLauf {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
faelligAm: zeile.faelligAm,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
anzahlSchritte: zeile.ergebnisse.length,
// „Vom Tisch“ heisst erledigt **oder** nicht zutreffend – wie ueberall sonst.
anzahlErledigt: zeile.ergebnisse.filter(
(ergebnis) =>
ergebnis.zustand === SchrittZustand.ERLEDIGT || ergebnis.zustand === SchrittZustand.NICHT_ZUTREFFEND,
).length,
zuletztAm: zeile.geaendertAm,
};
}
/**
* Laufend heisst: `OFFEN` oder `IN_ARBEIT`.
*
* Ein abgeschlossener Lauf kann nicht ueberfaellig sein — er ist fertig, auch
* wenn das Datum verstrichen ist. Ein archivierter erst recht nicht.
*/
const LAEUFT: Prisma.LaufWhereInput = { status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] } };
async function mandantenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.LaufWhereInput[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
/**
* Alles, was die Uebersicht zeigt – in einem Aufruf.
*
* `jetzt` kommt von aussen, damit „ueberfaellig“ pruefbar bleibt: eine Grenze,
* die sich aus `new Date()` im Inneren ergibt, laesst sich nur mit Warten
* testen.
*/
export async function uebersicht(
benutzer: HandelnderBenutzer,
jetzt: Date = new Date(),
): Promise<Uebersicht> {
const mandant = await mandantenBedingung(benutzer);
/*
* **Vor heute**, nicht vor jetzt. Ein Lauf, der heute faellig ist, soll heute
* erledigt werden und ist kein Rueckstand; verglichen mit dem Augenblick
* stuende er hierzulande ab zwei Uhr morgens in Warnfarbe, weil das
* Datumsfeld Mitternacht UTC speichert (Reviewbefund zu #015). Die Regel
* steht in `src/lib/fristen.ts` — Lesemodell und Anzeige benutzen dieselbe.
*/
const ueberfaelligWo: Prisma.LaufWhereInput = {
AND: [LAEUFT, { faelligAm: { lt: tagesbeginn(jetzt) } }, ...mandant],
};
const meineWo: Prisma.LaufWhereInput = {
AND: [LAEUFT, { zugewiesenAnId: benutzer.id }, ...mandant],
};
const [ueberfaellig, meine, zuletzt, mehrUeberfaellig, mehrMeine] = await Promise.all([
prisma.lauf.findMany({
where: ueberfaelligWo,
// Das laengst Faellige zuerst: je aelter, desto dringender.
orderBy: { faelligAm: "asc" },
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
prisma.lauf.findMany({
where: meineWo,
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { geaendertAm: "desc" }],
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
/*
* „Zuletzt bearbeitet“ ist bewusst **nicht** auf die eigene Person
* eingeschraenkt: ein Team teilt sich die Arbeit, und was der Kollege
* gestern angefasst hat, ist heute oft genau die Frage. Archivierte bleiben
* draussen — die stehen im Archiv.
*/
prisma.lauf.findMany({
where: { AND: [{ status: { not: LaufStatus.ARCHIVIERT } }, ...mandant] },
orderBy: { geaendertAm: "desc" },
take: UEBERSICHT_JE_BLOCK,
select: ZEILE,
}),
prisma.lauf.count({ where: ueberfaelligWo }),
prisma.lauf.count({ where: meineWo }),
]);
return {
ueberfaellig: ueberfaellig.map(zeileBauen),
meine: meine.map(zeileBauen),
zuletzt: zuletzt.map(zeileBauen),
mehrUeberfaellig: Math.max(0, mehrUeberfaellig - ueberfaellig.length),
mehrMeine: Math.max(0, mehrMeine - meine.length),
};
}
export type HaeufigeVorlage = {
readonly versionId: string;
readonly name: string;
readonly kategorie: string | null;
readonly version: number;
/** Wie oft daraus schon ein Lauf entstand – die Reihenfolge des Schnellstarts. */
readonly anzahlLaeufe: number;
};
/**
* Die Vorlagen, aus denen hier am haeufigsten Laeufe entstehen (#015).
*
* Gezaehlt wird ueber **alle** Versionen einer Vorlage, gestartet wird die
* zuletzt veroeffentlichte: „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in
* v3 oder v7 steht. Eine Rangfolge je Version haette nach jedem
* Veroeffentlichen wieder bei null angefangen.
*/
export async function haeufigeVorlagen(
benutzer: HandelnderBenutzer,
anzahl = 4,
): Promise<readonly HaeufigeVorlage[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
/*
* Gezaehlt wird **nur, was der Benutzer sehen darf**. Eine Vorlage ohne
* Kundenbezug gilt fuer alle (#009); ihr Zaehler sammelte sonst die Laeufe
* saemtlicher Kunden, und „12× gestartet“ waere eine Auskunft ueber fremde
* Betriebe — dazu noch eine, die die Reihenfolge des Schnellstarts nach
* fremder Arbeit sortiert (Reviewbefund zu #015).
*/
const laeufeBedingung = sichtbar === undefined ? {} : { where: { kundeId: { in: [...sichtbar] } } };
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
],
},
select: {
name: true,
kategorie: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
select: {
id: true,
version: true,
_count: { select: { laeufe: laeufeBedingung } },
},
},
},
});
return vorlagen
.flatMap((vorlage) => {
const neueste = vorlage.versionen[0];
// Ohne veroeffentlichte Version laesst sich nichts starten (#009).
if (!neueste) return [];
return [
{
versionId: neueste.id,
name: vorlage.name,
kategorie: vorlage.kategorie,
version: neueste.version,
anzahlLaeufe: vorlage.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
},
];
})
.sort((a, b) => b.anzahlLaeufe - a.anzahlLaeufe || a.name.localeCompare(b.name, "de"))
.slice(0, anzahl);
}