v1.22: Verwaltung sendet Direkt-E-Mails an Mieter mit CC an die Verwaltung
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user