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>
This commit was merged in pull request #29.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user