Zu Anmerkung 5 ("In Audit schaut die Scrollbar komisch aus").
`overflow-x-auto` allein genuegt nicht: nach CSS wird eine auf `visible`
stehende Ueberlaufachse auf `auto` hochgestuft, sobald die andere nicht
`visible` ist. Die Karte war damit ein Scrollbereich in beiden Richtungen
und konnte eine senkrechte Leiste zeigen, die nichts bewirkt — die Karte ist
so hoch wie ihr Inhalt, es gibt dort nichts zu scrollen.
Geklemmt wird durch `overflow-y-hidden` nichts, aus demselben Grund. Die
drei Stellen mit diesem Muster sind Protokoll, Mitarbeiterliste und
Importmappe; alle drei sind gleich behandelt.
Ob das *die* Ursache des gemeldeten Bildes ist, ist damit nicht bewiesen —
aus dem Bildschirmfoto allein laesst sich nicht ablesen, zu welchem Element
die zweite Leiste gehoert. Es ist der einzige Scrollbereich auf der Seite
und eine Aufraeumung, die fuer sich steht.
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 overflow-y-hidden ${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>
|
||
);
|
||
}
|