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
+126
View File
@@ -0,0 +1,126 @@
import { Building, Plus } from "lucide-react";
import { AbteilungArchivieren } from "@/components/abteilung-archivieren";
import { AbteilungFormular } from "@/components/abteilung-formular";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsListe, ungepflegteAbteilungen } from "@/server/queries/abteilungen";
/**
* Abteilungen (#011).
*
* An einer Abteilung haengen Ziel-OU und Standardgruppen — daraus entstehen in
* #012 die Befehle, die ein Konto anlegen. Deshalb verlangt diese Seite
* `verwaltung` und nicht bloss Lesen: wer sie sieht, sieht die Struktur des
* Verzeichnisses.
*/
export default async function AbteilungenSeite() {
// Jede Seite prueft selbst – das Admin-Layout darueber ist das zweite Netz,
// nicht das erste (siehe CLAUDE.md).
const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/abteilungen");
const [zeilen, offene, kundenEbene] = await Promise.all([
abteilungsListe(benutzer),
ungepflegteAbteilungen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene ? await kundenListe(benutzer) : [];
return (
<>
<h1 className="text-2xl font-bold">Abteilungen</h1>
<p className="mt-2 max-w-prose text-nebentext">
Ziel-OU und Standardgruppen einer Abteilung bestimmen, wo ein neues Konto landet und in welchen
Gruppen es liegt. Die Zuordnung läuft über den Abteilungsnamen der Person — ein Wert aus dem
Verzeichnis bleibt deshalb auch dann erhalten, wenn es dazu noch keine gepflegte Abteilung gibt.
</p>
{offene.length > 0 ? (
<section className="mt-6 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold text-faellig">Noch nicht gepflegt</h2>
<p className="mt-1 max-w-prose text-sm text-nebentext">
Diese Abteilungsnamen kommen bei Personen vor, es gibt aber keine Abteilung dazu. Ohne Ziel-OU und
Standardgruppen bleiben die Platzhalter <code className="mono">{"{{ou}}"}</code> und{" "}
<code className="mono">{"{{gruppen}}"}</code> im Lauf leer.
</p>
<ul className="mt-3 flex flex-wrap gap-2">
{offene.map((eintrag) => (
<li
key={eintrag.name}
className="rounded-pille bg-flaeche-hoch px-3 py-1.5 text-sm text-nebentext"
>
{eintrag.name}{" "}
<span className="text-xs">
({eintrag.anzahl} {eintrag.anzahl === 1 ? "Person" : "Personen"})
</span>
</li>
))}
</ul>
</section>
) : null}
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Building
className="size-[18px] shrink-0 text-nebentext"
strokeWidth={1.75}
aria-hidden="true"
/>
<span className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.name}
</span>
<span className="text-xs text-nebentext">
{zeile.anzahlMitarbeiter} {zeile.anzahlMitarbeiter === 1 ? "Person" : "Personen"}
</span>
</div>
{zeile.zielOu ? <p className="mono mt-1 text-xs text-nebentext">{zeile.zielOu}</p> : null}
{zeile.standardGruppen.length > 0 ? (
<p className="mono mt-1 text-xs text-nebentext">{zeile.standardGruppen.join(", ")}</p>
) : null}
{zeile.mailDomain ? <p className="mt-1 text-xs text-nebentext">@{zeile.mailDomain}</p> : null}
{zeile.notizen ? (
<p className="mt-2 max-w-prose text-sm text-nebentext">{zeile.notizen}</p>
) : null}
</div>
<AbteilungArchivieren abteilungId={zeile.id} archiviert={zeile.archiviert} />
</div>
<details className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-4 text-sm text-nebentext hover:text-text">
Bearbeiten
</summary>
<div className="px-4 pb-4">
<AbteilungFormular
werte={{
id: zeile.id,
name: zeile.name,
zielOu: zeile.zielOu,
standardGruppen: zeile.standardGruppen,
mailDomain: zeile.mailDomain,
notizen: zeile.notizen,
}}
/>
</div>
</details>
</li>
))}
</ul>
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Abteilung anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Name muss genauso lauten wie im Verzeichnis, sonst findet die Zuordnung ihn nicht.
</p>
<AbteilungFormular kunden={kunden.map(({ id, name }) => ({ id, name }))} />
</section>
</>
);
}
+1
View File
@@ -10,6 +10,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
const REITER = [
{ pfad: "/admin", beschriftung: "Einstellungen" },
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
] as const;
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
+282
View File
@@ -0,0 +1,282 @@
import Link from "next/link";
import { Plus, QrCode } from "lucide-react";
import { GeraetAktionen } from "@/components/geraet-aktionen";
import { GeraetAnlegen } from "@/components/geraet-anlegen";
import { GeraetBearbeiten } from "@/components/geraet-bearbeiten";
import { FELD, KAESTCHEN } from "@/lib/formular";
import { GERAETE_TYP_LISTE, GERAETE_TYP_TEXT } from "@/lib/geraete-text";
import { GeraetImportieren } from "@/components/geraet-importieren";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import {
besitzerKandidaten,
geraeteListe,
type BesitzerAuswahl,
type GeraeteZeile,
} from "@/server/queries/geraete";
import { GERAETE_IMPORT_HOECHSTZEILEN, GERAETE_IMPORT_SPALTEN } from "@/server/services/geraete";
/**
* Geraeteliste (#011).
*
* Die Filter laufen als GET-Formular ueber die Adresse, nicht ueber React-State:
* ein gesetzter Filter ist dann verlinkbar, der Zurueck-Knopf tut das Erwartete,
* und die Seite bleibt eine Server-Komponente, die nur die sichtbaren Zeilen
* ueberhaupt laedt. Die Mandantensicht wendet das Lesemodell selbst an.
*
* Karten statt Tabelle: ein Geraet hat sieben Angaben, von denen am Handy keine
* wegfallen darf — eine Tabelle waere dort nur seitwaerts lesbar, und
* horizontales Scrollen ist ausgeschlossen.
*/
const PILLE = "rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext";
function Kennzeichen({ zeile, kundenEbene }: { zeile: GeraeteZeile; kundenEbene: boolean }) {
return (
<div className="flex flex-wrap items-center gap-2">
<span className={PILLE}>{GERAETE_TYP_TEXT[zeile.typ]}</span>
{/* Im Modus Intern gibt es genau eine Organisation, und die erscheint
nirgends (#008). Im Modus Dienstleistung ist der Kunde dagegen die
wichtigste Angabe der Zeile. */}
{kundenEbene ? <span className={PILLE}>{zeile.kundeName}</span> : null}
{zeile.registriertAm === null ? <span className={PILLE}>Tag ohne Zuordnung</span> : null}
{zeile.archiviert ? <span className={PILLE}>archiviert</span> : null}
{zeile.anzahlLaeufe > 0 ? (
<span className={PILLE}>
{zeile.anzahlLaeufe} {zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}
</span>
) : null}
</div>
);
}
function Karte({
zeile,
darfBearbeiten,
darfLoeschen,
besitzer,
kundenEbene,
}: {
zeile: GeraeteZeile;
darfBearbeiten: boolean;
darfLoeschen: boolean;
besitzer: BesitzerAuswahl;
kundenEbene: boolean;
}) {
const angaben = [
zeile.besitzerName
? `${zeile.besitzerName}${zeile.besitzerAktiv === false ? " (deaktiviert)" : ""}`
: null,
zeile.modell,
zeile.seriennummer ? `S/N ${zeile.seriennummer}` : null,
zeile.inventarnummer ? `Inv. ${zeile.inventarnummer}` : null,
].filter((angabe): angabe is string => Boolean(angabe));
return (
<li className="rounded-karte border border-linie bg-flaeche">
<div className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="mono flex items-center gap-1.5 rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
<QrCode className="size-3.5" strokeWidth={1.75} aria-hidden="true" />
{zeile.tagCode}
</span>
<p className={zeile.archiviert ? "text-nebentext line-through" : "font-medium"}>
{zeile.anzeige}
</p>
</div>
<div className="mt-2">
<Kennzeichen zeile={zeile} kundenEbene={kundenEbene} />
</div>
{angaben.length > 0 ? (
<p className="mt-2 text-xs break-words text-nebentext">{angaben.join(" · ")}</p>
) : null}
</div>
{darfBearbeiten ? (
<GeraetAktionen
geraetId={zeile.id}
archiviert={zeile.archiviert}
anzahlLaeufe={zeile.anzahlLaeufe}
anzeige={zeile.anzeige}
darfLoeschen={darfLoeschen}
/>
) : null}
</div>
{darfBearbeiten ? (
<details>
<summary className="flex min-h-11 cursor-pointer list-none items-center border-t border-linie px-4 text-sm text-nebentext hover:text-text">
Stammdaten bearbeiten
</summary>
<GeraetBearbeiten
geraet={{
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
besitzerId: zeile.besitzerId,
besitzerName: zeile.besitzerName,
...(typeof zeile.besitzerAktiv === "boolean" ? { besitzerAktiv: zeile.besitzerAktiv } : {}),
}}
/*
* Nur Personen **dieses** Kunden. Die Liste wird einmal fuer die
* ganze Seite geholt; boete die Karte sie unveraendert an, koennte
* jemand im Modus Dienstleistung eine Person eines anderen Kunden
* waehlen — der Dienst weist das ab, aber erst nach dem Klick.
*/
besitzer={{
kandidaten: besitzer.kandidaten.filter((kandidat) => kandidat.kundeId === zeile.kundeId),
gekuerzt: besitzer.gekuerzt,
}}
/>
</details>
) : null}
</li>
);
}
export default async function GeraeteSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/geraete");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const typWunsch = einzeln("typ");
// Ein unbekannter Typ in der Adresse filtert nicht, sondern wird verworfen –
// eine Fehlerseite fuer einen vertippten Parameter waere die falsche Antwort.
const typ = GERAETE_TYP_LISTE.find((wert) => wert === typWunsch);
const filter = {
suche: einzeln("suche"),
...(typ ? { typ } : {}),
auchArchivierte: einzeln("archiviert") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "geraeteBearbeiten");
// Loeschen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer –
// deshalb `verwaltung` und nicht `geraeteBearbeiten` (#011, nach dem Review).
const darfLoeschen = darf(benutzer.rolle, "verwaltung");
const [zeilen, kundenEbene] = await Promise.all([geraeteListe(benutzer, filter), kundenEbeneSichtbar()]);
// Besitzerauswahl und Kundenwahl braucht nur, wer auch pflegen darf.
const besitzer = darfBearbeiten ? await besitzerKandidaten(benutzer) : { kandidaten: [], gekuerzt: false };
const kunden = darfBearbeiten && kundenEbene ? await kundenListe(benutzer) : [];
const kundenWahl = kunden.filter((kunde) => !kunde.archiviert).map(({ id, name }) => ({ id, name }));
return (
<>
<StammdatenReiter aktiv="/geraete" />
<h1 className="text-2xl font-bold">Geräte</h1>
<p className="mt-2 max-w-prose text-nebentext">
Nicht nur PCs: ein Serverschrank, ein Raum oder eine Firewall bekommen genauso einen Aufkleber. Der
Tag-Code eines Geräts wird nie geändert und nie neu vergeben — auch nicht, nachdem das Gerät
archiviert oder gelöscht wurde.
</p>
<form method="get" className="mt-6 flex flex-col gap-3 md:flex-row md:flex-wrap md:items-end">
<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={filter.suche}
placeholder="Hostname, Bezeichnung, Seriennummer, Tag-Code"
className={FELD}
/>
</label>
<label className="flex flex-col gap-1.5 text-sm">
<span className="text-nebentext">Typ</span>
<select name="typ" defaultValue={typ ?? ""} className={FELD}>
<option value="">alle</option>
{GERAETE_TYP_LISTE.map((wert) => (
<option key={wert} value={wert}>
{GERAETE_TYP_TEXT[wert]}
</option>
))}
</select>
</label>
<label className="flex min-h-11 items-center gap-2 text-sm text-nebentext">
<input
type="checkbox"
name="archiviert"
value="1"
defaultChecked={filter.auchArchivierte}
className={KAESTCHEN}
/>
auch archivierte
</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>
{filter.suche || typ || filter.auchArchivierte ? (
<Link
href="/geraete"
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>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Gerät passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Karte
key={zeile.id}
zeile={zeile}
darfBearbeiten={darfBearbeiten}
darfLoeschen={darfLoeschen}
besitzer={besitzer}
kundenEbene={kundenEbene}
/>
))}
</ul>
)}
{darfBearbeiten ? (
<div className="mt-8 flex flex-col gap-4">
<section className="rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Plus className="size-5 text-akzent" strokeWidth={2} aria-hidden="true" />
Gerät anlegen
</h2>
<p className="mt-1 mb-5 max-w-prose text-sm text-nebentext">
Der Tag-Code entsteht beim Anlegen und steht danach in der Meldung — er kommt auf den Aufkleber.
</p>
<GeraetAnlegen besitzer={besitzer} kunden={kundenWahl} />
</section>
<details className="rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center px-5 text-sm text-nebentext hover:text-text">
Geräte aus einer CSV-Datei einlesen
</summary>
<div className="px-5 pb-5">
<GeraetImportieren
hoechstZeilen={GERAETE_IMPORT_HOECHSTZEILEN}
spalten={Object.values(GERAETE_IMPORT_SPALTEN).map((namen) => namen[0])}
kunden={kundenWahl}
/>
</div>
</details>
</div>
) : null}
</>
);
}
+123
View File
@@ -0,0 +1,123 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ArrowLeft, Laptop } from "lucide-react";
import { MitarbeiterAktiv } from "@/components/mitarbeiter-aktiv";
import { MitarbeiterStammdaten } from "@/components/mitarbeiter-stammdaten";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { geraeteDesMitarbeiters, mitarbeiterHolen, vorgesetzterVon } from "@/server/queries/mitarbeiter";
/**
* Die Seite einer Person (#011).
*
* Sie beantwortet drei Fragen: wer ist das, welche Geraete gehoeren ihr, und was
* ist mit ihr passiert. Die Laufhistorie kommt mit #013 dazu — bis dahin steht
* hier, dass sie kommt, statt eine leere Ueberschrift zu zeigen.
*/
export default async function MitarbeiterSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const { benutzer } = await sitzungErforderlich("lesen", `/mitarbeiter/${id}`);
const person = await mitarbeiterHolen(id, benutzer);
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden (#009).
if (!person) notFound();
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [geraete, kundenEbene, vorgesetzter] = await Promise.all([
geraeteDesMitarbeiters(person.id, benutzer),
kundenEbeneSichtbar(),
vorgesetzterVon(person.id, benutzer),
]);
return (
<>
<Link
href="/mitarbeiter"
className="inline-flex min-h-11 items-center gap-2 text-sm text-nebentext hover:text-text"
>
<ArrowLeft className="size-4" strokeWidth={1.75} aria-hidden="true" />
Alle Personen
</Link>
<h1 className="mt-2 text-2xl font-bold">{person.anzeigename}</h1>
<div className="mt-2 flex flex-wrap items-center gap-2 text-sm text-nebentext">
{person.titel ? <span>{person.titel}</span> : null}
{person.abteilung ? <span>· {person.abteilung}</span> : null}
{kundenEbene ? <span>· {person.kundeName}</span> : null}
{person.aktiv ? null : <span className="text-faellig">· deaktiviert</span>}
{person.herkunft === Herkunft.ABGLEICH ? <span>· aus dem Verzeichnis</span> : null}
</div>
{vorgesetzter ? (
<p className="mt-1 text-sm text-nebentext">
Vorgesetzter:{" "}
<Link href={`/mitarbeiter/${vorgesetzter.id}`} className="text-akzent underline underline-offset-2">
{vorgesetzter.anzeigename}
</Link>
</p>
) : null}
{darfBearbeiten ? (
<div className="mt-6">
<MitarbeiterAktiv mitarbeiterId={person.id} aktiv={person.aktiv} />
</div>
) : null}
<section className="mt-8 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="flex items-center gap-2 text-lg font-semibold">
<Laptop className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Geräte
</h2>
{geraete.length === 0 ? (
<p className="mt-2 text-sm text-nebentext">Dieser Person ist kein Gerät zugeordnet.</p>
) : (
<ul className="mt-4 flex flex-col gap-2">
{geraete.map((geraet) => (
<li
key={geraet.id}
className="flex flex-wrap items-center gap-2 border-t border-linie py-3 first:border-t-0"
>
<span className="mono rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{geraet.tagCode}
</span>
<span className={geraet.archiviert ? "text-nebentext line-through" : ""}>{geraet.name}</span>
<span className="text-xs text-nebentext">{geraet.typ}</span>
</li>
))}
</ul>
)}
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Stammdaten</h2>
<div className="mt-4">
<MitarbeiterStammdaten
person={{
id: person.id,
vorname: person.vorname,
nachname: person.nachname,
anzeigename: person.anzeigename,
mail: person.mail,
upn: person.upn,
sam: person.sam,
abteilung: person.abteilung,
titel: person.titel,
ausVerzeichnis: person.herkunft === Herkunft.ABGLEICH,
}}
darfBearbeiten={darfBearbeiten}
/>
</div>
</section>
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-5">
<h2 className="text-lg font-semibold">Läufe</h2>
<p className="mt-2 max-w-prose text-sm text-nebentext">
Onboarding, Übergaben und Offboarding erscheinen hier chronologisch, sobald es Läufe gibt — sie
kommen mit dem nächsten Meilenstein.
</p>
</section>
</>
);
}
+143
View File
@@ -0,0 +1,143 @@
import Link from "next/link";
import { Plus, User } from "lucide-react";
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
import { MitarbeiterSuche } from "@/components/mitarbeiter-suche";
import { StammdatenReiter } from "@/components/stammdaten-reiter";
import { Herkunft } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { abteilungsNamen, mitarbeiterListe, type MitarbeiterZeile } from "@/server/queries/mitarbeiter";
/**
* Mitarbeiterliste (#011).
*
* Gesucht wird ueber Anzeigename, Mail, UPN und `sam` — im Ticket steht mal das
* eine, mal das andere. Standardmaessig erscheinen nur aktive Personen; fuers
* Offboarding gibt es den Umschalter.
*
* Die Filter laufen als GET-Formular ueber die Adresse: 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.
*/
function Zeile({ zeile, kundenEbene }: { zeile: MitarbeiterZeile; kundenEbene: boolean }) {
const kennungen = [zeile.mail, zeile.upn === zeile.mail ? null : zeile.upn, zeile.sam]
.filter(Boolean)
.join(" · ");
return (
<li className="rounded-karte border border-linie bg-flaeche">
<Link
href={`/mitarbeiter/${zeile.id}`}
className="flex flex-col gap-2 p-4 md:flex-row md:items-center md:justify-between"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<User className="size-[18px] shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
<span className={`font-medium ${zeile.aktiv ? "" : "text-nebentext line-through"}`}>
{zeile.anzeigename}
</span>
{zeile.aktiv ? null : (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
deaktiviert
</span>
)}
{zeile.herkunft === Herkunft.ABGLEICH ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
aus dem Verzeichnis
</span>
) : null}
{kundenEbene ? (
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-1 text-xs text-nebentext">
{zeile.kundeName}
</span>
) : null}
</div>
{kennungen ? <p className="mono mt-1 truncate text-xs text-nebentext">{kennungen}</p> : null}
<p className="mt-1 text-xs text-nebentext">
{[
zeile.abteilung,
zeile.titel,
`${zeile.anzahlGeraete} ${zeile.anzahlGeraete === 1 ? "Gerät" : "Geräte"}`,
`${zeile.anzahlLaeufe} ${zeile.anzahlLaeufe === 1 ? "Lauf" : "Läufe"}`,
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
</Link>
</li>
);
}
export default async function MitarbeiterSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const { benutzer } = await sitzungErforderlich("lesen", "/mitarbeiter");
const parameter = await searchParams;
const einzeln = (name: string): string =>
(Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? "";
const filter = {
suche: einzeln("suche"),
abteilung: einzeln("abteilung"),
auchDeaktivierte: einzeln("deaktivierte") === "1",
};
const darfBearbeiten = darf(benutzer.rolle, "mitarbeiterBearbeiten");
const [zeilen, abteilungen, kundenEbene] = await Promise.all([
mitarbeiterListe(benutzer, filter),
abteilungsNamen(benutzer),
kundenEbeneSichtbar(),
]);
const kunden = kundenEbene && darfBearbeiten ? await kundenListe(benutzer) : [];
return (
<>
<StammdatenReiter aktiv="/mitarbeiter" />
<h1 className="text-2xl font-bold">{kundenEbene ? "Personen" : "Mitarbeiter"}</h1>
<p className="mt-2 max-w-prose text-nebentext">
Personen werden nie gelöscht, nur deaktiviert — ein archivierter Lauf muss auch in zehn Jahren noch
sagen können, für wen er lief.
</p>
<div className="mt-6">
<MitarbeiterSuche werte={filter} abteilungen={abteilungen} ziel="/mitarbeiter" />
</div>
{zeilen.length === 0 ? (
<p className="mt-8 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Keine Person passt zu diesen Filtern.
</p>
) : (
<ul className="mt-6 flex flex-col gap-3">
{zeilen.map((zeile) => (
<Zeile key={zeile.id} zeile={zeile} kundenEbene={kundenEbene} />
))}
</ul>
)}
{darfBearbeiten ? (
<details className="mt-8 rounded-karte border border-linie bg-flaeche">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-5 text-sm text-nebentext hover:text-text">
<Plus className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
Person von Hand anlegen
</summary>
<div className="px-5 pb-5">
<p className="mb-5 max-w-prose text-sm text-nebentext">
Für das Onboarding, bevor der nächste Verzeichnisabgleich gelaufen ist. Eine so angelegte Person
bleibt vollständig änderbar.
</p>
<MitarbeiterAnlegen
kunden={kunden.map(({ id, name }) => ({ id, name }))}
abteilungen={abteilungen}
/>
</div>
</details>
) : null}
</>
);
}
+1 -3
View File
@@ -7,6 +7,7 @@ import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { kundenEbeneSichtbar } from "@/server/mandant";
import { kundenListe } from "@/server/queries/kunden";
import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server/queries/vorlagen";
import { FELD } from "@/lib/formular";
/**
* Vorlagenliste (#010).
@@ -17,9 +18,6 @@ import { vorlagenFilterwerte, vorlagenListe, type VorlagenZeile } from "@/server
* ueberhaupt laedt. Die Mandantensicht wenden die Lesemodelle selbst an (#009).
*/
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 Kennzeichen({ zeile }: { zeile: VorlagenZeile }) {
return (
<div className="flex flex-wrap items-center gap-2">
+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.
+444
View File
@@ -0,0 +1,444 @@
/**
* CSV lesen und schreiben (#011).
*
* Selbst geschrieben und ohne Abhaengigkeit: gebraucht werden hier wenige
* Spalten, und was an echten Dateien wehtut, ist nicht der Umfang des Formats,
* sondern seine Alltagsvarianten — deutsches Excel schreibt Semikolon, setzt ein
* BOM davor und beendet Zeilen mit CRLF.
*
* Framework- und datenbankfrei (siehe Schichtung in CLAUDE.md), damit die
* Randfaelle billig zu testen sind: tests/csv.test.ts haelt sie fest.
*/
export class CsvFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "CsvFehler";
}
}
/**
* Nur diese beiden. Tabulator und Pipe kommen in dieser Anwendung nicht vor, und
* eine Liste von Kandidaten, die niemand liefert, macht die Erkennung nur
* unzuverlaessiger.
*/
export const CSV_TRENNZEICHEN = [",", ";"] as const;
export type CsvTrennzeichen = (typeof CSV_TRENNZEICHEN)[number];
/** Byte Order Mark. Excel stellt es UTF-8-Dateien voran; es gehoert nicht zum Inhalt. */
const BOM = "";
const ZEILENENDE = "\r\n";
export type CsvZeile = {
/**
* Zeilennummer in der Datei, 1-basiert und einschliesslich Kopfzeile. Sie wird
* dem Benutzer gemeldet, muss also die Nummer sein, die sein Editor anzeigt —
* deshalb zaehlen auch Leerzeilen und Umbrueche innerhalb von
* Anfuehrungszeichen mit.
*/
readonly nummer: number;
/** Die Werte in der Reihenfolge der Datei, also auch ueberzaehlige. */
readonly werte: readonly string[];
/** Normalisierter Spaltenname zu Wert. Fehlende Spalten fehlen hier. */
readonly felder: ReadonlyMap<string, string>;
/**
* Gesetzt, wenn diese Zeile sich nicht sauber zerlegen liess.
*
* Dann sind `werte` und `felder` das, was bis zum Fehler zusammenkam — zu
* gebrauchen ist das nicht. Der Aufrufer meldet die Zeile und macht mit der
* naechsten weiter; **werfen waere hier falsch**, denn eine kaputte Zeile darf
* den Import der anderen nicht verhindern.
*/
readonly fehler?: string;
};
export type CsvTabelle = {
readonly trennzeichen: CsvTrennzeichen;
/** Die Kopfzeile, normalisiert — siehe `spaltenName`. */
readonly spalten: readonly string[];
readonly zeilen: readonly CsvZeile[];
};
export type ZerlegeOptionen = {
/** Erzwingt ein Trennzeichen, statt es aus der Kopfzeile zu erkennen. */
readonly trennzeichen?: CsvTrennzeichen;
/**
* Darf ein Wert in Anfuehrungszeichen ueber mehrere Zeilen gehen? Standard: nein.
*
* Das Format erlaubt es, und `csvErzeugen` schreibt es auch so. Beim **Lesen**
* einer fremden Datei ist es trotzdem die schlechtere Annahme: ein Umbruch in
* einem Hostnamen oder einer Seriennummer kommt nicht vor, ein vergessenes
* schliessendes Anfuehrungszeichen dagegen staendig. Ohne diese Grenze
* verschluckt eine einzige kaputte Zelle alle Folgezeilen bis zum naechsten
* `"` — und der Import meldet dafuer Erfolg, weil er die Zeilen nie gesehen
* hat. Mit der Grenze endet der Schaden an der Zeile, in der er beginnt.
*
* Wer eine selbst erzeugte Datei zurueckliest, schaltet es ein.
*/
readonly werteUeberZeilen?: boolean;
/**
* Obergrenze fuer die Datenzeilen (ohne Kopfzeile). Wird sie ueberschritten,
* bricht das Zerlegen mit einem `CsvFehler` ab, statt eine 50-MB-Datei erst
* ganz einzulesen.
*/
readonly hoechstZeilen?: number;
};
/**
* Spaltennamen vergleichbar machen.
*
* Gross- und Kleinschreibung ist laut Auftrag unerheblich; Leerraum,
* Unterstriche und Bindestriche kommen dazu, weil dieselbe Spalte je nach
* Werkzeug „serialNumber“, „Serial Number“ oder „serial_number“ heisst. Wer hier
* nach einem Namen sucht, sucht mit derselben Funktion.
*/
export function spaltenName(roh: string): string {
return roh
.replace(BOM, "")
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "");
}
type RoherSatz = { readonly nummer: number; readonly werte: readonly string[]; readonly fehler?: string };
/**
* Der eigentliche Zustandsautomat.
*
* `hoechstSaetze` bricht ab, sobald genug gelesen ist — die Erkennung des
* Trennzeichens braucht nur die erste Zeile, und die Obergrenze des Imports soll
* nicht erst nach dem vollstaendigen Einlesen greifen.
*/
function saetzeLesen(
text: string,
trennzeichen: CsvTrennzeichen,
hoechstSaetze?: number,
werteUeberZeilen = false,
): readonly RoherSatz[] {
const saetze: RoherSatz[] = [];
let werte: string[] = [];
let feld = "";
/** Zitierte Werte werden nicht getrimmt: Leerraum in Anfuehrungszeichen ist gewollt. */
let zitiert = false;
let inAnfuehrung = false;
/** Gesetzt, sobald diese Zeile nicht mehr zu gebrauchen ist. */
let satzFehler: string | undefined;
let zeile = 1;
let satzZeile = 1;
let pos = 0;
const feldAbschliessen = (): void => {
werte.push(zitiert ? feld : feld.trim());
feld = "";
zitiert = false;
};
const satzAbschliessen = (): void => {
feldAbschliessen();
// Leerzeilen fallen weg – auch die Zeilen aus lauter Trennzeichen, die Excel
// unter eine Tabelle schreibt. Die Zeilennummer laeuft trotzdem weiter.
if (satzFehler !== undefined) {
saetze.push({ nummer: satzZeile, werte, fehler: satzFehler });
} else if (werte.some((wert) => wert !== "")) {
saetze.push({ nummer: satzZeile, werte });
}
werte = [];
satzFehler = undefined;
};
/**
* Nach einem Fehler bis zum naechsten Zeilenumbruch weiterlesen und dort neu
* aufsetzen.
*
* Der Grund steht in `CsvZeile.fehler`: eine kaputte Zeile darf die naechsten
* nicht mitreissen. Genau das passierte vorher — ein nicht geschlossenes
* Anfuehrungszeichen verschluckte alle Folgezeilen bis zum naechsten `"`, und
* der Import meldete trotzdem Erfolg.
*/
const bisZeilenendeVerwerfen = (): void => {
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
pos += 1;
break;
}
pos += 1;
}
feld = "";
inAnfuehrung = false;
satzAbschliessen();
zeile += 1;
satzZeile = zeile;
};
while (pos < text.length) {
const zeichen = text.charAt(pos);
if (inAnfuehrung) {
if (zeichen === '"') {
if (text.charAt(pos + 1) === '"') {
// Verdoppeltes Anfuehrungszeichen: ein literales Zeichen im Wert.
feld += '"';
pos += 2;
continue;
}
inAnfuehrung = false;
pos += 1;
// Leerraum zwischen schliessendem Anfuehrungszeichen und Trennzeichen
// gehoert nicht zum Wert.
while (text.charAt(pos) === " " || text.charAt(pos) === "\t") pos += 1;
/*
* Nach dem schliessenden Anfuehrungszeichen darf nur ein Trennzeichen,
* ein Zeilenumbruch oder das Dateiende kommen. Steht dort etwas anderes,
* war das Anfuehrungszeichen nicht das Ende des Werts, sondern ein
* Zeichen mitten im Text — und alles Folgende wanderte bisher
* stillschweigend in diesen Wert.
*/
const naechstes = text.charAt(pos);
if (naechstes !== "" && naechstes !== trennzeichen && naechstes !== "\r" && naechstes !== "\n") {
satzFehler =
"Nach einem schließenden Anführungszeichen steht noch Text. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
}
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (!werteUeberZeilen) {
// Hier endet der Schaden: die Zeile gilt als kaputt, die naechste
// faengt sauber an. Siehe `werteUeberZeilen`.
satzFehler =
"Ein Anführungszeichen wurde in dieser Zeile geöffnet und nicht geschlossen. " +
"Ein Anführungszeichen innerhalb eines Werts muss verdoppelt werden.";
bisZeilenendeVerwerfen();
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
// Umbruch im Wert. CRLF wird zu einem einzelnen \n normalisiert, damit
// ein Wert nicht je nach Herkunft der Datei anders aussieht.
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
feld += "\n";
zeile += 1;
pos += 1;
continue;
}
feld += zeichen;
pos += 1;
continue;
}
// Ein Anfuehrungszeichen eroeffnet nur am Wortanfang; steht es mitten im Wert
// (ein Zoll-Mass etwa), ist es ein gewoehnliches Zeichen.
if (zeichen === '"' && feld.trim() === "") {
feld = "";
zitiert = true;
inAnfuehrung = true;
pos += 1;
continue;
}
if (zeichen === trennzeichen) {
feldAbschliessen();
pos += 1;
continue;
}
if (zeichen === "\r" || zeichen === "\n") {
if (zeichen === "\r" && text.charAt(pos + 1) === "\n") pos += 1;
satzAbschliessen();
pos += 1;
zeile += 1;
satzZeile = zeile;
if (hoechstSaetze !== undefined && saetze.length >= hoechstSaetze) return saetze;
continue;
}
feld += zeichen;
pos += 1;
}
if (inAnfuehrung) {
/*
* Am Dateiende offen: die Zeile, in der es begann, ist verloren — und mit
* ihr alles, was danach in den Wert gewandert ist. Gemeldet wird sie als
* fehlerhafte Zeile, **nicht** als Ausnahme: die Zeilen davor sind in
* Ordnung und sollen eingelesen werden.
*/
satzFehler =
"Hier wurde ein Anführungszeichen geöffnet und bis zum Dateiende nie geschlossen. " +
"Alles Folgende wurde als Teil dieses Werts gelesen.";
feld = "";
inAnfuehrung = false;
satzAbschliessen();
return saetze;
}
// Eine Datei muss nicht mit einem Umbruch enden.
if (feld !== "" || werte.length > 0) satzAbschliessen();
return saetze;
}
/**
* Das Trennzeichen aus der Kopfzeile ablesen, statt es zu raten.
*
* Geraten wuerde, wer irgendwo im Text Zeichen zaehlt: ein Komma in einem
* Wert steht dort genauso. Die Kopfzeile dagegen besteht aus Spaltennamen, und
* das richtige Trennzeichen ist das, das sie in mehr Felder zerlegt. Bei
* Gleichstand — also einer einspaltigen Datei — gewinnt das Komma, weil es das
* Zeichen der Norm ist.
*/
export function trennzeichenErkennen(text: string): CsvTrennzeichen {
let bestes: CsvTrennzeichen = ",";
let meiste = 0;
for (const kandidat of CSV_TRENNZEICHEN) {
const anzahl = saetzeLesen(text, kandidat, 1)[0]?.werte.length ?? 0;
if (anzahl > meiste) {
meiste = anzahl;
bestes = kandidat;
}
}
return bestes;
}
/**
* Zerlegt eine CSV-Datei in Kopfzeile und Zeilen.
*
* Kommt ein Spaltenname zweimal vor, gilt der erste. Ihn stillschweigend zu
* ueberschreiben waere die schlechtere Wahl: der Leser sieht die linke Spalte
* und erwartet, dass sie zaehlt.
*/
export function csvZerlegen(text: string, optionen: ZerlegeOptionen = {}): CsvTabelle {
const ohneBom = text.startsWith(BOM) ? text.slice(BOM.length) : text;
const trennzeichen = optionen.trennzeichen ?? trennzeichenErkennen(ohneBom);
// Eine Zeile mehr lesen als erlaubt, sonst waere die Ueberschreitung nicht von
// einer genau passenden Datei zu unterscheiden. Plus die Kopfzeile.
const grenze = optionen.hoechstZeilen === undefined ? undefined : Math.max(0, optionen.hoechstZeilen) + 2;
const saetze = saetzeLesen(ohneBom, trennzeichen, grenze, optionen.werteUeberZeilen ?? false);
const kopf = saetze[0];
if (!kopf) {
throw new CsvFehler("Die Datei ist leer – es fehlt schon die Kopfzeile.");
}
if (kopf.fehler !== undefined) {
// Bei einer kaputten Kopfzeile hilft keine Zeilenmeldung: ohne Spaltennamen
// hat keine einzige Zeile eine Bedeutung.
throw new CsvFehler(`Die Kopfzeile ist nicht lesbar. ${kopf.fehler}`);
}
if (optionen.hoechstZeilen !== undefined && saetze.length - 1 > optionen.hoechstZeilen) {
throw new CsvFehler(
`Die Datei hat mehr als ${optionen.hoechstZeilen} Zeilen. ` +
"Bitte in kleinere Dateien aufteilen und einzeln einlesen.",
);
}
const spalten = kopf.werte.map(spaltenName);
const zeilen: CsvZeile[] = saetze.slice(1).map((satz) => {
const felder = new Map<string, string>();
for (const [index, spalte] of spalten.entries()) {
if (spalte === "" || felder.has(spalte)) continue;
const wert = satz.werte[index];
// Fehlt die Spalte in dieser Zeile, fehlt auch der Eintrag – ein leerer
// String waere dasselbe wie eine leere Zelle, und das ist es nicht.
if (wert !== undefined) felder.set(spalte, wert);
}
return {
nummer: satz.nummer,
werte: satz.werte,
felder,
...(satz.fehler === undefined ? {} : { fehler: satz.fehler }),
};
});
return { trennzeichen, spalten, zeilen };
}
/**
* Der erste gesetzte Wert unter mehreren moeglichen Spaltennamen.
*
* Der Auftrag nennt englische Spalten, die Oberflaeche ist deutsch, und beide
* Schreibweisen sollen in einer Datei stehen duerfen. Leer gilt als nicht
* gesetzt, damit eine vorhandene aber leere Spalte die naechste nicht verdeckt.
*/
export function wertAus(zeile: CsvZeile, ...namen: readonly string[]): string {
for (const name of namen) {
const wert = zeile.felder.get(spaltenName(name))?.trim();
if (wert) return wert;
}
return "";
}
/** Enthaelt eine der Spalten, unter denen hier ueberhaupt etwas erwartet wird? */
export function hatSpalte(tabelle: CsvTabelle, ...namen: readonly string[]): boolean {
return namen.some((name) => tabelle.spalten.includes(spaltenName(name)));
}
export type ErzeugeOptionen = {
/**
* Standard ist das Semikolon: die erzeugten Dateien werden in aller Regel in
* einem deutschen Excel geoeffnet, und das zerlegt Kommadateien nicht von
* selbst.
*/
readonly trennzeichen?: CsvTrennzeichen;
/** Ohne BOM zeigt dasselbe Excel Umlaute als Kauderwelsch. Deshalb standardmaessig an. */
readonly mitBom?: boolean;
};
/**
* Werte, die eine Tabellenkalkulation als Formel liest, entschaerfen.
*
* Excel und LibreOffice werten eine Zelle aus, die mit `=`, `+`, `-` oder `@`
* beginnt. Steht in einem Geraetenamen `=HYPERLINK(…)`, fuehrt das beim
* Oeffnen der exportierten Datei zu einer Formel — der Wert kam aus unserer
* Datenbank, ausgefuehrt wird er auf dem Rechner des Empfaengers.
*
* Ein vorangestelltes Apostroph macht daraus wieder Text. Es ist in der Zelle
* nicht sichtbar, wandert aber beim Zurueckeinlesen mit — deshalb nur dort, wo
* es noetig ist.
*/
function formelEntschaerfen(wert: string): string {
return /^[=+\-@\t\r]/u.test(wert) ? `'${wert}` : wert;
}
function wertMaskieren(rohwert: string, trennzeichen: CsvTrennzeichen): string {
const wert = formelEntschaerfen(rohwert);
const noetig =
wert.includes(trennzeichen) ||
wert.includes('"') ||
wert.includes("\n") ||
wert.includes("\r") ||
wert !== wert.trim();
return noetig ? `"${wert.replaceAll('"', '""')}"` : wert;
}
/**
* Erzeugt eine CSV-Datei.
*
* Steht neben dem Zerlegen, obwohl der Export erst spaeter gebraucht wird: die
* Regeln fuer Anfuehrungszeichen und Trennzeichen gelten in beide Richtungen,
* und getrennt gepflegt laufen sie auseinander. Der Test schreibt und liest
* deshalb im Kreis.
*
* `spalten` gibt die Reihenfolge **und** die Kopfzeile vor, wie sie geschrieben
* wird — beim Export sollen die Spalten ruhig „serialNumber“ heissen.
*/
export function csvErzeugen(
spalten: readonly string[],
zeilen: readonly Readonly<Record<string, string | number | null | undefined>>[],
optionen: ErzeugeOptionen = {},
): string {
const trennzeichen = optionen.trennzeichen ?? ";";
const zeilenText = [
spalten.map((spalte) => wertMaskieren(spalte, trennzeichen)).join(trennzeichen),
...zeilen.map((zeile) =>
spalten
.map((spalte) => {
const wert = zeile[spalte];
return wertMaskieren(wert === null || wert === undefined ? "" : String(wert), trennzeichen);
})
.join(trennzeichen),
),
].join(ZEILENENDE);
return (optionen.mitBom === false ? "" : BOM) + zeilenText + ZEILENENDE;
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Gemeinsame Klassen der Formularfelder (#011).
*
* Stand vorher fuenfmal wortgleich in fuenf Dateien — und war damit genau die
* Sorte Wiederholung, die beim naechsten Feinschliff an vier Stellen
* unveraendert bleibt. In `src/lib`, weil Server- und Client-Komponenten sie
* gleichermassen brauchen.
*/
/** Eingabefelder sind am Handy 44 px hoch (`min-h-11`), sonst trifft sie niemand. */
export const FELD =
"min-h-11 rounded-karte border border-linie bg-grund px-3 text-sm text-text outline-none focus-visible:border-akzent";
/**
* Kontrollkaestchen.
*
* Die umschliessende Beschriftung ist 44 px hoch und schaltet mit, die
* Trefferflaeche stimmt also auch ohne diese Groesse — sichtbar zu klein bleibt
* es trotzdem.
*/
export const KAESTCHEN = "size-4 accent-akzent";
+23
View File
@@ -0,0 +1,23 @@
import { GeraeteTyp } from "@/generated/prisma/enums";
/**
* Beschriftungen der Geraete (#011).
*
* In `src/lib`, weil beide Seiten sie brauchen und nichts aus `src/server/*` ins
* Browser-Buendel geraten darf — dieselbe Regel wie bei `ROLLEN_TEXT`
* (CLAUDE.md). Die Liste der Typen kommt aus dem Enum, damit ein neuer Typ nicht
* an zwei Stellen eingetragen werden muss.
*/
export const GERAETE_TYP_TEXT: Readonly<Record<GeraeteTyp, string>> = {
PC: "PC",
NOTEBOOK: "Notebook",
SERVER: "Server",
FIREWALL: "Firewall",
SWITCH: "Switch",
DRUCKER: "Drucker",
RAUM: "Raum",
SONSTIGES: "Sonstiges",
};
export const GERAETE_TYP_LISTE: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
+14 -3
View File
@@ -12,6 +12,14 @@ export type Betriebsmodus = "INTERN" | "DIENSTLEISTUNG";
export type NavEintrag = {
readonly pfad: string;
readonly beschriftung: string;
/**
* Abweichende Beschriftung im Modus DIENSTLEISTUNG.
*
* Dort heisst „Mitarbeiter“ „Personen“: es sind die Leute des Kunden, nicht
* die eigenen Angestellten. Derselbe Eintrag, dieselbe Seite — nur das Wort
* passt sich an (#008, ausgebaut in #011).
*/
readonly beschriftungDienstleistung?: string;
/** Nur in diesem Modus zeigen. Fehlt der Wert, gilt der Eintrag in beiden. */
readonly nurModus?: Betriebsmodus;
readonly symbol: string;
@@ -29,10 +37,9 @@ export const NAVIGATION: readonly NavEintrag[] = [
{
pfad: "/mitarbeiter",
beschriftung: "Mitarbeiter & Geräte",
nurModus: "INTERN",
beschriftungDienstleistung: "Personen & Geräte",
symbol: "Users",
verfuegbar: false,
ausIssue: 11,
verfuegbar: true,
},
{
pfad: "/kunden",
@@ -59,5 +66,9 @@ export function navigationFuer(
return NAVIGATION.filter(
(e) =>
(e.nurModus === undefined || e.nurModus === modus) && (e.recht === undefined || darfEintrag(e.recht)),
).map((eintrag) =>
modus === "DIENSTLEISTUNG" && eintrag.beschriftungDienstleistung
? { ...eintrag, beschriftung: eintrag.beschriftungDienstleistung }
: eintrag,
);
}
+150
View File
@@ -0,0 +1,150 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
AbteilungsFehler,
abteilungAnlegen,
abteilungArchivieren,
abteilungBearbeiten,
} from "@/server/services/abteilungen";
/**
* Server Actions der Abteilungen (#011).
*
* Recht ist `verwaltung`: an einer Abteilung haengen Ziel-OU und
* Standardgruppen — also das, was die spaeteren Befehle in ein Verzeichnis
* schreiben. Ein Tippfehler dort legt ein Konto an der falschen Stelle an.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("verwaltung");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof AbteilungsFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const AbteilungsSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
zielOu: z.string().trim().optional(),
standardGruppen: z.string().trim().optional(),
mailDomain: z.string().trim().optional(),
notizen: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return AbteilungsSchema.safeParse({
name: formular.get("name"),
zielOu: formular.get("zielOu") ?? undefined,
standardGruppen: formular.get("standardGruppen") ?? undefined,
mailDomain: formular.get("mailDomain") ?? undefined,
notizen: formular.get("notizen") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
/** Standardgruppen kommen als eine Zeile mit Kommas – so, wie sie im Befehl stehen. */
function gruppenZerlegen(roh: string | undefined): string[] {
return (roh ?? "")
.split(",")
.map((gruppe) => gruppe.trim())
.filter(Boolean);
}
export async function abteilungAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const abteilung = await abteilungAnlegen(
{
...eingabe.data,
standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen),
kundeId: eingabe.data.kundeId || null,
},
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: `„${abteilung.name}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Abteilung konnte nicht angelegt werden.");
}
}
export async function abteilungBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const abteilungId = z.string().min(1).safeParse(formular.get("abteilungId"));
if (!abteilungId.success) return { fehler: "Es fehlt die Abteilung." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await abteilungBearbeiten(
abteilungId.data,
{ ...eingabe.data, standardGruppen: gruppenZerlegen(eingabe.data.standardGruppen) },
zugang.benutzer,
);
revalidatePath("/admin/abteilungen");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function abteilungArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ abteilungId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ abteilungId: formular.get("abteilungId"), archiviert: formular.get("archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await abteilungArchivieren(eingabe.data.abteilungId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/admin/abteilungen");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+289
View File
@@ -0,0 +1,289 @@
"use server";
import { revalidatePath } from "next/cache";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { z } from "@/lib/validierung";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { MandantFehler, kundenEbeneSichtbar, type HandelnderBenutzer } from "@/server/mandant";
import {
GeraeteFehler,
geraeteImportieren,
geraetAnlegen,
geraetArchivieren,
geraetBearbeiten,
geraetLoeschen,
type ImportAbweisung,
} from "@/server/services/geraete";
/**
* Server Actions der Geraete (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `geraeteBearbeiten` — ADMIN, REDAKTEUR und TECHNIKER. Lesen darf jeder
* Angemeldete, das prueft die Seite selbst.
*/
export type AktionsErgebnis = {
readonly fehler?: string;
readonly erfolg?: string;
/** Nur beim CSV-Import: je abgewiesene Zeile eine Nummer und ein Grund. */
readonly abgewiesen?: readonly ImportAbweisung[];
};
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Ungefangen landet der Wurf in Nexts Fehlergrenze,
* die Seite wird ausgehaengt — und bei einem Formular mit halb eingetippten
* Stammdaten oder einer eingelesenen CSV-Datei ist das genau die Arbeit, die weg
* ist. Befund aus dem Review von #010, geloest wie dort (siehe
* src/server/actions/vorlagen.ts).
*/
async function angemeldetOderMeldung(
recht: "geraeteBearbeiten" | "verwaltung" = "geraeteBearbeiten",
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
try {
const { benutzer } = await rechtErforderlich(recht);
// Die Dienste brauchen von der Sitzung nur Kennung und Rolle.
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof GeraeteFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const TypSchema = z.enum(GeraeteTyp);
const AnlegenSchema = z.object({
hostname: z.string().trim().optional(),
bezeichnung: z.string().trim().optional(),
seriennummer: z.string().trim().optional(),
inventarnummer: z.string().trim().optional(),
typ: TypSchema.optional(),
modell: z.string().trim().optional(),
notizen: z.string().trim().optional(),
mitarbeiterId: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
/**
* Ein Feld, das das Formular nicht mitschickt, ist `undefined` und heisst „nicht
* angefasst“. Ein mitgeschicktes leeres Feld heisst „geleert“ — beim Bearbeiten
* macht das den Unterschied.
*/
function feld(formular: FormData, name: string): string | undefined {
const wert = formular.get(name);
return typeof wert === "string" ? wert : undefined;
}
export async function geraetAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = AnlegenSchema.safeParse({
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
kundeId: feld(formular, "kundeId"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
// Eine Kundenwahl gibt es nur im Modus Dienstleistung; `kundeErmitteln`
// verwirft eine mitgeschickte Kennung im Modus Intern ohnehin (#008).
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const geraet = await geraetAnlegen(
{
hostname: eingabe.data.hostname ?? null,
bezeichnung: eingabe.data.bezeichnung ?? null,
seriennummer: eingabe.data.seriennummer ?? null,
inventarnummer: eingabe.data.inventarnummer ?? null,
...(eingabe.data.typ ? { typ: eingabe.data.typ } : {}),
modell: eingabe.data.modell ?? null,
notizen: eingabe.data.notizen ?? null,
mitarbeiterId: eingabe.data.mitarbeiterId ?? null,
kundeId,
},
zugang.benutzer,
);
revalidatePath("/geraete");
return {
erfolg: `„${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode}“ wurde angelegt, Tag-Code ${geraet.tagCode}.`,
};
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht angelegt werden.");
}
}
const BearbeitenSchema = AnlegenSchema.extend({
geraetId: z.string().min(1),
/** Kommt als verstecktes Feld mit, damit ein Aenderungsversuch auffaellt. */
tagCode: z.string().trim().optional(),
});
export async function geraetBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = BearbeitenSchema.safeParse({
geraetId: feld(formular, "geraetId"),
hostname: feld(formular, "hostname"),
bezeichnung: feld(formular, "bezeichnung"),
seriennummer: feld(formular, "seriennummer"),
inventarnummer: feld(formular, "inventarnummer"),
typ: feld(formular, "typ") || undefined,
modell: feld(formular, "modell"),
notizen: feld(formular, "notizen"),
mitarbeiterId: feld(formular, "mitarbeiterId"),
tagCode: feld(formular, "tagCode"),
});
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await geraetBearbeiten(
eingabe.data.geraetId,
{
// Ein Feld, das das Formular nicht mitschickt, bleibt unberuehrt; ein
// leeres Feld heisst „geleert“. Deshalb `?? null` und nicht `|| null`.
...(eingabe.data.hostname === undefined ? {} : { hostname: eingabe.data.hostname }),
...(eingabe.data.bezeichnung === undefined ? {} : { bezeichnung: eingabe.data.bezeichnung }),
...(eingabe.data.seriennummer === undefined ? {} : { seriennummer: eingabe.data.seriennummer }),
...(eingabe.data.inventarnummer === undefined ? {} : { inventarnummer: eingabe.data.inventarnummer }),
...(eingabe.data.typ === undefined ? {} : { typ: eingabe.data.typ }),
...(eingabe.data.modell === undefined ? {} : { modell: eingabe.data.modell }),
...(eingabe.data.notizen === undefined ? {} : { notizen: eingabe.data.notizen }),
...(eingabe.data.mitarbeiterId === undefined ? {} : { mitarbeiterId: eingabe.data.mitarbeiterId }),
/*
* Auch ein **leer** mitgeschickter tagCode ist ein Aenderungsversuch und
* gehoert vor den Dienst. Mit `? :` auf den Wahrheitswert fiel er weg,
* und damit lief genau das ins Leere, was `GeraeteAenderung.tagCode`
* ausdruecklich sichtbar machen soll.
*/
...(eingabe.data.tagCode === undefined ? {} : { tagCode: eingabe.data.tagCode }),
},
zugang.benutzer,
);
revalidatePath("/geraete");
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function geraetArchivierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ geraetId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
.safeParse({ geraetId: feld(formular, "geraetId"), archiviert: feld(formular, "archiviert") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
await geraetArchivieren(eingabe.data.geraetId, eingabe.data.archiviert === "true", zugang.benutzer);
revalidatePath("/geraete");
return {};
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
/**
* Endgueltig loeschen — nur `verwaltung`.
*
* Archivieren ist der Normalfall und steht jedem offen, der Geraete pflegt.
* Loeschen dagegen ist nicht ruecknehmbar und verbrennt den Tag-Code fuer immer:
* er kommt nach der Regel des Projekts nie zurueck, der gedruckte Aufkleber ist
* damit wertlos. Gebraucht wird es nur fuer den Vertipper von eben — und dafuer
* darf man den Administrator fragen. Abteilungen und Mitarbeiter haben aus
* demselben Grund gar keinen Loeschweg.
*/
export async function geraetLoeschenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("verwaltung");
if ("fehler" in zugang) return zugang;
const geraetId = z.string().min(1).safeParse(feld(formular, "geraetId"));
if (!geraetId.success) return { fehler: "Es fehlt das Gerät." };
try {
await geraetLoeschen(geraetId.data, zugang.benutzer);
revalidatePath("/geraete");
return { erfolg: "Gelöscht. Der Tag-Code bleibt vergeben." };
} catch (fehler) {
return alsErgebnis(fehler, "Das Gerät konnte nicht gelöscht werden.");
}
}
export async function geraeteImportierenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({
inhalt: z.string().min(1, "Bitte eine CSV-Datei auswählen oder den Inhalt einfügen."),
kundeId: z.string().trim().optional(),
})
.safeParse({ inhalt: feld(formular, "inhalt"), kundeId: feld(formular, "kundeId") });
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
try {
const ergebnis = await geraeteImportieren(eingabe.data.inhalt, zugang.benutzer, kundeId);
revalidatePath("/geraete");
const teile = [
`${ergebnis.angelegt} von ${ergebnis.gesamt} ${ergebnis.gesamt === 1 ? "Zeile" : "Zeilen"} angelegt`,
];
if (ergebnis.abgewiesen.length > 0) {
teile.push(
`${ergebnis.abgewiesen.length} ${ergebnis.abgewiesen.length === 1 ? "Zeile" : "Zeilen"} abgewiesen`,
);
}
return { erfolg: `${teile.join(", ")}.`, abgewiesen: ergebnis.abgewiesen };
} catch (fehler) {
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
}
}
+155
View File
@@ -0,0 +1,155 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { MandantFehler } from "@/server/mandant";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
MitarbeiterFehler,
mitarbeiterAktivSetzen,
mitarbeiterAnlegen,
mitarbeiterBearbeiten,
} from "@/server/services/mitarbeiter";
import type { HandelnderBenutzer } from "@/server/mandant";
/**
* Server Actions der Mitarbeiter (#011).
*
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Das Recht ist
* `mitarbeiterBearbeiten` — dieselben Rollen wie bei den Geraeten, weil der
* Techniker beim Onboarding vor der Person steht, bevor der naechste
* Verzeichnisabgleich gelaufen ist.
*/
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
*
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
* samt allem, was im Formular stand. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft sonst genau die Person, die gerade lange tippt (#010).
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("mitarbeiterBearbeiten");
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof MitarbeiterFehler || fehler instanceof MandantFehler) {
return { fehler: fehler.message };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const StammdatenSchema = z.object({
vorname: z.string().trim().min(1, "Bitte einen Vornamen angeben."),
nachname: z.string().trim().min(1, "Bitte einen Nachnamen angeben."),
anzeigename: z.string().trim().optional(),
mail: z.string().trim().optional(),
upn: z.string().trim().optional(),
sam: z.string().trim().optional(),
abteilung: z.string().trim().optional(),
titel: z.string().trim().optional(),
kundeId: z.string().trim().optional(),
});
function ausFormular(formular: FormData) {
return StammdatenSchema.safeParse({
vorname: formular.get("vorname"),
nachname: formular.get("nachname"),
anzeigename: formular.get("anzeigename") ?? undefined,
mail: formular.get("mail") ?? undefined,
upn: formular.get("upn") ?? undefined,
sam: formular.get("sam") ?? undefined,
abteilung: formular.get("abteilung") ?? undefined,
titel: formular.get("titel") ?? undefined,
kundeId: formular.get("kundeId") ?? undefined,
});
}
export async function mitarbeiterAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
const mitarbeiter = await mitarbeiterAnlegen(
{ ...eingabe.data, kundeId: eingabe.data.kundeId || null },
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
return { erfolg: `„${mitarbeiter.anzeigename}“ wurde angelegt.` };
} catch (fehler) {
return alsErgebnis(fehler, "Die Person konnte nicht angelegt werden.");
}
}
export async function mitarbeiterBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const mitarbeiterId = z.string().min(1).safeParse(formular.get("mitarbeiterId"));
if (!mitarbeiterId.success) return { fehler: "Es fehlt die Person." };
const eingabe = ausFormular(formular);
if (!eingabe.success) {
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
}
try {
await mitarbeiterBearbeiten(mitarbeiterId.data, eingabe.data, zugang.benutzer);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${mitarbeiterId.data}`);
return { erfolg: "Gespeichert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
export async function mitarbeiterAktivSetzenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ mitarbeiterId: z.string().min(1), aktiv: z.enum(["true", "false"]) })
.safeParse({ mitarbeiterId: formular.get("mitarbeiterId"), aktiv: formular.get("aktiv") });
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
try {
const mitarbeiter = await mitarbeiterAktivSetzen(
eingabe.data.mitarbeiterId,
eingabe.data.aktiv === "true",
zugang.benutzer,
);
revalidatePath("/mitarbeiter");
revalidatePath(`/mitarbeiter/${eingabe.data.mitarbeiterId}`);
return { erfolg: mitarbeiter.aktiv ? "Wieder aktiv." : "Deaktiviert." };
} catch (fehler) {
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
}
}
+10
View File
@@ -26,6 +26,16 @@ export const RECHTE = {
laeufeBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Geraete und deren Stammdaten pflegen, Tags registrieren. */
geraeteBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/**
* Mitarbeiter von Hand anlegen, bearbeiten und deaktivieren (#011).
*
* Dieselben Rollen wie bei den Geraeten, und aus demselben Grund: beim
* Onboarding steht der Techniker vor der Person, bevor der naechste
* Verzeichnisabgleich gelaufen ist. Die **Abteilungen** bleiben dagegen bei
* `verwaltung` — an ihnen haengen Ziel-OU und Standardgruppen, also das, was
* die spaeteren PowerShell-Befehle in ein Verzeichnis schreiben.
*/
mitarbeiterBearbeiten: ["ADMIN", "REDAKTEUR", "TECHNIKER"],
/** Vorlagen anlegen, bearbeiten und veroeffentlichen. */
vorlagenBearbeiten: ["ADMIN", "REDAKTEUR"],
/**
+110
View File
@@ -0,0 +1,110 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Abteilungen (#011).
*
* Die Zahl der zugeordneten Personen kommt aus einer zweiten Abfrage statt aus
* `_count`: die Zuordnung laeuft ueber den **Namen** im Freitextfeld
* `Mitarbeiter.abteilung`, nicht ueber einen Fremdschluessel. So bleibt ein
* Abteilungswert aus dem Verzeichnis erhalten, auch wenn es dazu (noch) keine
* gepflegte Abteilung gibt — und genau diese Faelle will man sehen.
*/
export type AbteilungsZeile = {
readonly id: string;
readonly name: string;
readonly zielOu: string | null;
readonly standardGruppen: readonly string[];
readonly mailDomain: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
readonly kundeId: string;
readonly anzahlMitarbeiter: number;
};
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const AUSWAHL = {
id: true,
name: true,
zielOu: true,
standardGruppen: true,
mailDomain: true,
notizen: true,
archiviert: true,
kundeId: true,
} satisfies Prisma.AbteilungSelect;
export async function abteilungsListe(
benutzer: HandelnderBenutzer,
auchArchivierte = true,
): Promise<readonly AbteilungsZeile[]> {
const mandant = await kundenBedingung(benutzer);
const zeilen = await prisma.abteilung.findMany({
where: { AND: [...(auchArchivierte ? [] : [{ archiviert: false }]), ...mandant] },
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
select: AUSWAHL,
});
// Eine Abfrage fuer alle Zaehlungen statt einer je Abteilung.
const zaehlung = await prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
});
const anzahlNach = new Map(
zaehlung.map((eintrag) => [`${eintrag.kundeId}\u0000${eintrag.abteilung ?? ""}`, eintrag._count._all]),
);
return zeilen.map((zeile) => ({
...zeile,
standardGruppen: zeile.standardGruppen,
anzahlMitarbeiter: anzahlNach.get(`${zeile.kundeId}\u0000${zeile.name}`) ?? 0,
}));
}
/**
* Abteilungsnamen, die bei Mitarbeitern vorkommen, aber nicht gepflegt sind.
*
* Das ist der Vorschlag fuer „Abteilung anlegen“ nach einem Verzeichnisabgleich:
* die Namen stehen schon in den Daten, es fehlen nur Ziel-OU und Gruppen.
*
* Gruppiert wird nach **Kunde und Name**, nicht nach Name allein. Sonst gilt
* „Vertrieb“ als gepflegt, sobald **irgendein** Kunde eine Abteilung dieses
* Namens hat — und beim Kunden ohne sie blieben `{{ou}}` und `{{gruppen}}` im
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
* richtig.
*/
export async function ungepflegteAbteilungen(
benutzer: HandelnderBenutzer,
): Promise<readonly { name: string; anzahl: number; kundeId: string }[]> {
const mandant = await kundenBedingung(benutzer);
const [vorhandene, benutzte] = await Promise.all([
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
prisma.mitarbeiter.groupBy({
by: ["abteilung", "kundeId"],
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
_count: { _all: true },
}),
]);
const schluessel = (kundeId: string, name: string) => `${kundeId}\u0000${name}`;
const gepflegt = new Set(vorhandene.map((zeile) => schluessel(zeile.kundeId, zeile.name)));
return benutzte
.filter(
(eintrag) =>
eintrag.abteilung !== null && !gepflegt.has(schluessel(eintrag.kundeId, eintrag.abteilung)),
)
.map((eintrag) => ({
name: eintrag.abteilung as string,
anzahl: eintrag._count._all,
kundeId: eintrag.kundeId,
}))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}
+220
View File
@@ -0,0 +1,220 @@
import type { Prisma } from "@/generated/prisma/client";
import type { GeraeteTyp } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Geraete (#011).
*
* Die Mandantensicht wenden diese Funktionen **selbst** an. Im Review von #009
* war genau das die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb damit die Kundenbedingung, weil beide in
* dasselbe Objektliteral gespreizt wurden. Deshalb liegt hier **jede**
* Teilbedingung in einem `AND`-Array, wo keine die andere ueberschreiben kann.
* `tests/geraete.test.ts` haelt das mit einem eigenen Test fest.
*/
export type GeraeteZeile = {
readonly id: string;
readonly tagCode: string;
readonly hostname: string | null;
readonly bezeichnung: string | null;
/** Hostname, sonst Bezeichnung, sonst der Tag-Code. Nie leer. */
readonly anzeige: string;
readonly typ: GeraeteTyp;
readonly modell: string | null;
readonly seriennummer: string | null;
readonly inventarnummer: string | null;
readonly notizen: string | null;
readonly archiviert: boolean;
/** Leer heisst: vorgedruckter Tag, noch keinem Objekt zugeordnet. */
readonly registriertAm: Date | null;
readonly kundeId: string;
readonly kundeName: string;
readonly besitzerId: string | null;
readonly besitzerName: string | null;
readonly besitzerAktiv: boolean | null;
readonly anzahlLaeufe: number;
};
export type GeraeteFilter = {
readonly suche?: string;
readonly typ?: GeraeteTyp;
readonly auchArchivierte?: boolean;
};
/**
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
* Hineinspreizen. `undefined` heisst „keine Einschraenkung“.
*
* Jede Geraetezeile traegt eine `kundeId` – auch im Modus INTERN, wo es die eine
* eigene Organisation ist. Es gibt hier also kein „gilt fuer alle“ wie bei den
* Vorlagen.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.GeraetWhereInput | undefined> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return undefined;
return { kundeId: { in: [...sichtbar] } };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der Zeit
* auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
tagCode: true,
hostname: true,
bezeichnung: true,
typ: true,
modell: true,
seriennummer: true,
inventarnummer: true,
notizen: true,
archiviert: true,
registriertAm: true,
kundeId: true,
kunde: { select: { name: true } },
mitarbeiter: { select: { id: true, anzeigename: true, aktiv: true } },
_count: { select: { laeufe: true } },
} satisfies Prisma.GeraetSelect;
type Rohzeile = Prisma.GeraetGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): GeraeteZeile {
return {
id: zeile.id,
tagCode: zeile.tagCode,
hostname: zeile.hostname,
bezeichnung: zeile.bezeichnung,
anzeige: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
modell: zeile.modell,
seriennummer: zeile.seriennummer,
inventarnummer: zeile.inventarnummer,
notizen: zeile.notizen,
archiviert: zeile.archiviert,
registriertAm: zeile.registriertAm,
kundeId: zeile.kundeId,
kundeName: zeile.kunde.name,
besitzerId: zeile.mitarbeiter?.id ?? null,
besitzerName: zeile.mitarbeiter?.anzeigename ?? null,
besitzerAktiv: zeile.mitarbeiter?.aktiv ?? null,
anzahlLaeufe: zeile._count.laeufe,
};
}
/**
* Die Suche geht ueber Hostname, Bezeichnung, Seriennummer und `tagCode`.
*
* Der `tagCode` ist dabei der Notnagel: wer einen Aufkleber in der Hand hat und
* die Kamera nicht benutzen kann, tippt ihn ab. Deshalb `contains` und nicht
* `equals` – die sechs Zeichen ohne das Praefix sollen genuegen.
*/
function sucheBedingung(suche: string): Prisma.GeraetWhereInput {
const wie = { contains: suche, mode: "insensitive" as const };
return {
OR: [
{ hostname: wie },
{ bezeichnung: wie },
{ seriennummer: wie },
{ inventarnummer: wie },
{ tagCode: wie },
],
};
}
export async function geraeteListe(
benutzer: HandelnderBenutzer,
filter: GeraeteFilter = {},
): Promise<readonly GeraeteZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
await kundenBedingung(benutzer),
filter.auchArchivierte ? undefined : { archiviert: false },
filter.typ ? { typ: filter.typ } : undefined,
suche ? sucheBedingung(suche) : undefined,
].filter((bedingung): bedingung is Prisma.GeraetWhereInput => bedingung !== undefined);
const zeilen = await prisma.geraet.findMany({
where: { AND: bedingungen },
// Archivierte nach unten, dann nach Anzeigename. Prisma kann nicht nach
// „Hostname, sonst Bezeichnung“ sortieren; die zwei Schluessel danach kommen
// dem nahe genug und sind stabil.
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }, { bezeichnung: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/**
* Ein einzelnes Geraet.
*
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function geraetHolen(
geraetId: string,
benutzer: HandelnderBenutzer,
): Promise<GeraeteZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.geraet.findFirst({
where: { AND: [{ id: geraetId }, ...(mandant ? [mandant] : [])] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type BesitzerKandidat = {
readonly id: string;
readonly anzeigename: string;
readonly aktiv: boolean;
/** Ein Geraet kann nur einer Person desselben Kunden gehoeren. */
readonly kundeId: string;
};
export type BesitzerAuswahl = {
readonly kandidaten: readonly BesitzerKandidat[];
/** Waren es mehr als die Grenze? Dann sagt die Oberflaeche das auch. */
readonly gekuerzt: boolean;
};
/**
* Die Personen, die als Besitzer in Frage kommen.
*
* Eigenes, schmales Lesemodell mit genau vier Feldern – die Geraeteseite braucht
* nichts weiter, und die Mitarbeitersicht aus #011 wird an anderer Stelle
* gebaut. Deaktivierte sind nicht dabei: wer offboardet, aendert den Besitzer
* nicht mehr, sondern gibt das Geraet zurueck.
*
* Die Grenze ist da, damit ein Verzeichnis mit zehntausend Personen nicht in ein
* Auswahlfeld gegossen wird. Dass gekuerzt wurde, bleibt nicht verborgen.
*/
export async function besitzerKandidaten(
benutzer: HandelnderBenutzer,
grenze = 500,
): Promise<BesitzerAuswahl> {
const sichtbar = await sichtbareKundenIds(benutzer);
const zeilen = await prisma.mitarbeiter.findMany({
where: {
AND: [{ aktiv: true }, ...(sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }])],
},
select: { id: true, anzeigename: true, aktiv: true, kundeId: true },
orderBy: { anzeigename: "asc" },
take: grenze + 1,
});
return {
/*
* `kundeId` bleibt drin: ein Geraet kann nur einer Person **desselben**
* Kunden gehoeren (zusammengesetzter Fremdschluessel, siehe
* services/geraete.ts). Ohne das Feld boete die Auswahlliste Personen an,
* die der Dienst danach abweist — eine Wahl, die sie nicht einloesen kann.
*/
kandidaten: zeilen
.slice(0, grenze)
.map(({ id, anzeigename, aktiv, kundeId }) => ({ id, anzeigename, aktiv, kundeId })),
gekuerzt: zeilen.length > grenze,
};
}
+216
View File
@@ -0,0 +1,216 @@
import type { Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodelle der Mitarbeiter (#011).
*
* Wie ueberall wenden sie die Mandantensicht **selbst** an. Im Review von #009
* lag genau hier die Fundstelle fuer drei Blocker: ein Suchfilter brachte ein
* zweites `OR` mit und ueberschrieb die Kundenbedingung — ein Buchstabe im
* Suchfeld genuegte. Deshalb liegen alle Teilbedingungen in einem `AND`, wo
* keine die andere erreichen kann.
*/
export type MitarbeiterZeile = {
readonly id: string;
readonly anzeigename: string;
readonly vorname: string;
readonly nachname: string;
readonly mail: string | null;
readonly upn: string | null;
readonly sam: string | null;
readonly abteilung: string | null;
readonly titel: string | null;
readonly aktiv: boolean;
readonly herkunft: Herkunft;
readonly kundeId: string;
readonly kundeName: string;
readonly anzahlGeraete: number;
readonly anzahlLaeufe: number;
};
export type MitarbeiterFilter = {
readonly suche?: string;
readonly abteilung?: string;
/** Standard ist „nur aktive“ – fuers Offboarding schaltet man es um. */
readonly auchDeaktivierte?: boolean;
};
/**
* Die Mandantenbedingung als **Liste von Teilbedingungen**, nie als Objekt zum
* Hineinspreizen: so kann kein zweiter Filter mit demselben Schluessel sie
* ueberschreiben. `[]` heisst „keine Einschraenkung“.
*
* Dieselbe Form passt fuer Mitarbeiter und Geraete, weil beide `kundeId`
* tragen — jede kundenbezogene Zeile tut das (#008).
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
const ZEILEN_AUSWAHL = {
id: true,
anzeigename: true,
vorname: true,
nachname: true,
mail: true,
upn: true,
sam: true,
abteilung: true,
titel: true,
aktiv: true,
herkunft: true,
kundeId: true,
kunde: { select: { name: true } },
_count: { select: { geraete: true, laeufe: true } },
} satisfies Prisma.MitarbeiterSelect;
type Rohzeile = Prisma.MitarbeiterGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: Rohzeile): MitarbeiterZeile {
return {
id: zeile.id,
anzeigename: zeile.anzeigename,
vorname: zeile.vorname,
nachname: zeile.nachname,
mail: zeile.mail,
upn: zeile.upn,
sam: zeile.sam,
abteilung: zeile.abteilung,
titel: zeile.titel,
aktiv: zeile.aktiv,
herkunft: zeile.herkunft,
kundeId: zeile.kundeId,
/*
* Immer der Name, auch bei der eigenen Organisation. Ob er **gezeigt** wird,
* entscheidet der Betriebsmodus — und das entscheidet die Seite mit
* `kundenEbeneSichtbar()`, nicht das Lesemodell. Vorher haengte es hier an
* `istEigeneOrganisation`, und im Modus Dienstleistung fehlte die Pille
* ausgerechnet bei einem Kunden: „keine Pille“ hiess dann nicht „kein
* Kunde“, sondern „ausgerechnet dieser eine“.
*/
kundeName: zeile.kunde.name,
anzahlGeraete: zeile._count.geraete,
anzahlLaeufe: zeile._count.laeufe,
};
}
export async function mitarbeiterListe(
benutzer: HandelnderBenutzer,
filter: MitarbeiterFilter = {},
): Promise<readonly MitarbeiterZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchDeaktivierte ? undefined : { aktiv: true },
filter.abteilung ? { abteilung: filter.abteilung } : undefined,
// Gesucht wird ueber die vier Felder, unter denen eine Person im Haus
// bekannt ist. Der Anzeigename allein reicht nicht: im Ticket steht oft nur
// die Mail-Adresse oder der sAMAccountName.
suche
? {
OR: [
{ anzeigename: { contains: suche, mode: "insensitive" as const } },
{ mail: { contains: suche, mode: "insensitive" as const } },
{ upn: { contains: suche, mode: "insensitive" as const } },
{ sam: { contains: suche, mode: "insensitive" as const } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: bedingungen },
orderBy: [{ aktiv: "desc" }, { nachname: "asc" }, { vorname: "asc" }],
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function mitarbeiterHolen(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<MitarbeiterZeile | undefined> {
const zeile = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type MitarbeiterGeraet = {
readonly id: string;
readonly tagCode: string;
readonly name: string;
readonly typ: string;
readonly archiviert: boolean;
};
/** Die Geraete einer Person – fuer die Mitarbeiterseite. */
export async function geraeteDesMitarbeiters(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly MitarbeiterGeraet[]> {
const zeilen = await prisma.geraet.findMany({
where: { AND: [{ mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
orderBy: [{ archiviert: "asc" }, { hostname: "asc" }],
select: { id: true, tagCode: true, hostname: true, bezeichnung: true, typ: true, archiviert: true },
});
return zeilen.map((zeile) => ({
id: zeile.id,
tagCode: zeile.tagCode,
// Hostname zuerst: danach sucht man. Ohne Hostname traegt die Bezeichnung.
name: zeile.hostname ?? zeile.bezeichnung ?? zeile.tagCode,
typ: zeile.typ,
archiviert: zeile.archiviert,
}));
}
/**
* Der Vorgesetzte einer Person – oder `undefined`.
*
* Die Verknuepfung laeuft ueber `vorgesetzterExterneId` und die `externeId`
* **derselben Verzeichnisquelle**: die Kennung ist nur innerhalb einer Quelle
* eindeutig, und ohne diese Einschraenkung zoege ein zweites Verzeichnis
* fremde Treffer herbei. Gefuellt wird das Feld vom Abgleich (#016); bei einer
* von Hand angelegten Person steht hier nichts, und das ist richtig so.
*/
export async function vorgesetzterVon(
mitarbeiterId: string,
benutzer: HandelnderBenutzer,
): Promise<{ readonly id: string; readonly anzeigename: string } | undefined> {
const person = await prisma.mitarbeiter.findFirst({
where: { AND: [{ id: mitarbeiterId }, ...(await kundenBedingung(benutzer))] },
select: { quelleId: true, vorgesetzterExterneId: true },
});
if (!person?.vorgesetzterExterneId || !person.quelleId) return undefined;
const vorgesetzter = await prisma.mitarbeiter.findFirst({
where: {
AND: [
{ quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
...(await kundenBedingung(benutzer)),
],
},
select: { id: true, anzeigename: true },
});
return vorgesetzter ?? undefined;
}
/** Die vorkommenden Abteilungsnamen – fuer den Filter. */
export async function abteilungsNamen(benutzer: HandelnderBenutzer): Promise<readonly string[]> {
const zeilen = await prisma.mitarbeiter.findMany({
where: { AND: [{ abteilung: { not: null } }, ...(await kundenBedingung(benutzer))] },
select: { abteilung: true },
distinct: ["abteilung"],
});
return zeilen
.map((zeile) => zeile.abteilung)
.filter((name): name is string => name !== null)
.sort((a, b) => a.localeCompare(b, "de"));
}
+214
View File
@@ -0,0 +1,214 @@
import type { Abteilung, Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Abteilungen (#011).
*
* Eine Abteilung ist mehr als eine Beschriftung: an ihr haengen die Ziel-OU und
* die Standardgruppen, also genau das, was die spaeteren PowerShell-Befehle in
* ein Verzeichnis schreiben (#012). Ein Tippfehler in der Ziel-OU legt ein Konto
* an der falschen Stelle an — deshalb gehoert das Pflegen zu `verwaltung` und
* nicht zur taeglichen Technikerarbeit.
*
* Der Name ist **je Kunde** eindeutig (`@@unique([kundeId, name])`); im Modus
* INTERN ist das die eine eigene Organisation.
*/
export class AbteilungsFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "AbteilungsFehler";
}
}
export type AbteilungsEingabe = {
readonly name: string;
readonly zielOu?: string | null;
readonly standardGruppen?: readonly string[];
readonly mailDomain?: string | null;
readonly notizen?: string | null;
readonly kundeId?: string | null;
};
function leerAlsNull(wert: string | null | undefined): string | null {
const sauber = wert?.trim();
return sauber ? sauber : null;
}
function namePruefen(roh: string): string {
const name = roh.trim();
if (!name) throw new AbteilungsFehler("Eine Abteilung braucht einen Namen.");
return name;
}
/**
* Eine Mail-Domain ist keine Adresse.
*
* Geprueft wird nur grob: kein `@`, mindestens ein Punkt. Strenger waere eine
* Zusage, die wir nicht halten koennen — gueltige Domains gibt es in mehr Formen,
* als ein Ausdruck sinnvoll abbildet.
*/
function mailDomainPruefen(wert: string | null): string | null {
if (wert === null) return null;
const domain = wert.replace(/^@/u, "");
if (domain.includes("@") || !domain.includes(".")) {
throw new AbteilungsFehler(
`„${wert}“ sieht nicht wie eine Mail-Domain aus. Erwartet wird etwa „firma.de“, keine Adresse.`,
);
}
return domain.toLowerCase();
}
/** Leere Einträge und Doppelungen raus, Reihenfolge bleibt. */
function gruppenSaeubern(gruppen: readonly string[] | undefined): string[] {
const gesehen = new Set<string>();
const sauber: string[] = [];
for (const gruppe of gruppen ?? []) {
const wert = gruppe.trim();
if (!wert || gesehen.has(wert)) continue;
gesehen.add(wert);
sauber.push(wert);
}
return sauber;
}
export async function abteilungAnlegen(
eingabe: AbteilungsEingabe,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const name = namePruefen(eingabe.name);
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
const vorhanden = await prisma.abteilung.findUnique({ where: { kundeId_name: { kundeId, name } } });
if (vorhanden) {
// Die Datenbank wuerde es ohnehin abweisen; hier steht der Satz, den ein
// Mensch lesen kann.
throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
}
return prisma.$transaction(async (tx) => {
const abteilung = await tx.abteilung.create({
data: {
kundeId,
name,
zielOu: leerAlsNull(eingabe.zielOu),
standardGruppen: gruppenSaeubern(eingabe.standardGruppen),
mailDomain: mailDomainPruefen(leerAlsNull(eingabe.mailDomain)),
notizen: leerAlsNull(eingabe.notizen),
},
});
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilung.id,
aktion: "Abteilung angelegt",
benutzerId: benutzer.id,
nachher: { name: abteilung.name, zielOu: abteilung.zielOu },
},
});
return abteilung;
});
}
export async function abteilungBearbeiten(
abteilungId: string,
aenderung: Partial<AbteilungsEingabe>,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
const daten: Prisma.AbteilungUpdateInput = {};
const geaendert: string[] = [];
if (aenderung.name !== undefined) {
const name = namePruefen(aenderung.name);
if (name !== vorher.name) {
const andere = await prisma.abteilung.findUnique({
where: { kundeId_name: { kundeId: vorher.kundeId, name } },
});
if (andere) throw new AbteilungsFehler(`Die Abteilung „${name}“ gibt es schon.`);
daten.name = name;
geaendert.push("Name");
}
}
if (aenderung.zielOu !== undefined) {
const zielOu = leerAlsNull(aenderung.zielOu);
if (zielOu !== vorher.zielOu) {
daten.zielOu = zielOu;
geaendert.push("Ziel-OU");
}
}
if (aenderung.mailDomain !== undefined) {
const mailDomain = mailDomainPruefen(leerAlsNull(aenderung.mailDomain));
if (mailDomain !== vorher.mailDomain) {
daten.mailDomain = mailDomain;
geaendert.push("Mail-Domain");
}
}
if (aenderung.notizen !== undefined) {
const notizen = leerAlsNull(aenderung.notizen);
if (notizen !== vorher.notizen) {
daten.notizen = notizen;
geaendert.push("Notizen");
}
}
if (aenderung.standardGruppen !== undefined) {
const gruppen = gruppenSaeubern(aenderung.standardGruppen);
if (gruppen.join("\u0000") !== vorher.standardGruppen.join("\u0000")) {
daten.standardGruppen = gruppen;
geaendert.push("Standardgruppen");
}
}
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilungId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
vorher: { name: vorher.name, zielOu: vorher.zielOu, standardGruppen: vorher.standardGruppen },
nachher: { name: nachher.name, zielOu: nachher.zielOu, standardGruppen: nachher.standardGruppen },
},
});
return nachher;
});
}
/**
* Archivieren statt loeschen.
*
* An einer Abteilung haengen Laeufe, und ein archivierter Lauf muss auch in zehn
* Jahren noch sagen koennen, fuer welche Abteilung er lief. Der Fremdschluessel
* steht deshalb auf `Restrict`, und es gibt hier keinen Loeschweg.
*/
export async function abteilungArchivieren(
abteilungId: string,
archiviert: boolean,
benutzer: HandelnderBenutzer,
): Promise<Abteilung> {
const vorher = await prisma.abteilung.findUnique({ where: { id: abteilungId } });
if (!vorher) throw new AbteilungsFehler("Diese Abteilung gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (vorher.archiviert === archiviert) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Abteilung",
entitaetId: abteilungId,
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
benutzerId: benutzer.id,
nachher: { archiviert },
},
});
return nachher;
});
}
+640
View File
@@ -0,0 +1,640 @@
import { Prisma, type Geraet } from "@/generated/prisma/client";
import { GeraeteTyp } from "@/generated/prisma/enums";
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
import { prisma } from "@/lib/db";
import { tagCodeEindeutigErzeugen } from "@/lib/tagcode";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Geraete und ihre Stammdaten (#011).
*
* Ein Geraet ist nicht nur ein PC: ein Serverschrank, ein Raum oder eine
* Firewall bekommen genauso einen Aufkleber. Drei Regeln tragen das Ganze:
*
* 1. Der `tagCode` wird beim Anlegen erzeugt, nie geaendert und nie
* wiederverwendet. Er landet zusaetzlich in `TagCodeVergabe`, damit er auch
* nach dem Loeschen des Geraets vergeben bleibt.
* 2. Ein **registriertes** Geraet braucht einen Hostname oder eine
* Bezeichnung. Ein vorgedruckter, noch nicht zugeordneter Tag
* (`registriertAm = null`) darf beides leer haben.
* 3. Geloescht wird nur, solange kein Lauf am Geraet haengt. Sonst archiviert.
*
* Alle drei stehen zusaetzlich in der Datenbank (CHECK `Geraet_tagcode_form`,
* CHECK `Geraet_registriert_braucht_namen`, Fremdschluessel auf `Restrict`).
* Hier stehen sie fuer die verstaendliche Meldung.
*/
export class GeraeteFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "GeraeteFehler";
}
}
export type NeuesGeraet = {
readonly hostname?: string | null;
readonly bezeichnung?: string | null;
readonly seriennummer?: string | null;
readonly inventarnummer?: string | null;
readonly typ?: GeraeteTyp;
readonly modell?: string | null;
readonly notizen?: string | null;
/** Besitzer bzw. Hauptnutzer. Server, Raeume und Poolgeraete haben keinen. */
readonly mitarbeiterId?: string | null;
/** Nur im Modus DIENSTLEISTUNG beachtet; im Modus INTERN verworfen (#008). */
readonly kundeId?: string | null;
/**
* `false` legt einen vorgedruckten Tag ohne Zuordnung an – dann sind Hostname
* und Bezeichnung erlaubt leer. Standard ist `true`, weil jedes von Hand oder
* per CSV angelegte Geraet ein echtes Objekt beschreibt.
*/
readonly registrieren?: boolean;
};
export type GeraeteAenderung = {
readonly hostname?: string | null;
readonly bezeichnung?: string | null;
readonly seriennummer?: string | null;
readonly inventarnummer?: string | null;
readonly typ?: GeraeteTyp;
readonly modell?: string | null;
readonly notizen?: string | null;
readonly mitarbeiterId?: string | null;
/**
* Nimmt einen mitgeschickten `tagCode` nur an, um ihn abzuweisen, wenn er
* abweicht. Ohne dieses Feld liefe ein Aenderungsversuch ins Leere und saehe
* fuer den Aufrufer wie ein Erfolg aus.
*/
readonly tagCode?: string;
/** Macht aus einem vorgedruckten Tag ein registriertes Geraet. */
readonly registrieren?: boolean;
};
/** Leer und „nur Leerraum“ sind dasselbe wie „nicht gesetzt“. */
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/** Wie das Geraet in einer Meldung heisst. Der tagCode ist immer da. */
function geraetName(geraet: {
readonly hostname: string | null;
readonly bezeichnung: string | null;
readonly tagCode: string;
}): string {
return geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode;
}
/**
* Die Pflichtregel aus dem Auftrag, an einer Stelle.
*
* Geprueft wird der **Zustand danach**, nicht die Eingabe: wer den Hostname
* leert, ohne eine Bezeichnung zu setzen, faellt genauso hierauf wie wer ohne
* beides anlegt.
*/
function namenPflichtPruefen(
hostname: string | null,
bezeichnung: string | null,
registriert: boolean,
): void {
if (registriert && !hostname && !bezeichnung) {
throw new GeraeteFehler(
"Ein registriertes Gerät braucht einen Hostname oder eine Bezeichnung — " +
"sonst steht es in jeder Liste nur als Aufkleber.",
);
}
}
/**
* Der Besitzer muss zum selben Kunden gehoeren.
*
* Die Datenbank erzwingt das ueber den zusammengesetzten Fremdschluessel
* `Geraet_besitzer_gleicher_kunde`. Hier steht es fuer die Meldung — „Foreign key
* constraint violated“ hilft niemandem.
*
* Ein **deaktivierter** Mitarbeiter wird bewusst nicht abgewiesen: beim
* Offboarding bleibt das Notebook der Person zugeordnet, bis es zurueck ist.
*/
async function besitzerPruefen(mitarbeiterId: string, kundeId: string): Promise<void> {
const person = await prisma.mitarbeiter.findUnique({
where: { id: mitarbeiterId },
select: { id: true, kundeId: true, anzeigename: true },
});
if (!person) throw new GeraeteFehler("Diese Person gibt es nicht.");
if (person.kundeId !== kundeId) {
throw new GeraeteFehler(
`„${person.anzeigename}“ gehört zu einem anderen Kunden und kann dieses Gerät nicht besitzen.`,
);
}
}
/**
* Zieht einen noch freien `tagCode`.
*
* Gefragt wird in beiden Buechern: `TagCodeVergabe` ist das Verzeichnis aller je
* vergebenen Codes, `Geraet.tagCode` der eindeutige Index, der im Zweifel
* entscheidet. Ein Code, der nur in einem von beiden steht, ist trotzdem
* vergeben.
*/
async function freienTagCodeZiehen(): Promise<string> {
return tagCodeEindeutigErzeugen(async (code) => {
const [vergabe, geraet] = await Promise.all([
prisma.tagCodeVergabe.findUnique({ where: { tagCode: code }, select: { tagCode: true } }),
prisma.geraet.findUnique({ where: { tagCode: code }, select: { id: true } }),
]);
return vergabe !== null || geraet !== null;
});
}
type SchreibAuftrag = {
readonly eingabe: NeuesGeraet;
readonly kundeId: string;
readonly durchBenutzerId: string;
readonly aktion: string;
/** Kommt in `Protokoll.nachher` mit – beim Import die Zeilennummer. */
readonly zusatz?: Readonly<Record<string, string | number>>;
};
/**
* Das Anlegen selbst, ohne Mandantenermittlung.
*
* Getrennt, weil der CSV-Import den Kunden **einmal** ermittelt und dann Zeile
* fuer Zeile schreibt. Zweimal denselben Weg zu bauen waere der sichere Weg,
* dass eine Regel nur in einem von beiden landet.
*/
async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
const { eingabe, kundeId, durchBenutzerId } = auftrag;
const registriert = eingabe.registrieren ?? true;
const hostname = text(eingabe.hostname);
const bezeichnung = text(eingabe.bezeichnung);
namenPflichtPruefen(hostname, bezeichnung, registriert);
const mitarbeiterId = text(eingabe.mitarbeiterId);
if (mitarbeiterId) await besitzerPruefen(mitarbeiterId, kundeId);
const tagCode = await freienTagCodeZiehen();
try {
return await prisma.$transaction(async (tx) => {
// Beides in einer Transaktion: ein Geraet mit einem Code, der nicht im
// Vergabebuch steht, waere nach dem Loeschen des Geraets wieder frei – und
// ein alter Aufkleber im Regal zeigte auf ein fremdes Objekt.
await tx.tagCodeVergabe.create({ data: { tagCode } });
const geraet = await tx.geraet.create({
data: {
kundeId,
tagCode,
hostname,
bezeichnung,
seriennummer: text(eingabe.seriennummer),
inventarnummer: text(eingabe.inventarnummer),
typ: eingabe.typ ?? GeraeteTyp.SONSTIGES,
modell: text(eingabe.modell),
notizen: text(eingabe.notizen),
mitarbeiterId,
registriertAm: registriert ? new Date() : null,
},
});
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraet.id,
aktion: auftrag.aktion,
benutzerId: durchBenutzerId,
nachher: {
tagCode: geraet.tagCode,
hostname: geraet.hostname,
bezeichnung: geraet.bezeichnung,
typ: geraet.typ,
...(auftrag.zusatz ?? {}),
},
},
});
return geraet;
});
} catch (fehler) {
// Zwei Anfragen haben in derselben Sekunde denselben Code gezogen. Bei 32^6
// Moeglichkeiten praktisch unmoeglich, aber wenn doch, dann lieber mit einer
// Meldung, die zum Wiederholen auffordert, als mit dem rohen Indexfehler.
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
throw new GeraeteFehler("Der erzeugte Tag-Code war schon vergeben. Bitte noch einmal versuchen.");
}
throw fehler;
}
}
/** Legt ein Geraet an und erzeugt seinen `tagCode`. */
export async function geraetAnlegen(eingabe: NeuesGeraet, benutzer: HandelnderBenutzer): Promise<Geraet> {
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
return geraetSchreiben({
eingabe,
kundeId,
durchBenutzerId: benutzer.id,
aktion: "Gerät angelegt",
});
}
/**
* Aendert die Stammdaten eines Geraets.
*
* Der Kunde bleibt, wie er ist: ein Geraet zu einem anderen Kunden zu schieben
* wuerde die Laeufe, die daran haengen, mandantenfremd machen. Wer sich vertan
* hat, legt das Geraet beim richtigen Kunden neu an — der alte Tag-Code bleibt
* dann vergeben, und genau das ist richtig.
*/
export async function geraetBearbeiten(
geraetId: string,
aenderung: GeraeteAenderung,
benutzer: HandelnderBenutzer,
): Promise<Geraet> {
const vorher = await prisma.geraet.findUnique({ where: { id: geraetId } });
if (!vorher) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (aenderung.tagCode !== undefined && aenderung.tagCode.trim() !== vorher.tagCode) {
throw new GeraeteFehler(
`Der Tag-Code ${vorher.tagCode} bleibt, wie er ist. Er steht auf einem Aufkleber, ` +
"der schon am Gerät klebt — und wird nie geändert und nie neu vergeben.",
);
}
const daten: Prisma.GeraetUpdateInput = {};
const geaendert: string[] = [];
const felder = [
["hostname", "Hostname", aenderung.hostname],
["bezeichnung", "Bezeichnung", aenderung.bezeichnung],
["seriennummer", "Seriennummer", aenderung.seriennummer],
["inventarnummer", "Inventarnummer", aenderung.inventarnummer],
["modell", "Modell", aenderung.modell],
["notizen", "Notizen", aenderung.notizen],
] as const;
for (const [feld, beschriftung, roh] of felder) {
if (roh === undefined) continue;
const wert = text(roh);
if (wert !== vorher[feld]) {
daten[feld] = wert;
geaendert.push(beschriftung);
}
}
if (aenderung.typ !== undefined && aenderung.typ !== vorher.typ) {
daten.typ = aenderung.typ;
geaendert.push("Typ");
}
if (aenderung.mitarbeiterId !== undefined) {
const wert = text(aenderung.mitarbeiterId);
if (wert !== vorher.mitarbeiterId) {
if (wert) await besitzerPruefen(wert, vorher.kundeId);
daten.mitarbeiter = wert ? { connect: { id: wert } } : { disconnect: true };
geaendert.push("Besitzer");
}
}
// Registrieren geht nur in eine Richtung: ein Geraet, das einmal gescannt und
// benannt wurde, wieder zum leeren Aufkleber zu machen, hiesse seine Geschichte
// zu verlieren.
const registriertNachher = vorher.registriertAm !== null || aenderung.registrieren === true;
if (aenderung.registrieren === true && vorher.registriertAm === null) {
daten.registriertAm = new Date();
geaendert.push("Registrierung");
}
namenPflichtPruefen(
aenderung.hostname === undefined ? vorher.hostname : text(aenderung.hostname),
aenderung.bezeichnung === undefined ? vorher.bezeichnung : text(aenderung.bezeichnung),
registriertNachher,
);
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
// Nur das, was sich geaendert hat – ein Protokoll, das jedes Mal alle
// Felder wiederholt, liest niemand mehr.
vorher: protokollAuszug(vorher, geaendert),
nachher: protokollAuszug(nachher, geaendert),
},
});
return nachher;
});
}
/** Die Felder zu den genannten Beschriftungen – fuer `Protokoll.vorher`/`nachher`. */
function protokollAuszug(geraet: Geraet, geaendert: readonly string[]): Prisma.InputJsonValue {
const auszug: Record<string, string | null> = { tagCode: geraet.tagCode };
const abbildung: Readonly<Record<string, string | null>> = {
Hostname: geraet.hostname,
Bezeichnung: geraet.bezeichnung,
Seriennummer: geraet.seriennummer,
Inventarnummer: geraet.inventarnummer,
Modell: geraet.modell,
Notizen: geraet.notizen,
Typ: geraet.typ,
Besitzer: geraet.mitarbeiterId,
Registrierung: geraet.registriertAm?.toISOString() ?? null,
};
for (const beschriftung of geaendert) {
if (beschriftung in abbildung) auszug[beschriftung] = abbildung[beschriftung] ?? null;
}
return auszug;
}
/**
* Archivieren statt Loeschen.
*
* Ein archiviertes Geraet erscheint nicht mehr in der Auswahl, seine Laeufe aber
* weiter im Archiv. Der `tagCode` bleibt vergeben.
*/
export async function geraetArchivieren(
geraetId: string,
archiviert: boolean,
benutzer: HandelnderBenutzer,
): Promise<void> {
const geraet = await prisma.geraet.findUnique({
where: { id: geraetId },
select: { id: true, kundeId: true, archiviert: true, hostname: true, bezeichnung: true, tagCode: true },
});
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(geraet.kundeId, benutzer);
if (geraet.archiviert === archiviert) return;
await prisma.$transaction(async (tx) => {
await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben",
benutzerId: benutzer.id,
vorher: { archiviert: geraet.archiviert },
nachher: { archiviert, tagCode: geraet.tagCode },
},
});
});
}
/**
* Loescht ein Geraet – nur, solange kein Lauf daran haengt.
*
* Der `tagCode` bleibt in `TagCodeVergabe` stehen und wird nie wieder vergeben.
* Das ist der Zweck dieses Buches: ohne es waere der Code nach dem Loeschen frei,
* und ein uebrig gebliebener Aufkleber zeigte irgendwann auf ein fremdes Objekt.
*/
export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenutzer): Promise<void> {
const geraet = await prisma.geraet.findUnique({
where: { id: geraetId },
select: {
id: true,
kundeId: true,
tagCode: true,
hostname: true,
bezeichnung: true,
typ: true,
_count: { select: { laeufe: true } },
},
});
if (!geraet) throw new GeraeteFehler("Dieses Gerät gibt es nicht.");
await kundeZugriffPruefen(geraet.kundeId, benutzer);
if (geraet._count.laeufe > 0) {
const anzahl = geraet._count.laeufe;
throw new GeraeteFehler(
`An „${geraetName(geraet)}“ ${anzahl === 1 ? "hängt ein Lauf" : `hängen ${anzahl} Läufe`}. ` +
"Das Gerät lässt sich deshalb nur archivieren — ein Lauf muss für immer nachvollziehbar bleiben.",
);
}
try {
await prisma.$transaction(async (tx) => {
await tx.geraet.delete({ where: { id: geraetId } });
await tx.protokoll.create({
data: {
entitaet: "Geraet",
entitaetId: geraetId,
aktion: "Gerät gelöscht",
benutzerId: benutzer.id,
vorher: {
tagCode: geraet.tagCode,
hostname: geraet.hostname,
bezeichnung: geraet.bezeichnung,
typ: geraet.typ,
},
},
});
});
} catch (fehler) {
// Zwischen Zaehlung und Loeschen kann ein Lauf entstanden sein; dann greift
// der Fremdschluessel auf Restrict.
if (
fehler instanceof Prisma.PrismaClientKnownRequestError &&
(fehler.code === "P2003" || fehler.code === "P2014")
) {
throw new GeraeteFehler(
`An „${geraetName(geraet)}“ hängt inzwischen ein Lauf. Das Gerät lässt sich nur archivieren.`,
);
}
throw fehler;
}
}
// ---- CSV-Import ------------------------------------------------------------
/**
* Obergrenze je Datei.
*
* Ohne Grenze belegt eine versehentlich exportierte 50-MB-Tabelle die Datenbank
* mit Zeilen, die niemand wollte, und der Import laeuft minutenlang. 500 ist
* grosszuegig fuer einen Bestand, der von Hand gepflegt wird, und die Oberflaeche
* nennt die Zahl, **bevor** jemand dagegen laeuft.
*/
export const GERAETE_IMPORT_HOECHSTZEILEN = 500;
/**
* Die Spalten aus dem Auftrag – englisch, wie dort genannt – und jeweils die
* deutsche Entsprechung. Gross- und Kleinschreibung, Leerraum, Unterstriche und
* Bindestriche sind unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts.
*/
export const GERAETE_IMPORT_SPALTEN = {
hostname: ["hostname"],
bezeichnung: ["label", "bezeichnung"],
seriennummer: ["serialNumber", "seriennummer"],
inventarnummer: ["inventoryNumber", "inventarnummer"],
typ: ["type", "typ"],
modell: ["model", "modell"],
} as const;
/**
* Woerter, die auf einen `GeraeteTyp` fuehren.
*
* Die Enum-Werte selbst kommen aus dem Enum, damit ein neuer Typ nicht in zwei
* Listen eingetragen werden muss. Dazu die Woerter, die in echten Exporten
* stehen — englisch und deutsch.
*/
const TYP_WOERTER: ReadonlyMap<string, GeraeteTyp> = new Map<string, GeraeteTyp>([
...Object.values(GeraeteTyp).map((typ) => [typ.toLowerCase(), typ] as const),
["laptop", GeraeteTyp.NOTEBOOK],
["nb", GeraeteTyp.NOTEBOOK],
["desktop", GeraeteTyp.PC],
["workstation", GeraeteTyp.PC],
["printer", GeraeteTyp.DRUCKER],
["room", GeraeteTyp.RAUM],
["other", GeraeteTyp.SONSTIGES],
["sonstige", GeraeteTyp.SONSTIGES],
]);
/** Die erlaubten Typen als Aufzaehlung – fuer die Fehlermeldung und die Oberflaeche. */
export const GERAETE_TYPEN: readonly GeraeteTyp[] = Object.values(GeraeteTyp);
export type ImportAbweisung = {
/** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
readonly zeile: number;
readonly grund: string;
};
export type ImportErgebnis = {
/** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
readonly gesamt: number;
readonly angelegt: number;
readonly abgewiesen: readonly ImportAbweisung[];
};
/**
* Liest einen Typ aus der Spalte `type`.
*
* Ein unbekannter Wert ist ein Zeilenfehler und **kein** stiller Standardwert:
* „Tablet“ als SONSTIGES durchzuwinken heisst, den Bestand still falsch zu
* fuellen. Eine leere Zelle ist etwas anderes — dort steht nichts, also gilt
* SONSTIGES.
*/
function typLesen(roh: string): GeraeteTyp {
if (!roh) return GeraeteTyp.SONSTIGES;
const typ = TYP_WOERTER.get(roh.trim().toLowerCase());
if (!typ) {
throw new GeraeteFehler(
`„${roh}“ ist kein bekannter Gerätetyp. Erlaubt sind: ${GERAETE_TYPEN.join(", ")}.`,
);
}
return typ;
}
/** Eine Zeile der Datei zu einer Geraeteeingabe. Wirft bei einem Zeilenfehler. */
function zeileLesen(zeile: CsvZeile): NeuesGeraet {
const hostname = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.hostname);
const bezeichnung = wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.bezeichnung);
if (!hostname && !bezeichnung) {
throw new GeraeteFehler("Weder ein Hostname noch eine Bezeichnung steht in dieser Zeile.");
}
return {
hostname,
bezeichnung,
seriennummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.seriennummer),
inventarnummer: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.inventarnummer),
typ: typLesen(wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.typ)),
modell: wertAus(zeile, ...GERAETE_IMPORT_SPALTEN.modell),
};
}
/**
* Spielt eine CSV-Datei mit Geraeten ein.
*
* **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
* sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden angelegt.
* Deshalb hat jede Zeile ihre eigene Transaktion: eine gemeinsame haette am Ende
* alles zurueckgerollt, was vor dem Fehler gelungen war.
*
* Der `tagCode` wird je Zeile erzeugt, genau wie beim Anlegen von Hand.
*/
export async function geraeteImportieren(
csvText: string,
benutzer: HandelnderBenutzer,
kundeIdAusEingabe?: string | null,
): Promise<ImportErgebnis> {
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
let tabelle;
try {
tabelle = csvZerlegen(csvText, { hoechstZeilen: GERAETE_IMPORT_HOECHSTZEILEN });
} catch (fehler) {
if (fehler instanceof CsvFehler) throw new GeraeteFehler(fehler.message);
throw fehler;
}
// Ohne eine der Namensspalten koennte keine einzige Zeile gelingen. Das als
// 500 gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
const alleNamen = Object.values(GERAETE_IMPORT_SPALTEN).flat();
if (!hatSpalte(tabelle, ...GERAETE_IMPORT_SPALTEN.hostname, ...GERAETE_IMPORT_SPALTEN.bezeichnung)) {
throw new GeraeteFehler(
"In der Kopfzeile fehlen „hostname“ und „label“ — eine davon muss es geben. " +
`Gelesen wurden diese Spalten: ${alleNamen.join(", ")}.`,
);
}
const abgewiesen: ImportAbweisung[] = [];
let angelegt = 0;
/*
* Doppelte Hostnamen in derselben Datei faengt diese Menge ab. Dieselbe Datei
* zweimal einzulesen verdoppelte sonst den Bestand — und weil ein tagCode nie
* zurueckkommt, waeren die Aufkleber dafuer schon gedruckt.
*/
const gesehene = new Set<string>();
for (const zeile of tabelle.zeilen) {
if (zeile.fehler !== undefined) {
abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
continue;
}
try {
const eingabe = zeileLesen(zeile);
const hostname = eingabe.hostname?.trim().toLowerCase();
if (hostname) {
if (gesehene.has(hostname)) {
abgewiesen.push({
zeile: zeile.nummer,
grund: `„${eingabe.hostname?.trim()}“ steht in dieser Datei schon weiter oben.`,
});
continue;
}
const schonDa = await prisma.geraet.findFirst({
where: { kundeId, hostname: { equals: eingabe.hostname?.trim(), mode: "insensitive" } },
select: { tagCode: true },
});
if (schonDa) {
abgewiesen.push({
zeile: zeile.nummer,
grund: `„${eingabe.hostname?.trim()}“ gibt es schon — Tag-Code ${schonDa.tagCode}.`,
});
continue;
}
gesehene.add(hostname);
}
await geraetSchreiben({
eingabe,
kundeId,
durchBenutzerId: benutzer.id,
aktion: "Gerät aus CSV angelegt",
zusatz: { zeile: zeile.nummer },
});
angelegt += 1;
} catch (fehler) {
if (fehler instanceof GeraeteFehler) {
abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
continue;
}
// Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
// wuerde in den folgenden Zeilen genauso auftreten.
throw fehler;
}
}
return { gesamt: tabelle.zeilen.length, angelegt, abgewiesen };
}
+252
View File
@@ -0,0 +1,252 @@
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
import { Herkunft } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
/**
* Mitarbeiter (#011).
*
* Die Personen, an denen die Historie haengt. Zwei Regeln tragen das:
*
* 1. **Nichts wird geloescht.** Ein Mitarbeiter wird auf `aktiv = false`
* gesetzt. Ein archivierter Lauf zeigt auf ihn, und dieser Verweis muss in
* zehn Jahren noch aufloesbar sein — deshalb steht der Fremdschluessel auf
* `Restrict` und es gibt hier gar keinen Loeschweg.
* 2. **Was aus dem Verzeichnis kommt, wird nicht von Hand ueberschrieben.** Bei
* `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt: der naechste
* Abgleich wuerde die Aenderung ohnehin zurueckdrehen, und ein Wert, der
* zwischen zwei Laeufen hin- und herspringt, ist schlimmer als ein falscher.
* Was **nicht** aus dem Verzeichnis kommt, bleibt aenderbar (#016 fuellt die
* Gegenseite).
*/
export class MitarbeiterFehler extends Error {
constructor(nachricht: string) {
super(nachricht);
this.name = "MitarbeiterFehler";
}
}
/**
* Felder, die der Verzeichnisabgleich fuellt.
*
* Als Daten und nicht als Kette von `if`s, damit die Sperre und die Oberflaeche
* aus derselben Liste lesen — sonst zeigt das Formular ein Feld als aenderbar,
* das der Dienst danach abweist.
*/
export const ABGEGLICHENE_FELDER = [
"vorname",
"nachname",
"anzeigename",
"mail",
"upn",
"sam",
"abteilung",
"titel",
] as const;
export type AbgeglichenesFeld = (typeof ABGEGLICHENE_FELDER)[number];
export type MitarbeiterEingabe = {
readonly vorname: string;
readonly nachname: string;
/** Leer: wird aus Vor- und Nachname gebildet. */
readonly anzeigename?: string | null;
readonly mail?: string | null;
readonly upn?: string | null;
readonly sam?: string | null;
readonly abteilung?: string | null;
readonly titel?: string | null;
readonly kundeId?: string | null;
};
function pflicht(wert: string, was: string): string {
const sauber = wert.trim();
if (!sauber) throw new MitarbeiterFehler(`${was} darf nicht leer sein.`);
return sauber;
}
function leerAlsNull(wert: string | null | undefined): string | null {
const sauber = wert?.trim();
return sauber ? sauber : null;
}
/**
* `sam` ist im Active Directory auf 20 Zeichen begrenzt.
*
* Die Regel steht hier, weil ein zu langer Wert erst beim `New-ADUser` im
* Gesicht des Technikers explodieren wuerde — und dann vor dem Kunden.
*/
function samPruefen(wert: string | null): string | null {
if (wert && wert.length > 20) {
throw new MitarbeiterFehler(
`„${wert}“ ist ${wert.length} Zeichen lang. Ein sAMAccountName fasst höchstens 20.`,
);
}
return wert;
}
function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachname: string): string {
const gewuenscht = leerAlsNull(eingabe.anzeigename);
return gewuenscht ?? `${vorname} ${nachname}`.trim();
}
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
export async function mitarbeiterAnlegen(
eingabe: MitarbeiterEingabe,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorname = pflicht(eingabe.vorname, "Der Vorname");
const nachname = pflicht(eingabe.nachname, "Der Nachname");
const kundeId = await kundeErmitteln(eingabe.kundeId, benutzer);
return prisma.$transaction(async (tx) => {
const mitarbeiter = await tx.mitarbeiter.create({
data: {
kundeId,
vorname,
nachname,
anzeigename: anzeigenameBilden(eingabe, vorname, nachname),
mail: leerAlsNull(eingabe.mail),
upn: leerAlsNull(eingabe.upn),
sam: samPruefen(leerAlsNull(eingabe.sam)),
abteilung: leerAlsNull(eingabe.abteilung),
titel: leerAlsNull(eingabe.titel),
herkunft: Herkunft.MANUELL,
},
});
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiter.id,
aktion: "Mitarbeiter angelegt",
benutzerId: benutzer.id,
nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
},
});
return mitarbeiter;
});
}
/**
* Aendert die Stammdaten.
*
* Bei `herkunft = ABGLEICH` sind die abgeglichenen Felder gesperrt — abgewiesen
* wird aber nur, was sich wirklich aendern soll. Ein Formular, das alle Felder
* mitschickt, darf deshalb weiterhin die freien Felder aendern, ohne an einem
* unveraenderten gesperrten Feld zu scheitern.
*/
export async function mitarbeiterBearbeiten(
mitarbeiterId: string,
aenderung: Partial<MitarbeiterEingabe>,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
const daten: Record<string, string | null> = {};
const geaendert: string[] = [];
function uebernehmen(feld: AbgeglichenesFeld | "anzeigename", neu: string | null) {
if (neu === vorher![feld]) return;
if (vorher!.herkunft === Herkunft.ABGLEICH && (ABGEGLICHENE_FELDER as readonly string[]).includes(feld)) {
throw new MitarbeiterFehler(
`„${vorher!.anzeigename}“ kommt aus dem Verzeichnis. ${feldName(feld)} wird dort gepflegt und ` +
"beim nächsten Abgleich ohnehin zurückgesetzt.",
);
}
daten[feld] = neu;
geaendert.push(feldName(feld));
}
if (aenderung.vorname !== undefined) uebernehmen("vorname", pflicht(aenderung.vorname, "Der Vorname"));
if (aenderung.nachname !== undefined) uebernehmen("nachname", pflicht(aenderung.nachname, "Der Nachname"));
if (aenderung.anzeigename !== undefined) {
const gewuenscht = leerAlsNull(aenderung.anzeigename);
uebernehmen(
"anzeigename",
gewuenscht ??
`${(daten["vorname"] as string) ?? vorher.vorname} ${(daten["nachname"] as string) ?? vorher.nachname}`.trim(),
);
}
if (aenderung.mail !== undefined) uebernehmen("mail", leerAlsNull(aenderung.mail));
if (aenderung.upn !== undefined) uebernehmen("upn", leerAlsNull(aenderung.upn));
if (aenderung.sam !== undefined) uebernehmen("sam", samPruefen(leerAlsNull(aenderung.sam)));
if (aenderung.abteilung !== undefined) uebernehmen("abteilung", leerAlsNull(aenderung.abteilung));
if (aenderung.titel !== undefined) uebernehmen("titel", leerAlsNull(aenderung.titel));
if (geaendert.length === 0) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: `${geaendert.join(" und ")} geändert`,
benutzerId: benutzer.id,
vorher: auszug(vorher, Object.keys(daten)),
nachher: auszug(nachher, Object.keys(daten)),
},
});
return nachher;
});
}
/**
* Deaktivieren und wieder aktivieren.
*
* Es gibt keinen Loeschweg — auch nicht fuer eine Person ohne Laeufe. Waere
* Loeschen manchmal moeglich, muesste jeder Aufrufer die Bedingung kennen, und
* beim Offboarding entscheidet sich niemand bewusst zwischen zwei Wegen.
*/
export async function mitarbeiterAktivSetzen(
mitarbeiterId: string,
aktiv: boolean,
benutzer: HandelnderBenutzer,
): Promise<Mitarbeiter> {
const vorher = await prisma.mitarbeiter.findUnique({ where: { id: mitarbeiterId } });
if (!vorher) throw new MitarbeiterFehler("Diese Person gibt es nicht (mehr).");
await kundeZugriffPruefen(vorher.kundeId, benutzer);
if (vorher.aktiv === aktiv) return vorher;
return prisma.$transaction(async (tx) => {
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
await tx.protokoll.create({
data: {
entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId,
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
benutzerId: benutzer.id,
vorher: { aktiv: vorher.aktiv },
nachher: { aktiv },
},
});
return nachher;
});
}
const FELD_NAMEN: Record<string, string> = {
vorname: "Vorname",
nachname: "Nachname",
anzeigename: "Anzeigename",
mail: "Mail-Adresse",
upn: "UPN",
sam: "sAMAccountName",
abteilung: "Abteilung",
titel: "Titel",
};
function feldName(feld: string): string {
return FELD_NAMEN[feld] ?? feld;
}
/** Nur die geaenderten Felder ins Protokoll – ein ganzer Datensatz waere Rauschen. */
function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJsonObject {
const ausschnitt: Record<string, string | null> = {};
for (const feld of felder) {
ausschnitt[feld] = (zeile as unknown as Record<string, string | null>)[feld] ?? null;
}
return ausschnitt;
}