Phase 1: Stammdatenverwaltung

Ab hier fuehrt die Wehr ihren Bestand in FLORIAN.

Datenmodell:
- Standort, Geraeteraum, Geraetetyp, Qualifikationstyp und Qualifikation
- Fahrzeug um Fahrgestellnummer, DIN-Typ, Erstzulassung, Sitzplaetze,
  Tankinhalt und UVV-Frist erweitert
- Geraet um Geraetetyp, Lebensdauerkategorie, errechnetes Lebensdauerende,
  Anschaffungspreis und die Lagerung erweitert
- Ein Geraet steht entweder an einem Standort oder ist auf einem Fahrzeug
  verlastet. Eine Pruefbedingung setzt das durch, ein Trigger stellt zusaetzlich
  sicher, dass ein angegebener Geraeteraum zum angegebenen Fahrzeug gehoert.
- RLS auf allen neuen Tabellen, Trigram-Indizes fuer die Volltextsuche

Fachlogik:
- lib/stammdaten/fristen.ts rechnet Prueftermine, Lebensdauer und den
  abgeleiteten Geraetestatus. Der Geraetetyp gibt vor, das Geraet darf
  abweichen.
- lib/stammdaten/qr.ts und qr-parser.ts erzeugen und lesen die Codes
- lib/stammdaten/etiketten.ts baut den A4-Bogen mit 65 Etiketten
- lib/stammdaten/import.ts liest CSV und Excel, erkennt deutsche Datums- und
  Zahlenformate, Windows-1252 und Dubletten innerhalb der Datei
- lib/pdf.ts spricht Gotenberg an

Oberflaeche:
- Geraeteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
  Blaettern, Spaltenauswahl und Massenbearbeitung
- Geraetedetail mit QR-Code, Pruefvorgabe aus dem Geraetetyp und Verlauf
- Fahrzeugliste und -detail mit Beladung nach Beladeordnung
- Mitgliederliste nach Abteilung, Mitgliedsdetail mit Qualifikationen
- Qualifikationen mit Ablaufwarnung je Vorwarnzeit des Typs
- Import-Assistent mit Zuordnung und Vorschau vor dem Uebernehmen
- QR-Scanner ueber die Kamera, mit Eingabefeld als Rueckfallweg

Behoben:
- Fristenrechnung lief ueber startOfDay aus date-fns und damit in der Zeitzone
  des Servers. Ein DATE aus der Datenbank kommt als UTC-Mitternacht und wurde
  dadurch auf den Vortag geschoben — Fristen waeren einen Tag zu frueh faellig
  geworden. Jetzt eigene Kalenderarithmetik auf UTC-Bestandteilen.
- ctx.sitzung blieb in tRPC-Prozeduren nullbar, weil t.middleware() die
  Verengung der vorigen Middleware verwirft. Middleware jetzt direkt an .use().
- CardTitle war ein div und damit keine Ueberschrift. Wer mit einer
  Sprachausgabe arbeitet, springt von Ueberschrift zu Ueberschrift; Karten
  tauchten in dieser Navigation nicht auf.

Daten und Tests:
- Seed um 3 Standorte, 20 Geraeteraeume, 14 Geraetetypen mit echten
  Pruefvorgaben, 49 Geraete und 107 Qualifikationen erweitert
