v1.22: Verwaltung sendet Direkt-E-Mails an Mieter mit CC an die Verwaltung

This commit is contained in:
2026-10-04 14:17:12 +02:00
parent 497abfcd4b
commit f15fc6ee0c
10 changed files with 410 additions and 8 deletions
+120 -4
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react'
import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react'
import { KeyRound, Mail, Plus, RefreshCcw, Search, SendHorizonal, ShieldCheck, UserCheck, UsersRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
@@ -12,8 +12,9 @@ import {
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { useStore } from '../store/store'
import type { BenutzerRolle } from '../types'
import type { Benutzer, BenutzerRolle } from '../types'
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
const tones = {
@@ -130,8 +131,104 @@ function NeuerBenutzerDialog() {
)
}
function EmailDialog({ benutzer: b, einheitLabel }: { benutzer: Benutzer; einheitLabel: string }) {
const { sendeBenutzerEmail } = useStore()
const [open, setOpen] = useState(false)
const [betreff, setBetreff] = useState('')
const [text, setText] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const [gesendet, setGesendet] = useState(false)
const hatEmail = Boolean(b.email)
const submit = async () => {
if (!betreff.trim() || !text.trim() || sende) return
setSende(true)
setFehler(null)
const err = await sendeBenutzerEmail(b.id, betreff.trim(), text.trim())
setSende(false)
if (err) {
setFehler(err)
return
}
setGesendet(true)
setBetreff('')
setText('')
window.setTimeout(() => {
setGesendet(false)
setOpen(false)
}, 1500)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded-lg bg-teal-50 px-2.5 py-1.5 text-xs font-medium text-teal-700 transition hover:bg-teal-100 dark:bg-teal-950 dark:text-teal-300 dark:hover:bg-teal-900"
title={hatEmail ? `E-Mail an ${b.name}` : `${b.name} hat keine E-Mail-Adresse hinterlegt`}
>
<Mail className="h-3.5 w-3.5" /> E-Mail
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>E-Mail an {b.name}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Empfänger</Label>
<div className="rounded-lg border bg-slate-50 px-3 py-2 text-sm text-slate-600 dark:bg-slate-900 dark:text-slate-300">
{b.name} · {hatEmail ? b.email : 'keine E-Mail hinterlegt'}
{einheitLabel && <span className="block text-xs text-slate-400">{einheitLabel}</span>}
</div>
<p className="text-xs text-slate-400">
Die Verwaltung erhält automatisch eine Kopie (CC).
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="m-betreff">Betreff</Label>
<Input
id="m-betreff"
placeholder="z. B. Terminabsprache Hausmeisterservice"
value={betreff}
onChange={(e) => setBetreff(e.target.value)}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="m-text">Nachricht</Label>
<Textarea
id="m-text"
rows={6}
placeholder="Ihre Nachricht an den Mieter …"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</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" role="alert">
{fehler}
</div>
)}
{gesendet && (
<div className="rounded-lg bg-emerald-50 px-3 py-2 text-sm text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
E-Mail wurde versendet (mit CC an die Verwaltung).
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>Schließen</Button>
<Button onClick={submit} disabled={!betreff.trim() || !text.trim() || sende || !hatEmail} className="gap-2">
<SendHorizonal className="h-4 w-4" /> {sende ? 'Wird gesendet …' : 'Senden'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export default function BenutzerVerwaltung() {
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, setzeBenutzerEmail, generiereCodes } = useStore()
const [suche, setSuche] = useState('')
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
const [anzahl, setAnzahl] = useState(5)
@@ -320,10 +417,23 @@ export default function BenutzerVerwaltung() {
{!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}
@{b.benutzername}
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
</div>
<div className="mt-1 flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5 shrink-0 text-slate-400" />
<input
defaultValue={b.email}
placeholder="E-Mail-Adresse …"
aria-label={`E-Mail für ${b.name}`}
className="h-6 w-60 rounded border border-transparent bg-transparent px-1.5 text-xs text-slate-600 transition hover:border-slate-200 focus:border-teal-400 focus:outline-none dark:text-slate-300 dark:hover:border-slate-700"
onBlur={(e) => {
const wert = e.target.value.trim()
if (wert !== b.email) setzeBenutzerEmail(b.id, wert)
}}
/>
</div>
</div>
{b.rolle === 'mieter' && (
<select
@@ -341,6 +451,12 @@ export default function BenutzerVerwaltung() {
</select>
)}
<div className="flex gap-1.5">
{b.rolle === 'mieter' && (
<EmailDialog
benutzer={b}
einheitLabel={einheit ? `${einheit.building}, Whg. ${einheit.number}` : ''}
/>
)}
<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"
+21
View File
@@ -111,6 +111,10 @@ interface Store extends Bootstrap {
setAktiv: (id: string, aktiv: boolean) => Promise<void>
setPasswort: (id: string, passwort: string) => Promise<void>
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
/** Direkt-E-Mail der Verwaltung an einen Mieter (immer mit CC an die Verwaltung, v1.22) */
sendeBenutzerEmail: (id: string, betreff: string, text: string) => Promise<string | null>
/** E-Mail-Adresse eines Benutzers pflegen (Verwaltung, v1.22) */
setzeBenutzerEmail: (id: string, email: string) => Promise<string | null>
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
// ── Zählerstände (Verwaltung pflegt, Mieter lesen) ──────
addZaehler: (z: {
@@ -344,6 +348,23 @@ export function StoreProvider({ children }: { children: ReactNode }) {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
},
async sendeBenutzerEmail(id, betreff, text) {
try {
await apiPost(`/api/users/${id}/email`, { betreff, text })
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async setzeBenutzerEmail(id, email) {
try {
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { email })
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
return null
} catch (e) {
return fehlerNachricht(e)
}
},
async generiereCodes(anzahl, einheitId) {
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
await ladeAlles()