- Store setzt 'me' erst nach dem Laden der Bootstrap-Daten (Start, Login, Registrierung) - vorher rendereten Seiten gegen leere Daten und brachen ab; Seite lief erst nach manuellem Reload (Race zwischen setMe und ladeAlles) - FehlerGrenze (ErrorBoundary) in App.tsx: Render-Fehler zeigen Hinweis + Reload-Button statt schwarzem Bildschirm
279 lines
9.1 KiB
TypeScript
279 lines
9.1 KiB
TypeScript
/** 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,
|
||
useState,
|
||
type ReactNode,
|
||
} from 'react'
|
||
import type {
|
||
Role,
|
||
Ticket,
|
||
TicketStatus,
|
||
Transaction,
|
||
DocumentItem,
|
||
Thread,
|
||
Benutzer,
|
||
BenutzerRolle,
|
||
RegistrierungsCode,
|
||
Unit,
|
||
Announcement,
|
||
} from '../types'
|
||
import { apiGet, apiPost, apiPatch, ApiFehler } from '../lib/api'
|
||
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
||
|
||
/** 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'
|
||
|
||
interface Bootstrap {
|
||
units: Unit[]
|
||
transactions: Transaction[]
|
||
documents: DocumentItem[]
|
||
tickets: Ticket[]
|
||
threads: Thread[]
|
||
announcements: Announcement[]
|
||
benutzer: Benutzer[]
|
||
codes: RegistrierungsCode[]
|
||
}
|
||
|
||
const LEER: Bootstrap = {
|
||
units: [],
|
||
transactions: [],
|
||
documents: [],
|
||
tickets: [],
|
||
threads: [],
|
||
announcements: [],
|
||
benutzer: [],
|
||
codes: [],
|
||
}
|
||
|
||
interface Store extends Bootstrap {
|
||
/** Erster /api/auth/me-Lauf ist abgeschlossen (sonst Ladebildschirm). */
|
||
bereit: boolean
|
||
me: Benutzer | null
|
||
role: Role
|
||
meineEinheit: Unit
|
||
login: (benutzername: string, passwort: string) => Promise<string | null>
|
||
logout: () => Promise<void>
|
||
registrieren: (
|
||
code: string,
|
||
benutzername: string,
|
||
name: string,
|
||
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,
|
||
rolle: BenutzerRolle,
|
||
email: string,
|
||
passwort: string,
|
||
einheitId: string | null,
|
||
) => Promise<string | null>
|
||
setAktiv: (id: string, aktiv: boolean) => Promise<void>
|
||
setPasswort: (id: string, passwort: string) => Promise<void>
|
||
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 [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(() => {
|
||
let aktiv = true
|
||
;(async () => {
|
||
try {
|
||
const { benutzer } = await apiGet<{ benutzer: Benutzer }>('/api/auth/me')
|
||
await ladeAlles() // erst Daten, dann me – sonst rendern Seiten gegen leere Daten
|
||
if (!aktiv) return
|
||
setMe(benutzer)
|
||
} catch {
|
||
/* nicht angemeldet – Login-Seite zeigen */
|
||
} finally {
|
||
if (aktiv) setBereit(true)
|
||
}
|
||
})()
|
||
return () => {
|
||
aktiv = false
|
||
}
|
||
}, [ladeAlles])
|
||
|
||
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),
|
||
})
|
||
await ladeAlles() // erst Daten, dann me (s. useEffect)
|
||
setMe(benutzer)
|
||
return null
|
||
} catch (e) {
|
||
return fehlerNachricht(e)
|
||
}
|
||
},
|
||
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),
|
||
})
|
||
await ladeAlles() // erst Daten, dann me (s. useEffect)
|
||
setMe(benutzer)
|
||
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,
|
||
),
|
||
}))
|
||
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>
|
||
}
|
||
|
||
export function useStore(): Store {
|
||
const ctx = useContext(StoreContext)
|
||
if (!ctx) throw new Error('useStore außerhalb von StoreProvider')
|
||
return ctx
|
||
}
|
||
|
||
export const fmtEUR = new Intl.NumberFormat('de-DE', {
|
||
style: 'currency',
|
||
currency: 'EUR',
|
||
})
|
||
|
||
export { DEMO_PASSWORT }
|