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 { 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(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(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 { 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 = (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; }