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
+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}
</>
);
}