feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+110
View File
@@ -0,0 +1,110 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Abteilungen (#011).
*
* Die Zahl der zugeordneten Personen kommt aus einer zweiten Abfrage statt aus
* `_count`: die Zuordnung laeuft ueber den **Namen** im Freitextfeld
* `Mitarbeiter.abteilung`, nicht ueber einen Fremdschluessel. So bleibt ein
* Abteilungswert aus dem Verzeichnis erhalten, auch wenn es dazu (noch) keine
* gepflegte Abteilung gibt — und genau diese Faelle will man sehen.
*/
export type AbteilungsZeile = {
readonly id: string;
readonly name: string;
readonly zielOu: string | null;
readonly standardGruppen: readonly string[];
readonly mailDomain: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
readonly kundeId: string;
readonly anzahlMitarbeiter: number;
};
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const AUSWAHL = {
id: true,
name: true,
zielOu: true,
standardGruppen: true,
mailDomain: true,
notizen: true,
archiviert: true,
kundeId: true,
} satisfies Prisma.AbteilungSelect;
export async function abteilungsListe(
benutzer: HandelnderBenutzer,
auchArchivierte = true,
): Promise<readonly AbteilungsZeile[]> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.abteilung.findMany({
where: { AND: [...(auchArchivierte ? [] : [{ archiviert: false }]), ...mandant] },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
// Eine Abfrage fuer alle Zaehlungen statt einer je Abteilung.
const zaehlung = await prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
});
const anzahlNach = new Map(
zaehlung.map((eintrag) => [`${eintrag.kundeId}\u0000${eintrag.abteilung ?? ""}`, eintrag._count._all]),
);
return zeilen.map((zeile) => ({
...zeile,
standardGruppen: zeile.standardGruppen,
anzahlMitarbeiter: anzahlNach.get(`${zeile.kundeId}\u0000${zeile.name}`) ?? 0,
}));
}
/**
* Abteilungsnamen, die bei Mitarbeitern vorkommen, aber nicht gepflegt sind.
*
* Das ist der Vorschlag fuer „Abteilung anlegen“ nach einem Verzeichnisabgleich:
* die Namen stehen schon in den Daten, es fehlen nur Ziel-OU und Gruppen.
*
* Gruppiert wird nach **Kunde und Name**, nicht nach Name allein. Sonst gilt
* „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
* Namens hat — und beim Kunden ohne sie blieben `{{ou}}` und `{{gruppen}}` im
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
* richtig.
*/
export async function ungepflegteAbteilungen(
benutzer: HandelnderBenutzer,
): Promise<readonly { name: string; anzahl: number; kundeId: string }[]> {
const mandant = await kundenBedingung(benutzer);
const [vorhandene, benutzte] = await Promise.all([
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
}),
]);
const schluessel = (kundeId: string, name: string) => `${kundeId}\u0000${name}`;
const gepflegt = new Set(vorhandene.map((zeile) => schluessel(zeile.kundeId, zeile.name)));
return benutzte
.filter(
(eintrag) =>
eintrag.abteilung !== null && !gepflegt.has(schluessel(eintrag.kundeId, eintrag.abteilung)),
)
.map((eintrag) => ({
name: eintrag.abteilung as string,
anzahl: eintrag._count._all,
kundeId: eintrag.kundeId,
}))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}
+220
View File
@@ -0,0 +1,220 @@
import type { Prisma } from "@/generated/prisma/client";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Geraete (#011).
*
* Die Mandantensicht wenden diese Funktionen **selbst** an. Im Review von #009
* war genau das die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb damit die Kundenbedingung, weil beide in
* dasselbe Objektliteral gespreizt wurden. Deshalb liegt hier **jede**
* Teilbedingung in einem `AND`-Array, wo keine die andere ueberschreiben kann.
* `tests/geraete.test.ts` haelt das mit einem eigenen Test fest.
*/
export type GeraeteZeile = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
/** Hostname, sonst Bezeichnung, sonst der Tag-Code. Nie leer. */
readonly anzeige: string;
readonly typ: GeraeteTyp;
readonly modell: string | null;
readonly seriennummer: string | null;
readonly inventarnummer: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
/** Leer heisst: vorgedruckter Tag, noch keinem Objekt zugeordnet. */
readonly registriertAm: Date | null;
readonly kundeId: string;
readonly kundeName: string;
readonly besitzerId: string | null;
readonly besitzerName: string | null;
readonly besitzerAktiv: boolean | null;
readonly anzahlLaeufe: number;
};
export type GeraeteFilter = {
readonly suche?: string;
readonly typ?: GeraeteTyp;
readonly auchArchivierte?: boolean;
};
/**
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
* Hineinspreizen. `undefined` heisst „keine Einschraenkung“.
*
* Jede Geraetezeile traegt eine `kundeId` – auch im Modus INTERN, wo es die eine
* eigene Organisation ist. Es gibt hier also kein „gilt fuer alle“ wie bei den
* Vorlagen.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.GeraetWhereInput | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
return { kundeId: { in: [...sichtbar] } };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der Zeit
* auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
tagCode: true,
hostname: true,
bezeichnung: true,
typ: true,
modell: true,
seriennummer: true,
inventarnummer: true,
notizen: true,
archiviert: true,
registriertAm: true,
kundeId: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } },
_count: { select: { laeufe: true } },
} satisfies Prisma.GeraetSelect;
type Rohzeile = Prisma.GeraetGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): GeraeteZeile {
return {
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
anzeige: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
archiviert: zeile.archiviert,
registriertAm: zeile.registriertAm,
kundeId: zeile.kundeId,
kundeName: zeile.kunde.name,
besitzerId: zeile.mitarbeiter?.id ?? null,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
anzahlLaeufe: zeile._count.laeufe,
};
}
/**
* Die Suche geht ueber Hostname, Bezeichnung, Seriennummer und `tagCode`.
*
* Der `tagCode` ist dabei der Notnagel: wer einen Aufkleber in der Hand hat und
* die Kamera nicht benutzen kann, tippt ihn ab. Deshalb `contains` und nicht
* `equals` – die sechs Zeichen ohne das Praefix sollen genuegen.
*/
function sucheBedingung(suche: string): Prisma.GeraetWhereInput {
const wie = { contains: suche, mode: "insensitive" as const };
return {
OR: [
{ hostname: wie },
{ bezeichnung: wie },
{ seriennummer: wie },
{ inventarnummer: wie },
{ tagCode: wie },
],
};
}
export async function geraeteListe(
benutzer: HandelnderBenutzer,
filter: GeraeteFilter = {},
): Promise<readonly GeraeteZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
await kundenBedingung(benutzer),
filter.auchArchivierte ? undefined : { archiviert: false },
filter.typ ? { typ: filter.typ } : undefined,
suche ? sucheBedingung(suche) : undefined,
].filter((bedingung): bedingung is Prisma.GeraetWhereInput => bedingung !== undefined);
const zeilen = await prisma.geraet.findMany({
where: { AND: bedingungen },
// Archivierte nach unten, dann nach Anzeigename. Prisma kann nicht nach
// „Hostname, sonst Bezeichnung“ sortieren; die zwei Schluessel danach kommen
// dem nahe genug und sind stabil.
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }, { bezeichnung: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/**
* Ein einzelnes Geraet.
*
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function geraetHolen(
geraetId: string,
benutzer: HandelnderBenutzer,
): Promise<GeraeteZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.geraet.findFirst({
where: { AND: [{ id: geraetId }, ...(mandant ? [mandant] : [])] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type BesitzerKandidat = {
readonly id: string;
readonly anzeigename: string;
readonly aktiv: boolean;
/** Ein Geraet kann nur einer Person desselben Kunden gehoeren. */
readonly kundeId: string;
};
export type BesitzerAuswahl = {
readonly kandidaten: readonly BesitzerKandidat[];
/** Waren es mehr als die Grenze? Dann sagt die Oberflaeche das auch. */
readonly gekuerzt: boolean;
};
/**
* Die Personen, die als Besitzer in Frage kommen.
*
* Eigenes, schmales Lesemodell mit genau vier Feldern – die Geraeteseite braucht
* nichts weiter, und die Mitarbeitersicht aus #011 wird an anderer Stelle
* gebaut. Deaktivierte sind nicht dabei: wer offboardet, aendert den Besitzer
* nicht mehr, sondern gibt das Geraet zurueck.
*
* Die Grenze ist da, damit ein Verzeichnis mit zehntausend Personen nicht in ein
* Auswahlfeld gegossen wird. Dass gekuerzt wurde, bleibt nicht verborgen.
*/
export async function besitzerKandidaten(
benutzer: HandelnderBenutzer,
grenze = 500,
): Promise<BesitzerAuswahl> {
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.mitarbeiter.findMany({
where: {
AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])],
},
select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
orderBy: { anzeigename: "asc" },
take: grenze + 1,
});
return {
/*
* `kundeId` bleibt drin: ein Geraet kann nur einer Person **desselben**
* Kunden gehoeren (zusammengesetzter Fremdschluessel, siehe
* services/geraete.ts). Ohne das Feld boete die Auswahlliste Personen an,
* die der Dienst danach abweist — eine Wahl, die sie nicht einloesen kann.
*/
kandidaten: zeilen
.slice(0, grenze)
.map(({ id, anzeigename, aktiv, kundeId }) => ({ id, anzeigename, aktiv, kundeId })),
gekuerzt: zeilen.length > grenze,
};
}
+216
View File
@@ -0,0 +1,216 @@
import type { Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Mitarbeiter (#011).
*
* Wie ueberall wenden sie die Mandantensicht **selbst** an. Im Review von #009
* lag genau hier die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb die Kundenbedingung — ein Buchstabe im
* Suchfeld genuegte. Deshalb liegen alle Teilbedingungen in einem `AND`, wo
* keine die andere erreichen kann.
*/
export type MitarbeiterZeile = {
readonly id: string;
readonly anzeigename: string;
readonly vorname: string;
readonly nachname: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean;
readonly herkunft: Herkunft;
readonly kundeId: string;
readonly kundeName: string;
readonly anzahlGeraete: number;
readonly anzahlLaeufe: number;
};
export type MitarbeiterFilter = {
readonly suche?: string;
readonly abteilung?: string;
/** Standard ist „nur aktive“ – fuers Offboarding schaltet man es um. */
readonly auchDeaktivierte?: boolean;
};
/**
* Die Mandantenbedingung als **Liste von Teilbedingungen**, nie als Objekt zum
* Hineinspreizen: so kann kein zweiter Filter mit demselben Schluessel sie
* ueberschreiben. `[]` heisst „keine Einschraenkung“.
*
* Dieselbe Form passt fuer Mitarbeiter und Geraete, weil beide `kundeId`
* tragen — jede kundenbezogene Zeile tut das (#008).
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const ZEILEN_AUSWAHL = {
id: true,
anzeigename: true,
vorname: true,
nachname: true,
mail: true,
upn: true,
sam: true,
abteilung: true,
titel: true,
aktiv: true,
herkunft: true,
kundeId: true,
kunde: { select: { name: true } },
_count: { select: { geraete: true, laeufe: true } },
} satisfies Prisma.MitarbeiterSelect;
type Rohzeile = Prisma.MitarbeiterGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): MitarbeiterZeile {
return {
id: zeile.id,
anzeigename: zeile.anzeigename,
vorname: zeile.vorname,
nachname: zeile.nachname,
mail: zeile.mail,
upn: zeile.upn,
sam: zeile.sam,
abteilung: zeile.abteilung,
titel: zeile.titel,
aktiv: zeile.aktiv,
herkunft: zeile.herkunft,
kundeId: zeile.kundeId,
/*
* Immer der Name, auch bei der eigenen Organisation. Ob er **gezeigt** wird,
* entscheidet der Betriebsmodus — und das entscheidet die Seite mit
* `kundenEbeneSichtbar()`, nicht das Lesemodell. Vorher haengte es hier an
* `istEigeneOrganisation`, und im Modus Dienstleistung fehlte die Pille
* ausgerechnet bei einem Kunden: „keine Pille“ hiess dann nicht „kein
* Kunde“, sondern „ausgerechnet dieser eine“.
*/
kundeName: zeile.kunde.name,
anzahlGeraete: zeile._count.geraete,
anzahlLaeufe: zeile._count.laeufe,
};
}
export async function mitarbeiterListe(
benutzer: HandelnderBenutzer,
filter: MitarbeiterFilter = {},
): Promise<readonly MitarbeiterZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchDeaktivierte ? undefined : { aktiv: true },
filter.abteilung ? { abteilung: filter.abteilung } : undefined,
// Gesucht wird ueber die vier Felder, unter denen eine Person im Haus
// bekannt ist. Der Anzeigename allein reicht nicht: im Ticket steht oft nur
// die Mail-Adresse oder der sAMAccountName.
suche
? {
OR: [
{ anzeigename: { contains: suche, mode: "insensitive" as const } },
{ mail: { contains: suche, mode: "insensitive" as const } },
{ upn: { contains: suche, mode: "insensitive" as const } },
{ sam: { contains: suche, mode: "insensitive" as const } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: bedingungen },
orderBy: [{ aktiv: "desc" }, { nachname: "asc" }, { vorname: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function mitarbeiterHolen(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<MitarbeiterZeile | undefined> {
const zeile = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type MitarbeiterGeraet = {
readonly id: string;
readonly tagCode: string;
readonly name: string;
readonly typ: string;
readonly archiviert: boolean;
};
/** Die Geraete einer Person – fuer die Mitarbeiterseite. */
export async function geraeteDesMitarbeiters(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly MitarbeiterGeraet[]> {
const zeilen = await prisma.geraet.findMany({
where: { AND: [{ mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }],
select: { id: true, tagCode: true, hostname: true, bezeichnung: true, typ: true, archiviert: true },
});
return zeilen.map((zeile) => ({
id: zeile.id,
tagCode: zeile.tagCode,
// Hostname zuerst: danach sucht man. Ohne Hostname traegt die Bezeichnung.
name: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
archiviert: zeile.archiviert,
}));
}
/**
* Der Vorgesetzte einer Person – oder `undefined`.
*
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId`
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer
* von Hand angelegten Person steht hier nichts, und das ist richtig so.
*/
export async function vorgesetzterVon(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
const person = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: { quelleId: true, vorgesetzterExterneId: true },
});
if (!person?.vorgesetzterExterneId || !person.quelleId) return undefined;
const vorgesetzter = await prisma.mitarbeiter.findFirst({
where: {
AND: [
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
...(await kundenBedingung(benutzer)),
],
},
select: { id: true, anzeigename: true },
});
return vorgesetzter ?? undefined;
}
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: [{ abteilung: { not: null } }, ...(await kundenBedingung(benutzer))] },
select: { abteilung: true },
distinct: ["abteilung"],
});
return zeilen
.map((zeile) => zeile.abteilung)
.filter((name): name is string => name !== null)
.sort((a, b) => a.localeCompare(b, "de"));
}