Second half of the mass import: the transactional loader, the /import page
and a template generated from the same schema the validation uses.
Everything happens in one transaction. A half-loaded organisation — areas
without departments, positions without people — is worse than none, because
it looks like data. The dry run is the same code path with a rollback at the
end, so the report is built against the real current state rather than a
copy, and nothing is cached between checking and committing: the file is
sent twice. That costs one upload and avoids server-side state that can
expire, fill up, or be confused between two people.
Personnel numbers are taken from the file, not reassigned. personnel_number
is GENERATED ALWAYS AS IDENTITY, so this needs OVERRIDING SYSTEM VALUE and a
hand-written insert — worth it, because the number is on payslips, in files
and on badges. An import that reissues it is not a migration. The identity
counter is advanced afterwards; without that the next hire draws a number
the import already used, and the unique index refuses it weeks later, far
from the cause.
Three defects the first real run against the database exposed, none of which
typecheck, lint or 231 tests could have found:
- weekly_hours is bound to employment type by a CHECK constraint: full time
is exactly 38.5. The import reached the insert and was rolled back. Now
it is a finding with a row number.
- Titles are restricted to a fixed list by another CHECK. Same treatment.
- setval() needs UPDATE on the sequence, which `usage, select` does not
grant. Migration 20260803120000 adds it; until it is applied, an import
containing people will fail at the last step and take itself back.
I also had exit_date > entry_date where the database has >=. Someone who
never starts enters and leaves the same day; the stricter rule would have
rejected a real case.
Verified against the live database through the actual route and session: a
file with four deliberate faults produced exactly four findings, each with
sheet, row and column, and the rollback left nothing behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
8.5 KiB
TypeScript
211 lines
8.5 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import { Button } from "@/components/ui/Button";
|
||
import { CARD_CLASS } from "@/components/ui/Card";
|
||
|
||
// Der Ablauf hat bewusst zwei Schritte: prüfen, dann übernehmen.
|
||
//
|
||
// Ein Import ist nicht rückgängig zu machen. Wer 800 Zeilen schickt, soll
|
||
// vorher sehen, was entstehen würde — und bei einem Fehler die Zeilennummer
|
||
// lesen, nicht „Import fehlgeschlagen".
|
||
|
||
type Befund = { blatt: string; zeile: number | null; spalte: string | null; wert?: string; meldung: string };
|
||
|
||
type Antwort = {
|
||
ok: boolean;
|
||
geprueft: boolean;
|
||
blaetter: string[];
|
||
fehler: Befund[];
|
||
hinweise: Befund[];
|
||
anzahl: Record<string, number>;
|
||
bericht?: Record<string, number>;
|
||
meldung?: string;
|
||
};
|
||
|
||
/** Mehr als das zeigt niemand durch; der Rest steht in der Anzahl. */
|
||
const MAX_ANZEIGE = 200;
|
||
|
||
export function ImportWorkbench() {
|
||
const [dateien, setDateien] = useState<File[]>([]);
|
||
const [antwort, setAntwort] = useState<Antwort | null>(null);
|
||
const [laeuft, setLaeuft] = useState<"pruefen" | "uebernehmen" | null>(null);
|
||
const [fehlschlag, setFehlschlag] = useState<string | null>(null);
|
||
const eingabe = useRef<HTMLInputElement>(null);
|
||
|
||
async function senden(nurPruefen: boolean) {
|
||
if (dateien.length === 0) return;
|
||
setLaeuft(nurPruefen ? "pruefen" : "uebernehmen");
|
||
setFehlschlag(null);
|
||
try {
|
||
const daten = new FormData();
|
||
for (const d of dateien) daten.append("datei", d);
|
||
if (nurPruefen) daten.append("pruefen", "1");
|
||
const antwort = await fetch("/api/import", { method: "POST", body: daten });
|
||
const inhalt = (await antwort.json()) as Antwort;
|
||
setAntwort(inhalt);
|
||
} catch {
|
||
setFehlschlag("Die Datei konnte nicht übertragen werden. Bitte erneut versuchen.");
|
||
} finally {
|
||
setLaeuft(null);
|
||
}
|
||
}
|
||
|
||
function neueAuswahl(liste: FileList | null) {
|
||
setDateien(liste ? Array.from(liste) : []);
|
||
// Ein alter Bericht zu einer neuen Datei ist schlimmer als keiner.
|
||
setAntwort(null);
|
||
setFehlschlag(null);
|
||
}
|
||
|
||
const uebernommen = antwort?.bericht !== undefined;
|
||
const bereit = antwort?.ok === true && antwort.geprueft && !uebernommen;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-4">
|
||
<section className={`${CARD_CLASS} p-5`}>
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-sm font-bold text-ink">Datei wählen</h2>
|
||
<p className="mt-1 max-w-prose text-sm text-ink-muted">
|
||
Eine Excel-Mappe mit den Blättern <strong>Standorte</strong>, <strong>Organisation</strong>,{" "}
|
||
<strong>Jobkatalog</strong>, <strong>Planstellen</strong>, <strong>Personen</strong>,{" "}
|
||
<strong>Historie</strong> und <strong>Angehörige</strong> — oder je Blatt eine CSV-Datei, deren Name dem
|
||
Blatt entspricht. Nicht jedes Blatt muss dabei sein.
|
||
</p>
|
||
</div>
|
||
<a
|
||
href="/api/import/template"
|
||
className="shrink-0 rounded-md border border-border px-3 py-2 text-sm font-semibold text-ink hover:bg-brand-50
|
||
focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-500"
|
||
>
|
||
Vorlage herunterladen
|
||
</a>
|
||
</div>
|
||
|
||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||
<input
|
||
ref={eingabe}
|
||
type="file"
|
||
multiple
|
||
accept=".xlsx,.xlsm,.csv"
|
||
onChange={(e) => neueAuswahl(e.target.files)}
|
||
className="block w-full max-w-md text-sm text-ink-muted file:mr-3 file:rounded-md file:border file:border-border
|
||
file:bg-surface file:px-3 file:py-2 file:text-sm file:font-semibold file:text-ink hover:file:bg-brand-50"
|
||
/>
|
||
<Button onClick={() => senden(true)} disabled={dateien.length === 0 || laeuft !== null}>
|
||
{laeuft === "pruefen" ? "Wird geprüft…" : "Prüfen"}
|
||
</Button>
|
||
</div>
|
||
{dateien.length > 0 && (
|
||
<p className="mt-2 text-xs text-ink-muted">
|
||
{dateien.length === 1 ? dateien[0].name : `${dateien.length} Dateien`} ausgewählt
|
||
</p>
|
||
)}
|
||
</section>
|
||
|
||
{fehlschlag && (
|
||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||
{fehlschlag}
|
||
</p>
|
||
)}
|
||
|
||
{antwort?.meldung && (
|
||
<p role="alert" className="rounded-md border border-danger-text/20 bg-danger-bg px-4 py-3 text-sm text-danger-text">
|
||
{antwort.meldung}
|
||
</p>
|
||
)}
|
||
|
||
{antwort && !antwort.meldung && (
|
||
<section className={`${CARD_CLASS} p-5`}>
|
||
<h2 className="text-sm font-bold text-ink">{uebernommen ? "Übernommen" : "Ergebnis der Prüfung"}</h2>
|
||
|
||
<table className="mt-3 w-full max-w-md text-sm">
|
||
<tbody>
|
||
{Object.entries(uebernommen ? antwort.bericht! : antwort.anzahl)
|
||
.filter(([, n]) => n > 0)
|
||
.map(([blatt, n]) => (
|
||
<tr key={blatt} className="border-b border-border-subtle last:border-0">
|
||
<td className="py-1.5 text-ink-body">{blatt}</td>
|
||
<td className="py-1.5 text-right font-semibold tabular-nums text-ink">{n}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
|
||
{uebernommen ? (
|
||
<p className="mt-4 text-sm text-ink-muted">
|
||
Die Daten stehen jetzt in der Anwendung. Der Vorgang ist im Audit-Log vermerkt.
|
||
</p>
|
||
) : antwort.ok ? (
|
||
<>
|
||
<p className="mt-4 text-sm text-ink-body">
|
||
Keine Beanstandung. Beim Übernehmen entsteht genau das oben Gezeigte — alles in einem Zug oder gar
|
||
nichts.
|
||
</p>
|
||
<div className="mt-3">
|
||
<Button onClick={() => senden(false)} disabled={laeuft !== null || !bereit}>
|
||
{laeuft === "uebernehmen" ? "Wird übernommen…" : "Übernehmen"}
|
||
</Button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="mt-4 text-sm font-semibold text-danger-text">
|
||
{antwort.fehler.length} Beanstandung{antwort.fehler.length === 1 ? "" : "en"} — es wurde nichts
|
||
geschrieben.
|
||
</p>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{antwort && antwort.fehler.length > 0 && (
|
||
<BefundListe titel="Zu beheben" befunde={antwort.fehler} art="fehler" />
|
||
)}
|
||
{antwort && antwort.hinweise.length > 0 && (
|
||
<BefundListe titel="Hinweise" befunde={antwort.hinweise} art="hinweis" />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BefundListe({ titel, befunde, art }: { titel: string; befunde: Befund[]; art: "fehler" | "hinweis" }) {
|
||
const sichtbar = befunde.slice(0, MAX_ANZEIGE);
|
||
return (
|
||
<section className={`overflow-x-auto ${CARD_CLASS}`}>
|
||
<h2 className="px-5 pt-5 text-sm font-bold text-ink">
|
||
{titel} <span className="font-normal text-ink-muted">({befunde.length})</span>
|
||
</h2>
|
||
<table className="mt-3 w-full min-w-[640px] text-sm">
|
||
<thead>
|
||
<tr className="border-y border-border bg-surface text-left text-[11px] font-bold uppercase tracking-wider text-ink-muted">
|
||
<th className="px-5 py-2">Blatt</th>
|
||
<th className="px-3 py-2">Zeile</th>
|
||
<th className="px-3 py-2">Spalte</th>
|
||
<th className="px-3 py-2">Wert</th>
|
||
<th className="px-5 py-2">Was zu tun ist</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{sichtbar.map((b, i) => (
|
||
<tr key={i} className="border-b border-border-subtle last:border-0">
|
||
<td className="whitespace-nowrap px-5 py-2 text-ink-body">{b.blatt}</td>
|
||
<td className="px-3 py-2 tabular-nums text-ink-muted">{b.zeile ?? "–"}</td>
|
||
<td className="px-3 py-2 text-ink-body">{b.spalte ?? "–"}</td>
|
||
<td className="max-w-[16rem] truncate px-3 py-2 text-ink-muted" title={b.wert}>
|
||
{b.wert ?? ""}
|
||
</td>
|
||
<td className={`px-5 py-2 ${art === "fehler" ? "text-danger-text" : "text-ink-muted"}`}>{b.meldung}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{befunde.length > sichtbar.length && (
|
||
<p className="px-5 py-3 text-xs text-ink-muted">
|
||
Weitere {befunde.length - sichtbar.length} nicht angezeigt. Oft hängen viele Meldungen an einer Ursache —
|
||
nach der Korrektur erneut prüfen.
|
||
</p>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|