feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #25.
This commit is contained in:
2026-09-25 10:52:44 +00:00
parent e3d80e5982
commit f85bc87324
81 changed files with 6784 additions and 59 deletions
+43
View File
@@ -0,0 +1,43 @@
"use client";
import { useActionState } from "react";
import { abteilungArchivierenAktion, type AktionsErgebnis } from "@/server/actions/abteilungen";
/** Archivieren statt loeschen – an einer Abteilung haengen Laeufe. */
export function AbteilungArchivieren({
abteilungId,
archiviert,
}: {
abteilungId: string;
archiviert: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
abteilungArchivierenAktion,
{},
);
return (
<div className="shrink-0">
<form action={aktion}>
<input type="hidden" name="abteilungId" value={abteilungId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button
type="submit"
disabled={laeuft}
className={`min-h-11 rounded-pille px-3 text-sm md:min-h-9 ${
archiviert
? "bg-flaeche-hoch text-nebentext hover:text-text"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-xs text-xs text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import {
abteilungAnlegenAktion,
abteilungBearbeitenAktion,
type AktionsErgebnis,
} from "@/server/actions/abteilungen";
export type AbteilungsWerte = {
readonly id?: string;
readonly name?: string;
readonly zielOu?: string | null;
readonly standardGruppen?: readonly string[];
readonly mailDomain?: string | null;
readonly notizen?: string | null;
};
/**
* Ein Formular fuer Anlegen und Bearbeiten.
*
* Bewusst dasselbe: die Felder sind identisch, und zwei Formulare liefen mit der
* Zeit auseinander — eines bekaeme ein Feld, das andere nicht.
*
* Die Ziel-OU und die Standardgruppen sind der Grund, warum diese Seite
* `verwaltung` verlangt: aus ihnen entstehen in #012 die PowerShell-Befehle, die
* ein Konto anlegen. Ein Tippfehler hier legt es an der falschen Stelle an.
*/
export function AbteilungFormular({
werte = {},
kunden = [],
}: {
werte?: AbteilungsWerte;
kunden?: readonly { id: string; name: string }[];
}) {
const bearbeiten = werte.id !== undefined;
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
bearbeiten ? abteilungBearbeitenAktion : abteilungAnlegenAktion,
{},
);
return (
<form action={aktion} className="flex flex-col gap-4">
{bearbeiten ? <input type="hidden" name="abteilungId" value={werte.id} /> : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Name</span>
<input name="name" required defaultValue={werte.name ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Domain (optional)</span>
<input
name="mailDomain"
defaultValue={werte.mailDomain ?? ""}
placeholder="firma.de"
className={FELD}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Ziel-OU (optional)</span>
<input
name="zielOu"
defaultValue={werte.zielOu ?? ""}
placeholder="OU=Buchhaltung,OU=Benutzer,DC=firma,DC=de"
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Standardgruppen, mit Komma getrennt</span>
<input
name="standardGruppen"
defaultValue={(werte.standardGruppen ?? []).join(", ")}
placeholder="G-Buchhaltung, G-Alle"
className={`${FELD} mono`}
/>
<span className="text-xs text-nebentext">
Die Reihenfolge bleibt erhalten — sie landet später so im Gruppen-Befehl.
</span>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen (optional)</span>
<textarea name="notizen" rows={2} defaultValue={werte.notizen ?? ""} className={`${FELD} py-2`} />
</label>
{!bearbeiten && kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" required defaultValue="" className={FELD}>
<option value="" disabled>
bitte wählen
</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : null}
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : bearbeiten ? "Speichern" : "Abteilung anlegen"}
</Button>
</form>
);
}
+1 -3
View File
@@ -5,9 +5,7 @@ import { Rolle } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { ROLLEN_TEXT } from "@/lib/rollen-text";
import { benutzerAnlegenAktion, type AktionsErgebnis } from "@/server/actions/benutzer";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Formular fuer ein lokales Konto. Als Client-Komponente, weil Erfolg und Fehler
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { einstellungenSpeichernAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
export type EinstellungsWerte = {
readonly eigeneOrganisationName: string;
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useActionState, useState } from "react";
import { Trash2 } from "lucide-react";
import {
geraetArchivierenAktion,
geraetLoeschenAktion,
type AktionsErgebnis,
} from "@/server/actions/geraete";
/**
* Archivieren, Freigeben und – nur wenn kein Lauf daran haengt – Loeschen.
*
* Als Client-Komponente, damit die Begruendung des Dienstes sichtbar wird. „An
* diesem Gerät hängen drei Läufe“ ist die Auskunft, die der Benutzer braucht; in
* einer Fehlerseite waere sie verloren.
*/
export function GeraetAktionen({
geraetId,
archiviert,
anzahlLaeufe,
anzeige,
darfLoeschen,
}: {
geraetId: string;
archiviert: boolean;
anzahlLaeufe: number;
anzeige: string;
/**
* Nur `verwaltung`. Archivieren darf jeder, der Geraete pflegt; Loeschen ist
* nicht ruecknehmbar und verbrennt den Tag-Code fuer immer.
*/
darfLoeschen: boolean;
}) {
const [archivErgebnis, archivAktion, archivLaeuft] = useActionState<AktionsErgebnis, FormData>(
geraetArchivierenAktion,
{},
);
const [loeschErgebnis, loeschAktion, loeschLaeuft] = useActionState<AktionsErgebnis, FormData>(
geraetLoeschenAktion,
{},
);
const [fragt, setFragt] = useState(false);
const knopf =
"min-h-11 rounded-pille px-3 text-sm border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
return (
<div className="flex shrink-0 flex-col items-start gap-2">
<div className="flex flex-wrap items-center gap-2">
<form action={archivAktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<input type="hidden" name="archiviert" value={archiviert ? "false" : "true"} />
<button type="submit" disabled={archivLaeuft} className={knopf}>
{archiviert ? "wieder freigeben" : "archivieren"}
</button>
</form>
{/*
* Loeschen erscheint nur, solange kein Lauf daran haengt. Der Dienst
* prueft es noch einmal – hier geht es darum, einen Knopf nicht
* anzubieten, der ohnehin nur eine Absage bringt.
*/}
{darfLoeschen && anzahlLaeufe === 0 && !fragt ? (
<button type="button" onClick={() => setFragt(true)} className={`${knopf} flex items-center gap-2`}>
<Trash2 className="size-4" strokeWidth={1.75} aria-hidden="true" />
löschen
</button>
) : null}
</div>
{fragt ? (
<div className="flex flex-col gap-2 rounded-karte border border-linie bg-grund p-3">
<p className="max-w-xs text-xs text-nebentext">
„{anzeige}“ wirklich löschen? Der Tag-Code bleibt für immer vergeben — ein Aufkleber zeigt nie auf
ein zweites Gerät.
</p>
<div className="flex flex-wrap gap-2">
<form action={loeschAktion}>
<input type="hidden" name="geraetId" value={geraetId} />
<button
type="submit"
disabled={loeschLaeuft}
className="min-h-11 rounded-pille bg-fehler/15 px-3 text-sm text-fehler hover:bg-fehler/25 md:min-h-10"
>
{loeschLaeuft ? "Wird gelöscht …" : "ja, löschen"}
</button>
</form>
<button type="button" onClick={() => setFragt(false)} className={knopf}>
abbrechen
</button>
</div>
</div>
) : null}
{archivErgebnis.fehler ? (
<p role="alert" className="max-w-xs text-xs text-fehler">
{archivErgebnis.fehler}
</p>
) : null}
{loeschErgebnis.fehler ? (
<p role="alert" className="max-w-xs text-xs text-fehler">
{loeschErgebnis.fehler}
</p>
) : null}
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { geraetAnlegenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
export type BesitzerWahl = {
readonly kandidaten: readonly {
readonly id: string;
readonly anzeigename: string;
/** Ein Geraet kann nur einer Person desselben Kunden gehoeren. */
readonly kundeId: string;
}[];
readonly gekuerzt: boolean;
};
/**
* Ein Geraet von Hand anlegen.
*
* Der Tag-Code wird dabei vom Dienst erzeugt und steht anschliessend in der
* Erfolgsmeldung — er ist das, was gleich auf den Aufkleber kommt, also soll ihn
* niemand erst in der Liste suchen muessen.
*/
export function GeraetAnlegen({
besitzer,
kunden,
}: {
besitzer: BesitzerWahl;
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraetAnlegenAktion, {});
/*
* Im Modus Dienstleistung haengt die Besitzerwahl am gewaehlten Kunden: ein
* Geraet kann nur einer Person desselben Kunden gehoeren. Ohne diese
* Einschraenkung stuenden Personen zur Auswahl, die der Dienst danach abweist.
* Im Modus Intern gibt es keine Kundenwahl — dann bleibt die Liste, wie sie ist.
*/
const [kundeId, setzeKunde] = useState("");
const waehlbar =
kunden.length === 0 || kundeId === ""
? besitzer.kandidaten
: besitzer.kandidaten.filter((person) => person.kundeId === kundeId);
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input name="hostname" className={`${FELD} mono`} placeholder="PC-VERTRIEB-07" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" className={FELD} placeholder="Serverschrank Keller" />
</label>
</div>
<p className="text-xs text-nebentext">
Eines von beiden genügt. Ein Serverschrank oder ein Raum hat keinen Hostname, ein Notebook keine
Bezeichnung.
</p>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue="SONSTIGES" className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Modell</span>
<input name="modell" className={FELD} placeholder="Latitude 5450" />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input name="seriennummer" className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input name="inventarnummer" className={`${FELD} mono`} />
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer (optional)</span>
<select name="mitarbeiterId" defaultValue="" className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{waehlbar.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
{kunden.length > 0 && kundeId === "" ? (
<p className="text-xs text-nebentext">
Ohne gewählten Kunden stehen hier alle sichtbaren Personen. Nach der Wahl bleiben nur die des Kunden
übrig — ein Gerät kann keiner fremden Person gehören.
</p>
) : null}
{besitzer.gekuerzt ? (
<p className="text-xs text-faellig">
Es werden nur die ersten {besitzer.kandidaten.length} aktiven Personen gezeigt. Wer nicht dabei ist,
lässt sich über dieses Feld vorerst nicht zuordnen — die Zuweisung über die Geräteseite kommt mit
#014.
</p>
) : null}
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen (optional)</span>
<textarea name="notizen" rows={2} className={`${FELD} py-2`} />
</label>
{/* Die Kundenwahl erscheint nur im Modus Dienstleistung; im Modus Intern
verwirft der Server eine mitgeschickte Kennung ohnehin (#008). */}
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select
name="kundeId"
value={kundeId}
onChange={(ereignis) => setzeKunde(ereignis.target.value)}
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}
{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}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Gerät anlegen"}
</Button>
</form>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useActionState } from "react";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import type { BesitzerWahl } from "@/components/geraet-anlegen";
import { geraetBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
export type GeraetZumBearbeiten = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
readonly typ: GeraeteTyp;
readonly modell: string | null;
readonly seriennummer: string | null;
readonly inventarnummer: string | null;
readonly notizen: string | null;
readonly besitzerId: string | null;
readonly besitzerName: string | null;
/** Nur fuer die Beschriftung, wenn der Besitzer nicht in der Auswahl steht. */
readonly besitzerAktiv?: boolean;
};
/**
* Stammdaten aendern.
*
* Der Tag-Code steht hier als Text und nicht als Feld: er wird nie geaendert. Er
* geht als verstecktes Feld mit, damit ein Aenderungsversuch — ein manipuliertes
* Formular etwa — am Dienst auffaellt und nicht stillschweigend ins Leere laeuft.
*
* Ist der bisherige Besitzer deaktiviert, steht er trotzdem in der Auswahl:
* sonst wuerde das Speichern der Seriennummer die Zuordnung mitnehmen.
*/
export function GeraetBearbeiten({
geraet,
besitzer,
}: {
geraet: GeraetZumBearbeiten;
besitzer: BesitzerWahl;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraetBearbeitenAktion, {});
/*
* Der bisherige Besitzer bleibt in der Auswahl, auch wenn er nicht (mehr) in
* den Kandidaten steht — sonst wuerde das Speichern der Seriennummer die
* Zuordnung mitnehmen.
*
* Der Zusatz sagt **warum** er fehlt, und raet nicht: deaktiviert ist der
* haeufige Fall, jenseits der Kandidatengrenze der andere. „(deaktiviert)“
* fest anzuhaengen waere bei einer aktiven Person schlicht falsch.
*/
const kandidaten = besitzer.kandidaten.some((person) => person.id === geraet.besitzerId)
? besitzer.kandidaten
: [
...besitzer.kandidaten,
...(geraet.besitzerId && geraet.besitzerName
? [
{
id: geraet.besitzerId,
anzeigename:
geraet.besitzerAktiv === false
? `${geraet.besitzerName} (deaktiviert)`
: `${geraet.besitzerName} (bisheriger Besitzer)`,
kundeId: "",
},
]
: []),
];
return (
<form action={aktion} className="flex flex-col gap-4 border-t border-linie p-4">
<input type="hidden" name="geraetId" value={geraet.id} />
<input type="hidden" name="tagCode" value={geraet.tagCode} />
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Hostname</span>
<input
name="hostname"
defaultValue={geraet.hostname ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Bezeichnung</span>
<input name="bezeichnung" defaultValue={geraet.bezeichnung ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue={geraet.typ} className={FELD}>
{GERAETE_TYP_LISTE.map((typ) => (
<option key={typ} value={typ}>
{GERAETE_TYP_TEXT[typ]}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Modell</span>
<input name="modell" defaultValue={geraet.modell ?? ""} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Seriennummer</span>
<input
name="seriennummer"
defaultValue={geraet.seriennummer ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Inventarnummer</span>
<input
name="inventarnummer"
defaultValue={geraet.inventarnummer ?? ""}
className={`${FELD} mono`}
autoComplete="off"
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Besitzer</span>
<select name="mitarbeiterId" defaultValue={geraet.besitzerId ?? ""} className={FELD}>
<option value="">niemand — Server, Raum oder Poolgerät</option>
{kandidaten.map((person) => (
<option key={person.id} value={person.id}>
{person.anzeigename}
</option>
))}
</select>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Notizen</span>
<textarea name="notizen" defaultValue={geraet.notizen ?? ""} rows={2} className={`${FELD} py-2`} />
</label>
{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}
<Button
type="submit"
variant="outline"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm"
>
{laeuft ? "Wird gespeichert …" : "Speichern"}
</Button>
</form>
);
}
+128
View File
@@ -0,0 +1,128 @@
"use client";
import { useActionState, useState } from "react";
import { Button } from "@/components/ui/button";
import { FELD } from "@/lib/formular";
import { geraeteImportierenAktion, type AktionsErgebnis } from "@/server/actions/geraete";
/**
* Geraete aus einer CSV-Datei einspielen.
*
* Die Datei wird **im Browser** gelesen und ihr Inhalt als Text mitgeschickt —
* derselbe Weg wie beim Vorlagenimport, und dieselbe Begruendung: das Format ist
* Text, die Dateien sind klein, und so gibt es einen Weg statt zwei.
*
* Die Obergrenze und die erwarteten Spalten stehen **ueber** dem Feld, nicht erst
* in der Fehlermeldung. Wer eine zu grosse Datei waehlt, hat sie sonst schon
* exportiert, bevor er es erfaehrt.
*/
export function GeraetImportieren({
hoechstZeilen,
spalten,
kunden,
}: {
hoechstZeilen: number;
spalten: readonly string[];
kunden: readonly { readonly id: string; readonly name: string }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(geraeteImportierenAktion, {});
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
(Bezeichnung, Seriennummer, Inventarnummer, Typ, Modell) gehen genauso, Groß- und Kleinschreibung ist
unerheblich. Trennzeichen darf Komma oder Semikolon sein. Höchstens {hoechstZeilen} Zeilen je Datei.
Jede Zeile bekommt ihren eigenen Tag-Code; eine fehlerhafte Zeile bricht den Import nicht ab.
</p>
<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={
"hostname;label;serialNumber;inventoryNumber;type;model\nPC-BUERO-01;;SN-4711;INV-9;PC;OptiPlex 7010"
}
/>
</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>
);
}
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeAnlegenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
export function KundeAnlegen() {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(kundeAnlegenAktion, {});
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { kundeBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/kunden";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
export function KundeBearbeiten({
kunde,
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useActionState } from "react";
import { mitarbeiterAktivSetzenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
/**
* Deaktivieren und wieder aktivieren.
*
* Der einzige „Weg hinaus“ – geloescht wird nichts. Ein archivierter Lauf zeigt
* auf diese Person, und dieser Verweis muss in zehn Jahren noch aufloesbar sein.
*/
export function MitarbeiterAktiv({ mitarbeiterId, aktiv }: { mitarbeiterId: string; aktiv: boolean }) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterAktivSetzenAktion,
{},
);
return (
<div>
<form action={aktion}>
<input type="hidden" name="mitarbeiterId" value={mitarbeiterId} />
<input type="hidden" name="aktiv" value={aktiv ? "false" : "true"} />
<button
type="submit"
disabled={laeuft}
className={`flex min-h-11 w-fit items-center rounded-pille px-4 text-sm md:min-h-10 ${
aktiv
? "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
: "bg-flaeche-hoch text-nebentext hover:text-text"
}`}
>
{aktiv ? "deaktivieren" : "wieder aktivieren"}
</button>
</form>
{ergebnis.fehler ? (
<p role="alert" className="mt-2 max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { mitarbeiterAnlegenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
import { FELD } from "@/lib/formular";
/**
* Eine Person von Hand anlegen.
*
* Gebraucht wird das beim Onboarding: der Techniker steht vor der Person, bevor
* der naechste Verzeichnisabgleich gelaufen ist. Die so angelegte Person traegt
* `herkunft = MANUELL` und bleibt vollstaendig aenderbar — im Gegensatz zu einer
* abgeglichenen (#016).
*/
export function MitarbeiterAnlegen({
kunden,
abteilungen,
}: {
kunden: readonly { id: string; name: string }[];
abteilungen: readonly string[];
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(mitarbeiterAnlegenAktion, {});
return (
<form action={aktion} className="flex flex-col gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorname</span>
<input name="vorname" required className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nachname</span>
<input name="nachname" required className={FELD} />
</label>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Adresse (optional)</span>
<input name="mail" type="email" className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung (optional)</span>
<input name="abteilung" list="abteilungs-vorschlaege" className={FELD} />
<datalist id="abteilungs-vorschlaege">
{abteilungen.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</label>
</div>
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center text-sm text-nebentext hover:text-text md:min-h-10">
Verzeichnis-Angaben (UPN, sAMAccountName, Titel)
</summary>
<div className="mt-3 grid gap-4 sm:grid-cols-3">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">UPN</span>
<input name="upn" className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">sAMAccountName (≤ 20)</span>
<input name="sam" maxLength={20} className={`${FELD} mono`} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input name="titel" className={FELD} />
</label>
</div>
</details>
{kunden.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Kunde</span>
<select name="kundeId" required defaultValue="" className={FELD}>
<option value="" disabled>
bitte wählen
</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</label>
) : 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}
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird angelegt …" : "Person anlegen"}
</Button>
</form>
);
}
+153
View File
@@ -0,0 +1,153 @@
"use client";
import { useActionState } from "react";
import { Lock } from "lucide-react";
import { Button } from "@/components/ui/button";
import { mitarbeiterBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/mitarbeiter";
import { FELD } from "@/lib/formular";
export type StammdatenPerson = {
readonly id: string;
readonly vorname: string;
readonly nachname: string;
readonly anzeigename: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
/** Bei `true` kommen die Stammdaten aus dem Verzeichnis und sind gesperrt. */
readonly ausVerzeichnis: boolean;
};
/**
* Stammdaten bearbeiten – oder ansehen, wenn sie aus dem Verzeichnis kommen.
*
* Bei `herkunft = ABGLEICH` ist **jedes** gepflegte Feld gesperrt, nicht nur
* einige: alles daran wird im Verzeichnis gepflegt, und der naechste Abgleich
* wuerde eine Aenderung ohnehin zurueckdrehen. Die Felder werden trotzdem
* gezeigt — man will die Werte sehen —, nur eben schreibgeschuetzt. Ein
* ausgeblendetes Feld waere eine schlechtere Auskunft als ein gesperrtes.
*/
export function MitarbeiterStammdaten({
person,
darfBearbeiten,
}: {
person: StammdatenPerson;
darfBearbeiten: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState<AktionsErgebnis, FormData>(
mitarbeiterBearbeitenAktion,
{},
);
/*
* Zwei verschiedene Gruende, dasselbe Ergebnis — aber nicht dieselbe Meldung.
* Wer die Rolle nicht hat, bekommt keinen Satz ueber das Verzeichnis zu lesen,
* der fuer ihn gar nicht der Grund ist.
*/
const gesperrt = person.ausVerzeichnis || !darfBearbeiten;
return (
<form action={aktion} className="flex flex-col gap-4">
<input type="hidden" name="mitarbeiterId" value={person.id} />
{gesperrt ? (
<div className="flex items-start gap-3 rounded-karte border border-linie bg-flaeche-hoch p-4">
<Lock
className="mt-0.5 size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<p className="max-w-prose text-sm text-nebentext">
{person.ausVerzeichnis
? "Diese Person kommt aus dem Verzeichnis. Die Stammdaten werden dort gepflegt — eine Änderung hier würde der nächste Abgleich zurücksetzen. Deaktivieren lässt sie sich trotzdem."
: "Zum Ändern der Stammdaten fehlt dir die Berechtigung. Dafür braucht es die Rolle Techniker, Redakteur oder Administrator."}
</p>
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Vorname</span>
<input name="vorname" required defaultValue={person.vorname} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Nachname</span>
<input
name="nachname"
required
defaultValue={person.nachname}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Anzeigename</span>
<input name="anzeigename" defaultValue={person.anzeigename} readOnly={gesperrt} className={FELD} />
</label>
<div className="grid gap-4 sm:grid-cols-2">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Mail-Adresse</span>
<input
name="mail"
type="email"
defaultValue={person.mail ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<input
name="abteilung"
defaultValue={person.abteilung ?? ""}
readOnly={gesperrt}
className={FELD}
/>
</label>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">UPN</span>
<input name="upn" defaultValue={person.upn ?? ""} readOnly={gesperrt} className={FELD} />
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">sAMAccountName</span>
<input
name="sam"
maxLength={20}
defaultValue={person.sam ?? ""}
readOnly={gesperrt}
className={`${FELD} mono`}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Titel</span>
<input name="titel" defaultValue={person.titel ?? ""} readOnly={gesperrt} className={FELD} />
</label>
</div>
{ergebnis.fehler ? (
<p role="alert" className="max-w-prose text-sm text-fehler">
{ergebnis.fehler}
</p>
) : null}
{ergebnis.erfolg ? (
<p role="status" className="text-sm text-erledigt">
{ergebnis.erfolg}
</p>
) : null}
{gesperrt ? null : (
<Button
type="submit"
disabled={laeuft}
className="h-11 self-start rounded-pille px-5 text-sm font-semibold"
>
{laeuft ? "Wird gespeichert …" : "Stammdaten speichern"}
</Button>
)}
</form>
);
}
+98
View File
@@ -0,0 +1,98 @@
import Link from "next/link";
import { FELD, KAESTCHEN } from "@/lib/formular";
/**
* Das Suchformular ueber Personen (#011).
*
* Eigenes Bauteil, weil dieselbe Suche an mehreren Stellen gebraucht wird: in
* der Personenliste, spaeter beim Starten eines Laufs (#013) und beim Zuweisen
* eines Geraets (#014). Dreimal dasselbe Formular abzuschreiben hiesse, den
* Umschalter „auch deaktivierte“ zweimal zu vergessen.
*
* Bewusst ein GET-Formular und keine Client-Komponente: ein gesetzter Filter ist
* dann verlinkbar, der Zurueck-Knopf tut das Erwartete, und die Seite bleibt
* eine Server-Komponente, die nur die sichtbaren Zeilen laedt.
*
* **Standard sind nur aktive Personen.** Wer offboardet, schaltet um — und sieht
* am gesetzten Haken, dass er es getan hat.
*/
export type MitarbeiterFilterWerte = {
readonly suche: string;
readonly abteilung: string;
readonly auchDeaktivierte: boolean;
};
export function MitarbeiterSuche({
werte,
abteilungen,
ziel,
versteckt = {},
}: {
werte: MitarbeiterFilterWerte;
abteilungen: readonly string[];
/** Adresse, auf die das Formular zeigt – der Filter landet in der Adresszeile. */
ziel: string;
/** Weitere Parameter, die erhalten bleiben sollen, etwa ein vorgewähltes Gerät. */
versteckt?: Readonly<Record<string, string>>;
}) {
const gesetzt = werte.suche !== "" || werte.abteilung !== "" || werte.auchDeaktivierte;
return (
<form method="get" action={ziel} className="flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
{Object.entries(versteckt).map(([name, wert]) => (
<input key={name} type="hidden" name={name} value={wert} />
))}
<label className="flex min-w-0 flex-1 flex-col gap-1.5 text-sm md:max-w-xs">
<span className="text-nebentext">Suche</span>
<input
name="suche"
defaultValue={werte.suche}
placeholder="Name, Mail, UPN oder sam"
className={FELD}
/>
</label>
{abteilungen.length > 0 ? (
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Abteilung</span>
<select name="abteilung" defaultValue={werte.abteilung} className={FELD}>
<option value="">alle</option>
{abteilungen.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
</label>
) : null}
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="deaktivierte"
value="1"
defaultChecked={werte.auchDeaktivierte}
className={KAESTCHEN}
/>
auch deaktivierte
</label>
<button
type="submit"
className="min-h-11 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
Filtern
</button>
{gesetzt ? (
<Link
href={ziel}
className="flex min-h-11 items-center px-2 text-sm text-nebentext underline underline-offset-2 hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</form>
);
}
+1 -3
View File
@@ -4,9 +4,7 @@ import { useActionState } from "react";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { modusWechselnAktion, type AktionsErgebnis } from "@/server/actions/einstellungen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
export type WechselAuskunft = {
readonly zielModus: "INTERN" | "DIENSTLEISTUNG";
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
/**
* Umschalter zwischen Personen und Geraeten (#011).
*
* Die Seitenleiste fuehrt beide unter einem Eintrag („Mitarbeiter & Geräte“),
* weil sie im Alltag zusammengehoeren: man sucht eine Person und landet bei
* ihrem Geraet, oder umgekehrt. Ein zweiter Eintrag in der Leiste waere eine
* Trennung, die es fachlich nicht gibt.
*
* Bewusst kein `usePathname`: die Seiten sind Server-Komponenten und kennen
* ihren Pfad ohnehin.
*/
const REITER = [
{ pfad: "/mitarbeiter", beschriftung: "Personen" },
{ pfad: "/geraete", beschriftung: "Geräte" },
] as const;
export function StammdatenReiter({ aktiv }: { aktiv: "/mitarbeiter" | "/geraete" }) {
return (
<nav aria-label="Stammdaten" className="mb-6 flex flex-wrap gap-2">
{REITER.map((reiter) => {
const istAktiv = reiter.pfad === aktiv;
return (
<Link
key={reiter.pfad}
href={reiter.pfad}
aria-current={istAktiv ? "page" : undefined}
className={`flex min-h-11 items-center rounded-pille px-4 text-sm transition-colors md:min-h-9 ${
istAktiv
? "bg-akzent-flaeche font-semibold text-akzent"
: "border border-linie text-nebentext hover:bg-flaeche-hoch hover:text-text"
}`}
>
{reiter.beschriftung}
</Link>
);
})}
</nav>
);
}
+1 -3
View File
@@ -9,14 +9,12 @@ import {
vorlageExportierenAktion,
type AktionsErgebnis,
} from "@/server/actions/vorlagen";
import { FELD } from "@/lib/formular";
// `w-fit`, damit ein Knopf in einem Block-Element nicht auf volle Breite geht –
// drei verschieden breite Balken untereinander sehen aus wie ein Fehler.
const KNOPF =
"flex w-fit min-h-11 items-center gap-2 rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) {
return (
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageAnlegenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Nur die Kopfdaten. Abschnitte, Schritte und Variablen entstehen danach im
+1 -3
View File
@@ -3,9 +3,7 @@
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { vorlageBearbeitenAktion, type AktionsErgebnis } from "@/server/actions/vorlagen";
const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
import { FELD } from "@/lib/formular";
/**
* Kopfdaten einer Vorlage: Name, Beschreibung, Kategorie, Schlagworte, Kunde.