Files
Laufzettel/src/server/queries/archiv.ts
T
florian.wildeandClaude Opus 5 ab5777a57e
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(archiv): Uebersicht, Volltextsuche und Druckansicht
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>
2026-09-25 20:59:38 +00:00

549 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}