v1.1: Rebrand Hinterstein Immobilien + echtes Auth-System
- Rebrand: Kunden-Logo, Joseph-Hinterstein-Portrait, Kontakt-Banner, Texte - Login-Pflicht auf /login (Route-Guard), Gruppen Verwaltung/Mieter - Mieter-Registrierung per einmaligem Registrierungscode - Benutzerverwaltung als 6. Modul (/benutzer, nur Verwaltung) - Demo-Zugänge mit Passwort 0815 (j.hinterstein, a.berger, m.wolf) - Mail-Server-API: Token-Auth (/send, X-Api-Token), Mail-Stack live auf Server - Store-Key hinterstein-mieterportal-v2, Doku (kimi.md, README) nachgeführt
This commit is contained in:
@@ -1,18 +1,20 @@
|
|||||||
# Hocks Mieterportal
|
# Hinterstein Immobilien · Mieterportal
|
||||||
|
|
||||||
Mieterportal der Hocks Verwaltung für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 ·
|
Mieterportal der **Hinterstein Immobilien** für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 ·
|
||||||
Ringweg 3 · Am Park 5) – analog zum [Schulportal](https://git.hocks.eu/ah-admin/Schulportal).
|
Ringweg 3 · Am Park 5) – analog zum [Schulportal](https://git.hocks.eu/ah-admin/Schulportal).
|
||||||
Live-Ziel: `https://mieterportal.hocks.eu`.
|
Live-Ziel: `https://mieterportal.hocks.eu`.
|
||||||
|
|
||||||
## Module
|
## Module
|
||||||
|
|
||||||
1. **Dashboard** – persönliche Übersicht (Mieter) bzw. Kennzahlen, Belegung und Einnahmen (Verwaltung)
|
1. **Dashboard** – persönliche Übersicht (Mieter) bzw. Kennzahlen, Belegung und Einnahmen (Verwaltung)
|
||||||
2. **Mietkonto & Zahlungen** – Buchungsverlauf, offene Forderungen, Nebenkosten; Verwaltung sieht Zahlungsstatus aller 50 Wohnungen
|
2. **Benutzerverwaltung** (nur Verwaltung) – Konten anlegen/pflegen, Registrierungscodes erzeugen
|
||||||
3. **Schadensmeldungen** – Tickets erfassen, priorisieren und Status pflegen
|
3. **Mietkonto & Zahlungen** – Buchungsverlauf, offene Forderungen, Nebenkosten; Verwaltung sieht Zahlungsstatus aller 50 Wohnungen
|
||||||
4. **Dokumente** – Verträge, Abrechnungen, Hausordnung; zentraler Such- und Filterzugriff
|
4. **Schadensmeldungen** – Tickets erfassen, priorisieren und Status pflegen
|
||||||
5. **Nachrichten** – Direktkommunikation Mieter ↔ Verwaltung
|
5. **Dokumente** – Verträge, Abrechnungen, Hausordnung; zentraler Such- und Filterzugriff
|
||||||
|
6. **Nachrichten** – Direktkommunikation Mieter ↔ Verwaltung
|
||||||
|
|
||||||
Der Rollenwechsel Mieter ↔ Verwaltung erfolgt über die Seitenleiste (Demo-Modus).
|
**Anmeldung erforderlich** – Gruppen: **Verwaltung** und **Mieter** (Registrierung der Mieter per Code auf der Startseite).
|
||||||
|
Demo-Zugänge (Passwort jeweils `0815`): `j.hinterstein` (Verwaltung) · `a.berger` (Mieterin) · Demo-Registrierungscode: `MP-2026-0815`.
|
||||||
Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. Dark-Mode-Umschalter
|
Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. Dark-Mode-Umschalter
|
||||||
in der Seitenleiste bzw. Mobilen Kopfzeile.
|
in der Seitenleiste bzw. Mobilen Kopfzeile.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="icon" type="image/png" href="/logo.png" />
|
<link rel="icon" type="image/png" href="/logo.png" />
|
||||||
<title>Hocks Mieterportal</title>
|
<title>Hinterstein Immobilien · Mieterportal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
# Kimi-Kontext: Hocks Mieterportal (mieterportal.hocks.eu)
|
# Kimi-Kontext: Hinterstein Immobilien · Mieterportal (mieterportal.hocks.eu)
|
||||||
|
|
||||||
> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 11:00 Uhr.
|
> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 14:30 Uhr.
|
||||||
> Bitte bei größeren Änderungen am Projekt aktualisieren.
|
> Bitte bei größeren Änderungen am Projekt aktualisieren.
|
||||||
> Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert.
|
> Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert.
|
||||||
|
|
||||||
## 1. Projektüberblick
|
## 1. Projektüberblick
|
||||||
|
|
||||||
Mieterportal der Hocks Verwaltung für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 ·
|
Mieterportal der **Hinterstein Immobilien** (Demo, Inhaber Joseph Hinterstein) für
|
||||||
Ringweg 3 · Am Park 5). Analog zum Schulportal: alle Mietangelegenheiten digital an
|
**50 Wohnungen in 3 Häusern** (Hauptstraße 12 · Ringweg 3 · Am Park 5). Analog zum
|
||||||
einem Ort. **Rollenmodus statt echter Anmeldung** (Demo): Umschalten Mieter ↔
|
Schulportal: alle Mietangelegenheiten digital an einem Ort.
|
||||||
Verwaltung über die Seitenleiste (Schaltfläche „Als Verwaltung anzeigen").
|
|
||||||
|
|
||||||
**Die 5 Module:**
|
**Authentifizierung (echtes Login seit v1.1):** Startseite `/login` mit
|
||||||
|
Login-Pflicht (Route-Guard `RequireAuth` in `App.tsx` leitet unangemeldete Besucher
|
||||||
|
auf `/login` um). **Gruppen:** `verwaltung` und `mieter`. Mieter können sich über
|
||||||
|
einen **Registrierungscode** selbst registrieren (`/registrieren`); Codes legt die
|
||||||
|
Verwaltung in der Benutzerverwaltung an (einmalig nutzbar, Wohnungsbezug).
|
||||||
|
Passwörter als **SHA-256-Hash** in localStorage, Session 8 h
|
||||||
|
(Key `mp_benutzer_session`). Demo-Zugänge (Passwort `0815`):
|
||||||
|
`j.hinterstein` (Verwaltung, Joseph Hinterstein), `a.berger` (Mieterin,
|
||||||
|
Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode: `MP-2026-0815`.
|
||||||
|
|
||||||
|
**Die 6 Module:**
|
||||||
|
|
||||||
1. **Dashboard** (`/`) – Mieter: Begrüßung, nächste Zahlung, Mietdiagramm, Ankündigungen,
|
1. **Dashboard** (`/`) – Mieter: Begrüßung, nächste Zahlung, Mietdiagramm, Ankündigungen,
|
||||||
offene Meldungen. Verwaltung: Kennzahlen (Vermietungsquote, Miet-Soll, Rückstände),
|
offene Meldungen. Verwaltung: Kennzahlen (Vermietungsquote, Miet-Soll, Rückstände),
|
||||||
@@ -25,21 +34,21 @@ Verwaltung über die Seitenleiste (Schaltfläche „Als Verwaltung anzeigen").
|
|||||||
Suche + Kategorie-Chips. Mieter sieht eigene + Hausdokumente (unitId=null).
|
Suche + Kategorie-Chips. Mieter sieht eigene + Hausdokumente (unitId=null).
|
||||||
5. **Nachrichten** (`/nachrichten`) – Chat Mieter ↔ Verwaltung, Ungelesen-Markierung,
|
5. **Nachrichten** (`/nachrichten`) – Chat Mieter ↔ Verwaltung, Ungelesen-Markierung,
|
||||||
neue Unterhaltungen.
|
neue Unterhaltungen.
|
||||||
|
6. **Benutzerverwaltung** (`/benutzer`, nur Gruppe `verwaltung`) – Nutzerliste mit
|
||||||
**Login-Seite** (`/login`): Hero mit generiertem Foto, Feature-Liste, Demo-Einstiege
|
Gruppen und Wohnungsbezug, Registrierungscodes erzeugen/deaktivieren,
|
||||||
(„Als Mieterin anmelden" / „Als Verwaltung anmelden"). Kein echtes Auth – der
|
Demo-Zugänge zurücksetzen.
|
||||||
Rollenwechsel setzt nur den Store.
|
|
||||||
|
|
||||||
## 2. Technischer Stack
|
## 2. Technischer Stack
|
||||||
|
|
||||||
| Teil | Technologie |
|
| Teil | Technologie |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frontend | React 18 + TypeScript, Vite 7, Tailwind v3.4 + shadcn/ui (Radix), React Router 7 (BrowserRouter), Recharts |
|
| Frontend | React 18 + TypeScript, Vite 7, Tailwind v3.4 + shadcn/ui (Radix), React Router 7 (BrowserRouter), Recharts |
|
||||||
| State | React Context + useReducer, Persistenz in localStorage (`hocks-mieterportal-v1`) |
|
| State | React Context + useReducer, Persistenz in localStorage (`hinterstein-mieterportal-v2`) |
|
||||||
| Daten | `src/data/seed.ts` (handschriftlich) + `src/data/generated.json` (maschinell via `beispieldaten.py`) |
|
| Daten | `src/data/seed.ts` (handschriftlich) + `src/data/generated.json` (maschinell via `beispieldaten.py`) |
|
||||||
| Branding | KI-generiert: `src/assets/logo.png` (Haus-Schlüssel-Emblem, transparent), `hero.jpg`, `modules.jpg` |
|
| Auth | `src/lib/auth.ts` (SHA-256, Session 8 h, Keys `mp_benutzer`/`mp_benutzer_session`), Route-Guard in `App.tsx` |
|
||||||
|
| Branding | Kunde: `src/assets/hinterstein/` (logo.png, joseph-hinterstein.jpg, kontakt.jpg); KI-generiert: `src/assets/logo.png`, `hero.jpg`, `modules.jpg` |
|
||||||
| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` |
|
| Webserver (Ziel) | Docker-Container `mp-portal` (nginx), statisches `dist/`, gemountet `/opt/mieterportal/dist` |
|
||||||
| Mail (optional) | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`), Muster aus Schulportal |
|
| Mail | `mail-server/`: Postfix-Relay (`mp-mail`) + Python-API (`mp-mail-api`) – **live auf dem Server** unter `/opt/mp-mail` |
|
||||||
| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
|
| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
|
||||||
|
|
||||||
## 3. Wichtige Dateien
|
## 3. Wichtige Dateien
|
||||||
@@ -58,7 +67,8 @@ ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.).
|
|||||||
| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle |
|
| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle |
|
||||||
| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
|
| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
|
||||||
| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) |
|
| `src/lib/theme.ts` | `mp-theme` in localStorage, Init vor erstem Render (main.tsx) |
|
||||||
| `src/pages/Login.tsx` | Landing/Login mit Hero-Bild und Demo-Einstiegen |
|
| `src/pages/Login.tsx` | Login-Pflicht: Hero mit Joseph-Hinterstein-Portrait, Logo, Feature-Liste, Login-Formular + Registrierung-Link + Demo-Einstiege |
|
||||||
|
| `src/pages/Benutzer.tsx` | Benutzerverwaltung (nur Verwaltung): Nutzer, Gruppen, Registrierungscodes |
|
||||||
| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow |
|
| `Dockerfile` | Optionaler All-in-One-Build (node build → nginx) für `docker build`-Workflow |
|
||||||
| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header |
|
| `docker/nginx.conf` | nginx-Site: SPA-Fallback, Gzip, Cache-Header für /assets/, Security-Header |
|
||||||
| `docker-compose.yml` | `mp-portal` (nginx + gemountetes dist) im externen Netz `npm_default`; Mail-Stack separat |
|
| `docker-compose.yml` | `mp-portal` (nginx + gemountetes dist) im externen Netz `npm_default`; Mail-Stack separat |
|
||||||
@@ -106,11 +116,17 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 5. Zugänge & Konfiguration
|
## 5. Zugänge & Konfiguration
|
||||||
|
|
||||||
- Keine echten Zugänge (Demo). Rollenwechsel im UI; Store-Reset über Browser-DevTools
|
- **Demo-Zugänge (Passwort `0815`):** `j.hinterstein` (Verwaltung), `a.berger`
|
||||||
(localStorage-Key `hocks-mieterportal-v1` löschen).
|
(Mieterin, Hauptstraße 12 Whg. 7), `m.wolf` (Mieter). Demo-Registrierungscode:
|
||||||
- Demo-Mieterin: **Anna Berger**, Hauptstraße 12, Whg. 7 (id `hs-7`).
|
`MP-2026-0815`. Passwörter werden als SHA-256-Hash gespeichert; Store-Reset über
|
||||||
- **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt in `mail-server/mail.env`
|
Browser-DevTools (localStorage-Keys `mp_benutzer`, `mp_benutzer_session`,
|
||||||
(nicht im Repo; Vorlage `mail.env.example`).
|
`hinterstein-mieterportal-v2` löschen).
|
||||||
|
- **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt auf dem Server in
|
||||||
|
`/opt/mp-mail/mail.env` (Vorlage `mail.env.example` im Repo).
|
||||||
|
- **Mail-Stack live (seit 02.10.2026):** Container `mp-mail` + `mp-mail-api` laufen
|
||||||
|
auf hocks.eu unter `/opt/mp-mail` (Relay über `hocks-eu.mail.protection.outlook.com`).
|
||||||
|
Testmail und API-`/send` (mit `X-Api-Token`) verifiziert (status=sent); ohne Token 401.
|
||||||
|
Noch nicht mit dem Portal-Frontend verkabelt (keine automatischen Benachrichtigungen).
|
||||||
|
|
||||||
## 6. Konventionen & Besonderheiten
|
## 6. Konventionen & Besonderheiten
|
||||||
|
|
||||||
@@ -149,13 +165,22 @@ bis 31.12.2026. Zertifikatsdateien liegen im npm-Container unter `/etc/letsencry
|
|||||||
(Backup der Zertifikate auf dem Host: `/root/le-mp/`). Fail2Ban-Ban nach frühen
|
(Backup der Zertifikate auf dem Host: `/root/le-mp/`). Fail2Ban-Ban nach frühen
|
||||||
Fehlversuchen ist nach ~40 min automatisch ausgelaufen.
|
Fehlversuchen ist nach ~40 min automatisch ausgelaufen.
|
||||||
|
|
||||||
|
**Erledigt 02.10. (v1.1 live):** Rebrand auf „Hinterstein Immobilien" (Kunden-Logo,
|
||||||
|
Joseph-Hinterstein-Portrait auf der Login-Seite, Kontakt-Banner), echtes
|
||||||
|
Authentifizierungssystem (Login-Pflicht, Gruppen Verwaltung/Mieter, SHA-256,
|
||||||
|
Session 8 h), Mieter-Registrierung per einmaligem Code, Benutzerverwaltung als
|
||||||
|
6. Modul (`/benutzer`), Demo-Zugänge mit Passwort `0815`. Mail-Stack auf dem Server
|
||||||
|
aktiviert (`/opt/mp-mail`, Testmail + API-/send verifiziert). Store-Key auf
|
||||||
|
`hinterstein-mieterportal-v2` gehoben. Live deployed und geprüft (HTTP 200, Titel
|
||||||
|
„Hinterstein Immobilien · Mieterportal").
|
||||||
|
|
||||||
**Offen:**
|
**Offen:**
|
||||||
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
|
- Zertifikatserneuerung prüfen (per 01.12.2026): NPM-Renewer sollte cert 9 über HTTP-Challenge
|
||||||
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
|
erneuern; sonst certbot/webroot + Kopie nach npm-9 wiederholen.
|
||||||
- Mail-Stack optional einrichten: `mail.env` aus Vorlage befüllen, Relay-Ziel in
|
- Portal-Frontend an Mail-API verkabeln: automatische E-Mail an die Verwaltung bei neuen
|
||||||
`postfix/main.cf`/`mail.env` setzen, `docker compose up -d --build` im mail-server-Verzeichnis.
|
Schadensmeldungen und an Mieter bei Antworten.
|
||||||
- Mögliche nächste Schritte: echte Anmeldung + serverseitige Datenbank (PostgreSQL/Node-API),
|
- Mögliche nächste Schritte: serverseitige Datenbank (PostgreSQL/Node-API) statt localStorage,
|
||||||
E-Mail-Benachrichtigung bei neuen Schadensmeldungen, Mehrseitigkeit für 50 echte Mietverhältnisse.
|
Mehrseitigkeit für 50 echte Mietverhältnisse.
|
||||||
|
|
||||||
## 9. Sonstiges
|
## 9. Sonstiges
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ PORT = int(os.environ.get("PORT", "8080"))
|
|||||||
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
|
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
|
||||||
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
DATA_DIR = os.environ.get("DATA_DIR", "/data")
|
||||||
SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu")
|
SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu")
|
||||||
SENDER_NAME = os.environ.get("SENDER_NAME", "Hocks Mieterportal")
|
SENDER_NAME = os.environ.get("SENDER_NAME", "Hinterstein Immobilien")
|
||||||
MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "")
|
MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "")
|
||||||
RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde
|
RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 486 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 16 KiB |
+25
-4
@@ -1,5 +1,5 @@
|
|||||||
import { Routes, Route } from 'react-router'
|
import { Routes, Route, Navigate } from 'react-router'
|
||||||
import { StoreProvider } from './store/store'
|
import { StoreProvider, useStore } from './store/store'
|
||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
@@ -7,19 +7,40 @@ import Payments from './pages/Payments'
|
|||||||
import Tickets from './pages/Tickets'
|
import Tickets from './pages/Tickets'
|
||||||
import Documents from './pages/Documents'
|
import Documents from './pages/Documents'
|
||||||
import Messages from './pages/Messages'
|
import Messages from './pages/Messages'
|
||||||
|
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||||
|
|
||||||
|
function RequireAuth({ children }: { children: React.ReactElement }) {
|
||||||
|
const { me } = useStore()
|
||||||
|
if (!me) return <Navigate to="/login" replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginGate() {
|
||||||
|
const { me } = useStore()
|
||||||
|
if (me) return <Navigate to="/" replace />
|
||||||
|
return <Login />
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<StoreProvider>
|
<StoreProvider>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<LoginGate />} />
|
||||||
<Route element={<AppLayout />}>
|
<Route
|
||||||
|
element={
|
||||||
|
<RequireAuth>
|
||||||
|
<AppLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/mietkonto" element={<Payments />} />
|
<Route path="/mietkonto" element={<Payments />} />
|
||||||
<Route path="/schadensmeldungen" element={<Tickets />} />
|
<Route path="/schadensmeldungen" element={<Tickets />} />
|
||||||
<Route path="/dokumente" element={<Documents />} />
|
<Route path="/dokumente" element={<Documents />} />
|
||||||
<Route path="/nachrichten" element={<Messages />} />
|
<Route path="/nachrichten" element={<Messages />} />
|
||||||
|
<Route path="/benutzer" element={<BenutzerVerwaltung />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</StoreProvider>
|
</StoreProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
@@ -1,17 +1,17 @@
|
|||||||
import { NavLink, Outlet } from 'react-router'
|
import { NavLink, Outlet, useNavigate } from 'react-router'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Wallet,
|
Wallet,
|
||||||
Wrench,
|
Wrench,
|
||||||
FileText,
|
FileText,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
|
UserCog,
|
||||||
LogOut,
|
LogOut,
|
||||||
Building2,
|
Building2,
|
||||||
Bell,
|
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
import logo from '../assets/logo.png'
|
import logo from '../assets/hinterstein/logo.png'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, end: true },
|
||||||
@@ -22,44 +22,46 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { role, dispatch, threads } = useStore()
|
const { role, me, logout, threads } = useStore()
|
||||||
|
const navigate = useNavigate()
|
||||||
const unread = threads.filter((t) =>
|
const unread = threads.filter((t) =>
|
||||||
role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin,
|
role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin,
|
||||||
).length
|
).length
|
||||||
|
|
||||||
|
const abmelden = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
const alleNavItems =
|
||||||
|
role === 'admin'
|
||||||
|
? [...navItems, { to: '/benutzer', label: 'Benutzerverwaltung', icon: UserCog }]
|
||||||
|
: navItems
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
|
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="hidden w-72 flex-col bg-[#0b3a44] text-white md:flex">
|
<aside className="hidden w-72 flex-col bg-[#22333b] text-white md:flex">
|
||||||
<div className="flex items-center gap-3 px-6 py-6">
|
<div className="flex items-center gap-3 px-6 py-6">
|
||||||
<img src={logo} alt="Hocks Logo" className="h-11 w-11" />
|
<img src={logo} alt="Logo Hinterstein Immobilien" className="h-12 w-12 rounded-full bg-white/95 object-contain p-1" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-bold leading-tight">Hocks Mieterportal</div>
|
<div className="text-lg font-bold leading-tight">Hinterstein</div>
|
||||||
<div className="text-xs text-teal-200/80">mieterportal.hocks.eu</div>
|
<div className="text-xs text-amber-200/90">Immobilien · Mieterportal</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mx-6 mb-4 rounded-xl bg-white/10 p-3 text-sm">
|
<div className="mx-6 mb-4 rounded-xl bg-white/10 p-3 text-sm">
|
||||||
<div className="mb-1 text-xs uppercase tracking-wide text-teal-200/70">
|
<div className="mb-1 text-xs uppercase tracking-wide text-slate-300/80">
|
||||||
Angemeldet als
|
Angemeldet als
|
||||||
</div>
|
</div>
|
||||||
<div className="font-semibold">
|
<div className="font-semibold">{me?.name}</div>
|
||||||
{role === 'tenant' ? 'Anna Berger (Mieterin)' : 'Hocks Verwaltung'}
|
<div className="text-xs text-slate-300/80">
|
||||||
|
{role === 'admin' ? 'Gruppe: Verwaltung' : 'Gruppe: Mieter'}
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
dispatch({ type: 'setRole', role: role === 'tenant' ? 'admin' : 'tenant' })
|
|
||||||
}
|
|
||||||
className="mt-2 w-full rounded-lg bg-teal-500/20 px-3 py-1.5 text-xs font-medium text-teal-100 transition hover:bg-teal-500/30"
|
|
||||||
>
|
|
||||||
{role === 'tenant'
|
|
||||||
? '→ Als Verwaltung anzeigen'
|
|
||||||
: '→ Als Mieterin anzeigen'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 px-4">
|
<nav className="flex-1 space-y-1 px-4">
|
||||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
{alleNavItems.map(({ to, label, icon: Icon, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -67,8 +69,8 @@ export default function AppLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium transition ${
|
`flex items-center gap-3 rounded-xl px-4 py-2.5 text-sm font-medium transition ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-teal-500 text-white shadow'
|
? 'bg-amber-500 text-slate-900 shadow'
|
||||||
: 'text-teal-100/80 hover:bg-white/10 hover:text-white'
|
: 'text-slate-200/80 hover:bg-white/10 hover:text-white'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -84,17 +86,17 @@ export default function AppLayout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t border-white/10 p-4">
|
<div className="border-t border-white/10 p-4">
|
||||||
<div className="mb-2 flex items-center gap-2 px-2 text-xs text-teal-200/70">
|
<div className="mb-2 flex items-center gap-2 px-2 text-xs text-slate-300/70">
|
||||||
<Building2 className="h-3.5 w-3.5" />
|
<Building2 className="h-3.5 w-3.5" />
|
||||||
3 Häuser · 50 Wohnungen
|
3 Häuser · 50 Wohnungen
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<NavLink
|
<button
|
||||||
to="/login"
|
onClick={abmelden}
|
||||||
className="flex flex-1 items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-teal-100/80 transition hover:bg-white/10 hover:text-white"
|
className="flex flex-1 items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-slate-200/80 transition hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
<LogOut className="h-4 w-4" /> Abmelden
|
<LogOut className="h-4 w-4" /> Abmelden
|
||||||
</NavLink>
|
</button>
|
||||||
<ThemeToggle dunkelStil />
|
<ThemeToggle dunkelStil />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,27 +105,19 @@ export default function AppLayout() {
|
|||||||
{/* Mobile top bar */}
|
{/* Mobile top bar */}
|
||||||
<div className="flex min-h-screen flex-1 flex-col">
|
<div className="flex min-h-screen flex-1 flex-col">
|
||||||
<header className="sticky top-0 z-20 flex items-center gap-3 border-b bg-white/90 px-4 py-3 backdrop-blur dark:bg-slate-900/90 md:hidden">
|
<header className="sticky top-0 z-20 flex items-center gap-3 border-b bg-white/90 px-4 py-3 backdrop-blur dark:bg-slate-900/90 md:hidden">
|
||||||
<img src={logo} alt="Hocks Logo" className="h-9 w-9" />
|
<img src={logo} alt="Logo Hinterstein Immobilien" className="h-9 w-9 rounded-full bg-white object-contain p-0.5 ring-1 ring-slate-200" />
|
||||||
<div className="flex-1 font-bold text-[#0b3a44] dark:text-teal-100">Hocks Mieterportal</div>
|
<div className="flex-1 font-bold text-[#22333b] dark:text-amber-100">Hinterstein Immobilien</div>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
dispatch({ type: 'setRole', role: role === 'tenant' ? 'admin' : 'tenant' })
|
|
||||||
}
|
|
||||||
className="rounded-lg bg-teal-50 px-2.5 py-1.5 text-xs font-semibold text-teal-700 dark:bg-teal-500/20 dark:text-teal-100"
|
|
||||||
>
|
|
||||||
{role === 'tenant' ? 'Verwaltung' : 'Mieterin'}
|
|
||||||
</button>
|
|
||||||
</header>
|
</header>
|
||||||
<nav className="sticky top-[57px] z-10 flex gap-1 overflow-x-auto border-b bg-white px-2 py-2 dark:bg-slate-900 md:hidden">
|
<nav className="sticky top-[57px] z-10 flex gap-1 overflow-x-auto border-b bg-white px-2 py-2 dark:bg-slate-900 md:hidden">
|
||||||
{navItems.map(({ to, label, icon: Icon, end }) => (
|
{alleNavItems.map(({ to, label, icon: Icon, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
end={end}
|
end={end}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium ${
|
`flex shrink-0 items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium ${
|
||||||
isActive ? 'bg-teal-600 text-white' : 'text-slate-600 dark:text-slate-300'
|
isActive ? 'bg-amber-500 text-slate-900' : 'text-slate-600 dark:text-slate-300'
|
||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -137,11 +131,6 @@ export default function AppLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Decorative fixed bell (placeholder for notifications) */}
|
|
||||||
<div className="pointer-events-none fixed bottom-4 right-4 hidden opacity-0">
|
|
||||||
<Bell />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -194,7 +194,7 @@ export const threads: Thread[] = [
|
|||||||
{
|
{
|
||||||
id: 'm-1-b',
|
id: 'm-1-b',
|
||||||
from: 'admin',
|
from: 'admin',
|
||||||
author: 'Hocks Verwaltung',
|
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.',
|
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',
|
at: '29.09.2026, 09:03',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/** 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'
|
||||||
|
|
||||||
|
/** Demo-Passwort aller Demo-Benutzer (wie Schulportal sha256("0815")). */
|
||||||
|
export const DEMO_PASSWORT = '0815'
|
||||||
|
|
||||||
|
export async function sha256(text: string): Promise<string> {
|
||||||
|
const daten = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
|
||||||
|
return Array.from(new Uint8Array(daten))
|
||||||
|
.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
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { KeyRound, Plus, RefreshCcw, Search, ShieldCheck, UserCheck, UsersRound } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { useStore } from '../store/store'
|
||||||
|
import type { BenutzerRolle } from '../types'
|
||||||
|
|
||||||
|
function Chip({ label, value, tone = 'slate' }: { label: string; value: number | string; tone?: 'slate' | 'amber' | 'teal' | 'blue' }) {
|
||||||
|
const tones = {
|
||||||
|
slate: 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-200',
|
||||||
|
amber: 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300',
|
||||||
|
teal: 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300',
|
||||||
|
blue: 'bg-blue-100 text-blue-800 dark:bg-blue-950 dark:text-blue-300',
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs font-semibold ${tones[tone]}`}>
|
||||||
|
{label}: {value}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NeuerBenutzerDialog() {
|
||||||
|
const { benutzerAnlegen, units } = useStore()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [bn, setBn] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
|
||||||
|
const [pw, setPw] = useState('0815')
|
||||||
|
const [einheitId, setEinheitId] = useState('hs-7')
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const submit = async () => {
|
||||||
|
setFehler(null)
|
||||||
|
const err = await benutzerAnlegen(bn, name, rolle, email, pw, rolle === 'mieter' ? einheitId : null)
|
||||||
|
if (err) return setFehler(err)
|
||||||
|
setName(''); setBn(''); setEmail(''); setPw('0815')
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||||||
|
<Plus className="h-4 w-4" /> Benutzer anlegen
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Neuen Benutzer anlegen</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nb-name">Name</Label>
|
||||||
|
<Input id="nb-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="Vor- und Nachname" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nb-bn">Benutzername</Label>
|
||||||
|
<Input id="nb-bn" value={bn} onChange={(e) => setBn(e.target.value)} placeholder="z. B. m.mueller" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nb-email">E-Mail</Label>
|
||||||
|
<Input id="nb-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Gruppe</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['mieter', 'verwaltung'] as const).map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRolle(r)}
|
||||||
|
className={`flex-1 rounded-lg px-3 py-2 text-sm font-semibold transition ${
|
||||||
|
rolle === r ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{r === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rolle === 'mieter' && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nb-einheit">Wohnung zuweisen</Label>
|
||||||
|
<select
|
||||||
|
id="nb-einheit"
|
||||||
|
value={einheitId}
|
||||||
|
onChange={(e) => setEinheitId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.building}, Whg. {u.number} ({u.size} m²)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nb-pw">Passwort (Initial)</Label>
|
||||||
|
<Input id="nb-pw" value={pw} onChange={(e) => setPw(e.target.value)} />
|
||||||
|
<p className="text-xs text-slate-500">Der Benutzer sollte das Passwort nach dem ersten Login ändern.</p>
|
||||||
|
</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">{fehler}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||||
|
<Button onClick={submit} disabled={!name.trim() || !bn.trim() || !email.trim() || !pw.trim()} className="bg-amber-500 text-slate-900 hover:bg-amber-400">
|
||||||
|
Anlegen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BenutzerVerwaltung() {
|
||||||
|
const { benutzer, codes, units, setAktiv, setPasswort, weiseEinheitZu, generiereCodes } = useStore()
|
||||||
|
const [suche, setSuche] = useState('')
|
||||||
|
const [gruppe, setGruppe] = useState<'alle' | BenutzerRolle>('alle')
|
||||||
|
const [anzahl, setAnzahl] = useState(5)
|
||||||
|
const [codeEinheit, setCodeEinheit] = useState('')
|
||||||
|
const [kopiert, setKopiert] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const gefiltert = useMemo(() => {
|
||||||
|
const q = suche.trim().toLowerCase()
|
||||||
|
return benutzer
|
||||||
|
.filter((b) => gruppe === 'alle' || b.rolle === gruppe)
|
||||||
|
.filter(
|
||||||
|
(b) =>
|
||||||
|
!q ||
|
||||||
|
b.name.toLowerCase().includes(q) ||
|
||||||
|
b.benutzername.toLowerCase().includes(q) ||
|
||||||
|
b.email.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [benutzer, suche, gruppe])
|
||||||
|
|
||||||
|
const freieCodes = codes.filter((c) => !c.verwendetAm)
|
||||||
|
|
||||||
|
const passwortZuruecksetzen = async (id: string, name: string) => {
|
||||||
|
if (!window.confirm(`Passwort von ${name} auf „0815“ zurücksetzen?`)) return
|
||||||
|
await setPasswort(id, '0815')
|
||||||
|
}
|
||||||
|
|
||||||
|
const kopieren = (text: string) => {
|
||||||
|
navigator.clipboard?.writeText(text).catch(() => {})
|
||||||
|
setKopiert(text)
|
||||||
|
setTimeout(() => setKopiert(null), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Benutzerverwaltung</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Konten der Gruppen Verwaltung und Mieter – Registrierungscodes für Neumieter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NeuerBenutzerDialog />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Chip label="Benutzer gesamt" value={benutzer.length} />
|
||||||
|
<Chip label="Verwaltung" value={benutzer.filter((b) => b.rolle === 'verwaltung').length} tone="amber" />
|
||||||
|
<Chip label="Mieter" value={benutzer.filter((b) => b.rolle === 'mieter').length} tone="teal" />
|
||||||
|
<Chip label="Aktive Codes" value={freieCodes.length} tone="blue" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Registrierungscodes */}
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<KeyRound className="h-4.5 w-4.5 text-amber-600" /> Registrierungscodes für Mieter
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={codeEinheit}
|
||||||
|
onChange={(e) => setCodeEinheit(e.target.value)}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
aria-label="Wohnung für Code"
|
||||||
|
>
|
||||||
|
<option value="">Wohnung wählen …</option>
|
||||||
|
{units.filter((u) => u.status === 'frei').map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||||||
|
))}
|
||||||
|
{units.filter((u) => u.status === 'vermietet').map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>{u.building}, Whg. {u.number}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={anzahl}
|
||||||
|
onChange={(e) => setAnzahl(Number(e.target.value))}
|
||||||
|
className="rounded-md border border-input bg-background px-3 py-1.5 text-sm"
|
||||||
|
aria-label="Anzahl Codes"
|
||||||
|
>
|
||||||
|
{[1, 5, 10, 20].map((n) => (
|
||||||
|
<option key={n} value={n}>{n}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
onClick={() => generiereCodes(anzahl, codeEinheit || null)}
|
||||||
|
className="gap-2 bg-amber-500 text-slate-900 hover:bg-amber-400"
|
||||||
|
>
|
||||||
|
<RefreshCcw className="h-4 w-4" /> Codes erzeugen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{codes.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Noch keine Codes erzeugt.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{codes.slice(0, 20).map((c) => {
|
||||||
|
const einheit = units.find((u) => u.id === c.einheitId)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.code}
|
||||||
|
onClick={() => kopieren(c.code)}
|
||||||
|
title="Klicken zum Kopieren"
|
||||||
|
className={`rounded-lg px-3 py-1.5 font-mono text-xs transition ${
|
||||||
|
c.verwendetAm
|
||||||
|
? 'bg-slate-100 text-slate-400 line-through dark:bg-slate-800'
|
||||||
|
: kopiert === c.code
|
||||||
|
? 'bg-emerald-100 text-emerald-800 ring-2 ring-emerald-400 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
: 'bg-amber-50 text-amber-900 ring-1 ring-amber-200 hover:ring-amber-400 dark:bg-amber-950 dark:text-amber-200 dark:ring-amber-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{c.code}
|
||||||
|
{einheit && <span className="ml-1.5 font-sans text-[10px] text-slate-500">({einheit.building.split(' ')[0]} {einheit.number})</span>}
|
||||||
|
{c.verwendetAm && <span className="ml-1 font-sans text-[10px]">verwendet</span>}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
|
Mieter registrieren sich auf der Startseite mit einem Code – der Code wird dabei verbraucht
|
||||||
|
und die Wohnung automatisch zugeordnet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benutzerliste */}
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm dark:bg-slate-900">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="flex items-center gap-2 font-semibold">
|
||||||
|
<UsersRound className="h-4.5 w-4.5 text-amber-600" /> Benutzerkonten
|
||||||
|
</h2>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={suche}
|
||||||
|
onChange={(e) => setSuche(e.target.value)}
|
||||||
|
placeholder="Suchen …"
|
||||||
|
className="w-56 pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['alle', 'mieter', 'verwaltung'] as const).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
onClick={() => setGruppe(g)}
|
||||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-semibold transition ${
|
||||||
|
gruppe === g
|
||||||
|
? 'bg-amber-500 text-slate-900'
|
||||||
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{g === 'alle' ? 'Alle' : g === 'mieter' ? 'Mieter' : 'Verwaltung'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{gefiltert.map((b) => {
|
||||||
|
const einheit = units.find((u) => u.id === b.einheitId)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={b.id}
|
||||||
|
className={`flex flex-wrap items-center gap-3 rounded-xl border p-3 transition ${
|
||||||
|
b.aktiv ? 'bg-white dark:bg-slate-900' : 'bg-slate-50 opacity-60 dark:bg-slate-950'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`inline-flex h-9 w-9 items-center justify-center rounded-full text-sm font-bold ${
|
||||||
|
b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'
|
||||||
|
}`}>
|
||||||
|
{b.name.split(' ').map((t) => t[0]).slice(0, 2).join('')}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-semibold">{b.name}</span>
|
||||||
|
<Badge variant="secondary" className={b.rolle === 'verwaltung' ? 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300' : 'bg-teal-100 text-teal-800 dark:bg-teal-950 dark:text-teal-300'}>
|
||||||
|
{b.rolle === 'verwaltung' ? (
|
||||||
|
<><ShieldCheck className="mr-1 h-3 w-3" /> Verwaltung</>
|
||||||
|
) : (
|
||||||
|
<><UserCheck className="mr-1 h-3 w-3" /> Mieter</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
{!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}
|
||||||
|
{einheit && ` · ${einheit.building}, Whg. ${einheit.number}`}
|
||||||
|
{b.letzterLogin && ` · zuletzt: ${b.letzterLogin}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{b.rolle === 'mieter' && (
|
||||||
|
<select
|
||||||
|
value={b.einheitId ?? ''}
|
||||||
|
onChange={(e) => weiseEinheitZu(b.id, e.target.value || null)}
|
||||||
|
className="rounded-md border border-input bg-background px-2 py-1.5 text-xs"
|
||||||
|
aria-label={`Wohnung für ${b.name}`}
|
||||||
|
>
|
||||||
|
<option value="">Keine Wohnung</option>
|
||||||
|
{units.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.building}, Whg. {u.number}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Passwort → 0815
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setAktiv(b.id, !b.aktiv)}
|
||||||
|
className={`rounded-lg px-2.5 py-1.5 text-xs font-semibold transition ${
|
||||||
|
b.aktiv
|
||||||
|
? 'bg-red-50 text-red-600 hover:bg-red-100 dark:bg-red-950 dark:text-red-300'
|
||||||
|
: 'bg-emerald-50 text-emerald-700 hover:bg-emerald-100 dark:bg-emerald-950 dark:text-emerald-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b.aktiv ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{gefiltert.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-slate-500">Keine Benutzer für diesen Filter.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ function StatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TenantDashboard() {
|
function TenantDashboard() {
|
||||||
const { demoUnit, transactions, tickets, threads, announcements } = useStore()
|
const { meineEinheit: demoUnit, me, transactions, tickets, threads, announcements } = useStore()
|
||||||
const myTx = transactions.filter((t) => t.unitId === demoUnit.id)
|
const myTx = transactions.filter((t) => t.unitId === demoUnit.id)
|
||||||
const openTx = myTx.filter((t) => t.status !== 'bezahlt')
|
const openTx = myTx.filter((t) => t.status !== 'bezahlt')
|
||||||
const myTickets = tickets.filter((t) => t.unitId === demoUnit.id)
|
const myTickets = tickets.filter((t) => t.unitId === demoUnit.id)
|
||||||
@@ -73,9 +73,9 @@ function TenantDashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Hallo, Frau Berger</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Hallo, {me?.name}</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Hauptstraße 12 · Wohnung {demoUnit.number} · {demoUnit.size} m²
|
{demoUnit.building} · Wohnung {demoUnit.number} · {demoUnit.size} m²
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
<div className="rounded-xl bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table'
|
} from '@/components/ui/table'
|
||||||
import { useStore, DEMO_UNIT_ID } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
|
|
||||||
const catIcon = (cat: string) => {
|
const catIcon = (cat: string) => {
|
||||||
switch (cat) {
|
switch (cat) {
|
||||||
@@ -28,13 +28,13 @@ const catIcon = (cat: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Documents() {
|
export default function Documents() {
|
||||||
const { documents, role, units } = useStore()
|
const { documents, role, units, meineEinheit } = useStore()
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const base =
|
const base =
|
||||||
role === 'tenant'
|
role === 'tenant'
|
||||||
? documents.filter((d) => d.unitId === DEMO_UNIT_ID || d.unitId === null)
|
? documents.filter((d) => d.unitId === meineEinheit.id || d.unitId === null)
|
||||||
: documents
|
: documents
|
||||||
const q = query.trim().toLowerCase()
|
const q = query.trim().toLowerCase()
|
||||||
return q
|
return q
|
||||||
|
|||||||
+199
-49
@@ -1,9 +1,11 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
import { KeyRound, ShieldCheck, Sparkles } from 'lucide-react'
|
import { KeyRound, MailPlus, Sparkles, UserCheck } from 'lucide-react'
|
||||||
import logo from '../assets/logo.png'
|
import logo from '../assets/hinterstein/logo.png'
|
||||||
import hero from '../assets/hero.jpg'
|
import hero from '../assets/hero.jpg'
|
||||||
import modules from '../assets/modules.jpg'
|
import joseph from '../assets/hinterstein/joseph-hinterstein.jpg'
|
||||||
import { useStore } from '../store/store'
|
import kontakt from '../assets/hinterstein/kontakt.jpg'
|
||||||
|
import { useStore, DEMO_PASSWORT } from '../store/store'
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
'Mietkonto & Nebenkostenabrechnungen',
|
'Mietkonto & Nebenkostenabrechnungen',
|
||||||
@@ -13,83 +15,231 @@ const features = [
|
|||||||
'Aktuelle Ankündigungen Ihres Wohnhauses',
|
'Aktuelle Ankündigungen Ihres Wohnhauses',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const demoZugaenge = [
|
||||||
|
{ benutzername: 'j.hinterstein', label: 'Joseph Hinterstein (Verwaltung)' },
|
||||||
|
{ benutzername: 'a.berger', label: 'Anna Berger (Mieterin)' },
|
||||||
|
]
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { dispatch } = useStore()
|
const { login, registrieren } = useStore()
|
||||||
|
|
||||||
const enter = (role: 'tenant' | 'admin') => {
|
const [modus, setModus] = useState<'anmelden' | 'registrieren'>('anmelden')
|
||||||
dispatch({ type: 'setRole', role })
|
const [bn, setBn] = useState('')
|
||||||
navigate('/')
|
const [pw, setPw] = useState('')
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
const [lade, setLade] = useState(false)
|
||||||
|
|
||||||
|
// Registrierung
|
||||||
|
const [code, setCode] = useState('')
|
||||||
|
const [rName, setRName] = useState('')
|
||||||
|
const [rEmail, setREmail] = useState('')
|
||||||
|
const [rBn, setRBn] = useState('')
|
||||||
|
const [rPw, setRPw] = useState('')
|
||||||
|
const [rPw2, setRPw2] = useState('')
|
||||||
|
|
||||||
|
const anmelden = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setLade(true)
|
||||||
|
setFehler(null)
|
||||||
|
const err = await login(bn, pw)
|
||||||
|
setLade(false)
|
||||||
|
if (err) setFehler(err)
|
||||||
|
else navigate('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registrierenSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setFehler(null)
|
||||||
|
if (rPw.length < 4) return setFehler('Das Passwort muss mindestens 4 Zeichen haben.')
|
||||||
|
if (rPw !== rPw2) return setFehler('Die Passwörter stimmen nicht überein.')
|
||||||
|
setLade(true)
|
||||||
|
const err = await registrieren(code, rBn, rName, rEmail, rPw)
|
||||||
|
setLade(false)
|
||||||
|
if (err) setFehler(err)
|
||||||
|
else navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'w-full rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-amber-500 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0b3a44]">
|
<div className="min-h-screen bg-[#22333b]">
|
||||||
<div className="mx-auto flex min-h-screen max-w-6xl flex-col">
|
<div className="mx-auto flex min-h-screen max-w-6xl flex-col">
|
||||||
<header className="flex items-center gap-3 px-6 py-6">
|
<header className="flex items-center gap-3 px-6 py-6">
|
||||||
<img src={logo} alt="Hocks Logo" className="h-12 w-12" />
|
<img src={logo} alt="Logo Hinterstein Immobilien" className="h-14 w-14 rounded-full bg-white/95 object-contain p-1" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xl font-bold text-white">Hocks Mieterportal</div>
|
<div className="text-xl font-bold text-white">Hinterstein Immobilien</div>
|
||||||
<div className="text-sm text-teal-200/80">mieterportal.hocks.eu</div>
|
<div className="text-sm text-amber-200/80">Mieterportal · mieterportal.hocks.eu</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid flex-1 items-center gap-8 px-6 pb-10 lg:grid-cols-2">
|
<div className="grid flex-1 items-start gap-8 px-6 pb-10 lg:grid-cols-2">
|
||||||
<div className="relative order-2 overflow-hidden rounded-3xl shadow-2xl lg:order-1">
|
{/* Linke Spalte: Hero + Ansprechpartner */}
|
||||||
<img src={hero} alt="Wohnanlage der Hocks Verwaltung" className="h-72 w-full object-cover lg:h-[460px]" />
|
<div className="space-y-6">
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#0b3a44]/80 via-transparent to-transparent" />
|
<div className="relative overflow-hidden rounded-3xl shadow-2xl">
|
||||||
<div className="absolute bottom-0 p-6 text-white">
|
<img src={hero} alt="Wohnanlage von Hinterstein Immobilien" className="h-64 w-full object-cover lg:h-[340px]" />
|
||||||
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
|
<div className="absolute inset-0 bg-gradient-to-t from-[#22333b]/85 via-transparent to-transparent" />
|
||||||
<div className="text-sm text-teal-100">
|
<div className="absolute bottom-0 p-6 text-white">
|
||||||
3 Wohnanlagen · 50 Wohnungen · eine Verwaltung
|
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
|
||||||
|
<div className="text-sm text-slate-200">3 Wohnanlagen · 50 Wohnungen · persönliche Verwaltung</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 rounded-3xl bg-white/10 p-5 backdrop-blur">
|
||||||
|
<img
|
||||||
|
src={joseph}
|
||||||
|
alt="Joseph Hinterstein, Ihr Ansprechpartner"
|
||||||
|
className="h-32 w-24 rounded-2xl object-cover object-top"
|
||||||
|
/>
|
||||||
|
<div className="text-sm text-slate-100">
|
||||||
|
<div className="text-base font-semibold text-white">Joseph Hinterstein</div>
|
||||||
|
<div className="text-amber-200/90">Ihr Ansprechpartner · Immobilienverwaltung</div>
|
||||||
|
<p className="mt-2 text-slate-300">
|
||||||
|
Persönlich erreichbar über das Nachrichten-Modul oder per E-Mail –
|
||||||
|
wir kümmern uns um Ihr Anliegen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<img src={kontakt} alt="Kontakt: joseph@hinterstein-immo.de" className="w-full max-w-md rounded-2xl shadow-lg" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="order-1 lg:order-2">
|
{/* Rechte Spalte: Login-/Registrier-Karte */}
|
||||||
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-white lg:text-4xl">
|
<h1 className="text-3xl font-bold text-white lg:text-4xl">
|
||||||
Willkommen im Mieterportal
|
Willkommen im Mieterportal
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 max-w-lg text-teal-100/90">
|
<p className="mt-3 max-w-lg text-slate-300">
|
||||||
Analog zum Schulportal der Hocks-Gruppe: alle Mietangelegenheiten
|
Alle Mietangelegenheiten digital an einem Ort – schnell, transparent
|
||||||
digital an einem Ort – schnell, transparent und ohne Papierkram.
|
und ohne Papierkram. Melden Sie sich mit Ihrem Konto an.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-6 space-y-2.5">
|
<ul className="mt-6 space-y-2.5">
|
||||||
{features.map((f) => (
|
{features.map((f) => (
|
||||||
<li key={f} className="flex items-center gap-2.5 text-sm text-teal-50">
|
<li key={f} className="flex items-center gap-2.5 text-sm text-slate-100">
|
||||||
<Sparkles className="h-4 w-4 shrink-0 text-teal-300" />
|
<Sparkles className="h-4 w-4 shrink-0 text-amber-300" />
|
||||||
{f}
|
{f}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
<div className="mt-8 rounded-2xl bg-white p-6 shadow-2xl dark:bg-slate-900">
|
||||||
<button
|
<div className="mb-4 flex gap-2">
|
||||||
onClick={() => enter('tenant')}
|
<button
|
||||||
className="flex items-center justify-center gap-2 rounded-xl bg-teal-500 px-6 py-3 font-semibold text-white shadow-lg transition hover:bg-teal-400"
|
onClick={() => { setModus('anmelden'); setFehler(null) }}
|
||||||
>
|
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||||
<KeyRound className="h-4.5 w-4.5" />
|
modus === 'anmelden' ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||||
Als Mieterin anmelden (Demo)
|
}`}
|
||||||
</button>
|
>
|
||||||
<button
|
<KeyRound className="h-4 w-4" /> Anmelden
|
||||||
onClick={() => enter('admin')}
|
</button>
|
||||||
className="flex items-center justify-center gap-2 rounded-xl border border-teal-300/40 bg-white/10 px-6 py-3 font-semibold text-white transition hover:bg-white/20"
|
<button
|
||||||
>
|
onClick={() => { setModus('registrieren'); setFehler(null) }}
|
||||||
<ShieldCheck className="h-4.5 w-4.5" />
|
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition ${
|
||||||
Als Verwaltung anmelden
|
modus === 'registrieren' ? 'bg-amber-500 text-slate-900' : 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300'
|
||||||
</button>
|
}`}
|
||||||
</div>
|
>
|
||||||
|
<MailPlus className="h-4 w-4" /> Registrieren (Mieter)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<img
|
{modus === 'anmelden' ? (
|
||||||
src={modules}
|
<form onSubmit={anmelden} className="space-y-3">
|
||||||
alt="Illustration der Portalmodule"
|
<div>
|
||||||
className="mt-8 hidden w-full max-w-md rounded-2xl opacity-90 lg:block"
|
<label htmlFor="bn" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
/>
|
Benutzername
|
||||||
|
</label>
|
||||||
|
<input id="bn" className={inputCls} value={bn} onChange={(e) => setBn(e.target.value)} autoComplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="pw" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input id="pw" type="password" className={inputCls} value={pw} onChange={(e) => setPw(e.target.value)} autoComplete="current-password" required />
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={lade || !bn || !pw}
|
||||||
|
className="w-full rounded-xl bg-amber-500 px-6 py-3 font-semibold text-slate-900 shadow transition hover:bg-amber-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{lade ? 'Bitte warten …' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="rounded-xl bg-slate-50 p-3 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300">
|
||||||
|
<div className="mb-1.5 font-semibold">Demo-Zugänge (Passwort: {DEMO_PASSWORT})</div>
|
||||||
|
{demoZugaenge.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d.benutzername}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setBn(d.benutzername); setPw(DEMO_PASSWORT); setFehler(null) }}
|
||||||
|
className="mr-2 mb-1.5 inline-flex items-center gap-1 rounded-lg bg-white px-2.5 py-1 font-medium text-slate-700 ring-1 ring-slate-200 transition hover:ring-amber-400 dark:bg-slate-700 dark:text-slate-200 dark:ring-slate-600"
|
||||||
|
>
|
||||||
|
<UserCheck className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400" />
|
||||||
|
{d.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={registrierenSubmit} className="space-y-3">
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Mieter registrieren sich mit dem persönlichen Registrierungscode aus dem
|
||||||
|
Anschreiben der Verwaltung. Demo-Code: <code className="rounded bg-slate-100 px-1.5 py-0.5 font-mono dark:bg-slate-800">MP-2026-0815</code>
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="code" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Registrierungscode</label>
|
||||||
|
<input id="code" className={inputCls} value={code} onChange={(e) => setCode(e.target.value)} placeholder="MP-XXXX-XXXX" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rname" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Name</label>
|
||||||
|
<input id="rname" className={inputCls} value={rName} onChange={(e) => setRName(e.target.value)} placeholder="Vor- und Nachname" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="remail" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">E-Mail</label>
|
||||||
|
<input id="remail" type="email" className={inputCls} value={rEmail} onChange={(e) => setREmail(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rbn" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Benutzername (frei wählbar)</label>
|
||||||
|
<input id="rbn" className={inputCls} value={rBn} onChange={(e) => setRBn(e.target.value)} autoComplete="username" required />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rpw" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Passwort</label>
|
||||||
|
<input id="rpw" type="password" className={inputCls} value={rPw} onChange={(e) => setRPw(e.target.value)} autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="rpw2" className="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Passwort wiederholen</label>
|
||||||
|
<input id="rpw2" type="password" className={inputCls} value={rPw2} onChange={(e) => setRPw2(e.target.value)} autoComplete="new-password" required />
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={lade || !code || !rName || !rEmail || !rBn || !rPw || !rPw2}
|
||||||
|
className="w-full rounded-xl bg-amber-500 px-6 py-3 font-semibold text-slate-900 shadow transition hover:bg-amber-400 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{lade ? 'Bitte warten …' : 'Registrieren & anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="px-6 pb-6 text-center text-xs text-teal-200/60">
|
<footer className="px-6 pb-6 text-center text-xs text-slate-400">
|
||||||
© 2026 Hocks Verwaltung · Impressum · Datenschutz
|
© 2026 Hinterstein Immobilien · joseph@hinterstein-immo.de · Impressum · Datenschutz
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-10
@@ -12,14 +12,16 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
|
|
||||||
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
||||||
const { dispatch, role } = useStore()
|
const { dispatch, role, me, meineEinheit } = useStore()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [subject, setSubject] = useState('')
|
const [subject, setSubject] = useState('')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
|
|
||||||
|
const verwaltungsName = 'Joseph Hinterstein'
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!subject.trim() || !text.trim()) return
|
if (!subject.trim() || !text.trim()) return
|
||||||
const id = `m-${Date.now()}`
|
const id = `m-${Date.now()}`
|
||||||
@@ -27,8 +29,8 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
type: 'newThread',
|
type: 'newThread',
|
||||||
thread: {
|
thread: {
|
||||||
id,
|
id,
|
||||||
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
|
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||||
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
||||||
subject: subject.trim(),
|
subject: subject.trim(),
|
||||||
unreadByAdmin: true,
|
unreadByAdmin: true,
|
||||||
unreadByTenant: false,
|
unreadByTenant: false,
|
||||||
@@ -36,7 +38,7 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
{
|
{
|
||||||
id: `${id}-1`,
|
id: `${id}-1`,
|
||||||
from: role,
|
from: role,
|
||||||
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
author: role === 'tenant' ? (me?.name ?? 'Mieter') : verwaltungsName,
|
||||||
text: text.trim(),
|
text: text.trim(),
|
||||||
at: new Date().toLocaleString('de-DE'),
|
at: new Date().toLocaleString('de-DE'),
|
||||||
},
|
},
|
||||||
@@ -82,9 +84,11 @@ function NewMessageDialog({ onCreated }: { onCreated: (id: string) => void }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Messages() {
|
export default function Messages() {
|
||||||
const { threads, role, dispatch, units } = useStore()
|
const { threads, role, dispatch, units, me, meineEinheit } = useStore()
|
||||||
|
const meinName = me?.name ?? 'Mieter'
|
||||||
|
const verwaltungsName = 'Joseph Hinterstein'
|
||||||
const visible = role === 'tenant'
|
const visible = role === 'tenant'
|
||||||
? threads.filter((t) => t.unitId === DEMO_UNIT_ID)
|
? threads.filter((t) => t.unitId === meineEinheit.id)
|
||||||
: threads
|
: threads
|
||||||
const [activeId, setActiveId] = useState(visible[0]?.id ?? '')
|
const [activeId, setActiveId] = useState(visible[0]?.id ?? '')
|
||||||
const active = visible.find((t) => t.id === activeId) ?? visible[0]
|
const active = visible.find((t) => t.id === activeId) ?? visible[0]
|
||||||
@@ -105,7 +109,7 @@ export default function Messages() {
|
|||||||
message: {
|
message: {
|
||||||
id: `${active.id}-${Date.now()}`,
|
id: `${active.id}-${Date.now()}`,
|
||||||
from: role,
|
from: role,
|
||||||
author: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung',
|
author: role === 'tenant' ? meinName : verwaltungsName,
|
||||||
text: draft.trim(),
|
text: draft.trim(),
|
||||||
at: new Date().toLocaleString('de-DE'),
|
at: new Date().toLocaleString('de-DE'),
|
||||||
},
|
},
|
||||||
@@ -120,7 +124,7 @@ export default function Messages() {
|
|||||||
<h1 className="text-2xl font-bold tracking-tight">Nachrichten</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Nachrichten</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{role === 'tenant'
|
{role === 'tenant'
|
||||||
? 'Direktdraht zur Hocks Verwaltung – Antwort meist innerhalb eines Werktags'
|
? 'Direktdraht zur Verwaltung – Antwort meist innerhalb eines Werktags'
|
||||||
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
|
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,7 +176,7 @@ export default function Messages() {
|
|||||||
<div className="text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
{role === 'admin'
|
{role === 'admin'
|
||||||
? `${active.tenant}`
|
? `${active.tenant}`
|
||||||
: 'Hocks Verwaltung · Hauptstraße 12'}
|
: 'Joseph Hinterstein · Hinterstein Immobilien'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[420px] flex-1 space-y-4 overflow-y-auto p-5">
|
<div className="max-h-[420px] flex-1 space-y-4 overflow-y-auto p-5">
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const statusStyle: Record<string, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TenantPayments() {
|
function TenantPayments() {
|
||||||
const { demoUnit, transactions } = useStore()
|
const { meineEinheit: demoUnit, transactions } = useStore()
|
||||||
const myTx = useMemo(
|
const myTx = useMemo(
|
||||||
() =>
|
() =>
|
||||||
transactions
|
transactions
|
||||||
@@ -48,7 +48,7 @@ function TenantPayments() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Hauptstraße 12 · Whg. {demoUnit.number} – alle Buchungen auf einen Blick
|
{demoUnit.building} · Whg. {demoUnit.number} – alle Buchungen auf einen Blick
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import { useStore, DEMO_TENANT, DEMO_UNIT_ID } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import type { TicketPriority, TicketStatus } from '../types'
|
import type { TicketPriority, TicketStatus } from '../types'
|
||||||
|
|
||||||
const statusStyle: Record<TicketStatus, string> = {
|
const statusStyle: Record<TicketStatus, string> = {
|
||||||
@@ -46,7 +46,7 @@ const categories = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
function NewTicketDialog() {
|
function NewTicketDialog() {
|
||||||
const { dispatch, role } = useStore()
|
const { dispatch, role, me, meineEinheit } = useStore()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [title, setTitle] = useState('')
|
const [title, setTitle] = useState('')
|
||||||
const [category, setCategory] = useState('Sanitär')
|
const [category, setCategory] = useState('Sanitär')
|
||||||
@@ -59,8 +59,8 @@ function NewTicketDialog() {
|
|||||||
type: 'addTicket',
|
type: 'addTicket',
|
||||||
ticket: {
|
ticket: {
|
||||||
id: `t-${Date.now()}`,
|
id: `t-${Date.now()}`,
|
||||||
unitId: role === 'tenant' ? DEMO_UNIT_ID : 'hs-1',
|
unitId: role === 'tenant' ? meineEinheit.id : 'hs-1',
|
||||||
tenant: role === 'tenant' ? DEMO_TENANT : 'Hocks Verwaltung (intern)',
|
tenant: role === 'tenant' ? (me?.name ?? 'Mieter') : 'Hinterstein Immobilien (intern)',
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
description: description.trim() || '–',
|
description: description.trim() || '–',
|
||||||
category,
|
category,
|
||||||
@@ -144,16 +144,16 @@ function NewTicketDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Tickets() {
|
export default function Tickets() {
|
||||||
const { tickets, units, role, dispatch } = useStore()
|
const { tickets, units, role, dispatch, meineEinheit } = useStore()
|
||||||
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
|
const [filter, setFilter] = useState<'alle' | TicketStatus>('alle')
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(role === 'tenant'
|
(role === 'tenant'
|
||||||
? tickets.filter((t) => t.unitId === DEMO_UNIT_ID)
|
? tickets.filter((t) => t.unitId === meineEinheit.id)
|
||||||
: tickets
|
: tickets
|
||||||
).filter((t) => filter === 'alle' || t.status === filter),
|
).filter((t) => filter === 'alle' || t.status === filter),
|
||||||
[tickets, role, filter],
|
[tickets, role, filter, meineEinheit],
|
||||||
)
|
)
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
|
|||||||
+272
-21
@@ -6,7 +6,17 @@ import {
|
|||||||
useReducer,
|
useReducer,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import type { Role, Ticket, Transaction, DocumentItem, Thread, Message } from '../types'
|
import type {
|
||||||
|
Role,
|
||||||
|
Ticket,
|
||||||
|
Transaction,
|
||||||
|
DocumentItem,
|
||||||
|
Thread,
|
||||||
|
Message,
|
||||||
|
Benutzer,
|
||||||
|
BenutzerRolle,
|
||||||
|
RegistrierungsCode,
|
||||||
|
} from '../types'
|
||||||
import {
|
import {
|
||||||
units,
|
units,
|
||||||
transactions,
|
transactions,
|
||||||
@@ -17,41 +27,109 @@ import {
|
|||||||
DEMO_TENANT,
|
DEMO_TENANT,
|
||||||
DEMO_UNIT_ID,
|
DEMO_UNIT_ID,
|
||||||
} from '../data/seed'
|
} from '../data/seed'
|
||||||
|
import {
|
||||||
|
sha256,
|
||||||
|
DEMO_PASSWORT,
|
||||||
|
ladeSession,
|
||||||
|
speichereSession,
|
||||||
|
beendeSession,
|
||||||
|
type Session,
|
||||||
|
} from '../lib/auth'
|
||||||
|
|
||||||
|
const DEMO_HASH = 'c57a5df4f97ed6914116e5fd1348406064834f22503d890eb571af442ac4b4c3' // sha256("0815")
|
||||||
|
|
||||||
|
/** 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 {
|
interface State {
|
||||||
role: Role
|
|
||||||
tickets: Ticket[]
|
tickets: Ticket[]
|
||||||
transactions: Transaction[]
|
transactions: Transaction[]
|
||||||
documents: DocumentItem[]
|
documents: DocumentItem[]
|
||||||
threads: Thread[]
|
threads: Thread[]
|
||||||
|
benutzer: Benutzer[]
|
||||||
|
codes: RegistrierungsCode[]
|
||||||
|
session: Session | null
|
||||||
|
tick: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type Action =
|
type Action =
|
||||||
| { type: 'setRole'; role: Role }
|
|
||||||
| { type: 'addTicket'; ticket: Ticket }
|
| { type: 'addTicket'; ticket: Ticket }
|
||||||
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
|
| { type: 'setTicketStatus'; id: string; status: Ticket['status'] }
|
||||||
| {
|
| { type: 'addMessage'; threadId: string; message: Message }
|
||||||
type: 'addMessage'
|
|
||||||
threadId: string
|
|
||||||
message: Message
|
|
||||||
}
|
|
||||||
| { type: 'newThread'; thread: Thread }
|
| { type: 'newThread'; thread: Thread }
|
||||||
| { type: 'markThreadRead'; threadId: string; role: Role }
|
| { 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 = {
|
const initialState: State = {
|
||||||
role: 'tenant',
|
|
||||||
tickets,
|
tickets,
|
||||||
transactions,
|
transactions,
|
||||||
documents,
|
documents,
|
||||||
threads,
|
threads,
|
||||||
|
benutzer: demoBenutzer,
|
||||||
|
codes: demoCodes,
|
||||||
|
session: ladeSession(),
|
||||||
|
tick: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'hocks-mieterportal-v1'
|
const STORAGE_KEY = 'hinterstein-mieterportal-v2'
|
||||||
|
|
||||||
function loadState(): State {
|
function loadState(): State {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY)
|
||||||
if (raw) return { ...initialState, ...JSON.parse(raw) }
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw)
|
||||||
|
return { ...initialState, ...parsed, session: ladeSession() }
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -60,8 +138,6 @@ function loadState(): State {
|
|||||||
|
|
||||||
function reducer(state: State, action: Action): State {
|
function reducer(state: State, action: Action): State {
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case 'setRole':
|
|
||||||
return { ...state, role: action.role }
|
|
||||||
case 'addTicket':
|
case 'addTicket':
|
||||||
return { ...state, tickets: [action.ticket, ...state.tickets] }
|
return { ...state, tickets: [action.ticket, ...state.tickets] }
|
||||||
case 'setTicketStatus':
|
case 'setTicketStatus':
|
||||||
@@ -98,16 +174,86 @@ function reducer(state: State, action: Action): State {
|
|||||||
: th,
|
: 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:
|
default:
|
||||||
return state
|
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 {
|
interface Store extends State {
|
||||||
dispatch: React.Dispatch<Action>
|
dispatch: React.Dispatch<Action>
|
||||||
demoUnit: typeof units[number]
|
|
||||||
units: typeof units
|
units: typeof units
|
||||||
announcements: typeof announcements
|
announcements: typeof announcements
|
||||||
|
/** Aktuell angemeldeter Benutzer (null = nicht eingeloggt). */
|
||||||
|
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]
|
||||||
|
login: (benutzername: string, passwort: string) => Promise<string | null>
|
||||||
|
logout: () => void
|
||||||
|
registrieren: (
|
||||||
|
code: string,
|
||||||
|
benutzername: string,
|
||||||
|
name: string,
|
||||||
|
email: string,
|
||||||
|
passwort: string,
|
||||||
|
) => Promise<string | null>
|
||||||
|
benutzerAnlegen: (
|
||||||
|
benutzername: string,
|
||||||
|
name: string,
|
||||||
|
rolle: BenutzerRolle,
|
||||||
|
email: string,
|
||||||
|
passwort: string,
|
||||||
|
einheitId: string | null,
|
||||||
|
) => Promise<string | null>
|
||||||
|
setAktiv: (id: string, aktiv: boolean) => void
|
||||||
|
setPasswort: (id: string, passwort: string) => Promise<void>
|
||||||
|
weiseEinheitZu: (id: string, einheitId: string | null) => void
|
||||||
|
generiereCodes: (anzahl: number, einheitId: string | null) => RegistrierungsCode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoreContext = createContext<Store | null>(null)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -119,16 +265,121 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
|
||||||
}, [state])
|
}, [state])
|
||||||
|
|
||||||
const value = useMemo<Store>(
|
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'),
|
||||||
|
}
|
||||||
|
dispatch({ type: 'registriert', benutzer: b, code: c.code })
|
||||||
|
speichereSession(b.id, b.rolle)
|
||||||
|
dispatch({ type: 'setSession', session: ladeSession() })
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
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'),
|
||||||
|
}))
|
||||||
|
dispatch({ type: 'codesHinzugefuegt', codes: neu })
|
||||||
|
return neu
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
...state,
|
...state,
|
||||||
dispatch,
|
dispatch,
|
||||||
demoUnit: units.find((u) => u.id === DEMO_UNIT_ID)!,
|
|
||||||
units,
|
units,
|
||||||
announcements,
|
announcements,
|
||||||
}),
|
me,
|
||||||
[state],
|
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])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
}
|
}
|
||||||
@@ -144,4 +395,4 @@ export const fmtEUR = new Intl.NumberFormat('de-DE', {
|
|||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
})
|
})
|
||||||
|
|
||||||
export { DEMO_TENANT, DEMO_UNIT_ID }
|
export { DEMO_TENANT, DEMO_UNIT_ID, DEMO_PASSWORT }
|
||||||
|
|||||||
@@ -1,5 +1,27 @@
|
|||||||
export type Role = 'tenant' | 'admin'
|
export type Role = 'tenant' | 'admin'
|
||||||
|
|
||||||
|
export type BenutzerRolle = 'verwaltung' | 'mieter'
|
||||||
|
|
||||||
|
export interface Benutzer {
|
||||||
|
id: string
|
||||||
|
benutzername: string
|
||||||
|
name: string
|
||||||
|
passwortHash: string
|
||||||
|
rolle: BenutzerRolle
|
||||||
|
email: string
|
||||||
|
einheitId: string | null
|
||||||
|
aktiv: boolean
|
||||||
|
erstelltAm: string
|
||||||
|
letzterLogin?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegistrierungsCode {
|
||||||
|
code: string
|
||||||
|
einheitId: string | null
|
||||||
|
erstelltAm: string
|
||||||
|
verwendetAm?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Unit {
|
export interface Unit {
|
||||||
id: string
|
id: string
|
||||||
building: string
|
building: string
|
||||||
|
|||||||
Reference in New Issue
Block a user