Phase 10: Mandantenfähigkeit (#15)
This commit was merged in pull request #15.
This commit is contained in:
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
|
||||
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
|
||||
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
|
||||
if (!sitzung.personId) redirect("/");
|
||||
|
||||
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const bild = await qrTextAlsDataUri(marke, 420);
|
||||
|
||||
|
||||
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
|
||||
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
|
||||
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
|
||||
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
|
||||
if (!sitzung.personId) redirect("/");
|
||||
|
||||
// In eine eigene Konstante, damit die Einengung auch im Rueckruf gilt:
|
||||
// TypeScript verfolgt sie ueber eine Eigenschaft hinweg nicht.
|
||||
const eigeneAkteId = sitzung.personId;
|
||||
|
||||
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||
const person = await db.person.findUnique({
|
||||
where: { id: sitzung.personId },
|
||||
where: { id: eigeneAkteId },
|
||||
select: {
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
|
||||
return person;
|
||||
});
|
||||
|
||||
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
|
||||
const code = ausweisErzeugen(eigeneAkteId, sitzung.organisationId);
|
||||
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
|
||||
const bild = await qrTextAlsDataUri(adresse, 420);
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { fuerOrganisation } from "@/lib/db";
|
||||
import { rechteVon } from "@/lib/authz";
|
||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
|
||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||
import { TrpcProvider } from "@/lib/trpc/provider";
|
||||
|
||||
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
|
||||
rollen={sitzung.rollen}
|
||||
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
||||
personName={sitzung.name}
|
||||
istVerwalter={sitzung.istVerwalter ?? false}
|
||||
/>
|
||||
|
||||
<div className="md:pl-64">
|
||||
<AufsichtHinweis />
|
||||
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
|
||||
@@ -48,7 +48,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
||||
eintrittsdatum: true,
|
||||
austrittsdatum: true,
|
||||
aktiv: true,
|
||||
letzteAnmeldung: true,
|
||||
// Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob
|
||||
// ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als
|
||||
// ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein.
|
||||
mitgliedschaft: {
|
||||
select: {
|
||||
art: true,
|
||||
benutzer: {
|
||||
select: { email: true, letzteAnmeldung: true, aktiv: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
rollen: { include: { rolle: true } },
|
||||
qualifikationen: {
|
||||
where: { geloeschtAm: null },
|
||||
@@ -86,6 +96,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
||||
"Anschrift",
|
||||
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
|
||||
],
|
||||
[
|
||||
"Zugang",
|
||||
person.mitgliedschaft?.benutzer ? (
|
||||
<>
|
||||
{person.mitgliedschaft.benutzer.email}
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
{person.mitgliedschaft.benutzer.aktiv
|
||||
? person.mitgliedschaft.benutzer.letzteAnmeldung
|
||||
? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}`
|
||||
: "noch nie angemeldet"
|
||||
: "Konto abgeschaltet"}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
kein Konto
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
Diese Person ist geführt, meldet sich aber nicht an.
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
],
|
||||
];
|
||||
/* eslint-enable react/jsx-key */
|
||||
|
||||
|
||||
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
|
||||
redirect("/pruefungen");
|
||||
}
|
||||
|
||||
// Eine Prüfung braucht einen Prüfer mit Namen und Akte — sie ist der
|
||||
// Nachweis gegenüber der Unfallkasse. Das Recht oben schließt die
|
||||
// Kreisaufsicht bereits aus; die Zusicherung steht hier trotzdem, weil
|
||||
// sich Rechte verschieben lassen und ein Protokoll ohne Prüfer wertlos ist.
|
||||
if (!sitzung.personId) redirect("/pruefungen");
|
||||
|
||||
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||
|
||||
if (!geraetId) redirect("/pruefungen");
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"use server";
|
||||
|
||||
import { sitzungNeuAusstellen } from "@/lib/auth";
|
||||
|
||||
/**
|
||||
* In eine andere Wehr wechseln.
|
||||
*
|
||||
* Eine Server Action und keine tRPC-Mutation: der Wechsel ändert das
|
||||
* Sitzungstoken im Cookie, und das kann nur die Anmeldeschicht. tRPC läuft
|
||||
* über dieselbe Anfrage, hat aber keinen Zugriff auf das Setzen des Cookies.
|
||||
*
|
||||
* Übergeben wird die Kennung der **Mitgliedschaft**, nicht die der Wehr. Das
|
||||
* ist der Unterschied zwischen einem Wunsch und einer Anweisung: eine fremde
|
||||
* Wehr zu nennen bringt nichts, weil es dazu keine Mitgliedschaft dieses
|
||||
* Kontos gibt. Geprüft wird das serverseitig im `jwt`-Rückruf, nicht hier.
|
||||
*/
|
||||
export async function wehrWechseln(mitgliedschaftId: string): Promise<void> {
|
||||
await sitzungNeuAusstellen({ user: { mitgliedschaftId } });
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { aktuelleSitzung } from "@/lib/auth";
|
||||
import { Wehrverwaltung } from "@/app/(app)/wehren/verwaltung";
|
||||
|
||||
export const metadata = { title: "Wehren und Konten" };
|
||||
|
||||
/**
|
||||
* Die Verwaltung der Installation.
|
||||
*
|
||||
* Der einzige Bereich, der über Mandantengrenzen hinweg arbeitet — und
|
||||
* deshalb der einzige, dessen Zugang nicht an einem Recht innerhalb einer Wehr
|
||||
* hängt, sondern am Konto selbst.
|
||||
*/
|
||||
export default async function WehrenSeite() {
|
||||
const sitzung = await aktuelleSitzung();
|
||||
if (!sitzung) redirect("/anmelden");
|
||||
if (!sitzung.istVerwalter) redirect("/");
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Wehren und Konten</h1>
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Wehren dieser Installation, die Anmeldekonten und wer zu welcher Wehr gehört.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Wehrverwaltung />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Building2, Eye, KeyRound, Plus, Trash2, UserPlus } from "lucide-react";
|
||||
import { MitgliedschaftArt } from "@prisma/client";
|
||||
import { trpc } from "@/lib/trpc/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||
import { alsDatum } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Wehren und Konten.
|
||||
*
|
||||
* Zwei Listen, die zusammengehören: eine Wehr ohne Konten kann niemand
|
||||
* benutzen, und ein Konto ohne Mitgliedschaft kommt nirgends hinein. Deshalb
|
||||
* stehen sie auf einer Seite und nicht in zwei Menüpunkten.
|
||||
*/
|
||||
export function Wehrverwaltung() {
|
||||
const werkzeuge = trpc.useUtils();
|
||||
|
||||
const [wehrOffen, setWehrOffen] = React.useState(false);
|
||||
const [kontoOffen, setKontoOffen] = React.useState(false);
|
||||
const [zuordnen, setZuordnen] = React.useState<{ id: string; email: string } | null>(null);
|
||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||
const [hinweis, setHinweis] = React.useState<string | null>(null);
|
||||
|
||||
const wehren = trpc.wehrverwaltung.wehren.useQuery();
|
||||
const konten = trpc.wehrverwaltung.konten.useQuery();
|
||||
|
||||
const entfernen = trpc.wehrverwaltung.mitgliedschaftEntfernen.useMutation({
|
||||
onSuccess: () => {
|
||||
setHinweis("Zuordnung entfernt. Die Personenakte in der Wehr bleibt bestehen.");
|
||||
void werkzeuge.wehrverwaltung.invalidate();
|
||||
},
|
||||
onError: (fehler) => setMeldung(fehler.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{meldung && <Fehlermeldung meldung={meldung} kennung="verwaltungsfehler" />}
|
||||
|
||||
{/* Eine gescheiterte Abfrage sähe sonst aus wie „es gibt nichts" — und
|
||||
genau dieser Unterschied ist hier wichtig, weil eine leere
|
||||
Wehrenliste ein plausibler Zustand ist. */}
|
||||
{(wehren.error || konten.error) && (
|
||||
<Fehlermeldung
|
||||
meldung={wehren.error?.message ?? konten.error?.message ?? "Unbekannter Fehler"}
|
||||
kennung="verwaltungsabfrage"
|
||||
/>
|
||||
)}
|
||||
{hinweis && (
|
||||
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
|
||||
{hinweis}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* --- Wehren ---------------------------------------------------- */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Wehren</h2>
|
||||
<Button onClick={() => setWehrOffen(true)}>
|
||||
<Plus aria-hidden />
|
||||
Wehr anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{wehren.isLoading &&
|
||||
[0, 1, 2].map((stelle) => (
|
||||
<div
|
||||
key={stelle}
|
||||
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
|
||||
/>
|
||||
))}
|
||||
|
||||
{wehren.data?.map((wehr) => (
|
||||
<Card key={wehr.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle className="text-base">{wehr.kurzname}</CardTitle>
|
||||
{!wehr.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
|
||||
</div>
|
||||
<CardDescription className="truncate">{wehr.name}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm">
|
||||
<p className="text-[var(--muted-foreground)]">
|
||||
Kennung <code className="text-xs">{wehr.slug}</code>
|
||||
{wehr.ort && ` · ${wehr.ort}`}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
{wehr._count.personen} {wehr._count.personen === 1 ? "Person" : "Personen"} ·{" "}
|
||||
{wehr._count.mitgliedschaften}{" "}
|
||||
{wehr._count.mitgliedschaften === 1 ? "Konto" : "Konten"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Konten ---------------------------------------------------- */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-lg font-semibold">Konten</h2>
|
||||
<Button variant="outline" onClick={() => setKontoOffen(true)}>
|
||||
<KeyRound aria-hidden />
|
||||
Konto anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Ein Konto gehört keiner Wehr — es gehört zu einer oder mehreren. Wer in zwei Wehren
|
||||
geführt wird, bekommt deshalb kein zweites Konto, sondern eine zweite Zuordnung.
|
||||
</p>
|
||||
|
||||
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
|
||||
<NichtsGefunden
|
||||
titel="Noch keine Konten"
|
||||
hinweis="Ohne Konto kommt niemand hinein — auch nicht in eine gerade angelegte Wehr."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{konten.isLoading &&
|
||||
[0, 1].map((stelle) => (
|
||||
<div
|
||||
key={stelle}
|
||||
className="h-32 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
|
||||
/>
|
||||
))}
|
||||
|
||||
{konten.data?.map((konto) => (
|
||||
<Card key={konto.id}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="truncate text-base">{konto.email}</CardTitle>
|
||||
<CardDescription>
|
||||
{konto.letzteAnmeldung
|
||||
? `zuletzt angemeldet am ${alsDatum(konto.letzteAnmeldung)}`
|
||||
: "noch nie angemeldet"}
|
||||
{konto.oidcSubject && " · über authentik verknüpft"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{konto.istVerwalter && <Badge variant="secondary">Verwalter</Badge>}
|
||||
{!konto.aktiv && <Badge variant="ausgesondert">abgeschaltet</Badge>}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setZuordnen({ id: konto.id, email: konto.email })}
|
||||
>
|
||||
<UserPlus aria-hidden />
|
||||
Wehr zuordnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{konto.mitgliedschaften.length === 0 ? (
|
||||
<p className="text-sm text-[var(--muted-foreground)]">
|
||||
Keiner Wehr zugeordnet — die Anmeldung führt damit nirgendwohin.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{konto.mitgliedschaften.map((eintrag) => (
|
||||
<li
|
||||
key={eintrag.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-md bg-[var(--muted)] px-3 py-1.5"
|
||||
>
|
||||
{eintrag.art === MitgliedschaftArt.AUFSICHT ? (
|
||||
<Eye className="size-4 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Building2 className="size-4 shrink-0" aria-hidden />
|
||||
)}
|
||||
|
||||
<span className="font-medium">{eintrag.organisation.kurzname}</span>
|
||||
|
||||
<span className="text-[var(--muted-foreground)]">
|
||||
{eintrag.art === MitgliedschaftArt.AUFSICHT
|
||||
? "Kreisaufsicht — nur lesen"
|
||||
: eintrag.person
|
||||
? `${eintrag.person.nachname}, ${eintrag.person.vorname}`
|
||||
: "Mitglied"}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-auto"
|
||||
onClick={() => entfernen.mutate({ id: eintrag.id })}
|
||||
disabled={entfernen.isPending}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
<span className="sr-only">Zuordnung entfernen</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<WehrAnlegen
|
||||
offen={wehrOffen}
|
||||
schliessen={() => setWehrOffen(false)}
|
||||
fehler={setMeldung}
|
||||
fertig={(name) => {
|
||||
setWehrOffen(false);
|
||||
setHinweis(`${name} angelegt, samt Rollen. Jetzt fehlt noch ein Konto dafür.`);
|
||||
void werkzeuge.wehrverwaltung.invalidate();
|
||||
}}
|
||||
/>
|
||||
|
||||
<KontoAnlegen
|
||||
offen={kontoOffen}
|
||||
schliessen={() => setKontoOffen(false)}
|
||||
fehler={setMeldung}
|
||||
fertig={(email) => {
|
||||
setKontoOffen(false);
|
||||
setHinweis(`Konto ${email} angelegt. Es gehört noch keiner Wehr an.`);
|
||||
void werkzeuge.wehrverwaltung.invalidate();
|
||||
}}
|
||||
/>
|
||||
|
||||
<WehrZuordnen
|
||||
konto={zuordnen}
|
||||
schliessen={() => setZuordnen(null)}
|
||||
fehler={setMeldung}
|
||||
fertig={() => {
|
||||
setZuordnen(null);
|
||||
void werkzeuge.wehrverwaltung.invalidate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WehrAnlegen({
|
||||
offen,
|
||||
schliessen,
|
||||
fertig,
|
||||
fehler,
|
||||
}: {
|
||||
offen: boolean;
|
||||
schliessen: () => void;
|
||||
fertig: (name: string) => void;
|
||||
fehler: (nachricht: string) => void;
|
||||
}) {
|
||||
const [slug, setSlug] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [kurzname, setKurzname] = React.useState("");
|
||||
const [ort, setOrt] = React.useState("");
|
||||
const [landkreis, setLandkreis] = React.useState("");
|
||||
|
||||
const anlegen = trpc.wehrverwaltung.wehrAnlegen.useMutation({
|
||||
onSuccess: (wehr) => {
|
||||
setSlug("");
|
||||
setName("");
|
||||
setKurzname("");
|
||||
setOrt("");
|
||||
setLandkreis("");
|
||||
fertig(wehr.kurzname);
|
||||
},
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wehr anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Sie bekommt sofort ihre Systemrollen. Ein Konto bekommt sie nicht — wer sie führen
|
||||
soll, wird im zweiten Schritt zugeordnet.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehr-name">Vollständiger Name</Label>
|
||||
<Input
|
||||
id="wehr-name"
|
||||
value={name}
|
||||
onChange={(ereignis) => setName(ereignis.target.value)}
|
||||
placeholder="Freiwillige Feuerwehr Musterhausen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehr-kurzname">Kurzname</Label>
|
||||
<Input
|
||||
id="wehr-kurzname"
|
||||
value={kurzname}
|
||||
onChange={(ereignis) => setKurzname(ereignis.target.value)}
|
||||
placeholder="FF Musterhausen"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehr-slug">Kennung</Label>
|
||||
<Input
|
||||
id="wehr-slug"
|
||||
value={slug}
|
||||
onChange={(ereignis) => setSlug(ereignis.target.value.toLowerCase())}
|
||||
placeholder="ff-musterhausen"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Kleinbuchstaben, Ziffern, Bindestriche. Sie steht später im Terminal und in
|
||||
Dateipfaden und lässt sich nicht mehr ändern.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehr-ort">Ort</Label>
|
||||
<Input
|
||||
id="wehr-ort"
|
||||
value={ort}
|
||||
onChange={(ereignis) => setOrt(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="wehr-landkreis">Landkreis</Label>
|
||||
<Input
|
||||
id="wehr-landkreis"
|
||||
value={landkreis}
|
||||
onChange={(ereignis) => setLandkreis(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={schliessen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
anlegen.mutate({
|
||||
slug,
|
||||
name,
|
||||
kurzname,
|
||||
ort: ort || null,
|
||||
landkreis: landkreis || null,
|
||||
})
|
||||
}
|
||||
disabled={!slug || !name || !kurzname || anlegen.isPending}
|
||||
>
|
||||
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function KontoAnlegen({
|
||||
offen,
|
||||
schliessen,
|
||||
fertig,
|
||||
fehler,
|
||||
}: {
|
||||
offen: boolean;
|
||||
schliessen: () => void;
|
||||
fertig: (email: string) => void;
|
||||
fehler: (nachricht: string) => void;
|
||||
}) {
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [passwort, setPasswort] = React.useState("");
|
||||
const [verwalter, setVerwalter] = React.useState(false);
|
||||
|
||||
const anlegen = trpc.wehrverwaltung.kontoAnlegen.useMutation({
|
||||
onSuccess: (konto) => {
|
||||
setEmail("");
|
||||
setPasswort("");
|
||||
setVerwalter(false);
|
||||
fertig(konto.email);
|
||||
},
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Konto anlegen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Die E-Mail-Adresse ist die Anmeldung und gilt über alle Wehren hinweg. Das Passwort
|
||||
geben Sie der Person selbst weiter — FLORIAN verschickt keines.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="konto-email">E-Mail-Adresse</Label>
|
||||
<Input
|
||||
id="konto-email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(ereignis) => setEmail(ereignis.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="konto-passwort">Erstes Passwort</Label>
|
||||
<Input
|
||||
id="konto-passwort"
|
||||
type="text"
|
||||
value={passwort}
|
||||
onChange={(ereignis) => setPasswort(ereignis.target.value)}
|
||||
placeholder="mindestens zwölf Zeichen"
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Steht im Klartext, damit Sie es weitergeben können. Es gehört nach der ersten
|
||||
Anmeldung geändert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={verwalter}
|
||||
onChange={(ereignis) => setVerwalter(ereignis.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
Verwaltet die Installation
|
||||
<span className="block text-xs text-[var(--muted-foreground)]">
|
||||
Darf Wehren anlegen und in jede hineinschalten. Sparsam vergeben — dieses
|
||||
Merkmal hebt die Trennung zwischen den Wehren auf.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={schliessen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => anlegen.mutate({ email, passwort, istVerwalter: verwalter })}
|
||||
disabled={!email || passwort.length < 12 || anlegen.isPending}
|
||||
>
|
||||
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Ein Konto einer Wehr zuordnen — als Mitglied oder als Kreisaufsicht. */
|
||||
function WehrZuordnen({
|
||||
konto,
|
||||
schliessen,
|
||||
fertig,
|
||||
fehler,
|
||||
}: {
|
||||
konto: { id: string; email: string } | null;
|
||||
schliessen: () => void;
|
||||
fertig: () => void;
|
||||
fehler: (nachricht: string) => void;
|
||||
}) {
|
||||
const [organisationId, setOrganisationId] = React.useState("");
|
||||
const [art, setArt] = React.useState<MitgliedschaftArt>(MitgliedschaftArt.MITGLIED);
|
||||
const [personId, setPersonId] = React.useState("");
|
||||
|
||||
const wehren = trpc.wehrverwaltung.wehren.useQuery(undefined, { enabled: konto !== null });
|
||||
const personen = trpc.wehrverwaltung.personenIn.useQuery(
|
||||
{ organisationId },
|
||||
{ enabled: konto !== null && Boolean(organisationId) && art === MitgliedschaftArt.MITGLIED },
|
||||
);
|
||||
|
||||
const anlegen = trpc.wehrverwaltung.mitgliedschaftAnlegen.useMutation({
|
||||
onSuccess: () => {
|
||||
setOrganisationId("");
|
||||
setPersonId("");
|
||||
setArt(MitgliedschaftArt.MITGLIED);
|
||||
fertig();
|
||||
},
|
||||
onError: (aufruf) => fehler(aufruf.message),
|
||||
});
|
||||
|
||||
// Die Wehr wechselt, die vorher gewählte Akte gehört zur alten.
|
||||
React.useEffect(() => setPersonId(""), [organisationId, art]);
|
||||
|
||||
return (
|
||||
<Dialog open={konto !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Wehr zuordnen</DialogTitle>
|
||||
<DialogDescription>
|
||||
{konto?.email} — als Mitglied mit eigener Akte oder als Kreisaufsicht, die nur
|
||||
zusieht.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zuordnung-wehr">Wehr</Label>
|
||||
<Select value={organisationId} onValueChange={setOrganisationId}>
|
||||
<SelectTrigger id="zuordnung-wehr">
|
||||
<SelectValue placeholder="wählen …" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{wehren.data?.map((wehr) => (
|
||||
<SelectItem key={wehr.id} value={wehr.id}>
|
||||
{wehr.kurzname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{Object.values(MitgliedschaftArt).map((wert) => (
|
||||
<Button
|
||||
key={wert}
|
||||
type="button"
|
||||
variant={art === wert ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => setArt(wert)}
|
||||
>
|
||||
{wert === MitgliedschaftArt.AUFSICHT ? "Kreisaufsicht" : "Mitglied"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{art === MitgliedschaftArt.MITGLIED ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="zuordnung-person">Personenakte in dieser Wehr</Label>
|
||||
<Select value={personId} onValueChange={setPersonId}>
|
||||
<SelectTrigger id="zuordnung-person">
|
||||
<SelectValue placeholder="wählen …" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{personen.data?.map((person) => (
|
||||
<SelectItem key={person.id} value={person.id}>
|
||||
{person.nachname}, {person.vorname}
|
||||
{person.mitgliedsnummer && ` (${person.mitgliedsnummer})`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-[var(--muted-foreground)]">
|
||||
Zur Auswahl steht, wer noch kein Konto hat. Eine Akte gehört zu höchstens einem
|
||||
— sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
|
||||
Die Kreisaufsicht bekommt <strong>keine</strong> Personenakte. Sie gehört der Wehr
|
||||
nicht an, taucht in keiner Mitgliederliste auf und kann dort nichts ändern — auch
|
||||
die Kasse und das Protokoll bleiben ihr verschlossen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={schliessen}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
konto &&
|
||||
anlegen.mutate({
|
||||
benutzerId: konto.id,
|
||||
organisationId,
|
||||
art,
|
||||
personId: art === MitgliedschaftArt.MITGLIED ? personId : null,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
!organisationId ||
|
||||
(art === MitgliedschaftArt.MITGLIED && !personId) ||
|
||||
anlegen.isPending
|
||||
}
|
||||
>
|
||||
{anlegen.isPending ? "Wird zugeordnet …" : "Zuordnen"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user