feat(stammdaten): Mitarbeiter, Abteilungen und Geraete (#25)
This commit was merged in pull request #25.
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user