Files
Laufzettel/src/components/mitarbeiter-importieren.tsx
T
florian.wilde ae290345b7
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
2026-09-26 00:21:56 +00:00

159 lines
6.3 KiB
TypeScript

"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { mitarbeiterImportierenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
/**
* Personen aus einer CSV-Datei einspielen (#016).
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt —
* derselbe Weg wie beim Geraete- und Vorlagenimport, und dieselbe Begruendung:
* das Format ist Text, die Dateien sind klein, und so gibt es einen Weg statt
* zwei.
*
* Was ueber dem Feld steht, muss der Dienst auch halten. Deshalb stehen dort nur
* Zusagen, die in `mitarbeiterImportieren` als Code nachzulesen sind: die
* Obergrenze, die Spalten, die Wiedererkennung, und dass eine leere Zelle nichts
* loescht.
*/
export function MitarbeiterImportieren({
hoechstZeilen,
spalten,
kunden,
}: {
hoechstZeilen: number;
spalten: readonly string[];
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterImportierenAktion,
{},
);
const [inhalt, setInhalt] = useState("");
const [leseFehler, setLeseFehler] = useState<string | null>(null);
async function dateiLesen(datei: File | undefined) {
if (!datei) return;
setLeseFehler(null);
try {
setInhalt(await datei.text());
} catch {
setLeseFehler("Die Datei ließ sich nicht lesen.");
}
}
return (
<form action={aktion} className="flex flex-col gap-4">
<p className="max-w-prose text-sm text-nebentext">
Spalten: <span className="mono text-text">{spalten.join(", ")}</span>. Die deutschen Namen (Vorname,
Nachname, Anzeigename, Abteilung, Titel) gehen genauso, Groß- und Kleinschreibung ist unerheblich.
Trennzeichen darf Komma oder Semikolon sein. Höchstens {hoechstZeilen} Zeilen je Datei. Eine
fehlerhafte Zeile bricht den Import nicht ab — sie wird unten mit ihrer Zeilennummer gemeldet.
</p>
<ul className="max-w-prose list-disc pl-5 text-sm text-nebentext">
<li>
Wiedererkannt wird eine Person über <span className="mono text-text">sam</span> oder{" "}
<span className="mono text-text">upn</span>, sonst über den Anzeigenamen. Wer schon da ist, wird
aktualisiert statt ein zweites Mal angelegt.
</li>
<li>
Eine leere Zelle löscht nichts: was in der Datei nicht steht, bleibt so, wie es ist. Fehlen{" "}
<span className="mono text-text">givenName</span> und <span className="mono text-text">sn</span>,
wird <span className="mono text-text">displayName</span> am letzten Leerzeichen geteilt.
</li>
<li>
Personen aus dem Verzeichnis werden nicht überschrieben; solche Zeilen werden gemeldet. Was hier
entsteht, gilt als von Hand angelegt und bleibt änderbar.
</li>
<li>
Eine deaktivierte Person wird erkannt und aktualisiert, aber <strong>nicht</strong> wieder aktiviert
— dafür gibt es den Schalter auf ihrer Seite.
</li>
<li>
Abteilungen legt der Import nicht an — der Name wird nur bei der Person eingetragen. Als Abteilung
mit Ziel-OU und Standardgruppen übernimmt sie ein Administrator.
</li>
</ul>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">CSV-Datei</span>
<input
type="file"
accept="text/csv,.csv,text/plain,.txt"
onChange={(ereignis) => void dateiLesen(ereignis.target.files?.[0])}
className="min-h-11 rounded-karte border border-linie bg-grund px-3 py-2 text-sm text-nebentext file:mr-3 file:rounded-pille file:border-0 file:bg-flaeche-hoch file:px-3 file:py-1.5 file:text-sm file:text-text"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">… oder Inhalt einfügen</span>
<textarea
name="inhalt"
rows={6}
required
value={inhalt}
onChange={(ereignis) => setInhalt(ereignis.target.value)}
className="mono rounded-karte border border-linie bg-grund px-3 py-2 text-xs text-text outline-none focus-visible:border-akzent"
placeholder={
"givenName;sn;displayName;mail;upn;sam;department;title\n" +
"Anna;Muster;Anna Muster;anna.muster@firma.de;anna.muster@firma.de;amuster;Vertrieb;Innendienst"
}
/>
</label>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" defaultValue="" className={FELD} required>
<option value="">bitte wählen</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{leseFehler ? (
<p role="alert" className="text-sm text-fehler">
{leseFehler}
</p>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{/* Je abgewiesene Zeile die Nummer, die der Editor des Benutzers anzeigt,
und der Grund. Eine Sammelmeldung waere hier nutzlos: die Datei muss an
genau diesen Zeilen geaendert werden. */}
{ergebnis.abgewiesen && ergebnis.abgewiesen.length > 0 ? (
<ul className="flex flex-col gap-1.5 rounded-karte border border-linie bg-grund p-3 text-xs">
{ergebnis.abgewiesen.map((abweisung) => (
<li key={abweisung.zeile} className="flex flex-wrap gap-2">
<span className="mono shrink-0 text-nebentext">Zeile {abweisung.zeile}</span>
<span className="min-w-0 text-fehler">{abweisung.grund}</span>
</li>
))}
</ul>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird eingelesen …" : "Einlesen"}
</Button>
</form>
);
}