Quelle anlegen, Verbindung testen, nach Zeitplan oder auf Knopfdruck abgleichen. Gelesen wird seitenweise mit searchPaginated, objectGUID kommt als Puffer und wird gemischt-endian zur GUID umsortiert. Wer im Verzeichnis verschwindet, wird hier inaktiv und nie geloescht. Drei Regeln verhindern, dass eine kaputte Eingabe wie "alle sind gegangen" aussieht: laesst sich kein einziger Eintrag zuordnen, wird niemand deaktiviert; uebersprungene Eintraege werden am dn erkannt und ausgenommen; eine von Hand angelegte Person wird uebernommen statt verdoppelt. Der Zeitplan laeuft ueber node-cron im selben Prozess, gestartet aus src/instrumentation.ts, und nimmt keine eigene Sperre - die liegt in der Datenbank, damit zwei Instanzen sich einig sind. Closes #16 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
167 lines
6.9 KiB
TypeScript
167 lines
6.9 KiB
TypeScript
import Link from "next/link";
|
|
import { Plus, Upload, User } from "lucide-react";
|
|
import { MitarbeiterAnlegen } from "@/components/mitarbeiter-anlegen";
|
|
import { MitarbeiterImportieren } from "@/components/mitarbeiter-importieren";
|
|
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";
|
|
import { MITARBEITER_IMPORT_HOECHSTZEILEN, MITARBEITER_IMPORT_SPALTEN } from "@/server/services/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}
|
|
|
|
{darfBearbeiten ? (
|
|
/*
|
|
* Der CSV-Import steht neben dem Formular und verlangt dasselbe Recht
|
|
* (#016): wer eine Person eintragen darf, darf auch zwanzig auf einmal
|
|
* eintragen. Zugeklappt, weil die tägliche Arbeit die Liste ist.
|
|
*/
|
|
<details className="mt-4 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">
|
|
<Upload className="size-4 text-akzent" strokeWidth={2} aria-hidden="true" />
|
|
Personen aus einer CSV-Datei einlesen
|
|
</summary>
|
|
<div className="px-5 pb-5">
|
|
<MitarbeiterImportieren
|
|
hoechstZeilen={MITARBEITER_IMPORT_HOECHSTZEILEN}
|
|
spalten={Object.values(MITARBEITER_IMPORT_SPALTEN).map((namen) => namen[0])}
|
|
kunden={kunden.map(({ id, name }) => ({ id, name }))}
|
|
/>
|
|
</div>
|
|
</details>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|