feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
This commit was merged in pull request #25.
This commit is contained in:
+444
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user