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
Binary file not shown.
Binary file not shown.
+21
View File
@@ -189,6 +189,27 @@ tar -czf mp-dist.tar.gz -C dist .
## 8. Verlauf / offene Punkte
**Erledigt 04.10. (v1.22):** Direkt-E-Mail der Verwaltung an Mieter mit
automatischem CC an die Verwaltung. Mail-API (mail-server/api/server.py,
auf dem Server manuell ausgerollt, Container mp-mail-api per docker restart –
NICHT Teil von deploy.sh) um cc-Feld erweitert: Body cc kommagetrennt,
einzeln per EMAIL_RE validiert, versenden setzt msg["Cc"]. ACHTUNG Repo-
Stand vorher von Server frisch gezogen (from_address/from_name war nur
serverseitig). API: POST /api/users/:id/email (adminOnly) – Betreff/Text
Pflicht, Mieter muss E-Mail hinterlegt haben (sonst 400 mit Hinweis),
HTML-Karte baueHtmlMail, CC = mail.verwaltungEmails() (alle aktiven
Verwaltungs-Accounts mit E-Mail), Versand fire-and-forget via sendeMail(…, cc).
PATCH /api/users/:id akzeptiert jetzt auch email (Format- + Eindeutigkeits-
prüfung, LEERE E-Mail darf mehrfach vorkommen – Eindeutigkeit nur bei
nicht-leer, sonst Fehler beim Leeren). Frontend Benutzerverwaltung:
EmailDialog pro Mieter (Empfängeranzeige, Betreff, Nachricht, Hinweis auf
CC, Senden deaktiviert ohne Mieter-E-Mail), E-Mail-Adresse jetzt inline im
Benutzer-Eintrag editierbar (onBlur → setzeBenutzerEmail). Store:
sendeBenutzerEmail + setzeBenutzerEmail. E2E verifiziert: E-Mail setzen,
senden → Postfix eine queue-ID mit 3 Empfängern (Testadresse bounced
erwartet, joseph@hinterstein-immo.de + axel@hocks.eu status=sent),
Fehlerfall ohne E-Mail sauber, Testdaten bereinigt.
**Erledigt 04.10. (v1.21):** Live-Stammdaten aus SharePoint-Export-CSV
importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import,
Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen,
Binary file not shown.
+19 -2
View File
@@ -12,7 +12,8 @@ Endpunkte:
GET /healthz → {"ok": true}
GET /outbox → aktuelle Outbox (Demo/Debug)
POST /send → {"ok": true, "id": ..., "versandt": true/false}
Body: {"to": "...", "subject": "...", "text": "...", "html": "..."}
Body: {"to": "...", "subject": "...", "text": "...", "html": "...",
optional "cc": "a@b.de, c@d.de", "from_address"/"from_name" (nur @hocks.eu)}
Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN
"""
@@ -78,8 +79,10 @@ def rate_limit_ok() -> bool:
def versenden(e: dict) -> bool:
msg = EmailMessage()
msg["From"] = f"{SENDER_NAME} <{SENDER_ADDRESS}>"
msg["From"] = f"{e.get('from_name') or SENDER_NAME} <{e.get('from_address') or SENDER_ADDRESS}>"
msg["To"] = e["to"]
if e.get("cc"):
msg["Cc"] = e["cc"] # Komma-getrennte CC-Empfaenger (v1.22)
msg["Subject"] = e["subject"]
msg.set_content(e.get("text") or "")
if e.get("html"):
@@ -172,12 +175,26 @@ class Handler(BaseHTTPRequestHandler):
if not rate_limit_ok():
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
return
# Optionaler eigener Absender (nur hocks.eu-Domain, sonst Fallback)
absender = str(body.get("from_address", "")).strip().lower()
absender_name = str(body.get("from_name", "")).strip()
if (not EMAIL_RE.match(absender) or not absender.endswith("@hocks.eu")
or len(absender_name) > 60 or any(c in absender_name for c in "<>\r\n")):
absender, absender_name = SENDER_ADDRESS, SENDER_NAME
# Optional CC (Komma-getrennt, einzeln validiert) – v1.22
cc = ','.join(
a for a in (x.strip() for x in str(body.get("cc", "")).split(","))
if a and EMAIL_RE.match(a)
)
eintrag = {
"id": uuid.uuid4().hex[:12],
"to": an,
"subject": betreff,
"text": str(body.get("text", "")),
"html": str(body.get("html", "")),
"cc": cc,
"from_address": absender,
"from_name": absender_name,
"versuche": 0,
"versandt": False,
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
+164
View File
@@ -0,0 +1,164 @@
# -*- coding: utf-8 -*-
"""UAT-Featureliste für das Hinterstein-Mieterportal (Arbeitsvorlage)."""
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from openpyxl.worksheet.datavalidation import DataValidation
AKZENT = '0F766E' # Hinterstein-Türkis
AKZENT_HELL = 'CCFBF1'
GRAU = 'F1F5F9'
DUNKEL = '22333B'
ROT = 'FEE2E2'
GELB = 'FEF9C3'
GRUEN = 'DCFCE7'
wb = Workbook()
# ── Blatt 1: Featureliste ─────────────────────────────────────────────
ws = wb.active
ws.title = 'Featureliste'
ws.sheet_view.showGridLines = False
ws.merge_cells('B2:K2')
c = ws['B2']
c.value = 'UAT & Feature-Wunschliste – Hinterstein Mieterportal'
c.font = Font(bold=True, size=16, color=DUNKEL)
ws.merge_cells('B3:K3')
c = ws['B3']
c.value = ('Gewünschte Features erfassen: Zeile ergänzen, Modul wählen, Priorität setzen. '
'Die Entwicklung aktualisiert den Status. Beispielzeilen 001–003 gern überschreiben.')
c.font = Font(size=10, color='64748B')
# Status-Kennzahlen (Formeln)
kennz = [('Offen', 'Offen'), ('In Prüfung', 'In Prüfung'), ('Umgesetzt', 'Umgesetzt'), ('Abgelehnt', 'Abgelehnt')]
for i, (label, wert) in enumerate(kennz):
col = 2 + i * 2
lc = ws.cell(row=5, column=col, value=label)
lc.font = Font(size=9, bold=True, color='64748B')
vc = ws.cell(row=6, column=col, value=f'=COUNTIF($I$10:$I$109,"{wert}")')
vc.font = Font(size=14, bold=True, color=AKZENT)
vc.alignment = Alignment(horizontal='left')
KOPF = ['ID', 'Modul', 'Feature / Anforderung', 'Beschreibung / Akzeptanzkriterium',
'Priorität', 'Wunschtermin', 'Eingereicht von', 'Eingereicht am', 'Status',
'Ergebnis im Test / Bemerkung']
BREITEN = [7, 20, 30, 48, 11, 13, 15, 12, 12, 34]
SPALTEN = ['B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K']
HEADER_ZEILE = 9
for j, (name, breite, buchstabe) in enumerate(zip(KOPF, BREITEN, SPALTEN)):
cell = ws.cell(row=HEADER_ZEILE, column=2 + j, value=name)
cell.font = Font(bold=True, color='FFFFFF', size=10)
cell.fill = PatternFill('solid', fgColor=DUNKEL)
cell.alignment = Alignment(vertical='center', wrap_text=True)
ws.column_dimensions[buchstabe].width = breite
ws.row_dimensions[HEADER_ZEILE].height = 28
MODULE = ['Dashboard', 'Mietkonto & Zahlungen', 'Schadensmeldungen', 'Zählerstände',
'Steuer & Kosten', 'Dokumente', 'Nachrichten', 'Benutzerverwaltung',
'Mein Profil', 'Häuser & Wohnungen', 'Mietverträge', 'Technik allgemein']
PRIOS = ['Hoch', 'Mittel', 'Niedrig']
STATUS = ['Offen', 'In Prüfung', 'Umgesetzt', 'Abgelehnt']
beispiele = [
['001', 'Steuer & Kosten', 'Nebenkostenabrechnung als PDF',
'Pro Wohnung und Abrechnungsjahr eine PDF-Abrechnung aus Kosten + Zählerständen erzeugen und im Dokumentenmodul ablegen.',
'Mittel', '', '', '', 'Offen', ''],
['002', 'Benutzerverwaltung', 'E-Mail-Adressen importierter Mieter erfassen',
'Fehlende E-Mail-Adressen der importierten Mieter erfassen, damit Benachrichtigungen versendet werden können.',
'Hoch', '', '', '', 'Offen', ''],
['003', 'Mietkonto & Zahlungen', 'Zahlungseingang manuell als erledigt markieren',
'Verwaltung kann eine offene Buchung als „bezahlt“ markieren; Saldo und Fälligkeiten aktualisieren sich.',
'Niedrig', '', '', '', 'Offen', ''],
]
duenn = Side(style='thin', color='E2E8F0')
for i in range(100):
zeile = 10 + i
daten = beispiele[i] if i < len(beispiele) else [f'{i+1:03d}', '', '', '', '', '', '', '', 'Offen', '']
for j, wert in enumerate(daten):
cell = ws.cell(row=zeile, column=2 + j, value=wert)
cell.font = Font(size=10)
cell.alignment = Alignment(vertical='top', wrap_text=(j in (2, 3, 9)))
cell.border = Border(bottom=duenn)
if i % 2 == 1:
cell.fill = PatternFill('solid', fgColor=GRAU)
dv_modul = DataValidation(type='list', formula1='"' + ','.join(MODULE) + '"', allow_blank=True)
dv_prio = DataValidation(type='list', formula1='"' + ','.join(PRIOS) + '"', allow_blank=True)
dv_status = DataValidation(type='list', formula1='"' + ','.join(STATUS) + '"', allow_blank=True)
ws.add_data_validation(dv_modul)
ws.add_data_validation(dv_prio)
ws.add_data_validation(dv_status)
dv_modul.add('C10:C109')
dv_prio.add('F10:F109')
dv_status.add('J10:J109')
# Bedingte Formatierung: Priorität + Status
from openpyxl.formatting.rule import CellIsRule
ws.conditional_formatting.add('F10:F109',
CellIsRule(operator='equal', formula=['"Hoch"'],
fill=PatternFill('solid', fgColor=ROT), font=Font(bold=True, size=10)))
ws.conditional_formatting.add('F10:F109',
CellIsRule(operator='equal', formula=['"Mittel"'],
fill=PatternFill('solid', fgColor=GELB), font=Font(size=10)))
ws.conditional_formatting.add('F10:F109',
CellIsRule(operator='equal', formula=['"Niedrig"'],
fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10)))
ws.conditional_formatting.add('J10:J109',
CellIsRule(operator='equal', formula=['"Umgesetzt"'],
fill=PatternFill('solid', fgColor=GRUEN), font=Font(size=10)))
ws.conditional_formatting.add('J10:J109',
CellIsRule(operator='equal', formula=['"Abgelehnt"'],
fill=PatternFill('solid', fgColor='E2E8F0'), font=Font(size=10, color='64748B')))
ws.freeze_panes = 'B10'
ws.sheet_view.zoomScale = 90
# ── Blatt 2: Anleitung ────────────────────────────────────────────────
wa = wb.create_sheet('Anleitung')
wa.sheet_view.showGridLines = False
wa.column_dimensions['B'].width = 28
wa.column_dimensions['C'].width = 95
wa.merge_cells('B2:C2')
c = wa['B2']
c.value = 'So verwenden Sie diese Liste'
c.font = Font(bold=True, size=14, color=DUNKEL)
bloecke = [
('Neuen Wunsch erfassen',
'In der Tabelle „Featureliste“ eine leere Zeile nutzen (oder eine Beispielzeile überschreiben): '
'Modul aus dem Dropdown wählen, Feature kurz benennen und im Feld „Beschreibung / Akzeptanzkriterium“ '
'notieren, wann das Feature als fertig gilt. Je konkreter das Akzeptanzkriterium, desto schneller die Umsetzung.'),
('Priorität',
'Hoch = dringend für den Betrieb · Mittel = wichtig, aber zeitunkritisch · Niedrig = Nice-to-have.'),
('Status (wird von der Entwicklung gepflegt)',
'Offen → In Prüfung (Aufwand wird bewertet) → Umgesetzt (nach Auslieferung) bzw. Abgelehnt (mit Begründung in der letzten Spalte).'),
('UAT – Abnahme testen',
'Nach Umsetzung im Portal gegen das Akzeptanzkriterium testen. Ergebnis in der Spalte '
'„Ergebnis im Test / Bemerkung“ festhalten: „OK“ oder Fehlerbeschreibung mit Schritten zum Nachvollziehen. '
'Gefundene Fehler bitte als neue Zeile mit Priorität „Hoch“ eintragen.'),
('Übertrag an die Entwicklung',
'Liste speichern und an die Entwicklung senden (E-Mail oder Kopie ins Projektverzeichnis). '
'Die Entwicklung legt die übernommenen Punkte als Versions-Einträge an und verweist auf die IDs.'),
]
zeile = 4
for titel, text in bloecke:
tc = wa.cell(row=zeile, column=2, value=titel)
tc.font = Font(bold=True, size=11, color=AKZENT)
tc.alignment = Alignment(vertical='top')
cc = wa.cell(row=zeile, column=3, value=text)
cc.font = Font(size=10, color='334155')
cc.alignment = Alignment(vertical='top', wrap_text=True)
wa.row_dimensions[zeile].height = 58
zeile += 2
wa.merge_cells(f'B{zeile}:C{zeile}')
c = wa.cell(row=zeile, column=2, value='Aktueller Portal-Stand: v1.21 (Live-Stammdaten importiert)')
c.font = Font(size=9, italic=True, color='94A3B8')
out = 'UAT-Featureliste Mieterportal.xlsx'
wb.save(out)
print('gespeichert:', out)
+50
View File
@@ -1238,11 +1238,61 @@ app.patch('/api/users/:id', auth, adminOnly, async (req, res, next) => {
await pool.query('UPDATE users SET aktiv = $1 WHERE id = $2', [Boolean(b.aktiv), req.params.id])
if (b.einheitId !== undefined)
await pool.query('UPDATE users SET einheit_id = $1 WHERE id = $2', [b.einheitId || null, req.params.id])
if (b.email !== undefined) {
const email = String(b.email || '').trim()
if (email && !/^\S+@\S+\.\S+$/.test(email))
return res.status(400).json({ fehler: 'Ungültige E-Mail-Adresse.' })
if (email) {
const { rows: fremde } = await pool.query(
'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [email, req.params.id],
)
if (fremde.length)
return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' })
}
await pool.query('UPDATE users SET email = $1 WHERE id = $2', [email, req.params.id])
}
const { rows: neu } = await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])
res.json({ benutzer: oeffentlicherBenutzer(neu[0]) })
} catch (e) { next(e) }
})
// Direkte E-Mail der Verwaltung an einen Mieter (immer mit CC an die
// Verwaltung, damit die Kommunikation dort vollständig nachvollziehbar ist)
app.post('/api/users/:id/email', auth, adminOnly, async (req, res, next) => {
try {
const betreff = String((req.body || {}).betreff || '').trim()
const text = String((req.body || {}).text || '').trim()
if (!betreff || !text)
return res.status(400).json({ fehler: 'Betreff und Nachricht erforderlich.' })
const { rows } = await pool.query(
`SELECT * FROM users WHERE id = $1 AND rolle = 'mieter'`, [req.params.id],
)
const mieter = rows[0]
if (!mieter) return res.status(404).json({ fehler: 'Mieter nicht gefunden.' })
if (!mieter.email)
return res.status(400).json({ fehler: `${mieter.name} hat keine E-Mail-Adresse hinterlegt (Profil).` })
const { rows: einheitRows } = await pool.query('SELECT * FROM units WHERE id = $1', [mieter.einheit_id])
const label = mail.einheitLabel(mapUnit(einheitRows[0]))
const html = mail.baueHtmlMail({
titel: 'Nachricht der Verwaltung',
untertitel: label,
einleitung: `Persönliche Nachricht von ${req.user.name} (Hinterstein Immobilien):`,
zeilen: [
['Wohnung', label],
['Mieter', mieter.name],
],
zitat: text,
aktionenHtml: mail.buttonHtml('Zum Mieterportal', `${process.env.PORTAL_URL || 'https://mieterportal.hocks.eu'}/nachrichten`),
})
const cc = await mail.verwaltungEmails()
const textVersion =
`Nachricht der Verwaltung an ${mieter.name} (${label}):\n\n„${text}“\n\n` +
`— Gesendet von ${req.user.name} über das Mieterportal`
mail.sendeMail(mieter.email, betreff, textVersion, html, cc)
res.json({ ok: true })
} catch (e) { next(e) }
})
// ── Registrierungscodes (nur Verwaltung) ─────────────────────
app.get('/api/codes', auth, adminOnly, async (_req, res, next) => {
try {
+15 -2
View File
@@ -108,13 +108,17 @@ function buttonHtml(text, url) {
return `<p style="margin:24px 0 0;"><a href="${htmlEscape(url)}" style="display:inline-block;background:${FARBE_BUTTON};color:${FARBE_BUTTON_TEXT};text-decoration:none;font-size:14px;font-weight:700;padding:12px 22px;border-radius:10px;">${htmlEscape(text)}</a></p>`
}
async function sendeMail(an, betreff, text, html) {
async function sendeMail(an, betreff, text, html, cc) {
if (!MAIL_API_URL || !MAIL_API_TOKEN || !an) return
try {
const res = await fetch(`${MAIL_API_URL}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Api-Token': MAIL_API_TOKEN },
body: JSON.stringify({ to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`, ...(html ? { html } : {}) }),
body: JSON.stringify({
to: an, subject: betreff, text: `${text}\n\n${FUSSZEILE_TEXT}`,
...(html ? { html } : {}),
...(cc ? { cc } : {}), // v1.22: Komma-getrennte CC-Empfänger
}),
})
if (!res.ok) console.warn(`[mail] Versand an ${an} fehlgeschlagen: HTTP ${res.status}`)
} catch (e) {
@@ -122,6 +126,14 @@ async function sendeMail(an, betreff, text, html) {
}
}
/** E-Mail-Adressen aller aktiven Verwaltungsnutzer (komma-getrennt, für CC). */
async function verwaltungEmails() {
const { rows } = await require('./db').pool.query(
`SELECT email FROM users WHERE rolle = 'verwaltung' AND aktiv AND email <> ''`,
)
return rows.map((r) => r.email).join(', ')
}
/** Alle aktiven Verwaltungsnutzer benachrichtigen. */
async function benachrichtigeVerwaltung(betreff, text, html) {
const { rows } = await require('./db').pool.query(
@@ -147,6 +159,7 @@ module.exports = {
sendeMail,
benachrichtigeVerwaltung,
benachrichtigeMieter,
verwaltungEmails,
einheitLabel,
baueHtmlMail,
buttonHtml,
+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()