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
+444
View File
@@ -0,0 +1,444 @@
/**
* CSV lesen und schreiben (#011).
*
* Selbst geschrieben und ohne Abhaengigkeit: gebraucht werden hier wenige
* Spalten, und was an echten Dateien wehtut, ist nicht der Umfang des Formats,
* sondern seine Alltagsvarianten — deutsches Excel schreibt Semikolon, setzt ein
* BOM davor und beendet Zeilen mit CRLF.
*
* Framework- und datenbankfrei (siehe Schichtung in CLAUDE.md), damit die
* Randfaelle billig zu testen sind: tests/csv.test.ts haelt sie fest.
*/
export class CsvFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "CsvFehler";
}
}
/**
* Nur diese beiden. Tabulator und Pipe kommen in dieser Anwendung nicht vor, und
* eine Liste von Kandidaten, die niemand liefert, macht die Erkennung nur
* unzuverlaessiger.
*/
export const CSV_TRENNZEICHEN = [",", ";"] as const;
export type CsvTrennzeichen = (typeof CSV_TRENNZEICHEN)[number];
/** Byte Order Mark. Excel stellt es UTF-8-Dateien voran; es gehoert nicht zum Inhalt. */
const BOM = "";
const ZEILENENDE = "\r\n";
export type CsvZeile = {
/**
* Zeilennummer in der Datei, 1-basiert und einschliesslich Kopfzeile. Sie wird
* dem Benutzer gemeldet, muss also die Nummer sein, die sein Editor anzeigt —
* deshalb zaehlen auch Leerzeilen und Umbrueche innerhalb von
* Anfuehrungszeichen mit.
*/
readonly nummer: number;
/** Die Werte in der Reihenfolge der Datei, also auch ueberzaehlige. */
readonly werte: readonly string[];
/** Normalisierter Spaltenname zu Wert. Fehlende Spalten fehlen hier. */
readonly felder: ReadonlyMap<string, string>;
/**
* Gesetzt, wenn diese Zeile sich nicht sauber zerlegen liess.
*
* Dann sind `werte` und `felder` das, was bis zum Fehler zusammenkam — zu
* gebrauchen ist das nicht. Der Aufrufer meldet die Zeile und macht mit der
* naechsten weiter; **werfen waere hier falsch**, denn eine kaputte Zeile darf
* den Import der anderen nicht verhindern.
*/
readonly fehler?: string;
};
export type CsvTabelle = {
readonly trennzeichen: CsvTrennzeichen;
/** Die Kopfzeile, normalisiert — siehe `spaltenName`. */
readonly spalten: readonly string[];
readonly zeilen: readonly CsvZeile[];
};
export type ZerlegeOptionen = {
/** Erzwingt ein Trennzeichen, statt es aus der Kopfzeile zu erkennen. */
readonly trennzeichen?: CsvTrennzeichen;
/**
* Darf ein Wert in Anfuehrungszeichen ueber mehrere Zeilen gehen? Standard: nein.
*
* Das Format erlaubt es, und `csvErzeugen` schreibt es auch so. Beim **Lesen**
* einer fremden Datei ist es trotzdem die schlechtere Annahme: ein Umbruch in
* einem Hostnamen oder einer Seriennummer kommt nicht vor, ein vergessenes
* schliessendes Anfuehrungszeichen dagegen staendig. Ohne diese Grenze
* verschluckt eine einzige kaputte Zelle alle Folgezeilen bis zum naechsten
* `"` — und der Import meldet dafuer Erfolg, weil er die Zeilen nie gesehen
* hat. Mit der Grenze endet der Schaden an der Zeile, in der er beginnt.
*
* Wer eine selbst erzeugte Datei zurueckliest, schaltet es ein.
*/
readonly werteUeberZeilen?: boolean;
/**
* Obergrenze fuer die Datenzeilen (ohne Kopfzeile). Wird sie ueberschritten,
* bricht das Zerlegen mit einem `CsvFehler` ab, statt eine 50-MB-Datei erst
* ganz einzulesen.
*/
readonly hoechstZeilen?: number;
};
/**
* Spaltennamen vergleichbar machen.
*
* Gross- und Kleinschreibung ist laut Auftrag unerheblich; Leerraum,
* Unterstriche und Bindestriche kommen dazu, weil dieselbe Spalte je nach
* Werkzeug „serialNumber“, „Serial Number“ oder „serial_number“ heisst. Wer hier
* nach einem Namen sucht, sucht mit derselben Funktion.
*/
export function spaltenName(roh: string): string {
return roh
.replace(BOM, "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
type RoherSatz = { readonly nummer: number; readonly werte: readonly string[]; readonly fehler?: string };
/**
* Der eigentliche Zustandsautomat.
*
* `hoechstSaetze` bricht ab, sobald genug gelesen ist — die Erkennung des
* Trennzeichens braucht nur die erste Zeile, und die Obergrenze des Imports soll
* nicht erst nach dem vollstaendigen Einlesen greifen.
*/
function saetzeLesen(
text: string,
trennzeichen: CsvTrennzeichen,
hoechstSaetze?: number,
werteUeberZeilen = false,
): readonly RoherSatz[] {
const saetze: RoherSatz[] = [];
let werte: string[] = [];
let feld = "";
/** Zitierte Werte werden nicht getrimmt: Leerraum in Anfuehrungszeichen ist gewollt. */
let zitiert = false;
let inAnfuehrung = false;
/** Gesetzt, sobald diese Zeile nicht mehr zu gebrauchen ist. */
let satzFehler: string | undefined;
let zeile = 1;
let satzZeile = 1;
let pos = 0;
const feldAbschliessen = (): void => {
werte.push(zitiert ? feld : feld.trim());
feld = "";
zitiert = false;
};
const satzAbschliessen = (): void => {
feldAbschliessen();
// Leerzeilen fallen weg – auch die Zeilen aus lauter Trennzeichen, die Excel
// unter eine Tabelle schreibt. Die Zeilennummer laeuft trotzdem weiter.
if (satzFehler !== undefined) {
saetze.push({ nummer: satzZeile, werte, fehler: satzFehler });
} else if (werte.some((wert) => wert !== "")) {
saetze.push({ nummer: satzZeile, werte });
}
werte = [];
satzFehler = undefined;
};
/**
* Nach einem Fehler bis zum naechsten Zeilenumbruch weiterlesen und dort neu
* aufsetzen.
*
* Der Grund steht in `CsvZeile.fehler`: eine kaputte Zeile darf die naechsten
* nicht mitreissen. Genau das passierte vorher — ein nicht geschlossenes
* Anfuehrungszeichen verschluckte alle Folgezeilen bis zum naechsten `"`, und
* der Import meldete trotzdem Erfolg.
*/
const bisZeilenendeVerwerfen = (): void => {
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
pos += 1;
break;
}
pos += 1;
}
feld = "";
inAnfuehrung = false;
satzAbschliessen();
zeile += 1;
satzZeile = zeile;
};
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (inAnfuehrung) {
if (zeichen === '"') {
if (text.charAt(pos + 1) === '"') {
// Verdoppeltes Anfuehrungszeichen: ein literales Zeichen im Wert.
feld += '"';
pos += 2;
continue;
}
inAnfuehrung = false;
pos += 1;
// Leerraum zwischen schliessendem Anfuehrungszeichen und Trennzeichen
// gehoert nicht zum Wert.
while (text.charAt(pos) === " " || text.charAt(pos) === "\t") pos += 1;
/*
* Nach dem schliessenden Anfuehrungszeichen darf nur ein Trennzeichen,
* ein Zeilenumbruch oder das Dateiende kommen. Steht dort etwas anderes,
* war das Anfuehrungszeichen nicht das Ende des Werts, sondern ein
* Zeichen mitten im Text — und alles Folgende wanderte bisher
* stillschweigend in diesen Wert.
*/
const naechstes = text.charAt(pos);
if (naechstes !== "" && naechstes !== trennzeichen && naechstes !== "\r" && naechstes !== "\n") {
satzFehler =
"Nach einem schließenden Anführungszeichen steht noch Text. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
}
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (!werteUeberZeilen) {
// Hier endet der Schaden: die Zeile gilt als kaputt, die naechste
// faengt sauber an. Siehe `werteUeberZeilen`.
satzFehler =
"Ein Anführungszeichen wurde in dieser Zeile geöffnet und nicht geschlossen. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
// Umbruch im Wert. CRLF wird zu einem einzelnen \n normalisiert, damit
// ein Wert nicht je nach Herkunft der Datei anders aussieht.
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
feld += "\n";
zeile += 1;
pos += 1;
continue;
}
feld += zeichen;
pos += 1;
continue;
}
// Ein Anfuehrungszeichen eroeffnet nur am Wortanfang; steht es mitten im Wert
// (ein Zoll-Mass etwa), ist es ein gewoehnliches Zeichen.
if (zeichen === '"' && feld.trim() === "") {
feld = "";
zitiert = true;
inAnfuehrung = true;
pos += 1;
continue;
}
if (zeichen === trennzeichen) {
feldAbschliessen();
pos += 1;
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
satzAbschliessen();
pos += 1;
zeile += 1;
satzZeile = zeile;
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
feld += zeichen;
pos += 1;
}
if (inAnfuehrung) {
/*
* Am Dateiende offen: die Zeile, in der es begann, ist verloren — und mit
* ihr alles, was danach in den Wert gewandert ist. Gemeldet wird sie als
* fehlerhafte Zeile, **nicht** als Ausnahme: die Zeilen davor sind in
* Ordnung und sollen eingelesen werden.
*/
satzFehler =
"Hier wurde ein Anführungszeichen geöffnet und bis zum Dateiende nie geschlossen. " +
"Alles Folgende wurde als Teil dieses Werts gelesen.";
feld = "";
inAnfuehrung = false;
satzAbschliessen();
return saetze;
}
// Eine Datei muss nicht mit einem Umbruch enden.
if (feld !== "" || werte.length > 0) satzAbschliessen();
return saetze;
}
/**
* Das Trennzeichen aus der Kopfzeile ablesen, statt es zu raten.
*
* Geraten wuerde, wer irgendwo im Text Zeichen zaehlt: ein Komma in einem
* Wert steht dort genauso. Die Kopfzeile dagegen besteht aus Spaltennamen, und
* das richtige Trennzeichen ist das, das sie in mehr Felder zerlegt. Bei
* Gleichstand — also einer einspaltigen Datei — gewinnt das Komma, weil es das
* Zeichen der Norm ist.
*/
export function trennzeichenErkennen(text: string): CsvTrennzeichen {
let bestes: CsvTrennzeichen = ",";
let meiste = 0;
for (const kandidat of CSV_TRENNZEICHEN) {
const anzahl = saetzeLesen(text, kandidat, 1)[0]?.werte.length ?? 0;
if (anzahl > meiste) {
meiste = anzahl;
bestes = kandidat;
}
}
return bestes;
}
/**
* Zerlegt eine CSV-Datei in Kopfzeile und Zeilen.
*
* Kommt ein Spaltenname zweimal vor, gilt der erste. Ihn stillschweigend zu
* ueberschreiben waere die schlechtere Wahl: der Leser sieht die linke Spalte
* und erwartet, dass sie zaehlt.
*/
export function csvZerlegen(text: string, optionen: ZerlegeOptionen = {}): CsvTabelle {
const ohneBom = text.startsWith(BOM) ? text.slice(BOM.length) : text;
const trennzeichen = optionen.trennzeichen ?? trennzeichenErkennen(ohneBom);
// Eine Zeile mehr lesen als erlaubt, sonst waere die Ueberschreitung nicht von
// einer genau passenden Datei zu unterscheiden. Plus die Kopfzeile.
const grenze = optionen.hoechstZeilen === undefined ? undefined : Math.max(0, optionen.hoechstZeilen) + 2;
const saetze = saetzeLesen(ohneBom, trennzeichen, grenze, optionen.werteUeberZeilen ?? false);
const kopf = saetze[0];
if (!kopf) {
throw new CsvFehler("Die Datei ist leer – es fehlt schon die Kopfzeile.");
}
if (kopf.fehler !== undefined) {
// Bei einer kaputten Kopfzeile hilft keine Zeilenmeldung: ohne Spaltennamen
// hat keine einzige Zeile eine Bedeutung.
throw new CsvFehler(`Die Kopfzeile ist nicht lesbar. ${kopf.fehler}`);
}
if (optionen.hoechstZeilen !== undefined && saetze.length - 1 > optionen.hoechstZeilen) {
throw new CsvFehler(
`Die Datei hat mehr als ${optionen.hoechstZeilen} Zeilen. ` +
"Bitte in kleinere Dateien aufteilen und einzeln einlesen.",
);
}
const spalten = kopf.werte.map(spaltenName);
const zeilen: CsvZeile[] = saetze.slice(1).map((satz) => {
const felder = new Map<string, string>();
for (const [index, spalte] of spalten.entries()) {
if (spalte === "" || felder.has(spalte)) continue;
const wert = satz.werte[index];
// Fehlt die Spalte in dieser Zeile, fehlt auch der Eintrag – ein leerer
// String waere dasselbe wie eine leere Zelle, und das ist es nicht.
if (wert !== undefined) felder.set(spalte, wert);
}
return {
nummer: satz.nummer,
werte: satz.werte,
felder,
...(satz.fehler === undefined ? {} : { fehler: satz.fehler }),
};
});
return { trennzeichen, spalten, zeilen };
}
/**
* Der erste gesetzte Wert unter mehreren moeglichen Spaltennamen.
*
* Der Auftrag nennt englische Spalten, die Oberflaeche ist deutsch, und beide
* Schreibweisen sollen in einer Datei stehen duerfen. Leer gilt als nicht
* gesetzt, damit eine vorhandene aber leere Spalte die naechste nicht verdeckt.
*/
export function wertAus(zeile: CsvZeile, ...namen: readonly string[]): string {
for (const name of namen) {
const wert = zeile.felder.get(spaltenName(name))?.trim();
if (wert) return wert;
}
return "";
}
/** Enthaelt eine der Spalten, unter denen hier ueberhaupt etwas erwartet wird? */
export function hatSpalte(tabelle: CsvTabelle, ...namen: readonly string[]): boolean {
return namen.some((name) => tabelle.spalten.includes(spaltenName(name)));
}
export type ErzeugeOptionen = {
/**
* Standard ist das Semikolon: die erzeugten Dateien werden in aller Regel in
* einem deutschen Excel geoeffnet, und das zerlegt Kommadateien nicht von
* selbst.
*/
readonly trennzeichen?: CsvTrennzeichen;
/** Ohne BOM zeigt dasselbe Excel Umlaute als Kauderwelsch. Deshalb standardmaessig an. */
readonly mitBom?: boolean;
};
/**
* Werte, die eine Tabellenkalkulation als Formel liest, entschaerfen.
*
* Excel und LibreOffice werten eine Zelle aus, die mit `=`, `+`, `-` oder `@`
* beginnt. Steht in einem Geraetenamen `=HYPERLINK(…)`, fuehrt das beim
* Oeffnen der exportierten Datei zu einer Formel — der Wert kam aus unserer
* Datenbank, ausgefuehrt wird er auf dem Rechner des Empfaengers.
*
* Ein vorangestelltes Apostroph macht daraus wieder Text. Es ist in der Zelle
* nicht sichtbar, wandert aber beim Zurueckeinlesen mit — deshalb nur dort, wo
* es noetig ist.
*/
function formelEntschaerfen(wert: string): string {
return /^[=+\-@\t\r]/u.test(wert) ? `'${wert}` : wert;
}
function wertMaskieren(rohwert: string, trennzeichen: CsvTrennzeichen): string {
const wert = formelEntschaerfen(rohwert);
const noetig =
wert.includes(trennzeichen) ||
wert.includes('"') ||
wert.includes("\n") ||
wert.includes("\r") ||
wert !== wert.trim();
return noetig ? `"${wert.replaceAll('"', '""')}"` : wert;
}
/**
* Erzeugt eine CSV-Datei.
*
* Steht neben dem Zerlegen, obwohl der Export erst spaeter gebraucht wird: die
* Regeln fuer Anfuehrungszeichen und Trennzeichen gelten in beide Richtungen,
* und getrennt gepflegt laufen sie auseinander. Der Test schreibt und liest
* deshalb im Kreis.
*
* `spalten` gibt die Reihenfolge **und** die Kopfzeile vor, wie sie geschrieben
* wird — beim Export sollen die Spalten ruhig „serialNumber“ heissen.
*/
export function csvErzeugen(
spalten: readonly string[],
zeilen: readonly Readonly<Record<string, string | number | null | undefined>>[],
optionen: ErzeugeOptionen = {},
): string {
const trennzeichen = optionen.trennzeichen ?? ";";
const zeilenText = [
spalten.map((spalte) => wertMaskieren(spalte, trennzeichen)).join(trennzeichen),
...zeilen.map((zeile) =>
spalten
.map((spalte) => {
const wert = zeile[spalte];
return wertMaskieren(wert === null || wert === undefined ? "" : String(wert), trennzeichen);
})
.join(trennzeichen),
),
].join(ZEILENENDE);
return (optionen.mitBom === false ? "" : BOM) + zeilenText + ZEILENENDE;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Gemeinsame Klassen der Formularfelder (#011).
*
* Stand vorher fuenfmal wortgleich in fuenf Dateien — und war damit genau die
* Sorte Wiederholung, die beim naechsten Feinschliff an vier Stellen
* unveraendert bleibt. In `src/lib`, weil Server- und Client-Komponenten sie
* gleichermassen brauchen.
*/
/** Eingabefelder sind am Handy 44 px hoch (`min-h-11`), sonst trifft sie niemand. */
export const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Kontrollkaestchen.
*
* Die umschliessende Beschriftung ist 44 px hoch und schaltet mit, die
* Trefferflaeche stimmt also auch ohne diese Groesse — sichtbar zu klein bleibt
* es trotzdem.
*/
export const KAESTCHEN = "size-4 accent-akzent";
+23
View File
@@ -0,0 +1,23 @@
import { GeraeteTyp } from "@/generated/prisma/enums";
/**
* Beschriftungen der Geraete (#011).
*
* In `src/lib`, weil beide Seiten sie brauchen und nichts aus `src/server/*` ins
* Browser-Buendel geraten darf — dieselbe Regel wie bei `ROLLEN_TEXT`
* (CLAUDE.md). Die Liste der Typen kommt aus dem Enum, damit ein neuer Typ nicht
* an zwei Stellen eingetragen werden muss.
*/
export const GERAETE_TYP_TEXT: Readonly<Record<GeraeteTyp, string>> = {
PC: "PC",
NOTEBOOK: "Notebook",
SERVER: "Server",
FIREWALL: "Firewall",
SWITCH: "Switch",
DRUCKER: "Drucker",
RAUM: "Raum",
SONSTIGES: "Sonstiges",
};
export const GERAETE_TYP_LISTE: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
+14 -3
View File
@@ -12,6 +12,14 @@ export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = {
readonly pfad: string;
readonly beschriftung: string;
/**
* Abweichende Beschriftung im Modus DIENSTLEISTUNG.
*
* Dort heisst „Mitarbeiter“ „Personen“: es sind die Leute des Kunden, nicht
* die eigenen Angestellten. Derselbe Eintrag, dieselbe Seite — nur das Wort
* passt sich an (#008, ausgebaut in #011).
*/
readonly beschriftungDienstleistung?: string;
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus;
readonly symbol: string;
@@ -29,10 +37,9 @@ export const NAVIGATION: readonly NavEintrag[] = [
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
nurModus: "INTERN",
beschriftungDienstleistung: "Personen & Geräte",
symbol: "Users",
verfuegbar: false,
ausIssue: 11,
verfuegbar: true,
},
{
pfad: "/kunden",
@@ -59,5 +66,9 @@ export function navigationFuer(
return NAVIGATION.filter(
(e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((eintrag) =>
modus === "DIENSTLEISTUNG" && eintrag.beschriftungDienstleistung
? { ...eintrag, beschriftung: eintrag.beschriftungDienstleistung }
: eintrag,
);
}