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;
}