v1.22: Verwaltung sendet Direkt-E-Mails an Mieter mit CC an die Verwaltung
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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.
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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