- 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10)
- Anleitung um sieben bebilderte Abschnitte erweitert, Changelog, Devlog,
  Aufwandsschaetzung und Roadmap fortgeschrieben

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-03 08:31:08 +02:00
co-authored by Claude Opus 5
parent 4b3021bb50
commit 26b62a3477
79 changed files with 11738 additions and 217 deletions
+121
View File
@@ -0,0 +1,121 @@
import "server-only";
import { env } from "@/lib/env";
/**
* PDF-Erzeugung über Gotenberg.
*
* Gotenberg ist ein eigener Container im Stack, der Chromium kapselt. Die
* Anwendung schickt fertiges HTML hin und bekommt ein PDF zurück — dadurch
* muss im Anwendungscontainer kein Browser stecken, und die Erzeugung kann
* abstürzen, ohne die Anwendung mitzunehmen.
*
* Regel für alle Vorlagen: das HTML muss vollständig eigenständig sein.
* Keine externen Schriften, keine CDN-Verweise, kein nachgeladenes CSS. Was
* nicht im übergebenen HTML steht, ist im PDF nicht drin.
*/
export interface PdfEinstellungen {
/** Seitengröße in Zoll. A4 ist 8.27 × 11.7. */
breite?: number;
hoehe?: number;
/** Ränder in Zoll. */
randOben?: number;
randUnten?: number;
randLinks?: number;
randRechts?: number;
quer?: boolean;
/** Hintergrundfarben und -bilder mitdrucken. */
hintergrundDrucken?: boolean;
/** Kopf- und Fußzeile als eigenes HTML-Fragment. */
kopfzeileHtml?: string;
fusszeileHtml?: string;
}
const A4 = { breite: 8.27, hoehe: 11.7 };
/**
* Rendert HTML zu PDF.
*
* @throws wenn Gotenberg nicht erreichbar ist oder das Zeitlimit reißt — der
* Aufrufer entscheidet, ob das ein Fehler für die Oberfläche ist oder
* ein Wiederholungsversuch im Hintergrundprozess.
*/
export async function htmlZuPdf(
html: string,
einstellungen: PdfEinstellungen = {},
): Promise<Buffer> {
if (!env.GOTENBERG_URL) {
throw new Error("GOTENBERG_URL ist nicht gesetzt — ohne Gotenberg gibt es keine PDFs.");
}
const formular = new FormData();
formular.append("files", new Blob([html], { type: "text/html" }), "index.html");
formular.append("paperWidth", String(einstellungen.breite ?? A4.breite));
formular.append("paperHeight", String(einstellungen.hoehe ?? A4.hoehe));
formular.append("marginTop", String(einstellungen.randOben ?? 0));
formular.append("marginBottom", String(einstellungen.randUnten ?? 0));
formular.append("marginLeft", String(einstellungen.randLinks ?? 0));
formular.append("marginRight", String(einstellungen.randRechts ?? 0));
formular.append("landscape", String(Boolean(einstellungen.quer)));
formular.append("printBackground", String(einstellungen.hintergrundDrucken ?? true));
// Ohne diese Einstellung wartet Chromium auf Netzwerkanfragen, die es bei
// eigenständigem HTML gar nicht gibt.
formular.append("skipNetworkIdleEvent", "true");
if (einstellungen.kopfzeileHtml) {
formular.append(
"files",
new Blob([einstellungen.kopfzeileHtml], { type: "text/html" }),
"header.html",
);
}
if (einstellungen.fusszeileHtml) {
formular.append(
"files",
new Blob([einstellungen.fusszeileHtml], { type: "text/html" }),
"footer.html",
);
}
const abbruch = AbortSignal.timeout(env.GOTENBERG_TIMEOUT * 1000);
const antwort = await fetch(`${env.GOTENBERG_URL}/forms/chromium/convert/html`, {
method: "POST",
body: formular,
signal: abbruch,
});
if (!antwort.ok) {
const text = await antwort.text().catch(() => "");
throw new Error(
`Gotenberg konnte das PDF nicht erzeugen (${antwort.status}). ${text.slice(0, 300)}`,
);
}
return Buffer.from(await antwort.arrayBuffer());
}
/** Erreichbarkeitsprüfung. */
export async function pdfDienstErreichbar(): Promise<boolean> {
if (!env.GOTENBERG_URL) return false;
try {
const antwort = await fetch(`${env.GOTENBERG_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
return antwort.ok;
} catch {
return false;
}
}
/** Maskiert Text für die Einbettung in HTML. */
export function html(text: string | null | undefined): string {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+205
View File
@@ -0,0 +1,205 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { qrAlsSvg } from "@/lib/stammdaten/qr";
/**
* Etikettenbogen für Geräte.
*
* Zielformat ist der gängige Bogen mit 65 Etiketten je DIN-A4-Seite
* (38,1 × 21,2 mm, 5 Spalten × 13 Zeilen) — den gibt es in jedem Schreibwaren-
* laden, und er passt in jeden Laserdrucker.
*
* Auf jedes Etikett kommen drei Dinge, in dieser Reihenfolge der Wichtigkeit:
* 1. der QR-Code, damit das Gerät am Regal gescannt werden kann
* 2. die Inventarnummer, damit man sie auch ohne Handy ablesen kann
* 3. die Bezeichnung, gekürzt — auf 21 mm Höhe passt nicht mehr
*
* Die Etiketten sind absichtlich karg. Ein Aufkleber im Geräteraum bekommt
* Staub, Öl und Schrammen ab; alles, was nicht gebraucht wird, macht den Code
* nur kleiner.
*/
/** Maße des Bogens in Millimetern. */
export const BOGEN = {
spalten: 5,
zeilen: 13,
etikettBreite: 38.1,
etikettHoehe: 21.2,
randOben: 10.7,
randLinks: 4.75,
abstandX: 2.5,
abstandY: 0,
} as const;
export const ETIKETTEN_JE_SEITE = BOGEN.spalten * BOGEN.zeilen;
export interface EtikettAngaben {
inventarnummer: string;
bezeichnung: string;
/** Zusatzzeile, z. B. der Funkrufname des Fahrzeugs. */
zusatz?: string | null;
}
/**
* Baut den Bogen als eigenständiges HTML.
*
* @param startPosition Ab welchem Etikett gedruckt wird, 1-basiert. Damit
* lässt sich ein angebrochener Bogen weiterverwenden —
* sonst wandert jeder Bogen mit drei benutzten Etiketten
* in den Müll.
*/
export async function etikettenHtml(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<string> {
const leerVorne = Math.max(0, Math.min(startPosition - 1, ETIKETTEN_JE_SEITE - 1));
const zellen: string[] = [];
for (let i = 0; i < leerVorne; i += 1) {
zellen.push('<div class="etikett leer"></div>');
}
for (const geraet of geraete) {
const qr = await qrAlsSvg(geraet.inventarnummer, 16);
zellen.push(`
<div class="etikett">
<div class="qr">${qr}</div>
<div class="text">
<div class="nummer">${html(geraet.inventarnummer)}</div>
<div class="bezeichnung">${html(kuerzen(geraet.bezeichnung, 34))}</div>
${geraet.zusatz ? `<div class="zusatz">${html(kuerzen(geraet.zusatz, 28))}</div>` : ""}
</div>
</div>
`);
}
// Die letzte Seite auffüllen, damit das Raster nicht verrutscht.
while (zellen.length % ETIKETTEN_JE_SEITE !== 0) {
zellen.push('<div class="etikett leer"></div>');
}
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Etiketten — ${html(wehrname)}</title>
<style>
/*
Keine externen Schriften: gedruckt wird mit dem, was das System hergibt.
Eine geladene Schrift würde den Druck verzögern und im Zweifel gar nicht
ankommen.
*/
@page {
size: A4;
margin: 0;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-print-color-adjust: exact;
}
.bogen {
width: 210mm;
padding-top: ${BOGEN.randOben}mm;
padding-left: ${BOGEN.randLinks}mm;
display: grid;
grid-template-columns: repeat(${BOGEN.spalten}, ${BOGEN.etikettBreite}mm);
grid-auto-rows: ${BOGEN.etikettHoehe}mm;
column-gap: ${BOGEN.abstandX}mm;
row-gap: ${BOGEN.abstandY}mm;
}
.etikett {
width: ${BOGEN.etikettBreite}mm;
height: ${BOGEN.etikettHoehe}mm;
padding: 1mm 1.2mm;
display: flex;
align-items: center;
gap: 1.2mm;
overflow: hidden;
}
.etikett.leer { visibility: hidden; }
.qr {
flex: 0 0 16mm;
width: 16mm;
height: 16mm;
display: flex;
align-items: center;
justify-content: center;
}
.qr svg { display: block; }
.text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3mm;
}
.nummer {
font-size: 7.5pt;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bezeichnung {
font-size: 5.5pt;
line-height: 1.15;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.zusatz {
font-size: 5pt;
color: #444;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<div class="bogen">
${zellen.join("\n")}
</div>
</body>
</html>`;
}
/** Erzeugt den Bogen als PDF. */
export async function etikettenPdf(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<Buffer> {
const inhalt = await etikettenHtml(geraete, wehrname, startPosition);
return htmlZuPdf(inhalt, {
randOben: 0,
randUnten: 0,
randLinks: 0,
randRechts: 0,
hintergrundDrucken: true,
});
}
function kuerzen(text: string, zeichen: number): string {
if (text.length <= zeichen) return text;
return `${text.slice(0, zeichen - 1).trimEnd()}…`;
}
+255
View File
@@ -0,0 +1,255 @@
import { formatInTimeZone } from "date-fns-tz";
/**
* Fristen- und Lebensdauerberechnung.
*
* Bewusst reine Funktionen ohne Datenbankzugriff: was hier steht, entscheidet
* am Ende darüber, ob ein Gerät als einsatzbereit gilt oder gesperrt wird.
* Solche Logik gehört an eine Stelle, an der man sie ohne laufenden Stack
* durchtesten kann.
*
* Alle Berechnungen laufen serverseitig und rechnen auf Kalendertagen — die
* Uhrzeit spielt bei Prüffristen keine Rolle, das Datum schon.
*/
/**
* Alle Rechnungen laufen über UTC-Kalendertage.
*
* Der naheliegende Weg wäre startOfDay() aus date-fns — der rechnet aber in
* der Zeitzone des Servers. Ein Prüfdatum kommt aus der Datenbank als
* DATE-Spalte und damit als UTC-Mitternacht; startOfDay() in Europe/Berlin
* schiebt es auf den Vortag zurück. Aus dem 01.03. wird der 28.02., und eine
* Frist wäre einen Tag zu früh fällig.
*
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht.
*/
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
}
/**
* Addiert Monate auf UTC-Basis. Überzählige Tage werden auf das Monatsende
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März.
*/
function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate();
const letzterTagImZielmonat = new Date(Date.UTC(jahr, monat + 1, 0)).getUTCDate();
return new Date(Date.UTC(jahr, monat, Math.min(tag, letzterTagImZielmonat)));
}
/** Addiert Jahre auf UTC-Basis, mit derselben Klemmung am 29. Februar. */
function jahreAddieren(basis: Date, jahre: number): Date {
return monateAddieren(basis, jahre * 12);
}
/**
* Der heutige Kalendertag in Europe/Berlin, als UTC-Mitternacht.
*
* Wichtig für die Stunden um Mitternacht: Um 00:30 Uhr Berliner Zeit ist es
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen.
*/
function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-")
.map(Number);
return new Date(Date.UTC(jahr!, monat! - 1, tag!));
}
/** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
}
/** Wie dringend eine Frist ist. */
export type Fristzustand =
| "ueberfaellig"
| "faellig"
| "bald_faellig"
| "in_ordnung"
| "ohne_frist";
export const FRISTZUSTAND_BEZEICHNUNG: Record<Fristzustand, string> = {
ueberfaellig: "überfällig",
faellig: "heute fällig",
bald_faellig: "läuft bald ab",
in_ordnung: "in Ordnung",
ohne_frist: "ohne Frist",
};
/**
* Vorgaben, die vom Gerätetyp kommen und vom einzelnen Gerät überschrieben
* werden dürfen. Der Hersteller einer bestimmten Charge schreibt manchmal
* etwas anderes vor als die Bauart allgemein.
*/
export interface Vorgabe {
pruefintervallMonate?: number | null;
lebensdauerJahre?: number | null;
lebensdauerBezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
vorwarnzeitMonate?: number | null;
}
/** Der jeweils speziellere Wert gewinnt: Gerät vor Typ. */
export function effektiverWert<T>(amGeraet: T | null | undefined, amTyp: T | null | undefined): T | null {
if (amGeraet !== null && amGeraet !== undefined) return amGeraet;
if (amTyp !== null && amTyp !== undefined) return amTyp;
return null;
}
/**
* Nächster Prüftermin.
*
* Gerechnet wird ab der letzten Prüfung, ersatzweise ab der Indienststellung.
* Ohne beides und ohne Intervall gibt es keinen Termin — das ist kein Fehler,
* sondern der Normalfall für Geräte ohne Prüfpflicht.
*/
export function naechstePruefung(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate?: number | null;
}): Date | null {
const { letztePruefung, indienststellung, intervallMonate } = angaben;
if (!intervallMonate || intervallMonate <= 0) return null;
const basis = letztePruefung ?? indienststellung;
if (!basis) return null;
return monateAddieren(utcTag(basis), intervallMonate);
}
/**
* Ende der Nutzungsdauer.
*
* Helme, Seile, Pressluftatmer und Schutzkleidung sind unabhängig vom Zustand
* nur eine begrenzte Zeit zugelassen. Woran sich das bemisst, ist je nach
* Vorschrift unterschiedlich: bei Seilen ab Herstellung, bei Schutzkleidung
* meist ab Indienststellung.
*/
export function lebensdauerEnde(angaben: {
herstellungsdatum?: Date | null;
indienststellung?: Date | null;
lebensdauerJahre?: number | null;
bezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
}): Date | null {
const { herstellungsdatum, indienststellung, lebensdauerJahre, bezug } = angaben;
if (!lebensdauerJahre || lebensdauerJahre <= 0) return null;
const basis =
bezug === "HERSTELLUNG"
? (herstellungsdatum ?? indienststellung)
: (indienststellung ?? herstellungsdatum);
if (!basis) return null;
return jahreAddieren(utcTag(basis), lebensdauerJahre);
}
/**
* Wie es um eine Frist steht.
*
* @param frist Stichtag, oder null wenn es keinen gibt
* @param vorwarnTage ab wann gewarnt wird
* @param heute überschreibbar, damit Tests nicht von der Uhr abhängen
*/
export function fristzustand(
frist: Date | null | undefined,
vorwarnTage = 30,
heute: Date = new Date(),
): Fristzustand {
if (!frist) return "ohne_frist";
const tage = tagesabstand(heutigerTag(heute), frist);
if (tage < 0) return "ueberfaellig";
if (tage === 0) return "faellig";
if (tage <= vorwarnTage) return "bald_faellig";
return "in_ordnung";
}
/** Verbleibende Kalendertage bis zur Frist. Negativ, wenn sie vorbei ist. */
export function tageBis(frist: Date | null | undefined, heute: Date = new Date()): number | null {
if (!frist) return null;
return tagesabstand(heutigerTag(heute), frist);
}
/**
* Welchen Status ein Gerät haben müsste.
*
* Vorsatz dabei: die Berechnung entscheidet nur über den Übergang
* einsatzbereit ⇄ pruefung_faellig. Wer ein Gerät gesperrt oder ausgesondert
* hat, hat das bewusst getan — das übersteuert keine Fristenrechnung.
*/
export function abgeleiteterGeraetStatus(angaben: {
aktuellerStatus: "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR";
naechstePruefung?: Date | null;
lebensdauerBis?: Date | null;
heute?: Date;
}): "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR" {
const { aktuellerStatus, naechstePruefung: pruefung, lebensdauerBis, heute = new Date() } = angaben;
// Von Hand gesetzte Zustände bleiben, wie sie sind.
if (
aktuellerStatus === "GESPERRT" ||
aktuellerStatus === "AUSGESONDERT" ||
aktuellerStatus === "IN_REPARATUR"
) {
return aktuellerStatus;
}
// Abgelaufene Lebensdauer wiegt schwerer als eine fällige Prüfung: das Gerät
// darf nicht mehr eingesetzt werden, auch wenn es äußerlich in Ordnung ist.
if (lebensdauerBis && fristzustand(lebensdauerBis, 0, heute) === "ueberfaellig") {
return "GESPERRT";
}
const zustand = fristzustand(pruefung, 0, heute);
if (zustand === "ueberfaellig" || zustand === "faellig") return "PRUEFUNG_FAELLIG";
return "EINSATZBEREIT";
}
/**
* Ablaufdatum einer Qualifikation.
* Ohne Gültigkeitsdauer gilt sie unbefristet — dann gibt es kein Ablaufdatum.
*/
export function qualifikationGueltigBis(
erworbenAm: Date,
gueltigkeitMonate: number | null | undefined,
): Date | null {
if (!gueltigkeitMonate || gueltigkeitMonate <= 0) return null;
return monateAddieren(utcTag(erworbenAm), gueltigkeitMonate);
}
/** Zustand einer Qualifikation, mit der Vorwarnzeit ihres Typs. */
export function qualifikationZustand(
gueltigBis: Date | null | undefined,
vorwarnzeitTage = 90,
heute: Date = new Date(),
): Fristzustand {
return fristzustand(gueltigBis, vorwarnzeitTage, heute);
}
/**
* Zeitfenster für das Fristen-Dashboard ab Phase 2. Steht hier, weil die
* Grenzen schon in Phase 1 für die Ablaufwarnung gebraucht werden.
*/
export function fristFenster(heute: Date = new Date()) {
const start = heutigerTag(heute);
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
return {
heute: start,
inSiebenTagen: plusTage(7),
inDreissigTagen: plusTage(30),
inNeunzigTagen: plusTage(90),
};
}
+602
View File
@@ -0,0 +1,602 @@
/**
* Feldbeschreibungen für den Import.
*
* Bewusst frei von Datei- und Datenbankzugriff: Was ein Wert bedeutet und wann
* er gültig ist, lässt sich so ohne laufenden Stack durchtesten. Das Einlesen
* der Datei steht in import.ts.
*
* Jede Wehr bringt eine andere Tabelle mit. Deshalb hat jedes Feld eine Liste
* gebräuchlicher Spaltenüberschriften — damit die Zuordnung meistens schon
* stimmt, bevor jemand etwas anklickt.
*/
export type FeldTyp = "text" | "zahl" | "dezimal" | "datum" | "auswahl" | "jaNein";
export interface ImportFeld {
/** Feldname im Datenmodell. */
schluessel: string;
bezeichnung: string;
typ: FeldTyp;
pflicht: boolean;
/** Überschriften, unter denen dieses Feld erfahrungsgemäß auftaucht. */
aliase: string[];
/** Erlaubte Werte bei typ "auswahl", jeweils Anzeige -> gespeicherter Wert. */
auswahl?: Record<string, string>;
hinweis?: string;
maxLaenge?: number;
}
/** Vergleichsform einer Überschrift: ohne Umlaute, Sonderzeichen, Groß/Klein. */
export function normalisiereUeberschrift(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]/g, "");
}
// -----------------------------------------------------------------------------
// Wertumwandlung
// -----------------------------------------------------------------------------
export interface WertErgebnis<T = unknown> {
ok: boolean;
wert?: T;
fehler?: string;
}
/**
* Datum aus dem, was in Tabellen tatsächlich steht.
*
* Abgedeckt: 02.09.2026, 2.9.2026, 02.09.26, 2026-09-02, 02/09/2026 und die
* Seriennummer, die Excel intern verwendet. Bei zweistelligen Jahren gilt die
* übliche Grenze: 70 und darüber ist 19xx, darunter 20xx.
*/
export function datumLesen(roh: string): WertErgebnis<Date> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
// Excel-Seriennummer (Tage seit dem 30.12.1899).
if (/^\d{5}$/.test(text)) {
const tage = Number(text);
const datum = new Date(Date.UTC(1899, 11, 30));
datum.setUTCDate(datum.getUTCDate() + tage);
return { ok: true, wert: datum };
}
// ISO: 2026-09-02
const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) {
return bauDatum(Number(iso[1]), Number(iso[2]), Number(iso[3]));
}
// Deutsch: 02.09.2026 oder 2.9.26, auch mit / oder -
const deutsch = text.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/);
if (deutsch) {
let jahr = Number(deutsch[3]);
if (jahr < 100) jahr += jahr >= 70 ? 1900 : 2000;
return bauDatum(jahr, Number(deutsch[2]), Number(deutsch[1]));
}
return { ok: false, fehler: `"${roh}" ist kein erkennbares Datum (erwartet: TT.MM.JJJJ)` };
}
function bauDatum(jahr: number, monat: number, tag: number): WertErgebnis<Date> {
if (monat < 1 || monat > 12) return { ok: false, fehler: `Monat ${monat} gibt es nicht` };
if (tag < 1 || tag > 31) return { ok: false, fehler: `Tag ${tag} gibt es nicht` };
const datum = new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
// Rollt der 31.02. auf den 03.03. weiter, war die Eingabe falsch.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) {
return { ok: false, fehler: `Den ${tag}.${monat}.${jahr} gibt es nicht` };
}
if (jahr < 1900 || jahr > 2100) {
return { ok: false, fehler: `Das Jahr ${jahr} ist unplausibel` };
}
return { ok: true, wert: datum };
}
/** Ganze Zahl. Punkt als Tausendertrenner wird entfernt. */
export function zahlLesen(roh: string): WertErgebnis<number> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const bereinigt = text.replace(/\./g, "").replace(/\s/g, "");
if (!/^-?\d+$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine ganze Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Dezimalzahl in deutscher oder englischer Schreibweise. */
export function dezimalLesen(roh: string): WertErgebnis<number> {
const text = roh.trim().replace(/[€\s]/g, "");
if (!text) return { ok: true, wert: undefined };
// 1.234,56 -> 1234.56 | 1,234.56 -> 1234.56 | 1234,56 -> 1234.56
let bereinigt = text;
const hatKomma = text.includes(",");
const hatPunkt = text.includes(".");
if (hatKomma && hatPunkt) {
bereinigt =
text.lastIndexOf(",") > text.lastIndexOf(".")
? text.replace(/\./g, "").replace(",", ".")
: text.replace(/,/g, "");
} else if (hatKomma) {
bereinigt = text.replace(",", ".");
}
if (!/^-?\d+(\.\d+)?$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Wahrheitswert aus den Schreibweisen, die in Tabellen vorkommen. */
export function jaNeinLesen(roh: string): WertErgebnis<boolean> {
const text = roh.trim().toLowerCase();
if (!text) return { ok: true, wert: undefined };
if (["ja", "j", "wahr", "true", "x", "1", "yes", "y"].includes(text)) {
return { ok: true, wert: true };
}
if (["nein", "n", "falsch", "false", "0", "no", "-"].includes(text)) {
return { ok: true, wert: false };
}
return { ok: false, fehler: `"${roh}" ist weder ja noch nein` };
}
/** Auswahlwert, tolerant gegenüber Schreibweise. */
export function auswahlLesen(roh: string, auswahl: Record<string, string>): WertErgebnis<string> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const gesucht = normalisiereUeberschrift(text);
for (const [anzeige, wert] of Object.entries(auswahl)) {
if (normalisiereUeberschrift(anzeige) === gesucht) return { ok: true, wert };
if (normalisiereUeberschrift(wert) === gesucht) return { ok: true, wert };
}
return {
ok: false,
fehler: `"${roh}" ist kein gültiger Wert. Erlaubt: ${Object.keys(auswahl).join(", ")}`,
};
}
/** Wandelt einen Rohwert nach der Feldbeschreibung um. */
export function wertLesen(feld: ImportFeld, roh: string): WertErgebnis {
const text = (roh ?? "").trim();
if (!text) {
return feld.pflicht
? { ok: false, fehler: `${feld.bezeichnung} ist ein Pflichtfeld` }
: { ok: true, wert: undefined };
}
switch (feld.typ) {
case "datum":
return datumLesen(text);
case "zahl":
return zahlLesen(text);
case "dezimal":
return dezimalLesen(text);
case "jaNein":
return jaNeinLesen(text);
case "auswahl":
return auswahlLesen(text, feld.auswahl ?? {});
case "text":
default:
if (feld.maxLaenge && text.length > feld.maxLaenge) {
return {
ok: false,
fehler: `${feld.bezeichnung} ist zu lang (${text.length} von höchstens ${feld.maxLaenge} Zeichen)`,
};
}
return { ok: true, wert: text };
}
}
// -----------------------------------------------------------------------------
// Felder je Datenart
// -----------------------------------------------------------------------------
const GERAETE_STATUS = {
einsatzbereit: "EINSATZBEREIT",
"Prüfung fällig": "PRUEFUNG_FAELLIG",
gesperrt: "GESPERRT",
ausgesondert: "AUSGESONDERT",
"in Reparatur": "IN_REPARATUR",
};
export const GERAET_FELDER: ImportFeld[] = [
{
schluessel: "inventarnummer",
bezeichnung: "Inventarnummer",
typ: "text",
pflicht: true,
maxLaenge: 60,
aliase: ["inventarnummer", "invnr", "inventarnr", "nummer", "nr", "invnummer", "barcode"],
hinweis: "Muss innerhalb der Wehr eindeutig sein.",
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 160,
aliase: ["bezeichnung", "name", "geraet", "geraetebezeichnung", "artikel", "beschreibung"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fabrikat", "marke", "lieferant"],
},
{
schluessel: "modell",
bezeichnung: "Modell",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["modell", "typ", "typbezeichnung", "ausfuehrung"],
},
{
schluessel: "seriennummer",
bezeichnung: "Seriennummer",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["seriennummer", "seriennr", "sn", "fabriknummer", "geraetenummer"],
},
{
schluessel: "herstellungsdatum",
bezeichnung: "Herstellungsdatum",
typ: "datum",
pflicht: false,
aliase: ["herstellungsdatum", "baujahr", "herstelldatum", "produktionsdatum", "fertigung"],
},
{
schluessel: "indienststellung",
bezeichnung: "Indienststellung",
typ: "datum",
pflicht: false,
aliase: ["indienststellung", "indienst", "anschaffung", "anschaffungsdatum", "kaufdatum", "beschaffung"],
},
{
schluessel: "anschaffungspreis",
bezeichnung: "Anschaffungspreis",
typ: "dezimal",
pflicht: false,
aliase: ["anschaffungspreis", "preis", "kosten", "betrag", "wert", "kaufpreis"],
},
{
schluessel: "status",
bezeichnung: "Status",
typ: "auswahl",
pflicht: false,
auswahl: GERAETE_STATUS,
aliase: ["status", "zustand", "einsatzbereit", "verfuegbarkeit"],
},
{
schluessel: "pruefintervallMonate",
bezeichnung: "Prüfintervall (Monate)",
typ: "zahl",
pflicht: false,
aliase: ["pruefintervall", "intervall", "pruefintervallmonate", "pruefungalle", "wartungsintervall"],
},
{
schluessel: "letztePruefung",
bezeichnung: "Letzte Prüfung",
typ: "datum",
pflicht: false,
aliase: ["letztepruefung", "letzteprufung", "gepruefjtam", "pruefdatum", "letztewartung"],
},
{
schluessel: "naechstePruefung",
bezeichnung: "Nächste Prüfung",
typ: "datum",
pflicht: false,
aliase: ["naechstepruefung", "faelligam", "faellig", "naechstewartung", "pruefungfaellig"],
},
{
schluessel: "lebensdauerJahre",
bezeichnung: "Lebensdauer (Jahre)",
typ: "zahl",
pflicht: false,
aliase: ["lebensdauer", "lebensdauerjahre", "nutzungsdauer", "maxalter"],
},
{
schluessel: "standort",
bezeichnung: "Standort",
typ: "text",
pflicht: false,
maxLaenge: 160,
aliase: ["standort", "ort", "lagerort", "geraetehaus", "raum"],
hinweis: "Wird über den Namen zugeordnet. Unbekannte Standorte werden angelegt.",
},
{
schluessel: "fahrzeug",
bezeichnung: "Fahrzeug",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["fahrzeug", "funkrufname", "verlastung", "verlastetauf"],
hinweis: "Funkrufname oder Kennzeichen. Wird nur zugeordnet, wenn das Fahrzeug existiert.",
},
{
schluessel: "geraeteraum",
bezeichnung: "Geräteraum",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["geraeteraum", "raumamfahrzeug", "fach", "g", "entnahmestelle"],
},
{
schluessel: "bemerkung",
bezeichnung: "Bemerkung",
typ: "text",
pflicht: false,
maxLaenge: 1000,
aliase: ["bemerkung", "notiz", "kommentar", "anmerkung", "hinweis"],
},
];
export const PERSON_FELDER: ImportFeld[] = [
{
schluessel: "nachname",
bezeichnung: "Nachname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["nachname", "name", "familienname", "zuname"],
},
{
schluessel: "vorname",
bezeichnung: "Vorname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["vorname", "rufname"],
},
{
schluessel: "mitgliedsnummer",
bezeichnung: "Mitgliedsnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["mitgliedsnummer", "mitgliednr", "nummer", "personalnummer", "nr"],
},
{
schluessel: "geburtsdatum",
bezeichnung: "Geburtsdatum",
typ: "datum",
pflicht: false,
aliase: ["geburtsdatum", "geboren", "gebdatum", "geburtstag"],
},
{
schluessel: "email",
bezeichnung: "E-Mail",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["email", "mail", "emailadresse", "epost"],
},
{
schluessel: "telefon",
bezeichnung: "Telefon",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["telefon", "tel", "festnetz", "telefonnummer"],
},
{
schluessel: "mobil",
bezeichnung: "Mobil",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["mobil", "handy", "mobilnummer", "handynummer"],
},
{
schluessel: "strasse",
bezeichnung: "Straße",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["strasse", "adresse", "anschrift", "strassehausnummer"],
},
{
schluessel: "plz",
bezeichnung: "PLZ",
typ: "text",
pflicht: false,
maxLaenge: 10,
aliase: ["plz", "postleitzahl"],
},
{
schluessel: "ort",
bezeichnung: "Ort",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["ort", "wohnort", "stadt", "gemeinde"],
},
{
schluessel: "eintrittsdatum",
bezeichnung: "Eintrittsdatum",
typ: "datum",
pflicht: false,
aliase: ["eintrittsdatum", "eintritt", "beitritt", "dabeiseit", "mitgliedseit"],
},
{
schluessel: "abteilung",
bezeichnung: "Abteilung",
typ: "text",
pflicht: false,
maxLaenge: 60,
aliase: ["abteilung", "gruppe", "status", "zugehoerigkeit"],
hinweis: "Zum Beispiel: Aktive, Jugendfeuerwehr, Passive, Ehrenmitglieder.",
},
];
export const FAHRZEUG_FELDER: ImportFeld[] = [
{
schluessel: "funkrufname",
bezeichnung: "Funkrufname",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["funkrufname", "funkruf", "rufname", "florian"],
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["bezeichnung", "name", "fahrzeug", "fahrzeugbezeichnung"],
},
{
schluessel: "dinTyp",
bezeichnung: "Typ nach DIN",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["dintyp", "typ", "fahrzeugtyp", "din", "kurzbezeichnung"],
},
{
schluessel: "kennzeichen",
bezeichnung: "Kennzeichen",
typ: "text",
pflicht: false,
maxLaenge: 20,
aliase: ["kennzeichen", "kfzkennzeichen", "amtlicheskennzeichen", "nummernschild"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fahrgestell", "marke", "fabrikat"],
},
{
schluessel: "aufbauhersteller",
bezeichnung: "Aufbauhersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["aufbauhersteller", "aufbau", "ausbau"],
},
{
schluessel: "fahrgestellnummer",
bezeichnung: "Fahrgestellnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["fahrgestellnummer", "fin", "vin", "fahrgestellnr"],
},
{
schluessel: "baujahr",
bezeichnung: "Baujahr",
typ: "zahl",
pflicht: false,
aliase: ["baujahr", "jahr", "bj"],
},
{
schluessel: "erstzulassung",
bezeichnung: "Erstzulassung",
typ: "datum",
pflicht: false,
aliase: ["erstzulassung", "ez", "zulassung"],
},
{
schluessel: "tuevBis",
bezeichnung: "TÜV bis",
typ: "datum",
pflicht: false,
aliase: ["tuevbis", "tuev", "hu", "hauptuntersuchung", "naechstertuev"],
},
{
schluessel: "sitzplaetze",
bezeichnung: "Sitzplätze",
typ: "zahl",
pflicht: false,
aliase: ["sitzplaetze", "besatzung", "plaetze", "sitze"],
},
{
schluessel: "tankinhalt",
bezeichnung: "Tankinhalt (Liter)",
typ: "zahl",
pflicht: false,
aliase: ["tankinhalt", "loeschwasser", "wassertank", "tank"],
},
];
export const IMPORT_ARTEN = {
geraet: { bezeichnung: "Geräte", felder: GERAET_FELDER },
person: { bezeichnung: "Mitglieder", felder: PERSON_FELDER },
fahrzeug: { bezeichnung: "Fahrzeuge", felder: FAHRZEUG_FELDER },
} as const;
export type ImportArt = keyof typeof IMPORT_ARTEN;
/**
* Schlägt für jede Spalte der Datei ein Zielfeld vor.
*
* Zuerst über die Aliasliste, dann über einen Teilstringvergleich. Was nicht
* zugeordnet werden kann, bleibt leer — die Zuordnung wird ohnehin angezeigt
* und lässt sich von Hand ändern, bevor irgendetwas geschrieben wird.
*/
export function spaltenZuordnen(
spalten: string[],
felder: ImportFeld[],
): Record<string, string | null> {
const zuordnung: Record<string, string | null> = {};
const vergeben = new Set<string>();
// Erste Runde: genaue Treffer über die Aliase.
for (const spalte of spalten) {
const normal = normalisiereUeberschrift(spalte);
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
(normalisiereUeberschrift(feld.schluessel) === normal ||
feld.aliase.some((alias) => normalisiereUeberschrift(alias) === normal)),
);
if (treffer) {
zuordnung[spalte] = treffer.schluessel;
vergeben.add(treffer.schluessel);
}
}
// Zweite Runde: Teiltreffer für alles, was übrig ist.
for (const spalte of spalten) {
if (zuordnung[spalte]) continue;
const normal = normalisiereUeberschrift(spalte);
if (!normal) {
zuordnung[spalte] = null;
continue;
}
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
feld.aliase.some((alias) => {
const a = normalisiereUeberschrift(alias);
return a.length >= 4 && (normal.includes(a) || a.includes(normal));
}),
);
zuordnung[spalte] = treffer?.schluessel ?? null;
if (treffer) vergeben.add(treffer.schluessel);
}
return zuordnung;
}
+344
View File
@@ -0,0 +1,344 @@
import "server-only";
import Papa from "papaparse";
import ExcelJS from "exceljs";
import {
IMPORT_ARTEN,
spaltenZuordnen,
wertLesen,
type ImportArt,
type ImportFeld,
} from "@/lib/stammdaten/import-felder";
/**
* Datei-Import für Stammdaten.
*
* Ablauf in drei Schritten, und der mittlere ist der wichtige:
*
* 1. Datei einlesen — CSV oder Excel, Spaltenüberschriften erkennen
* 2. Zuordnung und Vorschau — was landet wo, und was stimmt daran nicht
* 3. Übernehmen — erst hier wird geschrieben
*
* Zwischen 2 und 3 sitzt ein Mensch. Ohne diese Vorschau würde eine Wehr dem
* Ergebnis nicht trauen, und zu Recht: eine falsch zugeordnete Spalte
* verteilt Seriennummern auf Inventarnummern, und das merkt man erst Wochen
* später am Regal.
*/
/** Höchstwerte, damit ein versehentlich gewähltes 200-MB-Archiv nicht den
* Server beschäftigt. */
export const MAX_DATEIGROESSE = 10 * 1024 * 1024;
export const MAX_ZEILEN = 5_000;
export interface GeleseneTabelle {
spalten: string[];
zeilen: Record<string, string>[];
/** Wie viele Zeilen die Datei hatte, bevor gekürzt wurde. */
zeilenGesamt: number;
gekuerzt: boolean;
}
export interface ZellFehler {
zeile: number;
spalte: string;
feld: string;
meldung: string;
}
export interface VorschauZeile {
/** Zeilennummer in der Datei, 1-basiert ohne Überschrift. */
nummer: number;
werte: Record<string, unknown>;
rohwerte: Record<string, string>;
fehler: ZellFehler[];
/** true, wenn diese Zeile übernommen werden kann. */
uebernehmbar: boolean;
}
export interface ImportVorschau {
art: ImportArt;
felder: ImportFeld[];
spalten: string[];
zuordnung: Record<string, string | null>;
zeilen: VorschauZeile[];
zeilenGesamt: number;
gekuerzt: boolean;
/** Pflichtfelder, denen keine Spalte zugeordnet ist. */
fehlendePflichtfelder: string[];
anzahlUebernehmbar: number;
anzahlFehlerhaft: number;
}
// -----------------------------------------------------------------------------
// Schritt 1: Datei einlesen
// -----------------------------------------------------------------------------
/** Erkennt das Format an der Endung, ersatzweise am Inhalt. */
export function formatErkennen(dateiname: string): "csv" | "xlsx" | null {
const endung = dateiname.toLowerCase().split(".").pop();
if (endung === "csv" || endung === "txt") return "csv";
if (endung === "xlsx" || endung === "xlsm") return "xlsx";
return null;
}
/**
* CSV einlesen. Das Trennzeichen wird erraten — deutsche Tabellen kommen
* meistens mit Semikolon, weil Excel das im deutschen Sprachraum so schreibt.
*/
export function csvLesen(inhalt: string): GeleseneTabelle {
const ergebnis = Papa.parse<Record<string, string>>(inhalt, {
header: true,
skipEmptyLines: "greedy",
delimiter: "",
delimitersToGuess: [";", ",", "\t", "|"],
transformHeader: (kopf) => kopf.trim(),
});
const alleZeilen = (ergebnis.data ?? []).filter((zeile) =>
Object.values(zeile).some((wert) => (wert ?? "").toString().trim() !== ""),
);
const spalten = (ergebnis.meta.fields ?? []).filter((feld) => feld.trim() !== "");
return {
spalten,
zeilen: alleZeilen.slice(0, MAX_ZEILEN).map(zeileNormalisieren),
zeilenGesamt: alleZeilen.length,
gekuerzt: alleZeilen.length > MAX_ZEILEN,
};
}
/** Excel einlesen. Es wird immer das erste Arbeitsblatt genommen. */
export async function excelLesen(daten: Buffer): Promise<GeleseneTabelle> {
const mappe = new ExcelJS.Workbook();
await mappe.xlsx.load(daten as unknown as ArrayBuffer);
const blatt = mappe.worksheets[0];
if (!blatt) {
return { spalten: [], zeilen: [], zeilenGesamt: 0, gekuerzt: false };
}
const kopfzeile = blatt.getRow(1);
const spalten: string[] = [];
kopfzeile.eachCell({ includeEmpty: false }, (zelle, nummer) => {
const text = zellenText(zelle.value).trim();
spalten[nummer - 1] = text || `Spalte ${nummer}`;
});
const zeilen: Record<string, string>[] = [];
let gesamt = 0;
blatt.eachRow({ includeEmpty: false }, (zeile, nummer) => {
if (nummer === 1) return;
const datensatz: Record<string, string> = {};
let hatInhalt = false;
spalten.forEach((spalte, index) => {
if (!spalte) return;
const wert = zellenText(zeile.getCell(index + 1).value).trim();
datensatz[spalte] = wert;
if (wert) hatInhalt = true;
});
if (!hatInhalt) return;
gesamt += 1;
if (zeilen.length < MAX_ZEILEN) zeilen.push(datensatz);
});
return {
spalten: spalten.filter(Boolean),
zeilen,
zeilenGesamt: gesamt,
gekuerzt: gesamt > MAX_ZEILEN,
};
}
/** Excel liefert je nach Zelle Zahlen, Daten, Formelergebnisse oder Rich Text. */
function zellenText(wert: ExcelJS.CellValue): string {
if (wert === null || wert === undefined) return "";
if (wert instanceof Date) {
const tag = String(wert.getUTCDate()).padStart(2, "0");
const monat = String(wert.getUTCMonth() + 1).padStart(2, "0");
return `${tag}.${monat}.${wert.getUTCFullYear()}`;
}
if (typeof wert === "object") {
if ("text" in wert && typeof wert.text === "string") return wert.text;
if ("result" in wert) return zellenText(wert.result as ExcelJS.CellValue);
if ("richText" in wert && Array.isArray(wert.richText)) {
return wert.richText.map((teil) => teil.text).join("");
}
if ("hyperlink" in wert && typeof wert.text === "string") return wert.text;
return "";
}
return String(wert);
}
function zeileNormalisieren(zeile: Record<string, unknown>): Record<string, string> {
const ergebnis: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(zeile)) {
ergebnis[schluessel.trim()] = (wert ?? "").toString().trim();
}
return ergebnis;
}
/** Liest eine hochgeladene Datei, unabhängig vom Format. */
export async function tabelleLesen(
daten: Buffer,
dateiname: string,
): Promise<GeleseneTabelle> {
if (daten.length > MAX_DATEIGROESSE) {
throw new Error(
`Die Datei ist zu groß (${Math.round(daten.length / 1024 / 1024)} MB, erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB).`,
);
}
const format = formatErkennen(dateiname);
if (format === "xlsx") return excelLesen(daten);
if (format === "csv") return csvLesen(textAusPuffer(daten));
throw new Error(
`Das Format von "${dateiname}" wird nicht unterstützt. Erlaubt sind CSV und Excel (.xlsx).`,
);
}
/**
* Text aus dem Puffer. Deutsche Exporte kommen oft noch als Windows-1252 —
* eine BOM oder ungültige UTF-8-Folgen verraten das.
*/
export function textAusPuffer(daten: Buffer): string {
// UTF-8 mit BOM
if (daten.length >= 3 && daten[0] === 0xef && daten[1] === 0xbb && daten[2] === 0xbf) {
return daten.subarray(3).toString("utf8");
}
const alsUtf8 = daten.toString("utf8");
// Das Ersetzungszeichen zeigt an, dass es kein gültiges UTF-8 war.
if (alsUtf8.includes("�")) {
return daten.toString("latin1");
}
return alsUtf8;
}
// -----------------------------------------------------------------------------
// Schritt 2: Zuordnung und Vorschau
// -----------------------------------------------------------------------------
/**
* Erzeugt die Vorschau. Schreibt nichts — das ist der ganze Sinn.
*
* @param zuordnung optional; ohne Angabe wird automatisch zugeordnet
*/
export function vorschauErstellen(
tabelle: GeleseneTabelle,
art: ImportArt,
zuordnung?: Record<string, string | null>,
): ImportVorschau {
const felder = IMPORT_ARTEN[art].felder;
const zuord = zuordnung ?? spaltenZuordnen(tabelle.spalten, felder);
const zugeordneteFelder = new Set(
Object.values(zuord).filter((wert): wert is string => Boolean(wert)),
);
const fehlendePflichtfelder = felder
.filter((feld) => feld.pflicht && !zugeordneteFelder.has(feld.schluessel))
.map((feld) => feld.bezeichnung);
const zeilen: VorschauZeile[] = tabelle.zeilen.map((rohzeile, index) => {
const werte: Record<string, unknown> = {};
const fehler: ZellFehler[] = [];
for (const [spalte, feldSchluessel] of Object.entries(zuord)) {
if (!feldSchluessel) continue;
const feld = felder.find((f) => f.schluessel === feldSchluessel);
if (!feld) continue;
const ergebnis = wertLesen(feld, rohzeile[spalte] ?? "");
if (!ergebnis.ok) {
fehler.push({
zeile: index + 1,
spalte,
feld: feld.bezeichnung,
meldung: ergebnis.fehler ?? "Ungültiger Wert",
});
continue;
}
if (ergebnis.wert !== undefined) werte[feldSchluessel] = ergebnis.wert;
}
// Pflichtfelder, für die es gar keine Spalte gibt, melden wir je Zeile —
// sonst sieht man in der Vorschau nicht, dass die Zeile unbrauchbar ist.
for (const feld of felder) {
if (!feld.pflicht) continue;
if (werte[feld.schluessel] !== undefined) continue;
if (fehler.some((f) => f.feld === feld.bezeichnung)) continue;
fehler.push({
zeile: index + 1,
spalte: "—",
feld: feld.bezeichnung,
meldung: `${feld.bezeichnung} fehlt`,
});
}
return {
nummer: index + 1,
werte,
rohwerte: rohzeile,
fehler,
uebernehmbar: fehler.length === 0,
};
});
// Dubletten innerhalb der Datei aufspüren — die fallen sonst erst beim
// Schreiben auf, und dann ist die halbe Datei schon drin.
markiereDubletten(zeilen, art);
return {
art,
felder,
spalten: tabelle.spalten,
zuordnung: zuord,
zeilen,
zeilenGesamt: tabelle.zeilenGesamt,
gekuerzt: tabelle.gekuerzt,
fehlendePflichtfelder,
anzahlUebernehmbar: zeilen.filter((zeile) => zeile.uebernehmbar).length,
anzahlFehlerhaft: zeilen.filter((zeile) => !zeile.uebernehmbar).length,
};
}
/** Welches Feld eine Zeile innerhalb der Datei eindeutig macht. */
const SCHLUESSELFELD: Record<ImportArt, string> = {
geraet: "inventarnummer",
person: "mitgliedsnummer",
fahrzeug: "funkrufname",
};
function markiereDubletten(zeilen: VorschauZeile[], art: ImportArt): void {
const feld = SCHLUESSELFELD[art];
const gesehen = new Map<string, number>();
for (const zeile of zeilen) {
const wert = zeile.werte[feld];
if (typeof wert !== "string" || !wert) continue;
const schluessel = wert.toLowerCase();
const zuerst = gesehen.get(schluessel);
if (zuerst !== undefined) {
zeile.fehler.push({
zeile: zeile.nummer,
spalte: feld,
feld,
meldung: `Kommt in dieser Datei schon in Zeile ${zuerst} vor`,
});
zeile.uebernehmbar = false;
} else {
gesehen.set(schluessel, zeile.nummer);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Auswertung gescannter Codes.
*
* Bewusst ohne "server-only" und ohne Abhängigkeiten: Diese Funktion läuft im
* Browser, direkt am Kamerabild. Die Erzeugung von QR-Codes steht dagegen in
* qr.ts und bleibt auf dem Server.
*/
/**
* Liest die Inventarnummer aus dem, was ein Scanner geliefert hat.
*
* Akzeptiert wird alles, was plausibel ist: die volle URL aus unserem eigenen
* Code, ein Pfad, oder die blanke Inventarnummer — manche Wehr hat schon
* Etiketten mit reinen Nummern im Bestand, und die sollen weiter funktionieren.
* Fremde Codes (etwa der Hersteller-Barcode auf dem Gerät) geben null zurück.
*/
export function inventarnummerAusScan(eingabe: string): string | null {
const text = eingabe.trim();
if (!text) return null;
// Volle URL oder Pfad auf /g/<nr>
const treffer = text.match(/\/g\/([^/?#\s]+)/i);
if (treffer?.[1]) {
try {
return decodeURIComponent(treffer[1]);
} catch {
return treffer[1];
}
}
// Eine fremde URL ist keine Inventarnummer.
if (/^https?:\/\//i.test(text)) return null;
// Blanke Nummer: Buchstaben, Ziffern, Bindestrich, Schrägstrich, Punkt.
if (/^[A-Za-z0-9][A-Za-z0-9._/-]{0,59}$/.test(text)) return text;
return null;
}
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import QRCode from "qrcode";
import { env } from "@/lib/env";
/**
* QR-Codes für Geräte.
*
* Der Code enthält eine kurze URL auf die eigene Installation:
*
* https://<florian>/g/<inventarnummer>
*
* Bewusst die Inventarnummer und nicht die UUID: Die Nummer steht ohnehin
* lesbar auf dem Etikett, ist kurz genug für einen groben Code mit dicken
* Modulen, und wer den Code mit einem beliebigen Scanner liest, sieht sofort,
* um welches Gerät es geht. Eine UUID wäre 36 Zeichen lang und für niemanden
* lesbar.
*
* Die URL ist keine Berechtigung: /g/<nr> leitet nur weiter, und die Zielseite
* verlangt eine Anmeldung wie jede andere auch. Wer den Aufkleber fotografiert,
* bekommt dadurch keinen Zugang.
*/
/** Fehlerkorrektur. "M" verträgt rund 15 % Verlust — genug für ein Etikett,
* das im Geräteraum Staub und Schrammen abbekommt. */
const FEHLERKORREKTUR = "M" as const;
export function qrZiel(inventarnummer: string): string {
const basis = env.APP_URL.replace(/\/+$/, "");
return `${basis}/g/${encodeURIComponent(inventarnummer)}`;
}
/**
* QR-Code als SVG. SVG statt PNG, weil der Etikettenbogen als PDF gerendert
* wird — dort skaliert eine Vektorgrafik ohne Treppenkanten.
*/
export async function qrAlsSvg(inventarnummer: string, groesseMm = 20): Promise<string> {
const svg = await QRCode.toString(qrZiel(inventarnummer), {
type: "svg",
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 0,
width: Math.round(groesseMm * 8),
});
// qrcode setzt feste Pixelmaße; für das PDF sollen Millimeter gelten.
return svg
.replace(/width="[^"]+"/, `width="${groesseMm}mm"`)
.replace(/height="[^"]+"/, `height="${groesseMm}mm"`);
}
/** QR-Code als Data-URI. Für die Anzeige in der Gerätedetailseite. */
export async function qrAlsDataUri(inventarnummer: string, groessePx = 256): Promise<string> {
return QRCode.toDataURL(qrZiel(inventarnummer), {
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 1,
width: groessePx,
color: { dark: "#000000ff", light: "#ffffffff" },
});
}
/**
* Die Auswertung gescannter Codes steht in qr-parser.ts — sie läuft im
* Browser und darf deshalb nicht in einem "server-only"-Modul liegen.
*/
export { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
/**
* tRPC im Browser.
*
* Über dieses Objekt laufen alle Aufrufe aus Client-Komponenten. Die Typen
* kommen direkt aus dem Server-Router — ändert sich dort ein Feld, meckert
* hier der Compiler, und zwar bevor jemand die Seite öffnet.
*/
export const trpc = createTRPCReact<AppRouter>();
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "@/lib/trpc/client";
/**
* Hängt tRPC und React Query in den Baum.
*
* Die Einstellungen sind auf eine Verwaltungsanwendung zugeschnitten: Daten
* bleiben eine halbe Minute frisch, und beim Fensterwechsel wird nicht neu
* geladen. Wer eine Geräteliste offen hat und kurz ins Mailprogramm wechselt,
* soll nicht bei jeder Rückkehr ein Nachladen sehen.
*
* Ausnahme wird ab Phase 5 die Atemschutzüberwachung — dort zählt jede
* Sekunde, und die Ansicht bekommt eigene Einstellungen.
*/
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (versuche, fehler) => {
// Bei fehlender Berechtigung oder unbekanntem Datensatz bringt
// ein zweiter Versuch nichts.
const code = (fehler as { data?: { code?: string } })?.data?.code;
if (code === "FORBIDDEN" || code === "UNAUTHORIZED" || code === "NOT_FOUND") {
return false;
}
return versuche < 2;
},
},
},
}),
);
const [trpcClient] = React.useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
maxURLLength: 2000,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}