v1.20: Mieter pflegen eigene Stammdaten (Telefon, E-Mail, Bankverbindung)
This commit is contained in:
@@ -189,6 +189,22 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 8. Verlauf / offene Punkte
|
||||||
|
|
||||||
|
**Erledigt 04.10. (v1.20):** Stammdaten-Selbstpflege. users-Tabelle um
|
||||||
|
telefon/kontoinhaber/iban/bankname erweitert (ALTER ... ADD COLUMN IF NOT
|
||||||
|
EXISTS). PATCH /api/auth/profil (auth, eigene Daten): E-Mail (Pflicht +
|
||||||
|
Eindeutigkeitsprüfung case-insensitiv), Telefon, Kontoinhaber, IBAN
|
||||||
|
(Leerzeichen werden entfernt/großgeschrieben, Formatprüfung
|
||||||
|
^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$), Bankname. oeffentlicherBenutzer liefert die
|
||||||
|
neuen Felder mit (gelten damit auch für /api/auth/me, Bootstrap benutzer,
|
||||||
|
Benutzerverwaltung). Frontend: neue Seite /profil (Nav „Mein Profil", beide
|
||||||
|
Rollen): Ansichtskarte Konto & Wohnung (Name/Benutzername/Wohneit/ Mitglied
|
||||||
|
seit, readonly), editierbare Kontaktdaten (E-Mail, Telefon) und
|
||||||
|
Bankverbindung (nur Mieter, Hinweis auf Kaution). Store: aktualisiereProfil
|
||||||
|
→ PATCH, setMe mit Server-Antwort; Erfolgs-/Fehlermeldung inline. E2E
|
||||||
|
verifiziert: Speichern als a.berger (IBAN normalisiert), Auslesen via
|
||||||
|
/api/auth/me, Negativtests ungültige IBAN + fremde E-Mail werden sauber
|
||||||
|
abgelehnt. Demo-Daten der a.berger bleiben als Schaufenster-Datensatz.
|
||||||
|
|
||||||
**Erledigt 04.10. (v1.19):** Schadensmeldungen mit Kommunikation verknüpft.
|
**Erledigt 04.10. (v1.19):** Schadensmeldungen mit Kommunikation verknüpft.
|
||||||
threads-Tabelle um `ticket_id TEXT REFERENCES tickets(id)` erweitert (ALTER +
|
threads-Tabelle um `ticket_id TEXT REFERENCES tickets(id)` erweitert (ALTER +
|
||||||
Index idx_threads_ticket). POST /api/tickets legt den Auto-Thread bereits mit
|
Index idx_threads_ticket). POST /api/tickets legt den Auto-Thread bereits mit
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
erstellt_am TEXT NOT NULL,
|
erstellt_am TEXT NOT NULL,
|
||||||
letzter_login TEXT
|
letzter_login TEXT
|
||||||
);
|
);
|
||||||
|
-- Stammdaten zur Selbstpflege durch den Nutzer (v1.20)
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS telefon TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS kontoinhaber TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS iban TEXT;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS bankname TEXT;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS registration_codes (
|
CREATE TABLE IF NOT EXISTS registration_codes (
|
||||||
code TEXT PRIMARY KEY,
|
code TEXT PRIMARY KEY,
|
||||||
|
|||||||
@@ -135,6 +135,10 @@ const oeffentlicherBenutzer = (b) => ({
|
|||||||
aktiv: b.aktiv,
|
aktiv: b.aktiv,
|
||||||
erstelltAm: b.erstellt_am,
|
erstelltAm: b.erstellt_am,
|
||||||
letzterLogin: b.letzter_login ?? undefined,
|
letzterLogin: b.letzter_login ?? undefined,
|
||||||
|
telefon: b.telefon ?? null,
|
||||||
|
kontoinhaber: b.kontoinhaber ?? null,
|
||||||
|
iban: b.iban ?? null,
|
||||||
|
bankname: b.bankname ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
async function sessionAnlegen(res, userId, req) {
|
async function sessionAnlegen(res, userId, req) {
|
||||||
@@ -238,6 +242,31 @@ app.get('/api/auth/me', auth, (req, res) => {
|
|||||||
res.json({ benutzer: oeffentlicherBenutzer(req.user) })
|
res.json({ benutzer: oeffentlicherBenutzer(req.user) })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Stammdaten-Selbstpflege (v1.20): Telefon, E-Mail, Bankverbindung
|
||||||
|
app.patch('/api/auth/profil', auth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { email, telefon, kontoinhaber, iban, bankname } = req.body || {}
|
||||||
|
const emailNeu = String(email || '').trim()
|
||||||
|
if (!/^\S+@\S+\.\S+$/.test(emailNeu))
|
||||||
|
return res.status(400).json({ fehler: 'Gültige E-Mail-Adresse erforderlich.' })
|
||||||
|
const { rows: fremde } = await pool.query(
|
||||||
|
'SELECT id FROM users WHERE lower(email) = lower($1) AND id <> $2', [emailNeu, req.user.id],
|
||||||
|
)
|
||||||
|
if (fremde.length)
|
||||||
|
return res.status(400).json({ fehler: 'Diese E-Mail-Adresse wird bereits verwendet.' })
|
||||||
|
const ibanNeu = String(iban || '').trim().replace(/\s+/g, '').toUpperCase()
|
||||||
|
if (ibanNeu && !/^[A-Z]{2}\d{2}[A-Z0-9]{11,30}$/.test(ibanNeu))
|
||||||
|
return res.status(400).json({ fehler: 'Ungültige IBAN.' })
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE users SET email = $1, telefon = $2, kontoinhaber = $3, iban = $4, bankname = $5
|
||||||
|
WHERE id = $6 RETURNING *`,
|
||||||
|
[emailNeu, String(telefon || '').trim() || null, String(kontoinhaber || '').trim() || null,
|
||||||
|
ibanNeu || null, String(bankname || '').trim() || null, req.user.id],
|
||||||
|
)
|
||||||
|
res.json({ benutzer: oeffentlicherBenutzer(rows[0]) })
|
||||||
|
} catch (e) { next(e) }
|
||||||
|
})
|
||||||
|
|
||||||
app.post('/api/auth/register', async (req, res, next) => {
|
app.post('/api/auth/register', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { code, benutzername, name, email, passwortHash } = req.body || {}
|
const { code, benutzername, name, email, passwortHash } = req.body || {}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Zaehler from './pages/Zaehler'
|
|||||||
import Kosten from './pages/Kosten'
|
import Kosten from './pages/Kosten'
|
||||||
import Bestand from './pages/Bestand'
|
import Bestand from './pages/Bestand'
|
||||||
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||||
|
import Profil from './pages/Profil'
|
||||||
|
|
||||||
/** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */
|
/** Fängt Render-Fehler ab – statt schwarzem Bildschirm: Hinweis + Reload. */
|
||||||
class FehlerGrenze extends Component<{ children: ReactNode }, { fehler: boolean }> {
|
class FehlerGrenze extends Component<{ children: ReactNode }, { fehler: boolean }> {
|
||||||
@@ -88,6 +89,7 @@ export default function App() {
|
|||||||
<Route path="/bestand" element={<Bestand />} />
|
<Route path="/bestand" element={<Bestand />} />
|
||||||
<Route path="/dokumente" element={<Documents />} />
|
<Route path="/dokumente" element={<Documents />} />
|
||||||
<Route path="/nachrichten" element={<Messages />} />
|
<Route path="/nachrichten" element={<Messages />} />
|
||||||
|
<Route path="/profil" element={<Profil />} />
|
||||||
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
|
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Gauge,
|
Gauge,
|
||||||
Calculator,
|
Calculator,
|
||||||
|
UserRound,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
@@ -23,6 +24,7 @@ const navItems = [
|
|||||||
{ to: '/kosten', label: 'Steuer & Kosten', icon: Calculator },
|
{ to: '/kosten', label: 'Steuer & Kosten', icon: Calculator },
|
||||||
{ to: '/dokumente', label: 'Dokumente', icon: FileText },
|
{ to: '/dokumente', label: 'Dokumente', icon: FileText },
|
||||||
{ to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare },
|
{ to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare },
|
||||||
|
{ to: '/profil', label: 'Mein Profil', icon: UserRound },
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Check, IdCard, Phone, Landmark, Mail } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useStore } from '../store/store'
|
||||||
|
|
||||||
|
export default function Profil() {
|
||||||
|
const { me, role, meineEinheit, units, aktualisiereProfil } = useStore()
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [telefon, setTelefon] = useState('')
|
||||||
|
const [kontoinhaber, setKontoinhaber] = useState('')
|
||||||
|
const [iban, setIban] = useState('')
|
||||||
|
const [bankname, setBankname] = useState('')
|
||||||
|
const [sende, setSende] = useState(false)
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
const [gespeichert, setGespeichert] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!me) return
|
||||||
|
setEmail(me.email ?? '')
|
||||||
|
setTelefon(me.telefon ?? '')
|
||||||
|
setKontoinhaber(me.kontoinhaber ?? '')
|
||||||
|
setIban(me.iban ?? '')
|
||||||
|
setBankname(me.bankname ?? '')
|
||||||
|
}, [me])
|
||||||
|
|
||||||
|
if (!me) return null
|
||||||
|
|
||||||
|
const einheit = units.find((u) => u.id === me.einheitId) ?? meineEinheit
|
||||||
|
|
||||||
|
const speichern = async () => {
|
||||||
|
if (sende) return
|
||||||
|
setSende(true)
|
||||||
|
setFehler(null)
|
||||||
|
setGespeichert(false)
|
||||||
|
const err = await aktualisiereProfil({
|
||||||
|
email: email.trim(),
|
||||||
|
telefon: telefon.trim(),
|
||||||
|
kontoinhaber: kontoinhaber.trim(),
|
||||||
|
iban: iban.trim(),
|
||||||
|
bankname: bankname.trim(),
|
||||||
|
})
|
||||||
|
setSende(false)
|
||||||
|
if (err) {
|
||||||
|
setFehler(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setGespeichert(true)
|
||||||
|
window.setTimeout(() => setGespeichert(false), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Mein Profil</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Ihre Stammdaten – hier gepflegt, überall im Portal aktuell.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Feste Daten (nur Ansicht) */}
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||||
|
<IdCard className="h-4 w-4 text-teal-600" /> Konto & Wohnung
|
||||||
|
</div>
|
||||||
|
<dl className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-slate-400">Name</dt>
|
||||||
|
<dd className="font-medium">{me.name}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-slate-400">Benutzername</dt>
|
||||||
|
<dd className="font-medium">{me.benutzername}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-slate-400">
|
||||||
|
{role === 'admin' ? 'Gruppe' : 'Wohneinheit'}
|
||||||
|
</dt>
|
||||||
|
<dd className="font-medium">
|
||||||
|
{role === 'admin'
|
||||||
|
? 'Verwaltung'
|
||||||
|
: einheit
|
||||||
|
? `${einheit.building}, Whg. ${einheit.number}`
|
||||||
|
: '–'}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs uppercase tracking-wide text-slate-400">Mitglied seit</dt>
|
||||||
|
<dd className="font-medium">{me.erstelltAm}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kontaktdaten (editierbar) */}
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||||
|
<Phone className="h-4 w-4 text-teal-600" /> Kontaktdaten
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="p-email">E-Mail-Adresse</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Mail className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
id="p-email"
|
||||||
|
type="email"
|
||||||
|
className="pl-9"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Wird für Benachrichtigungen des Portals verwendet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="p-tel">Telefon</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Phone className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
id="p-tel"
|
||||||
|
className="pl-9"
|
||||||
|
placeholder="z. B. 0171 2345678"
|
||||||
|
value={telefon}
|
||||||
|
onChange={(e) => setTelefon(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bankverbindung (editierbar; Mieter) */}
|
||||||
|
{role === 'tenant' && (
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm lg:col-span-2">
|
||||||
|
<div className="mb-4 flex items-center gap-2 font-semibold">
|
||||||
|
<Landmark className="h-4 w-4 text-teal-600" /> Bankverbindung
|
||||||
|
<span className="text-xs font-normal text-slate-400">
|
||||||
|
(für Rückzahlungen, z. B. Kaution)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="p-konto">Kontoinhaber</Label>
|
||||||
|
<Input
|
||||||
|
id="p-konto"
|
||||||
|
placeholder="Vor- und Nachname"
|
||||||
|
value={kontoinhaber}
|
||||||
|
onChange={(e) => setKontoinhaber(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="p-iban">IBAN</Label>
|
||||||
|
<Input
|
||||||
|
id="p-iban"
|
||||||
|
placeholder="DE00 0000 0000 0000 0000 00"
|
||||||
|
value={iban}
|
||||||
|
onChange={(e) => setIban(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="p-bank">Bank</Label>
|
||||||
|
<Input
|
||||||
|
id="p-bank"
|
||||||
|
placeholder="z. B. Sparkasse"
|
||||||
|
value={bankname}
|
||||||
|
onChange={(e) => setBankname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button onClick={speichern} disabled={sende} className="gap-2">
|
||||||
|
{sende ? 'Wird gespeichert …' : 'Änderungen speichern'}
|
||||||
|
</Button>
|
||||||
|
{gespeichert && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-emerald-600">
|
||||||
|
<Check className="h-4 w-4" /> Gespeichert.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{fehler && <span className="text-sm font-medium text-red-600">{fehler}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,6 +80,14 @@ interface Store extends Bootstrap {
|
|||||||
meineEinheit: Unit
|
meineEinheit: Unit
|
||||||
login: (benutzername: string, passwort: string) => Promise<string | null>
|
login: (benutzername: string, passwort: string) => Promise<string | null>
|
||||||
logout: () => Promise<void>
|
logout: () => Promise<void>
|
||||||
|
/** Stammdaten-Selbstpflege (v1.20): Telefon, E-Mail, Bankverbindung */
|
||||||
|
aktualisiereProfil: (daten: {
|
||||||
|
email: string
|
||||||
|
telefon: string
|
||||||
|
kontoinhaber: string
|
||||||
|
iban: string
|
||||||
|
bankname: string
|
||||||
|
}) => Promise<string | null>
|
||||||
registrieren: (
|
registrieren: (
|
||||||
code: string,
|
code: string,
|
||||||
benutzername: string,
|
benutzername: string,
|
||||||
@@ -237,6 +245,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
setMe(null)
|
setMe(null)
|
||||||
setDaten(LEER)
|
setDaten(LEER)
|
||||||
},
|
},
|
||||||
|
async aktualisiereProfil(daten) {
|
||||||
|
try {
|
||||||
|
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>('/api/auth/profil', daten)
|
||||||
|
setMe(benutzer)
|
||||||
|
return null
|
||||||
|
} catch (e) {
|
||||||
|
return fehlerNachricht(e)
|
||||||
|
}
|
||||||
|
},
|
||||||
async registrieren(code, benutzername, name, email, passwort) {
|
async registrieren(code, benutzername, name, email, passwort) {
|
||||||
try {
|
try {
|
||||||
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
|
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ export interface Benutzer {
|
|||||||
aktiv: boolean
|
aktiv: boolean
|
||||||
erstelltAm: string
|
erstelltAm: string
|
||||||
letzterLogin?: string
|
letzterLogin?: string
|
||||||
|
/** Selbst gepflegte Stammdaten (v1.20) */
|
||||||
|
telefon?: string | null
|
||||||
|
kontoinhaber?: string | null
|
||||||
|
iban?: string | null
|
||||||
|
bankname?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegistrierungsCode {
|
export interface RegistrierungsCode {
|
||||||
|
|||||||
Reference in New Issue
Block a user