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:
+33
-3
@@ -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/
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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 |
@@ -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>
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
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'
|
||||
}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -73,3 +73,17 @@
|
||||
@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%);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user