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