Schulportal-Konventionen übernommen: Dark Mode (ThemeToggle), beispieldaten.py + generated.json, kimi.md, mail-server (Postfix-Relay + API), Deploy-Workflow (pscp/plink, npm_default), Screenshots in preview/

This commit is contained in:
2026-10-02 10:46:10 +02:00
parent ca97ea028f
commit b3dfb8ab7c
27 changed files with 7284 additions and 163 deletions
+33 -3
View File
@@ -1,6 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
*.log
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
.env
.env.local
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Projekt-spezifisch: Secrets und lokale Artefakte
mail-server/mail.env
mail-url.txt
mail-vorschau*.html
tmp-*
.chrome-tmp*/
mp-dist.tar.gz
shots/
*.bak-v*
.vite
_ref/
+23 -12
View File
@@ -1,6 +1,8 @@
# Hocks Mieterportal
Mieterportal für die Verwaltung von 50 Wohnungen in 3 Häusern — analog zum Schulportal der Hocks-Gruppe.
Mieterportal der Hocks Verwaltung 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).
Live-Ziel: `https://mieterportal.hocks.eu`.
## Module
@@ -10,26 +12,35 @@ Mieterportal für die Verwaltung von 50 Wohnungen in 3 Häusern — analog zum S
4. **Dokumente** – Verträge, Abrechnungen, Hausordnung; zentraler Such- und Filterzugriff
5. **Nachrichten** – Direktkommunikation Mieter ↔ Verwaltung
Der Rollenwechsel Mieter ↔ Verwaltung erfolgt über die Schaltfläche in der Seitenleiste. Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert.
Der Rollenwechsel Mieter ↔ Verwaltung erfolgt über die Seitenleiste (Demo-Modus).
Alle Demo-Daten werden lokal im Browser (localStorage) gespeichert. Dark-Mode-Umschalter
in der Seitenleiste bzw. Mobilen Kopfzeile.
## Lokale Entwicklung
## Entwicklung
```bash
npm install
npm run dev # http://localhost:3000
npm run build # Produktionsbuild nach dist/
python beispieldaten.py # generiert src/data/generated.json (Seed wählbar)
```
## Docker
Projekt-Kontext für Kimi Work: [kimi.md](kimi.md).
## Deployment (bewährter Workflow, s. kimi.md Kap. 4)
```bash
docker build -t mieterportal .
docker run -d -p 8080:80 --name mieterportal mieterportal
# oder:
docker compose up -d --build
./deploy.sh
```
Der Container liefert das gebaute Frontend über Nginx (SPA-Routing inklusive).
Für den Betrieb unter `https://mieterportal.hocks.eu` einfach einen Reverse Proxy
(Traefik, nginx, Caddy) davor setzen — Beispiel-Labels für Traefik inkl. Let's Encrypt
liegen in der `docker-compose.yml`.
baut das Frontend, lädt das `dist/`-Paket per `pscp` auf den Server (Port 38592,
Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Container
`mp-portal` neu. TLS/Subdomain liefert der Nginx Proxy Manager (npm_default-Netz).
Alternativ lokal ohne Server: `docker build -t mieterportal . && docker run -p 8080:80 mieterportal`.
## Optional: Mail-Stack
`mail-server/` enthält den aus dem Schulportal übernommenen Aufbau: Postfix-Relay
(nur intern, kein offenes Relay) + schlanke Python-API. Konfiguration über
`mail-server/mail.env` (Vorlage: `mail.env.example`), **nicht** im Repo.
Binary file not shown.
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""Hocks Mieterportal – Beispieldaten-Generator.
Erzeugt deterministisch die Demo-Daten des Portals und schreibt sie nach
``src/data/generated.json`` (Units + Transaktionen). Die handschriftlichen
Inhalte (Tickets, Dokumente, Nachrichten, Ankündigungen) liegen weiterhin in
``src/data/seed.ts``.
Aufruf: python beispieldaten.py (Standard-Seed 20261002)
python beispieldaten.py 42 (eigener Seed zum Experimentieren)
Achtung: Der Demo-Mieter „Anna Berger" (Whg. Hauptstraße 12 Nr. 7) und die
Algorithmen sind synchron zu src/data/seed.ts zu halten.
"""
import json
import sys
from pathlib import Path
SEED = int(sys.argv[1]) if len(sys.argv) > 1 else 20261002
BUILDINGS = [
{"name": "Hauptstraße 12", "prefix": "HS", "count": 20, "baseRent": 9.8},
{"name": "Ringweg 3", "prefix": "RW", "count": 15, "baseRent": 8.9},
{"name": "Am Park 5", "prefix": "AP", "count": 15, "baseRent": 10.4},
]
FIRST_NAMES = [
"Anna", "Jonas", "Marta", "Leon", "Sofia", "Felix", "Lena", "Paul",
"Emma", "Noah", "Clara", "Ben", "Nora", "Max", "Julia", "Tim",
"Sarah", "Finn", "Laura", "Jan", "Mia", "Tom", "Ela", "Kim",
]
LAST_NAMES = [
"Berger", "Klein", "Wolf", "Neumann", "Schäfer", "Krüger", "Brandt",
"Vogel", "Otto", "Horn", "Busch", "Thomas", "Pohl", "Sander", "Lindner",
]
DEMO_TENANT = "Anna Berger"
DEMO_UNIT_ID = "hs-7"
MONTHS = [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
]
def gen_units() -> list[dict]:
units: list[dict] = []
name_idx = 0
def next_name() -> str:
nonlocal name_idx
n = f"{FIRST_NAMES[name_idx % len(FIRST_NAMES)]} {LAST_NAMES[name_idx % len(LAST_NAMES)]}"
name_idx += 1
return n
for b in BUILDINGS:
for i in range(b["count"]):
num = i + 1
size = 38 + ((num * 7 + len(b["prefix"]) * 3) % 78) # 38–115 m²
cold = round(size * b["baseRent"])
warm = cold + round(2.4 * size) + (35 if num % 3 == 0 else 0)
floor = 0 if num <= 2 else min(4, (num - 1) // 4)
occupied = num % 9 != 0 # ~89 % Vermietung
if b["prefix"] == "HS" and num == 7:
tenant: str | None = DEMO_TENANT
elif occupied:
tenant = next_name()
else:
tenant = None
units.append({
"id": f"{b['prefix'].lower()}-{num}",
"building": b["name"],
"number": str(num),
"floor": floor,
"size": size,
"coldRent": cold,
"warmRent": warm,
"tenant": tenant,
"status": "vermietet" if occupied else "frei",
})
return units
def gen_transactions(units: list[dict]) -> list[dict]:
txs: list[dict] = []
for u in units:
if u["status"] != "vermietet":
continue
for m in range(12):
due = m + 1
if due <= 9:
status = "überfällig" if (ord(u["id"][3]) + m) % 11 == 0 else "bezahlt"
else:
status = "offen"
txs.append({
"id": f"tx-{u['id']}-{m}",
"unitId": u["id"],
"tenant": u["tenant"],
"month": MONTHS[m],
"year": 2026,
"amount": u["warmRent"],
"type": "Miete",
"status": status,
**({"paidAt": f"0{(m % 8) + 1}.0{due}.2026"} if status == "bezahlt" else {}),
})
return txs
def main() -> None:
units = gen_units()
data = {
"meta": {"seed": SEED, "units": len(units),
"rented": sum(1 for u in units if u["status"] == "vermietet")},
"units": units,
"transactions": gen_transactions(units),
}
out = Path(__file__).parent / "src" / "data" / "generated.json"
out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"{out} geschrieben: {data['meta']['units']} Wohnungen "
f"({data['meta']['rented']} vermietet), {len(data['transactions'])} Buchungen")
if __name__ == "__main__":
main()
+36 -33
View File
@@ -1,43 +1,46 @@
#!/usr/bin/env bash
# Hocks Mieterportal – Deployment auf hocks.eu
# Hocks Mieterportal – Deploy auf hocks.eu (Workflow aus dem Schulportal)
#
# SSH-Auth: Pageant (plink) oder OpenSSH-Schlüssel ~/.ssh/hocks_deploy.
# 1. npm run build 2. Paket (dist + compose + nginx) per pscp hochladen
# 3. Entpacken nach /opt/mieterportal, Container mp-portal neu starten
#
# Voraussetzungen: Pageant mit priv_ionos_hocks_vps.ppk läuft.
# Aufruf: ./deploy.sh
set -euo pipefail
SERVER="${SERVER:-hocks.eu}"
SSH_PORT="${SSH_PORT:-38592}"
SSH_USER="${SSH_USER:-root}"
SERVER="root@hocks.eu"
PORT=38592
HOSTKEY="SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw"
KEY="Z:\Devel\ssh\priv_ionos_hocks_vps.ppk"
PSCP="/c/Program Files/PuTTY/pscp.exe"
PLINK="/c/Program Files/PuTTY/plink.exe"
REMOTE_DIR="/opt/mieterportal"
REPO="https://git.hocks.eu/ah-admin/Mieterportal.git"
IMAGE="mieterportal:latest"
run() { # run <remote command>
local cmd="$1"
if command -v plink >/dev/null 2>&1 || [ -f "/c/Program Files/PuTTY/plink.exe" ]; then
local PLINK="${PLINK:-$(command -v plink || echo '/c/Program Files/PuTTY/plink.exe')}"
"$PLINK" -ssh -P "$SSH_PORT" -agent -batch "${SSH_USER}@${SERVER}" "$cmd"
else
ssh -p "$SSH_PORT" -i ~/.ssh/hocks_deploy -o BatchMode=yes \
-o StrictHostKeyChecking=accept-new "${SSH_USER}@${SERVER}" "$cmd"
fi
}
echo "==> 1/4 Frontend bauen"
npm run build
echo "==> Verbindung zu ${SSH_USER}@${SERVER}:${SSH_PORT} testen"
run "docker --version"
echo "==> 2/4 Paket erstellen und hochladen"
rm -rf tmp-deploy && mkdir -p tmp-deploy/docker
cp -r dist tmp-deploy/dist
cp docker-compose.yml tmp-deploy/
cp docker/nginx.conf tmp-deploy/docker/
tar -czf mp-dist.tar.gz -C tmp-deploy .
rm -rf tmp-deploy
"$PSCP" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" \
mp-dist.tar.gz "$SERVER":/tmp/mp-dist.tar.gz
rm -f mp-dist.tar.gz
echo "==> Repository auf dem Server aktualisieren (${REMOTE_DIR})"
run "mkdir -p ${REMOTE_DIR} && cd ${REMOTE_DIR} && \
(git rev-parse --git-dir >/dev/null 2>&1 || git clone ${REPO} .) && \
git fetch origin && git reset --hard origin/main"
echo "==> 3/4 Auf Server entpacken und Container ersetzen"
"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
mkdir -p $REMOTE_DIR &&
tar -xzf /tmp/mp-dist.tar.gz -C $REMOTE_DIR &&
rm -f /tmp/mp-dist.tar.gz &&
cd $REMOTE_DIR && docker compose up -d
"
echo "==> Docker-Image bauen"
run "cd ${REMOTE_DIR} && docker build -t ${IMAGE} ."
echo "==> Container ersetzen"
run "docker rm -f mieterportal 2>/dev/null || true"
run "cd ${REMOTE_DIR} && docker compose up -d"
echo "==> Status"
run "docker ps --filter name=mieterportal --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'"
echo "Fertig. Portal läuft auf dem Server (Routing: mieterportal.hocks.eu)."
echo "==> 4/4 Status prüfen"
"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
docker ps --filter name=mp-portal --format 'table {{.Names}}\t{{.Status}}' &&
curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js' | head -1
"
echo "Fertig: https://mieterportal.hocks.eu"
+16 -13
View File
@@ -1,15 +1,18 @@
# Hocks Mieterportal – Portal-Container (statisches Frontend hinter NPM)
# TLS + Subdomain liefert der Nginx Proxy Manager (npm_default-Netz).
# Deploy: ./deploy.sh baut dist/ lokal und lädt es auf den Server.
services:
mieterportal:
build: .
image: mieterportal:latest
container_name: mieterportal
mp-portal:
image: nginx:1.27-alpine
container_name: mp-portal
restart: unless-stopped
ports:
- "8080:80"
labels:
# Beispiel für Traefik – https://mieterportal.hocks.eu mit TLS
- "traefik.enable=true"
- "traefik.http.routers.mieterportal.rule=Host(`mieterportal.hocks.eu`)"
- "traefik.http.routers.mieterportal.entrypoints=websecure"
- "traefik.http.routers.mieterportal.tls.certresolver=letsencrypt"
- "traefik.http.services.mieterportal.loadbalancer.server.port=80"
volumes:
- ./dist:/usr/share/nginx/html:ro
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- npm_default
networks:
npm_default:
external: true
+5
View File
@@ -9,6 +9,11 @@ server {
gzip_types text/css application/javascript image/svg+xml application/json;
gzip_min_length 1024;
# HTML nie cachen (Assets sind hash-benannt → ein Reload genügt)
location = /index.html {
add_header Cache-Control "no-cache" always;
}
# Cache für gehashte Build-Assets
location /assets/ {
expires 30d;
+150
View File
@@ -0,0 +1,150 @@
# Kimi-Kontext: Hocks Mieterportal (mieterportal.hocks.eu)
> Projekt-Kontextdatei für Kimi Work. Stand: 02.10.2026, 11:00 Uhr.
> Bitte bei größeren Änderungen am Projekt aktualisieren.
> Konventionen und Workflow sind am Projekt Schulportal (sfg.hocks.eu) orientiert.
## 1. Projektüberblick
Mieterportal der Hocks Verwaltung für **50 Wohnungen in 3 Häusern** (Hauptstraße 12 ·
Ringweg 3 · Am Park 5). Analog zum Schulportal: alle Mietangelegenheiten digital an
einem Ort. **Rollenmodus statt echter Anmeldung** (Demo): Umschalten Mieter ↔
Verwaltung über die Seitenleiste (Schaltfläche „Als Verwaltung anzeigen").
**Die 5 Module:**
1. **Dashboard** (`/`) – Mieter: Begrüßung, nächste Zahlung, Mietdiagramm, Ankündigungen,
offene Meldungen. Verwaltung: Kennzahlen (Vermietungsquote, Miet-Soll, Rückstände),
Einnahmenverlauf + Belegung pro Haus (Recharts).
2. **Mietkonto & Zahlungen** (`/mietkonto`) – Buchungsverlauf 2026 mit Statusfilter,
Nebenkostenguthaben, Zahlungsdaten. Verwaltung: Zahlungsstatus aller Wohnungen,
Rückstandsliste, Haus-Filter.
3. **Schadensmeldungen** (`/schadensmeldungen`) – Tickets mit Kategorie, Dringlichkeit,
Status; Neuanlage per Dialog; Verwaltung pflegt Status per Select.
4. **Dokumente** (`/dokumente`) – Verträge/Abrechnungen/Hausordnung/Protokolle,
Suche + Kategorie-Chips. Mieter sieht eigene + Hausdokumente (unitId=null).
5. **Nachrichten** (`/nachrichten`) – Chat Mieter ↔ Verwaltung, Ungelesen-Markierung,
neue Unterhaltungen.
**Login-Seite** (`/login`): Hero mit generiertem Foto, Feature-Liste, Demo-Einstiege
(„Als Mieterin anmelden" / „Als Verwaltung anmelden"). Kein echtes Auth – der
Rollenwechsel setzt nur den Store.
## 2. Technischer Stack
| Teil | Technologie |
|---|---|
| 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`) |
| 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` |
| 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 |
| Server | Root-VPS hocks.eu (IONOS), Docker + Portainer, Subdomain via NPM (Nginx Proxy Manager, Let's Encrypt) |
## 3. Wichtige Dateien
Projektverzeichnis: `C:\Users\ah\Documents\Kimi\Workspaces\Mieterportal`
**Git:** `https://git.hocks.eu/ah-admin/Mieterportal` (Branch `main`). Secrets
ausgesperrt via `.gitignore` (`mail-server/mail.env` etc.).
| Datei | Inhalt |
|---|---|
| `src/store/store.tsx` | Store-Provider, Reducer, Actions (setRole/addTicket/setTicketStatus/addMessage/newThread/markThreadRead), `fmtEUR` |
| `src/data/seed.ts` | Tickets, Dokumente, Nachrichten-Threads, Ankündigungen + Re-Export units/transactions aus generated.json |
| `src/data/generated.json` | 50 Wohnungen + 552 Buchungen, erzeugt von `beispieldaten.py` (Seed 20261002) |
| `beispieldaten.py` | Deterministischer Generator: `python beispieldaten.py [seed]` |
| `src/types/index.ts` | Alle Typen (Unit, Transaction, Ticket, DocumentItem, Thread, Announcement) |
| `src/components/AppLayout.tsx` | Sidebar (Desktop) + Mobile Top-Bar/Nav, Rollenwechsel, Ungelesen-Badge, ThemeToggle |
| `src/components/ThemeToggle.tsx` | Dark-Mode-Umschalter (Sonne/Mond) |
| `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 |
| `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-compose.yml` | `mp-portal` (nginx + gemountetes dist) im externen Netz `npm_default`; Mail-Stack separat |
| `deploy.sh` | Bewährter Deploy-Workflow (s. Kap. 4) |
| `mail-server/` | Postfix-Relay + schlanke Mail-API (Muster Schulportal, angepasst) |
## 4. Deploy-Workflow (bewährt, aus Schulportal übernommen)
```bash
# 1. Bauen (im Projektverzeichnis)
npm run build
# 2. Paketieren + Hochladen (pscp braucht cwd = Projektverzeichnis)
tar -czf mp-dist.tar.gz -C dist .
"/c/Program Files/PuTTY/pscp.exe" -batch \
-hostkey "SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw" \
-i "Z:\Devel\ssh\priv_ionos_hocks_vps.ppk" -P 38592 \
mp-dist.tar.gz root@hocks.eu:/tmp/
# 3. Entpacken + Container neu starten + Hash prüfen
"/c/Program Files/PuTTY/plink.exe" -batch -hostkey "SHA256:4G3UxsugNkHH6BrPXScLXh+vX8P1sLgWKn8cZBKSRxw" \
-i "Z:\Devel\ssh\priv_ionos_hocks_vps.ppk" -P 38592 root@hocks.eu \
"rm -rf /opt/mieterportal/dist && mkdir -p /opt/mieterportal/dist && \
tar -xzf /tmp/mp-dist.tar.gz -C /opt/mieterportal/dist && \
cd /opt/mieterportal && docker compose up -d && sleep 2 && \
curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js'"
# Alternativ alles in einem Schritt:
./deploy.sh
```
- SSH: Port **38592**, Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk` (Pageant), Hostkey-Flag zwingend.
- **Vorsicht Fail2Ban:** zu viele gescheiterte SSH-Versuche von einer IP führen zu einem
Ban (Timeout-Verbindungsbild). Ban aufheben auf dem Server: `fail2ban-client unban --all`.
- Erst-Einrichtung auf dem Server (einmalig):
`mkdir -p /opt/mieterportal` + `docker-compose.yml` und `docker/nginx.conf` dorthin kopieren,
dann `docker compose up -d`. NPM-Proxy-Host `mieterportal.hocks.eu` → Container `mp-portal:80`.
- HTML wird mit `no-cache` geliefert (nginx-Config), Assets hash-benannt → Reload genügt.
Der eingebettete Kimi-Browser cached hart → beim Testen Cache-Buster anhängen: `?v=X`.
## 5. Zugänge & Konfiguration
- Keine echten Zugänge (Demo). Rollenwechsel im UI; Store-Reset über Browser-DevTools
(localStorage-Key `hocks-mieterportal-v1` löschen).
- Demo-Mieterin: **Anna Berger**, Hauptstraße 12, Whg. 7 (id `hs-7`).
- **Mail-API-Token:** bewusst nicht hier dokumentiert – liegt in `mail-server/mail.env`
(nicht im Repo; Vorlage `mail.env.example`).
## 6. Konventionen & Besonderheiten
- **Dark Mode:** class-Strategie, Präferenz `mp-theme`, Init vor erstem Render.
Feste Farbklassen werden in `index.css` für `.dark` umrechnen
(`.dark .bg-white`/`.bg-slate-50`/`.bg-teal-50`); Sidebar ist eigenes Dunkel-Teal.
- **Barrierefreiheit:** aria-pressed/labels am ThemeToggle und Rollenwechsel, Fokus-Ringe.
- **Formatierung:** Beträge via `fmtEUR` (Intl de-DE), Datumsformate TT.MM.JJJJ.
- **Datenpflege:** Bestandsdaten ändern → `beispieldaten.py` anpassen und neu ausführen,
nicht generated.json von Hand editieren.
- **Sicherheit:** kein offenes Relay (Postfix mynetworks loopback + Docker-Netz,
sender_canonical zentral). Upload/Download-Funktionen sind Demo-Darstellungen.
- **Keine destruktiven Aktionen auf dem Server ohne ausdrückliche Bestätigung.**
## 7. Testing
- `npm run build` (`tsc -b`) ist die echte Typprüfung; `npx tsc --noEmit` prüft wegen
Solution-tsconfig ggf. nichts.
- Headless-Screenshots (Dark Mode = Systempräferenz):
`"/c/Program Files/Google/Chrome/Application/chrome.exe" --headless --disable-gpu --screenshot=... --window-size=WxH "URL"`
- Screenshots ausgeliefert nach `preview/`.
## 8. Verlauf / offene Punkte
**Erledigt 02.10. (v1.0):** Portal komplett neu aufgesetzt (5 Module, Mieter-/Verwaltungsansicht,
KI-Branding: Logo/Hero/Illustration), Docker-Setup (Dockerfile, nginx, compose),
Repo in git.hocks.eu angelegt, Deploy-Workflow + Dark Mode + beispieldaten.py aus dem
Schulportal übernommen, mail-server-Struktur angelegt (noch nicht live verkabelt).
**Offen:**
- Deployment auf hocks.eu ausstehend (SSH-Zugang per Pageant-ppk geklärt; Fail2Ban-Ban
nach früher Fehlversuchen abwarten/aufheben, dann `./deploy.sh`).
- Mail-Stack optional einrichten: `mail.env` aus Vorlage befüllen, Relay-Ziel in
`postfix/main.cf` setzen, npm_default-Netz prüfen.
- Mögliche nächste Schritte: echte Anmeldung + serverseitige Datenbank (PostgreSQL/Node-API),
E-Mail-Benachrichtigung bei neuen Schadensmeldungen, Mehrseitigkeit für 50 echte Mietverhältnisse.
## 9. Sonstiges
- User kommuniziert auf Deutsch; alle UI-Texte und Antworten auf Deutsch.
- Dateien außerhalb des Workspaces immer erst in den Workspace kopieren.
- Vorsicht mit destruktiven Aktionen (Explizite Bestätigung einholen).
Binary file not shown.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""Hocks Mieterportal – schlanke Mail-API (Muster: Schulportal).
Nimmt Mails vom Portal entgegen (POST /send, Token-Pflicht) und leitet sie
über den internen Postfix-Relay (mp-mail:25) aus. Versand läuft über eine
Outbox mit Retry-Worker (Backoff bis 60 min, max. 10 Versuche) – fällt der
Versand aus (Rate-Limit etc.), wird automatisch erneut versucht.
Reine Standardbibliothek (python:3.12-alpine, keine pip-Abhängigkeiten).
Endpunkte:
GET /healthz → {"ok": true}
GET /outbox → aktuelle Outbox (Demo/Debug)
POST /send → {"ok": true, "id": ..., "versandt": true/false}
Body: {"to": "...", "subject": "...", "text": "...", "html": "..."}
Auth: Header X-Api-Token oder Body-Feld "token" == MAIL_API_TOKEN
"""
import json
import os
import re
import smtplib
import threading
import time
import uuid
from email.message import EmailMessage
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ── Konfiguration ─────────────────────────────────────────────
RELAY_HOST = os.environ.get("RELAY_HOST", "mp-mail")
RELAY_PORT = int(os.environ.get("RELAY_PORT", "25"))
PORT = int(os.environ.get("PORT", "8080"))
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
DATA_DIR = os.environ.get("DATA_DIR", "/data")
SENDER_ADDRESS = os.environ.get("SENDER_ADDRESS", "noreply@hocks.eu")
SENDER_NAME = os.environ.get("SENDER_NAME", "Hocks Mieterportal")
MAIL_API_TOKEN = os.environ.get("MAIL_API_TOKEN", "")
RATE_LIMIT = int(os.environ.get("RATE_LIMIT", "500")) # Mails pro Stunde
MAX_VERSUCHE = 10
BACKOFF_MAX_SEK = 3600
os.makedirs(DATA_DIR, exist_ok=True)
OUTBOX_DATEI = os.path.join(DATA_DIR, "outbox.json")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
_lock = threading.Lock()
_send_log: list[float] = [] # Timestamps für Rate-Limit
# ── Outbox ────────────────────────────────────────────────────
def outbox_laden() -> list[dict]:
try:
with open(OUTBOX_DATEI, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return []
def outbox_speichern(eintraege: list[dict]) -> None:
tmp = OUTBOX_DATEI + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(eintraege, f, ensure_ascii=False, indent=1)
os.replace(tmp, OUTBOX_DATEI)
def rate_limit_ok() -> bool:
jetzt = time.time()
global _send_log
with _lock:
_send_log = [t for t in _send_log if jetzt - t < 3600]
if len(_send_log) >= RATE_LIMIT:
return False
_send_log.append(jetzt)
return True
def versenden(e: dict) -> bool:
msg = EmailMessage()
msg["From"] = f"{SENDER_NAME} <{SENDER_ADDRESS}>"
msg["To"] = e["to"]
msg["Subject"] = e["subject"]
msg.set_content(e.get("text") or "")
if e.get("html"):
msg.add_alternative(e["html"], subtype="html")
with smtplib.SMTP(RELAY_HOST, RELAY_PORT, timeout=20) as smtp:
smtp.send_message(msg)
return True
def outbox_eintrag(e: dict) -> None:
eintraege = outbox_laden()
eintraege.append(e)
outbox_speichern(eintraege)
def worker() -> None:
"""Retry-Worker: versucht offene Outbox-Einträge mit Backoff erneut."""
while True:
time.sleep(30)
jetzt = time.time()
geaendert = False
eintraege = outbox_laden()
for e in eintraege:
if e.get("versandt") or e.get("versuche", 0) >= MAX_VERSUCHE:
continue
if jetzt - e.get("letzterVersuch", 0) < min(60 * 2 ** e.get("versuche", 0), BACKOFF_MAX_SEK):
continue
try:
e["versuche"] = e.get("versuche", 0) + 1
e["letzterVersuch"] = jetzt
versenden(e)
e["versandt"] = True
e["versandtAm"] = time.strftime("%d.%m.%Y %H:%M:%S")
except OSError:
pass # nächster Versuch später
geaendert = True
if geaendert:
outbox_speichern(eintraege)
# ── HTTP ──────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
server_version = "mp-mail-api/1.0"
def _antwort(self, code: int, body: dict) -> None:
daten = json.dumps(body, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(daten)))
if ALLOWED_ORIGIN != "*":
self.send_header("Access-Control-Allow-Origin", ALLOWED_ORIGIN)
self.end_headers()
self.wfile.write(daten)
def _body(self) -> dict:
try:
laenge = int(self.headers.get("Content-Length", "0"))
return json.loads(self.rfile.read(laenge) or b"{}")
except (ValueError, json.JSONDecodeError):
return {}
def do_OPTIONS(self) -> None: # noqa: N802
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type, X-Api-Token")
self.end_headers()
def do_GET(self) -> None: # noqa: N802
if self.path.split("?")[0] == "/healthz":
self._antwort(200, {"ok": True, "service": "mp-mail-api"})
elif self.path.split("?")[0] == "/outbox":
self._antwort(200, {"eintraege": outbox_laden()})
else:
self._antwort(404, {"fehler": "nicht gefunden"})
def do_POST(self) -> None: # noqa: N802
if self.path.split("?")[0] != "/send":
self._antwort(404, {"fehler": "nicht gefunden"})
return
body = self._body()
token = self.headers.get("X-Api-Token") or body.get("token") or ""
if not MAIL_API_TOKEN or token != MAIL_API_TOKEN:
self._antwort(401, {"fehler": "ungueltiger Token"})
return
an = str(body.get("to", "")).strip()
betreff = str(body.get("subject", "")).strip()
if not EMAIL_RE.match(an) or not betreff:
self._antwort(400, {"fehler": "Empfaenger oder Betreff ungueltig"})
return
if not rate_limit_ok():
self._antwort(429, {"fehler": "Rate-Limit erreicht"})
return
eintrag = {
"id": uuid.uuid4().hex[:12],
"to": an,
"subject": betreff,
"text": str(body.get("text", "")),
"html": str(body.get("html", "")),
"versuche": 0,
"versandt": False,
"angenommenAm": time.strftime("%d.%m.%Y %H:%M:%S"),
}
try:
versenden(eintrag)
eintrag["versandt"] = True
eintrag["versandtAm"] = eintrag["angenommenAm"]
eintrag["letzterVersuch"] = time.time()
except OSError:
outbox_eintrag(eintrag) # Worker versucht es spaeter erneut
self._antwort(200, {"ok": True, "id": eintrag["id"], "versandt": eintrag["versandt"]})
def log_message(self, fmt: str, *args) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {self.address_string()} {fmt % args}")
if __name__ == "__main__":
threading.Thread(target=worker, daemon=True).start()
print(f"mp-mail-api lauscht auf :{PORT} (Relay {RELAY_HOST}:{RELAY_PORT}, Limit {RATE_LIMIT}/h)")
ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()
+53
View File
@@ -0,0 +1,53 @@
# Hocks Mieterportal – Mail-Stack (Muster aus dem Schulportal-Projekt)
#
# Postfix-Relay: nimmt Mails intern entgegen und liefert sie über den
# konfigurierten Relayhost aus. Port 25 wird NICHT nach außen veröffentlicht –
# nur im Docker-Netz (npm_default, erreichbar für den NPM-Proxy).
#
# Start: cd mail-server && cp mail.env.example mail.env # Werte befüllen
# docker compose up -d --build
services:
# Postfix-Relay (nur intern)
mp-mail:
build: ./postfix
image: mp-postfix:latest
container_name: mp-mail
restart: unless-stopped
env_file: mail.env
environment:
# Direktzustellung wie Schulportal, z. B.:
# RELAY_HOST: "[<mx-von-hocks.eu>]:25"
RELAY_HOST: "${RELAY_HOST:-}"
expose:
- "25"
networks:
- npm_default
# Kleine HTTP-API, über die das Portal Mails an den Relay übergibt.
mp-mail-api:
image: python:3.12-alpine
container_name: mp-mail-api
restart: unless-stopped
env_file: mail.env
environment:
RELAY_HOST: mp-mail
RELAY_PORT: "25"
PORT: "8080"
ALLOWED_ORIGIN: https://mieterportal.hocks.eu
DATA_DIR: /data
command: ["python", "/app/server.py"]
volumes:
- ./api/server.py:/app/server.py:ro
- mp-mail-data:/data
depends_on:
- mp-mail
networks:
- npm_default
volumes:
mp-mail-data:
networks:
npm_default:
external: true
+13
View File
@@ -0,0 +1,13 @@
# Hocks Mieterportal – Mail-Relay-Konfiguration
# Absenderadresse, auf die alle Mails umgeschrieben werden (Nicht-Antworten-Konto)
CANONICAL_ADDRESS=noreply@hocks.eu
# Absender, den die Mail-API im From-Header setzt (sollte = CANONICAL_ADDRESS)
SENDER_ADDRESS=noreply@hocks.eu
# Optional: Relayhost fuer Postfix (Direktzustellung wie Schulportal),
# z. B. "[<mx-von-hocks.eu>]:25" – leer lassen = direkte Auslieferung
RELAY_HOST=
# Token fuer die Mail-API (wird spaeter ggf. vom nginx-Proxy unter /api/ injiziert)
MAIL_API_TOKEN=<zufaelliges-langes-token>
+13
View File
@@ -0,0 +1,13 @@
FROM alpine:3.20
RUN apk add --no-cache postfix ca-certificates
COPY main.cf /etc/postfix/main.cf
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh && \
mkdir -p /var/spool/postfix /etc/postfix
# Port 25 nur intern im Docker-Netz (kein -p beim Start!)
EXPOSE 25
ENTRYPOINT ["/entrypoint.sh"]
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -e
# Absender-Umschreibung auf die Nicht-Antworten-Adresse des hocks.eu-Tenants.
# Bei Direktzustellung an den M365-Eingang vertraut der Konnektor der
# Absender-IP – Absenderadresse wird zentral umgeschrieben, damit keine
# fremden Absenderdomains entstehen.
CANONICAL_ADDRESS="${CANONICAL_ADDRESS:-noreply@hocks.eu}"
printf '/.+/ %s\n' "$CANONICAL_ADDRESS" > /etc/postfix/sender_canonical
# Optionalen Relayhost aus der Umgebung setzen (z. B. [mx-von-hocks.eu]:25)
if [ -n "${RELAY_HOST:-}" ]; then
postconf -e "relayhost = ${RELAY_HOST}"
fi
# Chroot in Containern deaktivieren (sonst lokale Unix-Sockets nicht erreichbar)
postconf -F '*/*/chroot=n'
echo "MP-Mail-Relay startet (Relayhost: ${RELAY_HOST:-direkt}, Absender -> $CANONICAL_ADDRESS)"
exec postfix start-fg
+32
View File
@@ -0,0 +1,32 @@
# Hocks Mail-Relay (Mieterportal): liefert intern angenommene Mails über den
# konfigurierten Relayhost aus (O365-MX-Direktzustellung wie im Schulportal,
# oder smarthost mit AUTH-Erweiterung möglich).
# KEIN SMTP AUTH noetig bei Direktzustellung – wichtig: Container ist nicht
# oeffentlich exponiert, Relaying nur aus dem Docker-Netz (mynetworks).
compatibility_level = 3.6
myhostname = mail.mieterportal.hocks.eu
mydomain = hocks.eu
myorigin = $myhostname
# relayhost wird zur Laufzeit vom Entrypoint aus $RELAY_HOST gesetzt
# (Direktzustellung: [mx-von-hocks.eu]:25). Leer = direkte Auslieferung.
smtp_tls_security_level = encrypt
smtp_tls_CApath = /etc/ssl/certs
smtp_tls_loglevel = 1
# Alle Absender (Envelope + Header) auf die Nicht-Antworten-Adresse umschreiben
sender_canonical_classes = envelope_sender, header_sender
sender_canonical_maps = regexp:/etc/postfix/sender_canonical
# Nur intern erreichbar – Relaying nur aus dem Docker-Netz erlaubt (kein offenes Relay!)
inet_interfaces = all
inet_protocols = ipv4
mynetworks = 127.0.0.0/8 172.16.0.0/12
smtpd_relay_restrictions = permit_mynetworks, defer
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_client_restrictions = permit_mynetworks, reject
maillog_file = /dev/stdout
biff = no
append_dot_mydomain = no
Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

+17 -12
View File
@@ -10,6 +10,7 @@ import {
Bell,
} from 'lucide-react'
import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle'
import logo from '../assets/logo.png'
const navItems = [
@@ -27,7 +28,7 @@ export default function AppLayout() {
).length
return (
<div className="flex min-h-screen bg-slate-50">
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
{/* Sidebar */}
<aside className="hidden w-72 flex-col bg-[#0b3a44] text-white md:flex">
<div className="flex items-center gap-3 px-6 py-6">
@@ -87,30 +88,34 @@ export default function AppLayout() {
<Building2 className="h-3.5 w-3.5" />
3 Häuser · 50 Wohnungen
</div>
<NavLink
to="/login"
className="flex 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"
>
<LogOut className="h-4 w-4" /> Abmelden
</NavLink>
<div className="flex items-center gap-2">
<NavLink
to="/login"
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"
>
<LogOut className="h-4 w-4" /> Abmelden
</NavLink>
<ThemeToggle dunkelStil />
</div>
</div>
</aside>
{/* Mobile top bar */}
<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 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" />
<div className="flex-1 font-bold text-[#0b3a44]">Hocks Mieterportal</div>
<div className="flex-1 font-bold text-[#0b3a44] dark:text-teal-100">Hocks Mieterportal</div>
<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"
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>
<nav className="sticky top-[57px] z-10 flex gap-1 overflow-x-auto border-b bg-white px-2 py-2 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 }) => (
<NavLink
key={to}
@@ -118,7 +123,7 @@ export default function AppLayout() {
end={end}
className={({ isActive }) =>
`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'
isActive ? 'bg-teal-600 text-white' : 'text-slate-600 dark:text-slate-300'
}`
}
>
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useState } from 'react'
import { Moon, Sun } from 'lucide-react'
import { istDunkel, setzeTheme } from '@/lib/theme'
/** Dark-Mode-Umschalter (Sonne/Mond) mit gespeicherter Präferenz. */
export default function ThemeToggle({ dunkelStil = false }: { dunkelStil?: boolean }) {
const [dunkel, setDunkel] = useState(istDunkel())
useEffect(() => {
const sync = () => setDunkel(istDunkel())
window.addEventListener('storage', sync)
return () => window.removeEventListener('storage', sync)
}, [])
const umschalten = () => {
const neu = !dunkel
setzeTheme(neu)
setDunkel(neu)
}
return (
<button
type="button"
onClick={umschalten}
aria-pressed={dunkel}
aria-label={dunkel ? 'Helles Design aktivieren' : 'Dunkles Design aktivieren'}
title={dunkel ? 'Helles Design aktivieren' : 'Dunkles Design aktivieren'}
className={`inline-flex h-8 w-8 items-center justify-center rounded-md border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dunkelStil
? 'border-white/20 text-teal-100 hover:bg-white/10 hover:text-white'
: 'border-input text-muted-foreground hover:bg-accent hover:text-accent-foreground'
}`}
>
{dunkel ? (
<Sun className="h-4 w-4" aria-hidden="true" />
) : (
<Moon className="h-4 w-4" aria-hidden="true" />
)}
</button>
)
}
File diff suppressed because it is too large Load Diff
+4 -86
View File
@@ -6,97 +6,15 @@ import type {
Thread,
Announcement,
} from '../types'
const buildings = [
{ name: 'Hauptstraße 12', prefix: 'HS', count: 20, baseRent: 9.8 },
{ name: 'Ringweg 3', prefix: 'RW', count: 15, baseRent: 8.9 },
{ name: 'Am Park 5', prefix: 'AP', count: 15, baseRent: 10.4 },
]
const firstNames = [
'Anna', 'Jonas', 'Marta', 'Leon', 'Sofia', 'Felix', 'Lena', 'Paul',
'Emma', 'Noah', 'Clara', 'Ben', 'Nora', 'Max', 'Julia', 'Tim',
'Sarah', 'Finn', 'Laura', 'Jan', 'Mia', 'Tom', 'Ela', 'Kim',
]
const lastNames = [
'Berger', 'Klein', 'Wolf', 'Neumann', 'Schäfer', 'Krüger', 'Brandt',
'Vogel', 'Otto', 'Horn', 'Busch', 'Thomas', 'Pohl', 'Sander', 'Lindner',
]
import generated from './generated.json'
// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7
export const DEMO_TENANT = 'Anna Berger'
export const DEMO_UNIT_ID = 'hs-7'
let nameIdx = 0
function nextName(): string {
const n = `${firstNames[nameIdx % firstNames.length]} ${lastNames[nameIdx % lastNames.length]}`
nameIdx++
return n
}
export const units: Unit[] = buildings.flatMap((b) =>
Array.from({ length: b.count }, (_, i) => {
const num = i + 1
const size = 38 + ((num * 7 + b.prefix.length * 3) % 78) // 38–115 m²
const coldRent = Math.round(size * b.baseRent)
const warmRent = coldRent + Math.round(2.4 * size) + (num % 3 === 0 ? 35 : 0)
const floor = num <= 2 ? 0 : Math.min(4, Math.floor((num - 1) / 4))
const occupied = num % 9 !== 0 // ~89 % Vermietung -> 50 Wohnungen, ~5 frei
const tenant =
b.prefix === 'HS' && num === 7
? DEMO_TENANT
: occupied
? nextName()
: null
return {
id: `${b.prefix.toLowerCase()}-${num}`,
building: b.name,
number: `${num}`,
floor,
size,
coldRent,
warmRent,
tenant,
status: occupied ? ('vermietet' as const) : ('frei' as const),
}
}),
)
const monthNames = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
]
export const transactions: Transaction[] = units
.filter((u) => u.status === 'vermietet')
.flatMap((u) => {
const txs: Transaction[] = []
for (let m = 0; m < 12; m++) {
const dueMonth = m + 1
txs.push({
id: `tx-${u.id}-${m}`,
unitId: u.id,
tenant: u.tenant!,
month: monthNames[m],
year: 2026,
amount: u.warmRent,
type: 'Miete',
status:
dueMonth <= 9
? (u.id.charCodeAt(3) + m) % 11 === 0
? 'überfällig'
: 'bezahlt'
: dueMonth === 10
? 'offen'
: 'offen',
paidAt:
dueMonth <= 9 && (u.id.charCodeAt(3) + m) % 11 !== 0
? `0${(m % 8) + 1}.0${dueMonth}.2026`
: undefined,
})
}
return txs
})
// Maschinell erzeugte Bestandsdaten (beispieldaten.py -> generated.json)
export const units = generated.units as Unit[]
export const transactions = generated.transactions as Transaction[]
export const tickets: Ticket[] = [
{
+14
View File
@@ -72,4 +72,18 @@
body {
@apply bg-background text-foreground;
}
}
/* Dark-Mode-Umrechnung fester Oberflächenfarben (Karten/Zeilen) */
.dark {
color-scheme: dark;
}
.dark .bg-white {
background-color: hsl(222 47% 11%);
}
.dark .bg-slate-50 {
background-color: hsl(222 47% 14%);
}
.dark .bg-teal-50 {
background-color: hsl(180 60% 12%);
}
+29
View File
@@ -0,0 +1,29 @@
/** Dark-Mode-Helfer: Klasse auf <html>, Präferenz in localStorage. */
export const THEME_KEY = 'mp-theme'
export function initTheme() {
let dunkel = false
try {
const gespeichert = localStorage.getItem(THEME_KEY)
dunkel = gespeichert
? gespeichert === 'dark'
: window.matchMedia('(prefers-color-scheme: dark)').matches
} catch {
dunkel = false
}
document.documentElement.classList.toggle('dark', dunkel)
}
export function istDunkel(): boolean {
return document.documentElement.classList.contains('dark')
}
export function setzeTheme(dunkel: boolean) {
document.documentElement.classList.toggle('dark', dunkel)
try {
localStorage.setItem(THEME_KEY, dunkel ? 'dark' : 'light')
} catch {
// Speicher nicht verfügbar – Theme gilt nur für diese Sitzung
}
}
+3
View File
@@ -2,8 +2,11 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import './index.css'
import { initTheme } from './lib/theme'
import App from './App.tsx'
initTheme()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
+4 -4
View File
@@ -128,7 +128,7 @@ function TenantDashboard() {
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={70} />
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
<Bar dataKey="Betrag" fill="#0d9488" radius={[6, 6, 0, 0]} />
<Bar dataKey="Betrag" fill="#0d9488" radius={[6, 6, 0, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>
@@ -276,7 +276,7 @@ function AdminDashboard() {
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={80} />
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
<Area type="monotone" dataKey="Einnahmen" stroke="#0d9488" fill="url(#gIncome)" strokeWidth={2.5} />
<Area type="monotone" dataKey="Einnahmen" stroke="#0d9488" fill="url(#gIncome)" strokeWidth={2.5} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
</div>
@@ -291,8 +291,8 @@ function AdminDashboard() {
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#94a3b8" />
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
<Tooltip />
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} />
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} />
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 0]} isAnimationActive={false} />
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
</BarChart>
</ResponsiveContainer>
</div>