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;
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
|
||||
import { kundenEbeneSichtbar, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { kommentareDesLaufs, type KommentarZeile } from "@/server/queries/laeufe";
|
||||
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
|
||||
|
||||
/**
|
||||
* Das Lesemodell der Druckansicht (#015).
|
||||
*
|
||||
* **Warum das kein zweites `laufZumAbarbeiten` ist** — drei Unterschiede, und
|
||||
* jeder einzelne waere Grund genug:
|
||||
*
|
||||
* 1. **Hier entsteht kein Passwort.** `laufZumAbarbeiten` ruft
|
||||
* `passwortErzeugen()` und fuellt die Befehle mit `{ art: "befehl" }`. Diese
|
||||
* Abfrage loest die Vorlagentexte — Schritt-Titel und Befehl — mit
|
||||
* `{ art: "druck" }` auf; dort steht statt des Passworts `PASSWORT_IM_DRUCK`.
|
||||
* Ein Schalter an der bestehenden Funktion haette den Zweig, der **nie** ein
|
||||
* Passwort erzeugen darf, mitten in die Funktion gelegt, die eines erzeugt —
|
||||
* ein Ausdruck liegt herum und wird abgeheftet, und
|
||||
* `{{generiertes_passwort}}` ist nirgends gespeichert, also auf dem Papier
|
||||
* ohnehin nicht nachvollziehbar.
|
||||
* 2. **Vom Mitarbeiter werden nur drei Felder geholt:** Anzeigename, UPN und
|
||||
* Abteilung. Nicht „alles lesen und beim Anzeigen weglassen“ — was nicht
|
||||
* geladen wird, kann auch kein spaeteres Feld in die Vorlage rutschen lassen.
|
||||
* 3. **Der Ausdruck ist ein Beleg, keine Arbeitsflaeche.** Er traegt keine
|
||||
* Skripte, keine Ergebnis-Kennungen zum Schreiben, keine Hilfetexte und keine
|
||||
* Doku-Links — dafuer Zeitpunkte und Namen, die `LaufZeile` nicht fuehrt:
|
||||
* begonnen am, abgeschlossen von wem, archiviert am und von wem.
|
||||
*
|
||||
* Gelesen wird wie ueberall ueber `Lauf.vorlagenVersionId`, also die Version, mit
|
||||
* der der Lauf gestartet wurde (#009). Die Mandantensicht wendet diese Datei
|
||||
* selbst an: ein Lauf eines fremden Kunden ist `undefined` — „gibt es nicht“ und
|
||||
* „darfst du nicht“ sind dieselbe Antwort.
|
||||
*/
|
||||
|
||||
export type DruckPerson = {
|
||||
readonly name: string;
|
||||
readonly upn: string | null;
|
||||
readonly abteilung: string | null;
|
||||
/**
|
||||
* Der Name steht nur als Freitext im Lauf; im Verzeichnis gab es die Person
|
||||
* beim Start noch nicht. Dann gibt es weder UPN noch Abteilung, und der
|
||||
* Ausdruck sagt das, statt ein leeres Feld zu zeigen.
|
||||
*/
|
||||
readonly nurFreitext: boolean;
|
||||
};
|
||||
|
||||
export type DruckKopf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly vorlagenName: string;
|
||||
readonly version: number;
|
||||
/** Hostname, sonst Bezeichnung, sonst der Tag-Code – wie in den Listen. */
|
||||
readonly geraet: string | null;
|
||||
readonly tagCode: string | null;
|
||||
readonly person: DruckPerson | null;
|
||||
/** Die beim Start gewaehlte Abteilung – nicht die des Verzeichniseintrags. */
|
||||
readonly abteilungImLauf: string | null;
|
||||
readonly ticket: string | null;
|
||||
/** Nur im Modus DIENSTLEISTUNG gefuellt; im Modus INTERN gibt es keine Kundenebene (#008). */
|
||||
readonly kundeName: string | null;
|
||||
readonly faelligAm: Date | null;
|
||||
readonly erstelltAm: Date;
|
||||
readonly erstelltVon: string | null;
|
||||
readonly gestartetAm: Date | null;
|
||||
readonly zugewiesenAn: string | null;
|
||||
readonly abgeschlossenAm: Date | null;
|
||||
readonly abgeschlossenVon: string | null;
|
||||
readonly archiviertAm: Date | null;
|
||||
readonly archiviertVon: string | null;
|
||||
};
|
||||
|
||||
export type DruckSchritt = {
|
||||
readonly nummer: number;
|
||||
readonly titel: string;
|
||||
readonly pflicht: boolean;
|
||||
readonly typ: SchrittTyp;
|
||||
readonly zustand: SchrittZustand;
|
||||
/** Der erfasste Wert als Text, oder `null`, wenn keiner erfasst wurde. */
|
||||
readonly wert: string | null;
|
||||
readonly notiz: string | null;
|
||||
/** Nur bei `BEFEHL`: der Befehlstext mit eingesetzten Werten, ohne Passwort. */
|
||||
readonly befehl: string | null;
|
||||
readonly bearbeitetVon: string | null;
|
||||
readonly bearbeitetAm: Date | null;
|
||||
readonly kommentare: readonly KommentarZeile[];
|
||||
};
|
||||
|
||||
export type DruckAbschnitt = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
/** Zweistellig aus der Position, nie gespeichert (Gestaltungsregel). */
|
||||
readonly nummer: string;
|
||||
readonly schritte: readonly DruckSchritt[];
|
||||
};
|
||||
|
||||
export type LaufZumDrucken = {
|
||||
readonly kopf: DruckKopf;
|
||||
readonly abschnitte: readonly DruckAbschnitt[];
|
||||
/** Die Kommentare am Lauf selbst; die an einem Schritt stehen beim Schritt. */
|
||||
readonly kommentare: readonly KommentarZeile[];
|
||||
readonly anzahlSchritte: number;
|
||||
/** Erledigt oder nicht zutreffend – beides ist vom Tisch. */
|
||||
readonly anzahlVomTisch: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Der erfasste Wert als eine Zeile Text.
|
||||
*
|
||||
* `wert` ist Json, weil ein Schritt vom Typ `AKTION` (Phase 4) dort auch ein
|
||||
* Protokoll oder erzeugte Kennungen ablegen soll (CLAUDE.md, „Erweiterbarkeit“).
|
||||
* Deshalb faellt hier nichts weg, was kein String ist: ein Objekt wird als JSON
|
||||
* gedruckt. Lesbar genug fuer Papier — und in jedem Fall besser als das
|
||||
* `[object Object]`, das ein `String(wert)` daraus machte.
|
||||
*/
|
||||
function wertAlsText(wert: unknown): string | null {
|
||||
if (wert === null || wert === undefined) return null;
|
||||
if (typeof wert === "string") return wert.trim() === "" ? null : wert;
|
||||
if (typeof wert === "number" || typeof wert === "boolean") return String(wert);
|
||||
return JSON.stringify(wert);
|
||||
}
|
||||
|
||||
function befehlAusKonfig(konfig: unknown): string {
|
||||
if (typeof konfig !== "object" || konfig === null) return "";
|
||||
const befehl = (konfig as Record<string, unknown>)["befehl"];
|
||||
return typeof befehl === "string" ? befehl : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Der ganze Lauf als Beleg. `undefined` heisst „gibt es nicht oder darfst du
|
||||
* nicht sehen“.
|
||||
*/
|
||||
export async function laufZumDrucken(
|
||||
laufId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<LaufZumDrucken | undefined> {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
const mandant = sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
|
||||
|
||||
const lauf = await prisma.lauf.findFirst({
|
||||
where: { AND: [{ id: laufId }, ...mandant] },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
status: true,
|
||||
ticket: true,
|
||||
faelligAm: true,
|
||||
erstelltAm: true,
|
||||
gestartetAm: true,
|
||||
abgeschlossenAm: true,
|
||||
archiviertAm: true,
|
||||
mitarbeiterName: true,
|
||||
kunde: { select: { name: true } },
|
||||
// Genau drei Felder. Kein Geburtsdatum, kein `dn`, kein `sam`, keine
|
||||
// Kennung aus dem Verzeichnis: was nicht geladen wird, steht auch auf
|
||||
// keinem Blatt, das irgendwo liegen bleibt.
|
||||
mitarbeiter: { select: { anzeigename: true, upn: true, abteilung: true } },
|
||||
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
||||
abteilung: { select: { name: true } },
|
||||
erstelltVon: { select: { name: true } },
|
||||
zugewiesenAn: { select: { name: true } },
|
||||
abgeschlossenVon: { select: { name: true } },
|
||||
archiviertVon: { select: { name: true } },
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
version: true,
|
||||
vorlage: { select: { name: true } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
titel: true,
|
||||
schritte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: { id: true, titel: true, typ: true, pflicht: true, konfig: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
ergebnisse: {
|
||||
select: {
|
||||
id: true,
|
||||
vorlagenSchrittId: true,
|
||||
zustand: true,
|
||||
wert: true,
|
||||
notiz: true,
|
||||
aktualisiertAm: true,
|
||||
aktualisiertVon: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!lauf) return undefined;
|
||||
|
||||
const kundenEbene = await kundenEbeneSichtbar();
|
||||
/*
|
||||
* Die Platzhalterwerte **ohne** Technikernamen und mit dem Anlegezeitpunkt als
|
||||
* „heute“:
|
||||
*
|
||||
* - `{{techniker}}` bliebe sonst der Name dessen, der gerade druckt — und das
|
||||
* ist nicht unbedingt der, der gearbeitet hat. Wer welchen Schritt erledigt
|
||||
* hat, steht am Schritt und muss nicht in einem Titel behauptet werden.
|
||||
* - `{{datum}}` waere sonst der Tag des Ausdrucks. Ein Lauf von vorletztem
|
||||
* Jahr trueg dann im Schritt-Titel das heutige Datum, und zwei Ausdrucke
|
||||
* desselben Laufs waeren verschieden.
|
||||
*/
|
||||
const werte = await platzhalterWerteDesLaufs({ laufId, technikerName: null, jetzt: lauf.erstelltAm });
|
||||
|
||||
// Die Mandantensicht steckt schon in `kommentareDesLaufs`; ein zweites
|
||||
// Lesemodell fuer dieselben Zeilen waere eine Stelle mehr, die sie vergessen
|
||||
// kann.
|
||||
const alleKommentare = await kommentareDesLaufs(laufId, benutzer);
|
||||
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
|
||||
|
||||
let nummer = 0;
|
||||
let anzahlSchritte = 0;
|
||||
let anzahlVomTisch = 0;
|
||||
|
||||
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
|
||||
const schritte = abschnitt.schritte.map((schritt) => {
|
||||
nummer += 1;
|
||||
anzahlSchritte += 1;
|
||||
const ergebnis = ergebnisse.get(schritt.id);
|
||||
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
|
||||
if (zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND) {
|
||||
anzahlVomTisch += 1;
|
||||
}
|
||||
|
||||
const roherBefehl = schritt.typ === SchrittTyp.BEFEHL ? befehlAusKonfig(schritt.konfig) : "";
|
||||
|
||||
return {
|
||||
nummer,
|
||||
titel: platzhalterErsetzen(schritt.titel, werte, { art: "druck" }).text,
|
||||
pflicht: schritt.pflicht,
|
||||
typ: schritt.typ,
|
||||
zustand,
|
||||
wert: wertAlsText(ergebnis?.wert),
|
||||
notiz: ergebnis?.notiz ?? null,
|
||||
/*
|
||||
* Der Befehl **unmaskiert**: `{ art: "druck" }` maskiert nicht, und das
|
||||
* ist hier richtig. Der Beleg haelt fest, was zu tun war; Maskierzeichen
|
||||
* waeren auf Papier Rauschen, und kopieren kann man von dort ohnehin
|
||||
* nichts. Wer den Befehl **ausfuehren** will, nimmt die Laufseite — dort
|
||||
* ist er fuer die gewaehlte Shell maskiert (#012). Das Passwort steht
|
||||
* hier als `PASSWORT_IM_DRUCK`; erzeugt wird keines.
|
||||
*/
|
||||
befehl: roherBefehl === "" ? null : platzhalterErsetzen(roherBefehl, werte, { art: "druck" }).text,
|
||||
/*
|
||||
* „Wer wann“ nur, wenn der Schritt nicht mehr offen ist: die
|
||||
* Ergebniszeilen entstehen beim Anlegen des Laufs, `aktualisiertAm`
|
||||
* traegt also auch an einem unberuehrten Schritt einen Zeitstempel, der
|
||||
* nichts sagt. Am Zustand und nicht am Urheber, weil `aktualisiertVonId`
|
||||
* leer bleiben darf — dieselbe Regel wie in `laufZumAbarbeiten` (#013).
|
||||
*/
|
||||
bearbeitetVon:
|
||||
ergebnis?.aktualisiertVon?.name ?? (zustand === SchrittZustand.OFFEN ? null : "System"),
|
||||
bearbeitetAm: zustand === SchrittZustand.OFFEN ? null : (ergebnis?.aktualisiertAm ?? null),
|
||||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === ergebnis?.id),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
id: abschnitt.id,
|
||||
titel: abschnitt.titel,
|
||||
nummer: String(index + 1).padStart(2, "0"),
|
||||
schritte,
|
||||
};
|
||||
});
|
||||
|
||||
const person: DruckPerson | null = lauf.mitarbeiter
|
||||
? {
|
||||
name: lauf.mitarbeiter.anzeigename,
|
||||
upn: lauf.mitarbeiter.upn,
|
||||
abteilung: lauf.mitarbeiter.abteilung,
|
||||
nurFreitext: false,
|
||||
}
|
||||
: lauf.mitarbeiterName
|
||||
? { name: lauf.mitarbeiterName, upn: null, abteilung: null, nurFreitext: true }
|
||||
: null;
|
||||
|
||||
return {
|
||||
kopf: {
|
||||
id: lauf.id,
|
||||
titel: lauf.titel,
|
||||
status: lauf.status,
|
||||
vorlagenName: lauf.vorlagenVersion.vorlage.name,
|
||||
version: lauf.vorlagenVersion.version,
|
||||
geraet: lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung ?? lauf.geraet?.tagCode ?? null,
|
||||
tagCode: lauf.geraet?.tagCode ?? null,
|
||||
person,
|
||||
abteilungImLauf: lauf.abteilung?.name ?? null,
|
||||
ticket: lauf.ticket,
|
||||
kundeName: kundenEbene ? lauf.kunde.name : null,
|
||||
faelligAm: lauf.faelligAm,
|
||||
erstelltAm: lauf.erstelltAm,
|
||||
erstelltVon: lauf.erstelltVon?.name ?? null,
|
||||
gestartetAm: lauf.gestartetAm,
|
||||
zugewiesenAn: lauf.zugewiesenAn?.name ?? null,
|
||||
abgeschlossenAm: lauf.abgeschlossenAm,
|
||||
abgeschlossenVon: lauf.abgeschlossenVon?.name ?? null,
|
||||
archiviertAm: lauf.archiviertAm,
|
||||
archiviertVon: lauf.archiviertVon?.name ?? null,
|
||||
},
|
||||
abschnitte,
|
||||
kommentare: alleKommentare.filter((kommentar) => kommentar.schrittergebnisId === null),
|
||||
anzahlSchritte,
|
||||
anzahlVomTisch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { LaufStatus, SchrittZustand } from "@/generated/prisma/enums";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { tagesbeginn } from "@/lib/fristen";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Die Uebersicht: was heute ansteht (#015).
|
||||
*
|
||||
* Das ist die Seite, die ein Techniker morgens aufschlaegt — und die einzige,
|
||||
* die ihn **anspricht** statt nur Daten zu zeigen. Sie beantwortet vier Fragen,
|
||||
* in dieser Reihenfolge:
|
||||
*
|
||||
* 1. Was ist ueberfaellig? (Das Einzige, was sofort etwas bedeutet.)
|
||||
* 2. Was liegt bei mir?
|
||||
* 3. Woran war ich zuletzt?
|
||||
* 4. Was faengt man haeufig an?
|
||||
*
|
||||
* Alle vier Abfragen laufen nebeneinander und tragen die Mandantensicht selbst.
|
||||
* Sie sind bewusst **gekappt**: eine Uebersicht, die alles zeigt, ist keine.
|
||||
*/
|
||||
|
||||
/** So viele Zeilen zeigt jeder Block hoechstens. */
|
||||
export const UEBERSICHT_JE_BLOCK = 5;
|
||||
|
||||
export type UebersichtsLauf = {
|
||||
readonly id: string;
|
||||
readonly titel: string;
|
||||
readonly status: LaufStatus;
|
||||
readonly vorlagenName: string;
|
||||
readonly geraetName: string | null;
|
||||
readonly mitarbeiterName: string | null;
|
||||
readonly faelligAm: Date | null;
|
||||
readonly zugewiesenAn: string | null;
|
||||
readonly anzahlSchritte: number;
|
||||
readonly anzahlErledigt: number;
|
||||
/** Wann zuletzt an diesem Lauf etwas geschah – fuer „zuletzt bearbeitet“. */
|
||||
readonly zuletztAm: Date;
|
||||
};
|
||||
|
||||
export type Uebersicht = {
|
||||
readonly ueberfaellig: readonly UebersichtsLauf[];
|
||||
readonly meine: readonly UebersichtsLauf[];
|
||||
readonly zuletzt: readonly UebersichtsLauf[];
|
||||
/** Mehr, als der Block zeigt? Dann fuehrt die Seite in die Liste. */
|
||||
readonly mehrUeberfaellig: number;
|
||||
readonly mehrMeine: number;
|
||||
};
|
||||
|
||||
const ZEILE = {
|
||||
id: true,
|
||||
titel: true,
|
||||
status: true,
|
||||
faelligAm: true,
|
||||
geaendertAm: true,
|
||||
zugewiesenAn: { select: { name: true } },
|
||||
mitarbeiter: { select: { anzeigename: true } },
|
||||
mitarbeiterName: true,
|
||||
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
|
||||
vorlagenVersion: { select: { vorlage: { select: { name: true } } } },
|
||||
ergebnisse: { select: { zustand: true } },
|
||||
} satisfies Prisma.LaufSelect;
|
||||
|
||||
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILE }>;
|
||||
|
||||
function zeileBauen(zeile: Rohzeile): UebersichtsLauf {
|
||||
return {
|
||||
id: zeile.id,
|
||||
titel: zeile.titel,
|
||||
status: zeile.status,
|
||||
vorlagenName: zeile.vorlagenVersion.vorlage.name,
|
||||
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
|
||||
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
|
||||
faelligAm: zeile.faelligAm,
|
||||
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
|
||||
anzahlSchritte: zeile.ergebnisse.length,
|
||||
// „Vom Tisch“ heisst erledigt **oder** nicht zutreffend – wie ueberall sonst.
|
||||
anzahlErledigt: zeile.ergebnisse.filter(
|
||||
(ergebnis) =>
|
||||
ergebnis.zustand === SchrittZustand.ERLEDIGT || ergebnis.zustand === SchrittZustand.NICHT_ZUTREFFEND,
|
||||
).length,
|
||||
zuletztAm: zeile.geaendertAm,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Laufend heisst: `OFFEN` oder `IN_ARBEIT`.
|
||||
*
|
||||
* Ein abgeschlossener Lauf kann nicht ueberfaellig sein — er ist fertig, auch
|
||||
* wenn das Datum verstrichen ist. Ein archivierter erst recht nicht.
|
||||
*/
|
||||
const LAEUFT: Prisma.LaufWhereInput = { status: { in: [LaufStatus.OFFEN, LaufStatus.IN_ARBEIT] } };
|
||||
|
||||
async function mandantenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.LaufWhereInput[]> {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Alles, was die Uebersicht zeigt – in einem Aufruf.
|
||||
*
|
||||
* `jetzt` kommt von aussen, damit „ueberfaellig“ pruefbar bleibt: eine Grenze,
|
||||
* die sich aus `new Date()` im Inneren ergibt, laesst sich nur mit Warten
|
||||
* testen.
|
||||
*/
|
||||
export async function uebersicht(
|
||||
benutzer: HandelnderBenutzer,
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<Uebersicht> {
|
||||
const mandant = await mandantenBedingung(benutzer);
|
||||
/*
|
||||
* **Vor heute**, nicht vor jetzt. Ein Lauf, der heute faellig ist, soll heute
|
||||
* erledigt werden und ist kein Rueckstand; verglichen mit dem Augenblick
|
||||
* stuende er hierzulande ab zwei Uhr morgens in Warnfarbe, weil das
|
||||
* Datumsfeld Mitternacht UTC speichert (Reviewbefund zu #015). Die Regel
|
||||
* steht in `src/lib/fristen.ts` — Lesemodell und Anzeige benutzen dieselbe.
|
||||
*/
|
||||
const ueberfaelligWo: Prisma.LaufWhereInput = {
|
||||
AND: [LAEUFT, { faelligAm: { lt: tagesbeginn(jetzt) } }, ...mandant],
|
||||
};
|
||||
const meineWo: Prisma.LaufWhereInput = {
|
||||
AND: [LAEUFT, { zugewiesenAnId: benutzer.id }, ...mandant],
|
||||
};
|
||||
|
||||
const [ueberfaellig, meine, zuletzt, mehrUeberfaellig, mehrMeine] = await Promise.all([
|
||||
prisma.lauf.findMany({
|
||||
where: ueberfaelligWo,
|
||||
// Das laengst Faellige zuerst: je aelter, desto dringender.
|
||||
orderBy: { faelligAm: "asc" },
|
||||
take: UEBERSICHT_JE_BLOCK,
|
||||
select: ZEILE,
|
||||
}),
|
||||
prisma.lauf.findMany({
|
||||
where: meineWo,
|
||||
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { geaendertAm: "desc" }],
|
||||
take: UEBERSICHT_JE_BLOCK,
|
||||
select: ZEILE,
|
||||
}),
|
||||
/*
|
||||
* „Zuletzt bearbeitet“ ist bewusst **nicht** auf die eigene Person
|
||||
* eingeschraenkt: ein Team teilt sich die Arbeit, und was der Kollege
|
||||
* gestern angefasst hat, ist heute oft genau die Frage. Archivierte bleiben
|
||||
* draussen — die stehen im Archiv.
|
||||
*/
|
||||
prisma.lauf.findMany({
|
||||
where: { AND: [{ status: { not: LaufStatus.ARCHIVIERT } }, ...mandant] },
|
||||
orderBy: { geaendertAm: "desc" },
|
||||
take: UEBERSICHT_JE_BLOCK,
|
||||
select: ZEILE,
|
||||
}),
|
||||
prisma.lauf.count({ where: ueberfaelligWo }),
|
||||
prisma.lauf.count({ where: meineWo }),
|
||||
]);
|
||||
|
||||
return {
|
||||
ueberfaellig: ueberfaellig.map(zeileBauen),
|
||||
meine: meine.map(zeileBauen),
|
||||
zuletzt: zuletzt.map(zeileBauen),
|
||||
mehrUeberfaellig: Math.max(0, mehrUeberfaellig - ueberfaellig.length),
|
||||
mehrMeine: Math.max(0, mehrMeine - meine.length),
|
||||
};
|
||||
}
|
||||
|
||||
export type HaeufigeVorlage = {
|
||||
readonly versionId: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: string | null;
|
||||
readonly version: number;
|
||||
/** Wie oft daraus schon ein Lauf entstand – die Reihenfolge des Schnellstarts. */
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Vorlagen, aus denen hier am haeufigsten Laeufe entstehen (#015).
|
||||
*
|
||||
* Gezaehlt wird ueber **alle** Versionen einer Vorlage, gestartet wird die
|
||||
* zuletzt veroeffentlichte: „PC-Neuinstallation“ ist dieselbe Arbeit, ob sie in
|
||||
* v3 oder v7 steht. Eine Rangfolge je Version haette nach jedem
|
||||
* Veroeffentlichen wieder bei null angefangen.
|
||||
*/
|
||||
export async function haeufigeVorlagen(
|
||||
benutzer: HandelnderBenutzer,
|
||||
anzahl = 4,
|
||||
): Promise<readonly HaeufigeVorlage[]> {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
/*
|
||||
* Gezaehlt wird **nur, was der Benutzer sehen darf**. Eine Vorlage ohne
|
||||
* Kundenbezug gilt fuer alle (#009); ihr Zaehler sammelte sonst die Laeufe
|
||||
* saemtlicher Kunden, und „12× gestartet“ waere eine Auskunft ueber fremde
|
||||
* Betriebe — dazu noch eine, die die Reihenfolge des Schnellstarts nach
|
||||
* fremder Arbeit sortiert (Reviewbefund zu #015).
|
||||
*/
|
||||
const laeufeBedingung = sichtbar === undefined ? {} : { where: { kundeId: { in: [...sichtbar] } } };
|
||||
const vorlagen = await prisma.vorlage.findMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ archiviert: false },
|
||||
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
|
||||
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
|
||||
],
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
kategorie: true,
|
||||
versionen: {
|
||||
where: { status: "VEROEFFENTLICHT" },
|
||||
orderBy: { version: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
_count: { select: { laeufe: laeufeBedingung } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return vorlagen
|
||||
.flatMap((vorlage) => {
|
||||
const neueste = vorlage.versionen[0];
|
||||
// Ohne veroeffentlichte Version laesst sich nichts starten (#009).
|
||||
if (!neueste) return [];
|
||||
return [
|
||||
{
|
||||
versionId: neueste.id,
|
||||
name: vorlage.name,
|
||||
kategorie: vorlage.kategorie,
|
||||
version: neueste.version,
|
||||
anzahlLaeufe: vorlage.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => b.anzahlLaeufe - a.anzahlLaeufe || a.name.localeCompare(b.name, "de"))
|
||||
.slice(0, anzahl);
|
||||
}
|
||||
Reference in New Issue
Block a user