v1.3: Serverseitige Datenbank (PostgreSQL + Node-API) ersetzt localStorage
- server/: Express-API (Auth, Bootstrap, Tickets, Threads, Benutzerverwaltung, Codes) mit pg-Schema (11 Tabellen), idempotem Seed (50 Wohnungen, 552 Buchungen) - mp-db (PostgreSQL 16, Volume mp_pgdata) + mp-api im Compose-Stack, nginx proxiet /api/ an mp-api; deploy.sh baut API-Image auf dem Server - Frontend: Store laedt /api/bootstrap, Schreibzugriff async, kein localStorage mehr (ausser Theme), Ladebildschirm beim Session-Check, vite-Proxy fuer Dev - Rollenrechte serverseitig: Mieter nur eigene Wohnung (getestet, 403-Verweis) - Mail-Benachrichtigungen komplett serverseitig (server/src/mail.js) - beispieldaten.py schreibt server/seed/generated.json; Verwaltungs-Mail = axel@hocks.eu - E2E live verifiziert: Login, Scoping, Ticket + Zustellung (status=sent), Registrierung, Codes; Testdaten bereinigt; Doku nachgefuehrt
This commit is contained in:
+15
-2
@@ -9,14 +9,27 @@ import Documents from './pages/Documents'
|
||||
import Messages from './pages/Messages'
|
||||
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||
|
||||
function Ladebildschirm() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[#22333b]">
|
||||
<div className="text-center text-slate-300">
|
||||
<div className="text-lg font-semibold text-white">Hinterstein Immobilien</div>
|
||||
<div className="mt-1 text-sm">Mieterportal wird geladen …</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactElement }) {
|
||||
const { me } = useStore()
|
||||
const { me, bereit } = useStore()
|
||||
if (!bereit) return <Ladebildschirm />
|
||||
if (!me) return <Navigate to="/login" replace />
|
||||
return children
|
||||
}
|
||||
|
||||
function LoginGate() {
|
||||
const { me } = useStore()
|
||||
const { me, bereit } = useStore()
|
||||
if (!bereit) return <Ladebildschirm />
|
||||
if (me) return <Navigate to="/" replace />
|
||||
return <Login />
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
import type {
|
||||
Unit,
|
||||
Transaction,
|
||||
Ticket,
|
||||
DocumentItem,
|
||||
Thread,
|
||||
Announcement,
|
||||
} from '../types'
|
||||
import generated from './generated.json'
|
||||
|
||||
// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7
|
||||
export const DEMO_TENANT = 'Anna Berger'
|
||||
export const DEMO_UNIT_ID = 'hs-7'
|
||||
|
||||
// Maschinell erzeugte Bestandsdaten (beispieldaten.py -> generated.json)
|
||||
export const units = generated.units as Unit[]
|
||||
export const transactions = generated.transactions as Transaction[]
|
||||
|
||||
export const tickets: Ticket[] = [
|
||||
{
|
||||
id: 't-1',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
tenant: DEMO_TENANT,
|
||||
title: 'Wasserhahn im Bad tropft',
|
||||
description:
|
||||
'Der Wasserhahn am Waschbecken tropft seit einigen Tagen permanent, auch bei ganz zugedrehtem Ventil.',
|
||||
category: 'Sanitär',
|
||||
priority: 'mittel',
|
||||
status: 'in Bearbeitung',
|
||||
createdAt: '28.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-2',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
tenant: DEMO_TENANT,
|
||||
title: 'Briefkastenschlüssel defekt',
|
||||
description: 'Der Schlüssel für den Briefkasten lässt sich nicht mehr drehen, vermutlich Schloss verklebt.',
|
||||
category: 'Schlüssel',
|
||||
priority: 'niedrig',
|
||||
status: 'offen',
|
||||
createdAt: '30.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-3',
|
||||
unitId: 'rw-4',
|
||||
tenant: units.find((u) => u.id === 'rw-4')?.tenant ?? '',
|
||||
title: 'Heizkörper wird nicht warm',
|
||||
description: 'Der Heizkörper im Wohnzimmer bleibt kalt, obwohl das Thermostat voll aufgedreht ist.',
|
||||
category: 'Heizung',
|
||||
priority: 'hoch',
|
||||
status: 'offen',
|
||||
createdAt: '01.10.2026',
|
||||
},
|
||||
{
|
||||
id: 't-4',
|
||||
unitId: 'ap-2',
|
||||
tenant: units.find((u) => u.id === 'ap-2')?.tenant ?? '',
|
||||
title: 'Rollladen im Schlafzimmer klemmt',
|
||||
description: 'Der elektrische Rollladen fährt nur noch zur Hälfte hoch.',
|
||||
category: 'Fenster / Rollladen',
|
||||
priority: 'mittel',
|
||||
status: 'in Bearbeitung',
|
||||
createdAt: '25.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-5',
|
||||
unitId: 'hs-14',
|
||||
tenant: units.find((u) => u.id === 'hs-14')?.tenant ?? '',
|
||||
title: 'Flurbeleuchtung im Treppenhaus defekt',
|
||||
description: 'Im 3. OG ist die Bewegungsmelder-Leuchte ausgefallen.',
|
||||
category: 'Elektrik',
|
||||
priority: 'hoch',
|
||||
status: 'erledigt',
|
||||
createdAt: '18.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-6',
|
||||
unitId: 'rw-9',
|
||||
tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
|
||||
title: 'Schimmel im Bad – Lüftungsproblem?',
|
||||
description: 'In der Fensterfalz des Bades sammelt sich Kondenswasser, bitte um Prüfung der Lüftung.',
|
||||
category: 'Sanitär',
|
||||
priority: 'hoch',
|
||||
status: 'in Bearbeitung',
|
||||
createdAt: '22.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-7',
|
||||
unitId: 'ap-11',
|
||||
tenant: units.find((u) => u.id === 'ap-11')?.tenant ?? '',
|
||||
title: 'Markise auf dem Balkon lose',
|
||||
description: 'Die Befestigung der Markise wackelt bei Wind deutlich.',
|
||||
category: 'Balkon / Außenanlage',
|
||||
priority: 'hoch',
|
||||
status: 'offen',
|
||||
createdAt: '29.09.2026',
|
||||
},
|
||||
{
|
||||
id: 't-8',
|
||||
unitId: 'hs-3',
|
||||
tenant: units.find((u) => u.id === 'hs-3')?.tenant ?? '',
|
||||
title: 'Spülmaschinen-Anschluss undicht',
|
||||
description: 'Unter der Spüle steht Wasser, vermutlich kommt es vom Geräteanschluss.',
|
||||
category: 'Sanitär',
|
||||
priority: 'mittel',
|
||||
status: 'erledigt',
|
||||
createdAt: '10.09.2026',
|
||||
},
|
||||
]
|
||||
|
||||
export const documents: DocumentItem[] = [
|
||||
{
|
||||
id: 'd-1',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
title: 'Mietvertrag Anna Berger – Hauptstraße 12, Whg. 7',
|
||||
category: 'Mietvertrag',
|
||||
size: '1,8 MB',
|
||||
date: '01.03.2024',
|
||||
},
|
||||
{
|
||||
id: 'd-2',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
title: 'Nebenkostenabrechnung 2025',
|
||||
category: 'Nebenkostenabrechnung',
|
||||
size: '640 KB',
|
||||
date: '31.05.2026',
|
||||
},
|
||||
{
|
||||
id: 'd-3',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
title: 'Heizkostenabrechnung 2025',
|
||||
category: 'Nebenkostenabrechnung',
|
||||
size: '410 KB',
|
||||
date: '31.05.2026',
|
||||
},
|
||||
{
|
||||
id: 'd-4',
|
||||
unitId: null,
|
||||
title: 'Hausordnung – gültige Fassung 2026',
|
||||
category: 'Hausordnung',
|
||||
size: '220 KB',
|
||||
date: '01.01.2026',
|
||||
},
|
||||
{
|
||||
id: 'd-5',
|
||||
unitId: null,
|
||||
title: 'Protokoll Hausversammlung 14.06.2026',
|
||||
category: 'Protokoll',
|
||||
size: '350 KB',
|
||||
date: '14.06.2026',
|
||||
},
|
||||
{
|
||||
id: 'd-6',
|
||||
unitId: null,
|
||||
title: 'Mülltonnen-Belegungsplan & Abfuhrtermine 2026',
|
||||
category: 'Sonstiges',
|
||||
size: '180 KB',
|
||||
date: '02.01.2026',
|
||||
},
|
||||
{
|
||||
id: 'd-7',
|
||||
unitId: 'rw-4',
|
||||
title: 'Mietvertrag – Ringweg 3, Whg. 4',
|
||||
category: 'Mietvertrag',
|
||||
size: '1,6 MB',
|
||||
date: '15.08.2025',
|
||||
},
|
||||
{
|
||||
id: 'd-8',
|
||||
unitId: 'ap-2',
|
||||
title: 'Mietvertrag – Am Park 5, Whg. 2',
|
||||
category: 'Mietvertrag',
|
||||
size: '1,7 MB',
|
||||
date: '01.06.2024',
|
||||
},
|
||||
]
|
||||
|
||||
export const threads: Thread[] = [
|
||||
{
|
||||
id: 'm-1',
|
||||
unitId: DEMO_UNIT_ID,
|
||||
tenant: DEMO_TENANT,
|
||||
subject: 'Mietkaution – Rückzahlungsnachweis',
|
||||
unreadByAdmin: false,
|
||||
unreadByTenant: true,
|
||||
messages: [
|
||||
{
|
||||
id: 'm-1-a',
|
||||
from: 'tenant',
|
||||
author: DEMO_TENANT,
|
||||
text: 'Guten Tag, könnten Sie mir bitte den aktuellen Stand meiner Mietkaution mit Nachweis der Verzinsung zusenden?',
|
||||
at: '27.09.2026, 10:12',
|
||||
},
|
||||
{
|
||||
id: 'm-1-b',
|
||||
from: 'admin',
|
||||
author: 'Joseph Hinterstein',
|
||||
text: 'Guten Tag Frau Berger, die Kaution wurde mit 1.500,00 € auf dem Treuhandkonto verbucht. Die Verzinsung für 2025 beträgt 18,40 € – den Nachweis finden Sie ab sofort auch unter Dokumente.',
|
||||
at: '29.09.2026, 09:03',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'm-2',
|
||||
unitId: 'rw-9',
|
||||
tenant: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
|
||||
subject: 'Terminabstimmung Wohnungsbegehung (Schimmel)',
|
||||
unreadByAdmin: true,
|
||||
unreadByTenant: false,
|
||||
messages: [
|
||||
{
|
||||
id: 'm-2-a',
|
||||
from: 'tenant',
|
||||
author: units.find((u) => u.id === 'rw-9')?.tenant ?? '',
|
||||
text: 'Aufgrund der Schadensmeldung schlage ich einen Begehungstermin nächste Woche vor. Passt Mittwoch oder Freitag Nachmittag?',
|
||||
at: '01.10.2026, 08:45',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export const announcements: Announcement[] = [
|
||||
{
|
||||
id: 'a-1',
|
||||
title: 'Wasserabschaltung Hauptstraße 12',
|
||||
text: 'Am Donnerstag, 08.10.2026, 09:00–13:00 Uhr findet eine Druckerhöhungs-Pumpen-Wartung statt. In dieser Zeit steht kein Wasser zur Verfügung.',
|
||||
date: '02.10.2026',
|
||||
tag: 'Technik',
|
||||
},
|
||||
{
|
||||
id: 'a-2',
|
||||
title: 'Herbst-Hausbegehung mit Hausmeister',
|
||||
text: 'Am Samstag, 17.10.2026, 10:00 Uhr geht es gemeinsam durch alle Häuser. Anschließend gibt es Kaffee im Hof von Am Park 5.',
|
||||
date: '28.09.2026',
|
||||
tag: 'Termin',
|
||||
},
|
||||
{
|
||||
id: 'a-3',
|
||||
title: 'Tiefgarage Ringweg 3: Reinigung',
|
||||
text: 'Die Tiefgarage wird am 21.10.2026 gereinigt. Bitte Fahrzeuge bis 07:30 Uhr entfernen, sonst erfolgt ein kostenpflichtiges Versetzen.',
|
||||
date: '20.09.2026',
|
||||
tag: 'Hinweis',
|
||||
},
|
||||
{
|
||||
id: 'a-4',
|
||||
title: 'Nebenkostenabrechnungen 2025 versandt',
|
||||
text: 'Alle Abrechnungen liegen digital im Dokumentenbereich vor. Nachzahlungen sind bis 31.10.2026 überwiesen.',
|
||||
date: '31.05.2026',
|
||||
tag: 'Abrechnung',
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,54 @@
|
||||
/** Dünner Fetch-Client für die Portal-API (gleicher Ursprung, Session-Cookie). */
|
||||
|
||||
export class ApiFehler extends Error {
|
||||
status: number
|
||||
constructor(status: number, nachricht: string) {
|
||||
super(nachricht)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
interface Antwort<T> {
|
||||
json: T | null
|
||||
status: number
|
||||
}
|
||||
|
||||
async function anfrage<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<Antwort<T>> {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(pfad, {
|
||||
credentials: 'same-origin',
|
||||
...init,
|
||||
headers: {
|
||||
...(json !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
...(json !== undefined ? { body: JSON.stringify(json) } : {}),
|
||||
})
|
||||
} catch {
|
||||
throw new ApiFehler(0, 'Keine Verbindung zum Server.')
|
||||
}
|
||||
let daten: T | null = null
|
||||
try {
|
||||
daten = (await res.json()) as T
|
||||
} catch {
|
||||
/* leerer Body */
|
||||
}
|
||||
return { json: daten, status: res.status }
|
||||
}
|
||||
|
||||
/** Führt eine API-Anfrage aus und liefert Daten oder eine deutschsprachige Fehlermeldung. */
|
||||
export async function api<T>(pfad: string, init?: RequestInit, json?: unknown): Promise<T> {
|
||||
const { json: daten, status } = await anfrage<T>(pfad, init, json)
|
||||
if (status >= 400 || daten === null) {
|
||||
const fehler =
|
||||
(daten as { fehler?: string } | null)?.fehler ??
|
||||
(status === 401 ? 'Sitzung abgelaufen – bitte erneut anmelden.' : `Fehler (${status}).`)
|
||||
throw new ApiFehler(status, fehler)
|
||||
}
|
||||
return daten
|
||||
}
|
||||
|
||||
export const apiGet = <T>(pfad: string) => api<T>(pfad)
|
||||
export const apiPost = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'POST' }, body ?? {})
|
||||
export const apiPatch = <T>(pfad: string, body?: unknown) => api<T>(pfad, { method: 'PATCH' }, body ?? {})
|
||||
+5
-46
@@ -1,10 +1,8 @@
|
||||
/** Authentifizierung + Session, Muster aus dem Schulportal. */
|
||||
|
||||
import type { Benutzer, BenutzerRolle } from '../types'
|
||||
|
||||
export const SESSION_KEY = 'mp_benutzer_session'
|
||||
export const SESSION_DAUER_MS = 8 * 60 * 60 * 1000 // 8 h
|
||||
export const BENUTZER_CHANGED_EVENT = 'mp-benutzer-changed'
|
||||
/** Passwort-Hashing (wie bisher: SHA-256 im Browser, Vergleich serverseitig).
|
||||
*
|
||||
* Die Sitzung selbst liegt seit v1.3 serverseitig (HttpOnly-Cookie,
|
||||
* Tabelle sessions) – es gibt nichts mehr im localStorage zu verwalten.
|
||||
*/
|
||||
|
||||
/** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */
|
||||
export const DEMO_PASSWORT = '0815'
|
||||
@@ -15,42 +13,3 @@ export async function sha256(text: string): Promise<string> {
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
benutzerId: string
|
||||
rolle: BenutzerRolle
|
||||
bis: number
|
||||
}
|
||||
|
||||
export function ladeSession(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY)
|
||||
if (!raw) return null
|
||||
const s = JSON.parse(raw) as Session
|
||||
if (s.bis < Date.now()) {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
return null
|
||||
}
|
||||
return s
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function speichereSession(benutzerId: string, rolle: BenutzerRolle) {
|
||||
const s: Session = { benutzerId, rolle, bis: Date.now() + SESSION_DAUER_MS }
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(s))
|
||||
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
export function beendeSession() {
|
||||
localStorage.removeItem(SESSION_KEY)
|
||||
window.dispatchEvent(new Event(BENUTZER_CHANGED_EVENT))
|
||||
}
|
||||
|
||||
export function aktuellerBenutzer(alle: Benutzer[]): Benutzer | null {
|
||||
const s = ladeSession()
|
||||
if (!s) return null
|
||||
const b = alle.find((x) => x.id === s.benutzerId && x.aktiv)
|
||||
return b ?? null
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
/** E-Mail-Benachrichtigungen über die Portal-Mail-API.
|
||||
*
|
||||
* Der Browser ruft nur denselben Ursprung an: POST /api/mail/send wird vom
|
||||
* Portal-nginx (Container mp-portal) an die Mail-API (mp-mail-api:8080)
|
||||
* weitergereicht und dabei serverseitig mit dem API-Token authentifiziert.
|
||||
* Das Token liegt also weder im Repo noch im JavaScript.
|
||||
*
|
||||
* Versand ist bewusst „fire-and-forget": Klappt der Versand nicht (Offline,
|
||||
* API down), bleibt die Portal-Aktion trotzdem erhalten – der Fehler landet
|
||||
* nur in der Browser-Konsole.
|
||||
*/
|
||||
|
||||
import type { Benutzer } from '../types'
|
||||
|
||||
const ENDPUNKT = '/api/mail/send'
|
||||
|
||||
export async function sendeMail(an: string, betreff: string, text: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(ENDPUNKT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ to: an, subject: betreff, text }),
|
||||
})
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Fire-and-forget an eine oder mehrere Adressen (null/leer = nichts senden). */
|
||||
export function sendeBenachrichtigung(an: string | string[] | null | undefined, betreff: string, text: string) {
|
||||
const adressen = (Array.isArray(an) ? an : an ? [an] : []).filter(Boolean)
|
||||
for (const adresse of adressen) {
|
||||
sendeMail(adresse, betreff, text).then((ok) => {
|
||||
if (!ok) console.warn(`[mail] Versand an ${adresse} fehlgeschlagen`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const FUSSZEILE =
|
||||
'\n\n— Automatische Benachrichtigung aus dem Mieterportal der Hinterstein Immobilien'
|
||||
|
||||
export function nachrichtenText(koerper: string): string {
|
||||
return `${koerper}${FUSSZEILE}`
|
||||
}
|
||||
|
||||
/** E-Mail-Adressen aller aktiven Verwaltungsbenutzer. */
|
||||
export function verwaltungsAdressen(benutzer: Benutzer[]): string[] {
|
||||
return benutzer
|
||||
.filter((b) => b.rolle === 'verwaltung' && b.aktiv && b.email)
|
||||
.map((b) => b.email)
|
||||
}
|
||||
|
||||
/** E-Mail-Adressen der Mieter einer bestimmten Wohnung. */
|
||||
export function mieterAdressenFuerEinheit(benutzer: Benutzer[], einheitId: string): string[] {
|
||||
return benutzer
|
||||
.filter((b) => b.rolle === 'mieter' && b.aktiv && b.einheitId === einheitId && b.email)
|
||||
.map((b) => b.email)
|
||||
}
|
||||
|
||||
/** Anzeigename einer Wohnung, z. B. „Hauptstraße 12, Whg. 7". */
|
||||
export function einheitLabel(einheit: { building: string; number: string }): string {
|
||||
return `${einheit.building}, Whg. ${einheit.number}`
|
||||
}
|
||||
@@ -215,7 +215,10 @@ export default function BenutzerVerwaltung() {
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
onClick={() => generiereCodes(anzahl, codeEinheit || null)}
|
||||
onClick={async () => {
|
||||
const neu = await generiereCodes(anzahl, codeEinheit || null)
|
||||
if (neu.length > 0) kopieren(neu[0].code)
|
||||
}}
|
||||
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
|
||||
>
|
||||
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
|
||||
|
||||
+13
-86
@@ -13,71 +13,21 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useStore } from '../store/store'
|
||||
import {
|
||||
sendeBenachrichtigung,
|
||||
verwaltungsAdressen,
|
||||
mieterAdressenFuerEinheit,
|
||||
nachrichtenText,
|
||||
} from '../lib/mail'
|
||||
|
||||
/** Gegenstelle benachrichtigen: Mieter → Verwaltung, Verwaltung → Mieter der Wohnung. */
|
||||
function benachrichtigeGegenstelle(opts: {
|
||||
benutzer: ReturnType<typeof useStore>['benutzer']
|
||||
vonRolle: 'tenant' | 'admin'
|
||||
einheitId: string
|
||||
betreff: string
|
||||
text: string
|
||||
}) {
|
||||
const adressen =
|
||||
opts.vonRolle === 'tenant'
|
||||
? verwaltungsAdressen(opts.benutzer)
|
||||
: mieterAdressenFuerEinheit(opts.benutzer, opts.einheitId)
|
||||
sendeBenachrichtigung(adressen, opts.betreff, nachrichtenText(opts.text))
|
||||
}
|
||||
|
||||
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
const { dispatch, role, me, meineEinheit, benutzer } = useStore()
|
||||
const { newThread, role, meineEinheit } = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [subject, setSubject] = useState('')
|
||||
const [text, setText] = useState('')
|
||||
|
||||
const verwaltungsName = 'Joseph Hinterstein'
|
||||
|
||||
const submit = () => {
|
||||
const submit = async () => {
|
||||
if (!subject.trim() || !text.trim()) return
|
||||
const id = `m-${Date.now()}`
|
||||
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
|
||||
const autor = role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName
|
||||
dispatch({
|
||||
type: 'newThread',
|
||||
thread: {
|
||||
id,
|
||||
unitId,
|
||||
tenant: autor,
|
||||
subject: subject.trim(),
|
||||
unreadByAdmin: true,
|
||||
unreadByTenant: false,
|
||||
messages: [
|
||||
{
|
||||
id: `${id}-1`,
|
||||
from: role,
|
||||
author: autor,
|
||||
text: text.trim(),
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
benachrichtigeGegenstelle({
|
||||
benutzer,
|
||||
vonRolle: role,
|
||||
einheitId: unitId,
|
||||
betreff:
|
||||
role === 'tenant'
|
||||
? `Neue Nachricht im Mieterportal: ${subject.trim()}`
|
||||
: `Nachricht der Verwaltung: ${subject.trim()}`,
|
||||
text: `Neue Unterhaltung von ${autor}:\n\n„${text.trim()}“`,
|
||||
})
|
||||
const id = await newThread(
|
||||
subject.trim(),
|
||||
text.trim(),
|
||||
role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||
)
|
||||
if (!id) return
|
||||
setSubject('')
|
||||
setText('')
|
||||
setOpen(false)
|
||||
@@ -117,9 +67,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||
}
|
||||
|
||||
export default function Messages() {
|
||||
const { threads, role, dispatch, units, me, meineEinheit, benutzer } = useStore()
|
||||
const meinName = me?.name ?? 'Mieter'
|
||||
const verwaltungsName = 'Joseph Hinterstein'
|
||||
const { threads, role, units, meineEinheit, markThreadRead, addMessage } = useStore()
|
||||
const visible = role === 'tenant'
|
||||
? threads.filter((t) => t.unitId === meineEinheit.id)
|
||||
: threads
|
||||
@@ -130,35 +78,14 @@ export default function Messages() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
dispatch({ type: 'markThreadRead', threadId: active.id, role })
|
||||
markThreadRead(active.id)
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [active?.id, active?.messages.length, dispatch, role])
|
||||
}, [active?.id, active?.messages.length, markThreadRead, role])
|
||||
|
||||
const send = () => {
|
||||
const send = async () => {
|
||||
if (!active || !draft.trim()) return
|
||||
const text = draft.trim()
|
||||
const autor = role === 'tenant' ? meinName : verwaltungsName
|
||||
dispatch({
|
||||
type: 'addMessage',
|
||||
threadId: active.id,
|
||||
message: {
|
||||
id: `${active.id}-${Date.now()}`,
|
||||
from: role,
|
||||
author: autor,
|
||||
text,
|
||||
at: new Date().toLocaleString('de-DE'),
|
||||
},
|
||||
})
|
||||
benachrichtigeGegenstelle({
|
||||
benutzer,
|
||||
vonRolle: role,
|
||||
einheitId: active.unitId,
|
||||
betreff:
|
||||
role === 'tenant'
|
||||
? `Neue Antwort im Mieterportal: ${active.subject}`
|
||||
: `Antwort der Verwaltung: ${active.subject}`,
|
||||
text: `Neue Nachricht von ${autor} in der Unterhaltung „${active.subject}“:\n\n„${text}“`,
|
||||
})
|
||||
await addMessage(active.id, text)
|
||||
setDraft('')
|
||||
}
|
||||
|
||||
|
||||
+16
-43
@@ -13,12 +13,6 @@ import {
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
sendeBenachrichtigung,
|
||||
verwaltungsAdressen,
|
||||
nachrichtenText,
|
||||
einheitLabel,
|
||||
} from '../lib/mail'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -52,45 +46,26 @@ const categories = [
|
||||
]
|
||||
|
||||
function NewTicketDialog() {
|
||||
const { dispatch, role, me, meineEinheit, benutzer, units } = useStore()
|
||||
const { addTicket, role, meineEinheit } = useStore()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [category, setCategory] = useState('Sanitär')
|
||||
const [priority, setPriority] = useState<TicketPriority>('mittel')
|
||||
const [description, setDescription] = useState('')
|
||||
const [sende, setSende] = useState(false)
|
||||
|
||||
const submit = () => {
|
||||
if (!title.trim()) return
|
||||
const unitId = role === 'tenant' ? meineEinheit.id : 'hs-1'
|
||||
const einheit = units.find((u) => u.id === unitId) ?? meineEinheit
|
||||
const mieterName = role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)'
|
||||
dispatch({
|
||||
type: 'addTicket',
|
||||
ticket: {
|
||||
id: `t-${Date.now()}`,
|
||||
unitId,
|
||||
tenant: mieterName,
|
||||
title: title.trim(),
|
||||
description: description.trim() || '–',
|
||||
category,
|
||||
priority,
|
||||
status: 'offen',
|
||||
createdAt: new Date().toLocaleDateString('de-DE'),
|
||||
},
|
||||
const submit = async () => {
|
||||
if (!title.trim() || sende) return
|
||||
setSende(true)
|
||||
const err = await addTicket({
|
||||
title: title.trim(),
|
||||
description: description.trim() || '–',
|
||||
category,
|
||||
priority,
|
||||
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||
})
|
||||
// Verwaltung per E-Mail benachrichtigen (fire-and-forget)
|
||||
sendeBenachrichtigung(
|
||||
verwaltungsAdressen(benutzer),
|
||||
`Neue Schadensmeldung: ${title.trim()} (${einheitLabel(einheit)})`,
|
||||
nachrichtenText(
|
||||
`Im Mieterportal wurde eine neue Schadensmeldung erstellt.\n\n` +
|
||||
`Wohnung: ${einheitLabel(einheit)}\n` +
|
||||
`Meldung von: ${mieterName}\n` +
|
||||
`Kategorie: ${category}\n` +
|
||||
`Dringlichkeit: ${priority}\n\n` +
|
||||
`${title.trim()}\n${description.trim() || '–'}`,
|
||||
),
|
||||
)
|
||||
setSende(false)
|
||||
if (err) return
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setOpen(false)
|
||||
@@ -158,7 +133,7 @@ function NewTicketDialog() {
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button onClick={submit} disabled={!title.trim()}>Meldung absenden</Button>
|
||||
<Button onClick={submit} disabled={!title.trim() || sende}>{sende ? 'Wird gesendet …' : 'Meldung absenden'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -166,7 +141,7 @@ function NewTicketDialog() {
|
||||
}
|
||||
|
||||
export default function Tickets() {
|
||||
const { tickets, units, role, dispatch, meineEinheit } = useStore()
|
||||
const { tickets, units, role, setTicketStatus, meineEinheit } = useStore()
|
||||
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
|
||||
|
||||
const visible = useMemo(
|
||||
@@ -247,9 +222,7 @@ export default function Tickets() {
|
||||
{role === 'admin' ? (
|
||||
<Select
|
||||
value={t.status}
|
||||
onValueChange={(v) =>
|
||||
dispatch({ type: 'setTicketStatus', id: t.id, status: v as TicketStatus })
|
||||
}
|
||||
onValueChange={(v) => setTicketStatus(t.id, v as TicketStatus)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-40">
|
||||
<SelectValue />
|
||||
|
||||
+204
-324
@@ -1,240 +1,72 @@
|
||||
/** Zentraler Store des Mieterportals – seit v1.3 komplett serverseitig
|
||||
* (Node-API + PostgreSQL). Der Browser hält keine Mietdaten mehr im
|
||||
* localStorage; die Sitzung läuft über ein HttpOnly-Cookie (8 h).
|
||||
*
|
||||
* Das Store-Interface bleibt absichtlich nahe an der bisherigen Form, damit
|
||||
* die Seiten nur minimal angepasst werden mussten: Lesezugriff synchron über
|
||||
* den State, Schreibzugriff über asynchrone Methoden (rufen die API auf und
|
||||
* aktualisieren danach den lokalen State aus der Antwort/Neuladung).
|
||||
*/
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import type {
|
||||
Role,
|
||||
Ticket,
|
||||
TicketStatus,
|
||||
Transaction,
|
||||
DocumentItem,
|
||||
Thread,
|
||||
Message,
|
||||
Benutzer,
|
||||
BenutzerRolle,
|
||||
RegistrierungsCode,
|
||||
Unit,
|
||||
Announcement,
|
||||
} from '../types'
|
||||
import {
|
||||
units,
|
||||
transactions,
|
||||
tickets,
|
||||
documents,
|
||||
threads,
|
||||
announcements,
|
||||
DEMO_TENANT,
|
||||
DEMO_UNIT_ID,
|
||||
} from '../data/seed'
|
||||
import {
|
||||
sha256,
|
||||
DEMO_PASSWORT,
|
||||
ladeSession,
|
||||
speichereSession,
|
||||
beendeSession,
|
||||
type Session,
|
||||
} from '../lib/auth'
|
||||
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
|
||||
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
||||
|
||||
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
|
||||
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
|
||||
export const DEMO_TENANT = 'Anna Berger'
|
||||
export const DEMO_UNIT_ID = 'hs-7'
|
||||
|
||||
/** Demo-Benutzer (Passwort aller: 0815). */
|
||||
export const demoBenutzer: Benutzer[] = [
|
||||
{
|
||||
id: 'b-joseph',
|
||||
benutzername: 'j.hinterstein',
|
||||
name: 'Joseph Hinterstein',
|
||||
passwortHash: DEMO_HASH,
|
||||
rolle: 'verwaltung',
|
||||
email: 'joseph@hinterstein-immo.de',
|
||||
einheitId: null,
|
||||
aktiv: true,
|
||||
erstelltAm: '01.03.2024',
|
||||
},
|
||||
{
|
||||
id: 'b-anna',
|
||||
benutzername: 'a.berger',
|
||||
name: DEMO_TENANT,
|
||||
passwortHash: DEMO_HASH,
|
||||
rolle: 'mieter',
|
||||
email: 'anna.berger@example.de',
|
||||
einheitId: DEMO_UNIT_ID,
|
||||
aktiv: true,
|
||||
erstelltAm: '01.03.2024',
|
||||
letzterLogin: '02.10.2026, 09:12',
|
||||
},
|
||||
{
|
||||
id: 'b-jonas',
|
||||
benutzername: 'm.wolf',
|
||||
name: 'Jonas Klein',
|
||||
passwortHash: DEMO_HASH,
|
||||
rolle: 'mieter',
|
||||
email: 'jonas.klein@example.de',
|
||||
einheitId: 'rw-4',
|
||||
aktiv: true,
|
||||
erstelltAm: '15.08.2025',
|
||||
},
|
||||
]
|
||||
|
||||
/** Demo-Registrierungscode (Verwaltung kann weitere erzeugen). */
|
||||
const demoCodes: RegistrierungsCode[] = [
|
||||
{
|
||||
code: 'MP-2026-0815',
|
||||
einheitId: 'ap-2',
|
||||
erstelltAm: '02.10.2026',
|
||||
},
|
||||
]
|
||||
|
||||
interface State {
|
||||
tickets: Ticket[]
|
||||
interface Bootstrap {
|
||||
units: Unit[]
|
||||
transactions: Transaction[]
|
||||
documents: DocumentItem[]
|
||||
tickets: Ticket[]
|
||||
threads: Thread[]
|
||||
announcements: Announcement[]
|
||||
benutzer: Benutzer[]
|
||||
codes: RegistrierungsCode[]
|
||||
session: Session | null
|
||||
tick: number
|
||||
}
|
||||
|
||||
type Action =
|
||||
| { type: 'addTicket'; ticket: Ticket }
|
||||
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
|
||||
| { type: 'addMessage'; threadId: string; message: Message }
|
||||
| { type: 'newThread'; thread: Thread }
|
||||
| { type: 'markThreadRead'; threadId: string; role: Role }
|
||||
| { type: 'setSession'; session: Session | null }
|
||||
| { type: 'registriert'; benutzer: Benutzer; code: string }
|
||||
| { type: 'benutzerAngelegt'; benutzer: Benutzer }
|
||||
| { type: 'benutzerAktiv'; id: string; aktiv: boolean }
|
||||
| { type: 'passwortGesetzt'; id: string; hash: string }
|
||||
| { type: 'einheitZugewiesen'; id: string; einheitId: string | null }
|
||||
| { type: 'codesHinzugefuegt'; codes: RegistrierungsCode[] }
|
||||
|
||||
const initialState: State = {
|
||||
tickets,
|
||||
transactions,
|
||||
documents,
|
||||
threads,
|
||||
benutzer: demoBenutzer,
|
||||
codes: demoCodes,
|
||||
session: ladeSession(),
|
||||
tick: 0,
|
||||
const LEER: Bootstrap = {
|
||||
units: [],
|
||||
transactions: [],
|
||||
documents: [],
|
||||
tickets: [],
|
||||
threads: [],
|
||||
announcements: [],
|
||||
benutzer: [],
|
||||
codes: [],
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
|
||||
|
||||
function loadState(): State {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw)
|
||||
return { ...initialState, ...parsed, session: ladeSession() }
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return initialState
|
||||
}
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case 'addTicket':
|
||||
return { ...state, tickets: [action.ticket, ...state.tickets] }
|
||||
case 'setTicketStatus':
|
||||
return {
|
||||
...state,
|
||||
tickets: state.tickets.map((t) =>
|
||||
t.id === action.id ? { ...t, status: action.status } : t,
|
||||
),
|
||||
}
|
||||
case 'addMessage':
|
||||
return {
|
||||
...state,
|
||||
threads: state.threads.map((th) =>
|
||||
th.id === action.threadId
|
||||
? {
|
||||
...th,
|
||||
unreadByAdmin: action.message.from === 'tenant',
|
||||
unreadByTenant: action.message.from === 'admin',
|
||||
messages: [...th.messages, action.message],
|
||||
}
|
||||
: th,
|
||||
),
|
||||
}
|
||||
case 'newThread':
|
||||
return { ...state, threads: [action.thread, ...state.threads] }
|
||||
case 'markThreadRead':
|
||||
return {
|
||||
...state,
|
||||
threads: state.threads.map((th) =>
|
||||
th.id === action.threadId
|
||||
? action.role === 'tenant'
|
||||
? { ...th, unreadByTenant: false }
|
||||
: { ...th, unreadByAdmin: false }
|
||||
: th,
|
||||
),
|
||||
}
|
||||
case 'setSession':
|
||||
return { ...state, session: action.session, tick: state.tick + 1 }
|
||||
case 'registriert':
|
||||
return {
|
||||
...state,
|
||||
benutzer: [...state.benutzer, action.benutzer],
|
||||
codes: state.codes.map((c) =>
|
||||
c.code === action.code ? { ...c, verwendetAm: new Date().toLocaleDateString('de-DE') } : c,
|
||||
),
|
||||
}
|
||||
case 'benutzerAngelegt':
|
||||
return { ...state, benutzer: [...state.benutzer, action.benutzer] }
|
||||
case 'benutzerAktiv':
|
||||
return {
|
||||
...state,
|
||||
benutzer: state.benutzer.map((b) =>
|
||||
b.id === action.id ? { ...b, aktiv: action.aktiv } : b,
|
||||
),
|
||||
}
|
||||
case 'passwortGesetzt':
|
||||
return {
|
||||
...state,
|
||||
benutzer: state.benutzer.map((b) =>
|
||||
b.id === action.id ? { ...b, passwortHash: action.hash } : b,
|
||||
),
|
||||
}
|
||||
case 'einheitZugewiesen':
|
||||
return {
|
||||
...state,
|
||||
benutzer: state.benutzer.map((b) =>
|
||||
b.id === action.id ? { ...b, einheitId: action.einheitId } : b,
|
||||
),
|
||||
}
|
||||
case 'codesHinzugefuegt':
|
||||
return { ...state, codes: [...action.codes, ...state.codes] }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
}
|
||||
|
||||
function neuerCode(): string {
|
||||
const teile = () =>
|
||||
Array.from(crypto.getRandomValues(new Uint8Array(2)))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
return `MP-${teile()}-${teile()}`
|
||||
}
|
||||
|
||||
interface Store extends State {
|
||||
dispatch: React.Dispatch<Action>
|
||||
units: typeof units
|
||||
announcements: typeof announcements
|
||||
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
|
||||
interface Store extends Bootstrap {
|
||||
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
|
||||
bereit: boolean
|
||||
me: Benutzer | null
|
||||
/** Abgeleitete Portal-Rolle für die Modul-Ansichten. */
|
||||
role: Role
|
||||
/** Einheit des angemeldeten Mieters (Demo-Fallback hs-7). */
|
||||
meineEinheit: (typeof units)[number]
|
||||
meineEinheit: Unit
|
||||
login: (benutzername: string, passwort: string) => Promise<string | null>
|
||||
logout: () => void
|
||||
logout: () => Promise<void>
|
||||
registrieren: (
|
||||
code: string,
|
||||
benutzername: string,
|
||||
@@ -242,6 +74,11 @@ interface Store extends State {
|
||||
email: string,
|
||||
passwort: string,
|
||||
) => Promise<string | null>
|
||||
addTicket: (t: { title: string; description: string; category: string; priority: string; unitId?: string }) => Promise<string | null>
|
||||
setTicketStatus: (id: string, status: TicketStatus) => Promise<void>
|
||||
newThread: (subject: string, text: string, unitId?: string) => Promise<string | null>
|
||||
addMessage: (threadId: string, text: string) => Promise<void>
|
||||
markThreadRead: (threadId: string) => void
|
||||
benutzerAnlegen: (
|
||||
benutzername: string,
|
||||
name: string,
|
||||
@@ -250,136 +87,179 @@ interface Store extends State {
|
||||
passwort: string,
|
||||
einheitId: string | null,
|
||||
) => Promise<string | null>
|
||||
setAktiv: (id: string, aktiv: boolean) => void
|
||||
setAktiv: (id: string, aktiv: boolean) => Promise<void>
|
||||
setPasswort: (id: string, passwort: string) => Promise<void>
|
||||
weiseEinheitZu: (id: string, einheitId: string | null) => void
|
||||
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
|
||||
weiseEinheitZu: (id: string, einheitId: string | null) => Promise<void>
|
||||
generiereCodes: (anzahl: number, einheitId: string | null) => Promise<RegistrierungsCode[]>
|
||||
}
|
||||
|
||||
const StoreContext = createContext<Store | null>(null)
|
||||
|
||||
export function StoreProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(reducer, undefined, loadState)
|
||||
const [bereit, setBereit] = useState(false)
|
||||
const [me, setMe] = useState<Benutzer | null>(null)
|
||||
const [daten, setDaten] = useState<Bootstrap>(LEER)
|
||||
|
||||
/** Lädt alle Stammdaten neu (nach Login/Registrierung und nach Schreibaktionen). */
|
||||
const ladeAlles = useCallback(async () => {
|
||||
const b = await apiGet<Bootstrap>('/api/bootstrap')
|
||||
setDaten(b)
|
||||
}, [])
|
||||
|
||||
// Start: bestehende Session prüfen, dann Daten laden
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||
}, [state])
|
||||
|
||||
const value = useMemo<Store>(() => {
|
||||
const me =
|
||||
(state.session &&
|
||||
state.benutzer.find((b) => b.id === state.session!.benutzerId && b.aktiv)) ||
|
||||
null
|
||||
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
|
||||
const meineEinheit =
|
||||
units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
|
||||
units.find((u) => u.id === DEMO_UNIT_ID)!
|
||||
|
||||
const login = async (benutzername: string, passwort: string) => {
|
||||
const b = state.benutzer.find(
|
||||
(x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase(),
|
||||
)
|
||||
if (!b) return 'Benutzername oder Passwort falsch.'
|
||||
if (!b.aktiv) return 'Dieses Konto ist deaktiviert – bitte die Verwaltung kontaktieren.'
|
||||
if ((await sha256(passwort)) !== b.passwortHash)
|
||||
return 'Benutzername oder Passwort falsch.'
|
||||
speichereSession(b.id, b.rolle)
|
||||
dispatch({ type: 'setSession', session: ladeSession() })
|
||||
return null
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
beendeSession()
|
||||
dispatch({ type: 'setSession', session: null })
|
||||
}
|
||||
|
||||
const registrieren = async (
|
||||
code: string,
|
||||
benutzername: string,
|
||||
name: string,
|
||||
email: string,
|
||||
passwort: string,
|
||||
) => {
|
||||
const c = state.codes.find(
|
||||
(x) => x.code.toUpperCase() === code.trim().toUpperCase() && !x.verwendetAm,
|
||||
)
|
||||
if (!c) return 'Der Registrierungscode ist ungültig oder bereits verwendet.'
|
||||
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
|
||||
return 'Dieser Benutzername ist bereits vergeben.'
|
||||
const b: Benutzer = {
|
||||
id: `b-${Date.now()}`,
|
||||
benutzername: benutzername.trim(),
|
||||
name: name.trim(),
|
||||
passwortHash: await sha256(passwort),
|
||||
rolle: 'mieter',
|
||||
email: email.trim(),
|
||||
einheitId: c.einheitId,
|
||||
aktiv: true,
|
||||
erstelltAm: new Date().toLocaleDateString('de-DE'),
|
||||
let aktiv = true
|
||||
;(async () => {
|
||||
try {
|
||||
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
|
||||
if (!aktiv) return
|
||||
setMe(benutzer)
|
||||
await ladeAlles()
|
||||
} catch {
|
||||
/* nicht angemeldet – Login-Seite zeigen */
|
||||
} finally {
|
||||
if (aktiv) setBereit(true)
|
||||
}
|
||||
dispatch({ type: 'registriert', benutzer: b, code: c.code })
|
||||
speichereSession(b.id, b.rolle)
|
||||
dispatch({ type: 'setSession', session: ladeSession() })
|
||||
return null
|
||||
})()
|
||||
return () => {
|
||||
aktiv = false
|
||||
}
|
||||
}, [ladeAlles])
|
||||
|
||||
const benutzerAnlegen = async (
|
||||
benutzername: string,
|
||||
name: string,
|
||||
rolle: BenutzerRolle,
|
||||
email: string,
|
||||
passwort: string,
|
||||
einheitId: string | null,
|
||||
) => {
|
||||
if (state.benutzer.some((x) => x.benutzername.toLowerCase() === benutzername.trim().toLowerCase()))
|
||||
return 'Dieser Benutzername ist bereits vergeben.'
|
||||
const b: Benutzer = {
|
||||
id: `b-${Date.now()}`,
|
||||
benutzername: benutzername.trim(),
|
||||
name: name.trim(),
|
||||
passwortHash: await sha256(passwort),
|
||||
rolle,
|
||||
email: email.trim(),
|
||||
einheitId: rolle === 'mieter' ? einheitId : null,
|
||||
aktiv: true,
|
||||
erstelltAm: new Date().toLocaleDateString('de-DE'),
|
||||
const role: Role = me?.rolle === 'verwaltung' ? 'admin' : 'tenant'
|
||||
const meineEinheit =
|
||||
daten.units.find((u) => u.id === (me?.einheitId ?? DEMO_UNIT_ID)) ??
|
||||
daten.units.find((u) => u.id === DEMO_UNIT_ID) ??
|
||||
daten.units[0]
|
||||
|
||||
const fehlerNachricht = (e: unknown): string =>
|
||||
e instanceof ApiFehler ? e.message : 'Ein unerwarteter Fehler ist aufgetreten.'
|
||||
|
||||
const value = useMemo<Store>(() => ({
|
||||
...daten,
|
||||
bereit,
|
||||
me,
|
||||
role,
|
||||
meineEinheit,
|
||||
// ── Auth ────────────────────────────────────────────────
|
||||
async login(benutzername, passwort) {
|
||||
try {
|
||||
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/login', {
|
||||
benutzername,
|
||||
passwortHash: await sha256(passwort),
|
||||
})
|
||||
setMe(benutzer)
|
||||
await ladeAlles()
|
||||
return null
|
||||
} catch (e) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
dispatch({ type: 'benutzerAngelegt', benutzer: b })
|
||||
return null
|
||||
}
|
||||
|
||||
const setPasswort = async (id: string, passwort: string) => {
|
||||
dispatch({ type: 'passwortGesetzt', id, hash: await sha256(passwort) })
|
||||
}
|
||||
|
||||
const generiereCodes = (anzahl: number, einheitId: string | null) => {
|
||||
const neu: RegistrierungsCode[] = Array.from({ length: anzahl }, () => ({
|
||||
code: neuerCode(),
|
||||
einheitId,
|
||||
erstelltAm: new Date().toLocaleDateString('de-DE'),
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
await apiPost('/api/auth/logout')
|
||||
} catch {
|
||||
/* Session ggf. bereits ungültig – trotzdem lokal abmelden */
|
||||
}
|
||||
setMe(null)
|
||||
setDaten(LEER)
|
||||
},
|
||||
async registrieren(code, benutzername, name, email, passwort) {
|
||||
try {
|
||||
const { benutzer } = await apiPost<{ benutzer: Benutzer }>('/api/auth/register', {
|
||||
code,
|
||||
benutzername,
|
||||
name,
|
||||
email,
|
||||
passwortHash: await sha256(passwort),
|
||||
})
|
||||
setMe(benutzer)
|
||||
await ladeAlles()
|
||||
return null
|
||||
} catch (e) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
},
|
||||
// ── Schadensmeldungen ───────────────────────────────────
|
||||
async addTicket(t) {
|
||||
try {
|
||||
await apiPost('/api/tickets', t)
|
||||
await ladeAlles()
|
||||
return null
|
||||
} catch (e) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
},
|
||||
async setTicketStatus(id, status) {
|
||||
const { ticket } = await apiPatch<{ ticket: Ticket }>(`/api/tickets/${id}`, { status })
|
||||
setDaten((d) => ({ ...d, tickets: d.tickets.map((t) => (t.id === id ? ticket : t)) }))
|
||||
},
|
||||
// ── Nachrichten ─────────────────────────────────────────
|
||||
async newThread(subject, text, unitId) {
|
||||
try {
|
||||
const { thread } = await apiPost<{ thread: Thread }>('/api/threads', { subject, text, unitId })
|
||||
setDaten((d) => ({ ...d, threads: [thread, ...d.threads.filter((t) => t.id !== thread.id)] }))
|
||||
return thread.id
|
||||
} catch (e) {
|
||||
console.error(fehlerNachricht(e))
|
||||
return null
|
||||
}
|
||||
},
|
||||
async addMessage(threadId, text) {
|
||||
const { thread } = await apiPost<{ thread: Thread }>(`/api/threads/${threadId}/messages`, { text })
|
||||
setDaten((d) => ({ ...d, threads: d.threads.map((t) => (t.id === threadId ? thread : t)) }))
|
||||
},
|
||||
markThreadRead(threadId) {
|
||||
// Optimistisch lokal markieren, serverseitig nachmelden
|
||||
setDaten((d) => ({
|
||||
...d,
|
||||
threads: d.threads.map((t) =>
|
||||
t.id === threadId
|
||||
? role === 'tenant'
|
||||
? { ...t, unreadByTenant: false }
|
||||
: { ...t, unreadByAdmin: false }
|
||||
: t,
|
||||
),
|
||||
}))
|
||||
dispatch({ type: 'codesHinzugefuegt', codes: neu })
|
||||
return neu
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
dispatch,
|
||||
units,
|
||||
announcements,
|
||||
me,
|
||||
role,
|
||||
meineEinheit,
|
||||
login,
|
||||
logout,
|
||||
registrieren,
|
||||
benutzerAnlegen,
|
||||
setAktiv: (id, aktiv) => dispatch({ type: 'benutzerAktiv', id, aktiv }),
|
||||
setPasswort,
|
||||
weiseEinheitZu: (id, einheitId) => dispatch({ type: 'einheitZugewiesen', id, einheitId }),
|
||||
generiereCodes,
|
||||
}
|
||||
}, [state])
|
||||
apiPost(`/api/threads/${threadId}/read`).catch(() => {})
|
||||
},
|
||||
// ── Benutzerverwaltung (Verwaltung) ─────────────────────
|
||||
async benutzerAnlegen(benutzername, name, rolle, email, passwort, einheitId) {
|
||||
try {
|
||||
await apiPost('/api/users', {
|
||||
benutzername,
|
||||
name,
|
||||
rolle,
|
||||
email,
|
||||
einheitId,
|
||||
passwortHash: await sha256(passwort),
|
||||
})
|
||||
await ladeAlles()
|
||||
return null
|
||||
} catch (e) {
|
||||
return fehlerNachricht(e)
|
||||
}
|
||||
},
|
||||
async setAktiv(id, aktiv) {
|
||||
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { aktiv })
|
||||
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
||||
},
|
||||
async setPasswort(id, passwort) {
|
||||
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, {
|
||||
passwortHash: await sha256(passwort),
|
||||
})
|
||||
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
||||
},
|
||||
async weiseEinheitZu(id, einheitId) {
|
||||
const { benutzer } = await apiPatch<{ benutzer: Benutzer }>(`/api/users/${id}`, { einheitId })
|
||||
setDaten((d) => ({ ...d, benutzer: d.benutzer.map((b) => (b.id === id ? benutzer : b)) }))
|
||||
},
|
||||
async generiereCodes(anzahl, einheitId) {
|
||||
const { codes } = await apiPost<{ codes: RegistrierungsCode[] }>('/api/codes', { anzahl, einheitId })
|
||||
await ladeAlles()
|
||||
return codes
|
||||
},
|
||||
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||
|
||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||
}
|
||||
@@ -395,4 +275,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
|
||||
currency: 'EUR',
|
||||
})
|
||||
|
||||
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }
|
||||
export { DEMO_PASSWORT }
|
||||
|
||||
Reference in New Issue
Block a user