Files
Mieterportal/src/pages/BenutzerVerwaltung.tsx
T

372 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useState } from 'react'
import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useStore } from '../store/store'
import type { BenutzerRolle } from '../types'
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
const tones = {
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
teal: 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300',
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
}
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${tones[tone]}`}>
{label}: {value}
</span>
)
}
function NeuerBenutzerDialog() {
const { benutzerAnlegen, units } = useStore()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [bn, setBn] = useState('')
const [email, setEmail] = useState('')
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
const [pw, setPw] = useState('0815')
const [einheitId, setEinheitId] = useState(units[0]?.id ?? '')
const [fehler, setFehler] = useState<string | null>(null)
const submit = async () => {
setFehler(null)
const err = await benutzerAnlegen(bn, name, rolle, email, pw, rolle === 'mieter' ? einheitId : null)
if (err) return setFehler(err)
setName(''); setBn(''); setEmail(''); setPw('0815')
setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400">
<Plus className="h-4 w-4" /> Benutzer anlegen
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="nb-name">Name</Label>
<Input id="nb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Vor- und Nachname" />
</div>
<div className="space-y-1.5">
<Label htmlFor="nb-bn">Benutzername</Label>
<Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label htmlFor="nb-email">E-Mail</Label>
<Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</div>
<div className="space-y-1.5">
<Label>Gruppe</Label>
<div className="flex gap-2">
{(['mieter', 'verwaltung'] as const).map((r) => (
<button
key={r}
type="button"
onClick={() => setRolle(r)}
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold transition ${
rolle === r ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
}`}
>
{r === 'mieter' ? 'Mieter' : 'Verwaltung'}
</button>
))}
</div>
</div>
</div>
{rolle === 'mieter' && (
<div className="space-y-1.5">
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label>
<select
id="nb-einheit"
value={einheitId}
onChange={(e) => setEinheitId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.building}, Whg. {u.number} ({u.size} m²)
</option>
))}
</select>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="nb-pw">Passwort (Initial)</Label>
<Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} />
<p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
</div>
{fehler && (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-950 dark:text-red-300">{fehler}</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
<Button onClick={submit} disabled={!name.trim() || !bn.trim() || !email.trim() || !pw.trim()} className="bg-amber-500 text-slate-900 hover:bg-amber-400">
Anlegen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function BenutzerVerwaltung() {
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
const [suche, setSuche] = useState('')
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
const [anzahl, setAnzahl] = useState(5)
const [codeEinheit, setCodeEinheit] = useState('')
const [kopiert, setKopiert] = useState<string | null>(null)
const gefiltert = useMemo(() => {
const q = suche.trim().toLowerCase()
return benutzer
.filter((b) => gruppe === 'alle' || b.rolle === gruppe)
.filter(
(b) =>
!q ||
b.name.toLowerCase().includes(q) ||
b.benutzername.toLowerCase().includes(q) ||
b.email.toLowerCase().includes(q),
)
}, [benutzer, suche, gruppe])
const freieCodes = codes.filter((c) => !c.verwendetAm)
const passwortZuruecksetzen = async (id: string, name: string) => {
if (!window.confirm(`Passwort von ${name} auf „0815“ zurücksetzen?`)) return
await setPasswort(id, '0815')
}
const kopieren = (text: string) => {
navigator.clipboard?.writeText(text).catch(() => {})
setKopiert(text)
setTimeout(() => setKopiert(null), 1500)
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-bold tracking-tight">Benutzerverwaltung</h1>
<p className="text-sm text-slate-500">
Konten der Gruppen Verwaltung und Mieter – Registrierungscodes für Neumieter
</p>
</div>
<NeuerBenutzerDialog />
</div>
<div className="flex flex-wrap gap-2">
<Chip label="Benutzer gesamt" value={benutzer.length} />
<Chip label="Verwaltung" value={benutzer.filter((b) => b.rolle === 'verwaltung').length} tone="amber" />
<Chip label="Mieter" value={benutzer.filter((b) => b.rolle === 'mieter').length} tone="teal" />
<Chip label="Aktive Codes" value={freieCodes.length} tone="blue" />
</div>
{/* Registrierungscodes */}
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold">
<KeyRound className="h-4.5 w-4.5 text-amber-600" /> Registrierungscodes für Mieter
</h2>
<div className="flex flex-wrap items-center gap-2">
<select
value={codeEinheit}
onChange={(e) => setCodeEinheit(e.target.value)}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
aria-label="Wohnung für Code"
>
<option value="">Wohnung wählen …</option>
{units.filter((u) => u.status === 'frei').map((u) => (
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
))}
{units.filter((u) => u.status === 'vermietet').map((u) => (
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
))}
</select>
<select
value={anzahl}
onChange={(e) => setAnzahl(Number(e.target.value))}
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
aria-label="Anzahl Codes"
>
{[1, 5, 10, 20].map((n) => (
<option key={n} value={n}>{n}</option>
))}
</select>
<Button
onClick={async () => {
const neu = await generiereCodes(anzahl, codeEinheit || null)
if (neu.length > 0) kopieren(neu[0].code)
}}
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
>
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
</Button>
</div>
</div>
{codes.length === 0 ? (
<p className="text-sm text-slate-500">Noch keine Codes erzeugt.</p>
) : (
<div className="flex flex-wrap gap-2">
{codes.slice(0, 20).map((c) => {
const einheit = units.find((u) => u.id === c.einheitId)
return (
<button
key={c.code}
onClick={() => kopieren(c.code)}
title="Klicken zum Kopieren"
className={`rounded-lg px-3 py-1.5 font-mono text-xs transition ${
c.verwendetAm
? 'bg-slate-100 text-slate-400 line-through dark:bg-slate-800'
: kopiert === c.code
? 'bg-emerald-100 text-emerald-800 ring-2 ring-emerald-400 dark:bg-emerald-950 dark:text-emerald-300'
: 'bg-amber-50 text-amber-900 ring-1 ring-amber-200 hover:ring-amber-400 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-800'
}`}
>
{c.code}
{einheit && <span className="ml-1.5 font-sans text-[10px] text-slate-500">({einheit.building.split(' ')[0]} {einheit.number})</span>}
{c.verwendetAm && <span className="ml-1 font-sans text-[10px]">verwendet</span>}
</button>
)
})}
</div>
)}
<p className="mt-2 text-xs text-slate-400">
Mieter registrieren sich auf der Startseite mit einem Code – der Code wird dabei verbraucht
und die Wohnung automatisch zugeordnet.
</p>
</div>
{/* Benutzerliste */}
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h2 className="flex items-center gap-2 font-semibold">
<UsersRound className="h-4.5 w-4.5 text-amber-600" /> Benutzerkonten
</h2>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Suchen …"
className="w-56 pl-8"
/>
</div>
<div className="flex gap-1">
{(['alle', 'mieter', 'verwaltung'] as const).map((g) => (
<button
key={g}
onClick={() => setGruppe(g)}
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
gruppe === g
? 'bg-amber-500 text-slate-900'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
}`}
>
{g === 'alle' ? 'Alle' : g === 'mieter' ? 'Mieter' : 'Verwaltung'}
</button>
))}
</div>
</div>
</div>
<div className="space-y-2">
{gefiltert.map((b) => {
const einheit = units.find((u) => u.id === b.einheitId)
return (
<div
key={b.id}
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 transition ${
b.aktiv ? 'bg-white dark:bg-slate-900' : 'bg-slate-50 opacity-60 dark:bg-slate-950'
}`}
>
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold ${
b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'
}`}>
{b.name.split(' ').map((t) => t[0]).slice(0, 2).join('')}
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold">{b.name}</span>
<Badge variant="secondary" className={b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'}>
{b.rolle === 'verwaltung' ? (
<><ShieldCheck className="mr-1 h-3 w-3" /> Verwaltung</>
) : (
<><UserCheck className="mr-1 h-3 w-3" /> Mieter</>
)}
</Badge>
{!b.aktiv && <Badge variant="secondary" className="bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300">deaktiviert</Badge>}
</div>
<div className="truncate text-xs text-slate-500">
@{b.benutzername} · {b.email}
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
</div>
</div>
{b.rolle === 'mieter' && (
<select
value={b.einheitId ?? ''}
onChange={(e) => weiseEinheitZu(b.id, e.target.value || null)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-xs"
aria-label={`Wohnung für ${b.name}`}
>
<option value="">Keine Wohnung</option>
{units.map((u) => (
<option key={u.id} value={u.id}>
{u.building}, Whg. {u.number}
</option>
))}
</select>
)}
<div className="flex gap-1.5">
<button
onClick={() => passwortZuruecksetzen(b.id, b.name)}
className="rounded-lg bg-slate-100 px-2.5 py-1.5 text-xs font-medium text-slate-600 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
>
Passwort → 0815
</button>
<button
onClick={() => setAktiv(b.id, !b.aktiv)}
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition ${
b.aktiv
? 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-950 dark:text-red-300'
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300'
}`}
>
{b.aktiv ? 'Deaktivieren' : 'Aktivieren'}
</button>
</div>
</div>
)
})}
{gefiltert.length === 0 && (
<p className="py-6 text-center text-sm text-slate-500">Keine Benutzer für diesen Filter.</p>
)}
</div>
</div>
</div>
)
}