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
|
node_modules
|
||||||
dist
|
dist
|
||||||
*.log
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.env
|
*.suo
|
||||||
.env.local
|
*.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
|
# 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
|
## 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
|
4. **Dokumente** – Verträge, Abrechnungen, Hausordnung; zentraler Such- und Filterzugriff
|
||||||
5. **Nachrichten** – Direktkommunikation Mieter ↔ Verwaltung
|
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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # http://localhost:3000
|
npm run dev # http://localhost:3000
|
||||||
npm run build # Produktionsbuild nach dist/
|
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
|
```bash
|
||||||
docker build -t mieterportal .
|
./deploy.sh
|
||||||
docker run -d -p 8080:80 --name mieterportal mieterportal
|
|
||||||
# oder:
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Der Container liefert das gebaute Frontend über Nginx (SPA-Routing inklusive).
|
baut das Frontend, lädt das `dist/`-Paket per `pscp` auf den Server (Port 38592,
|
||||||
Für den Betrieb unter `https://mieterportal.hocks.eu` einfach einen Reverse Proxy
|
Pageant-Key `Z:\Devel\ssh\priv_ionos_hocks_vps.ppk`) und startet den nginx-Container
|
||||||
(Traefik, nginx, Caddy) davor setzen — Beispiel-Labels für Traefik inkl. Let's Encrypt
|
`mp-portal` neu. TLS/Subdomain liefert der Nginx Proxy Manager (npm_default-Netz).
|
||||||
liegen in der `docker-compose.yml`.
|
|
||||||
|
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
|
#!/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
|
# Aufruf: ./deploy.sh
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SERVER="${SERVER:-hocks.eu}"
|
SERVER="root@hocks.eu"
|
||||||
SSH_PORT="${SSH_PORT:-38592}"
|
PORT=38592
|
||||||
SSH_USER="${SSH_USER:-root}"
|
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"
|
REMOTE_DIR="/opt/mieterportal"
|
||||||
REPO="https://git.hocks.eu/ah-admin/Mieterportal.git"
|
|
||||||
IMAGE="mieterportal:latest"
|
|
||||||
|
|
||||||
run() { # run <remote command>
|
echo "==> 1/4 Frontend bauen"
|
||||||
local cmd="$1"
|
npm run build
|
||||||
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 "==> Verbindung zu ${SSH_USER}@${SERVER}:${SSH_PORT} testen"
|
echo "==> 2/4 Paket erstellen und hochladen"
|
||||||
run "docker --version"
|
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})"
|
echo "==> 3/4 Auf Server entpacken und Container ersetzen"
|
||||||
run "mkdir -p ${REMOTE_DIR} && cd ${REMOTE_DIR} && \
|
"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
|
||||||
(git rev-parse --git-dir >/dev/null 2>&1 || git clone ${REPO} .) && \
|
mkdir -p $REMOTE_DIR &&
|
||||||
git fetch origin && git reset --hard origin/main"
|
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"
|
echo "==> 4/4 Status prüfen"
|
||||||
run "cd ${REMOTE_DIR} && docker build -t ${IMAGE} ."
|
"$PLINK" -batch -hostkey "$HOSTKEY" -i "$KEY" -P "$PORT" "$SERVER" "
|
||||||
|
docker ps --filter name=mp-portal --format 'table {{.Names}}\t{{.Status}}' &&
|
||||||
echo "==> Container ersetzen"
|
curl -s https://mieterportal.hocks.eu/ | grep -o 'index-[^\"]*\.js' | head -1
|
||||||
run "docker rm -f mieterportal 2>/dev/null || true"
|
"
|
||||||
run "cd ${REMOTE_DIR} && docker compose up -d"
|
echo "Fertig: https://mieterportal.hocks.eu"
|
||||||
|
|
||||||
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)."
|
|
||||||
|
|||||||
+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:
|
services:
|
||||||
mieterportal:
|
mp-portal:
|
||||||
build: .
|
image: nginx:1.27-alpine
|
||||||
image: mieterportal:latest
|
container_name: mp-portal
|
||||||
container_name: mieterportal
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
volumes:
|
||||||
- "8080:80"
|
- ./dist:/usr/share/nginx/html:ro
|
||||||
labels:
|
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||||
# Beispiel für Traefik – https://mieterportal.hocks.eu mit TLS
|
networks:
|
||||||
- "traefik.enable=true"
|
- npm_default
|
||||||
- "traefik.http.routers.mieterportal.rule=Host(`mieterportal.hocks.eu`)"
|
|
||||||
- "traefik.http.routers.mieterportal.entrypoints=websecure"
|
networks:
|
||||||
- "traefik.http.routers.mieterportal.tls.certresolver=letsencrypt"
|
npm_default:
|
||||||
- "traefik.http.services.mieterportal.loadbalancer.server.port=80"
|
external: true
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ server {
|
|||||||
gzip_types text/css application/javascript image/svg+xml application/json;
|
gzip_types text/css application/javascript image/svg+xml application/json;
|
||||||
gzip_min_length 1024;
|
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
|
# Cache für gehashte Build-Assets
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
expires 30d;
|
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,
|
Bell,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
|
import ThemeToggle from './ThemeToggle'
|
||||||
import logo from '../assets/logo.png'
|
import logo from '../assets/logo.png'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -27,7 +28,7 @@ export default function AppLayout() {
|
|||||||
).length
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen bg-slate-50">
|
<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-[#0b3a44] 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">
|
||||||
@@ -87,30 +88,34 @@ export default function AppLayout() {
|
|||||||
<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>
|
||||||
<NavLink
|
<div className="flex items-center gap-2">
|
||||||
to="/login"
|
<NavLink
|
||||||
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"
|
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>
|
<LogOut className="h-4 w-4" /> Abmelden
|
||||||
|
</NavLink>
|
||||||
|
<ThemeToggle dunkelStil />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* 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 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="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
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
dispatch({ type: 'setRole', role: role === 'tenant' ? 'admin' : 'tenant' })
|
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'}
|
{role === 'tenant' ? 'Verwaltung' : 'Mieterin'}
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</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 }) => (
|
{navItems.map(({ to, label, icon: Icon, end }) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
@@ -118,7 +123,7 @@ export default function AppLayout() {
|
|||||||
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'
|
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,
|
Thread,
|
||||||
Announcement,
|
Announcement,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
|
import generated from './generated.json'
|
||||||
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',
|
|
||||||
]
|
|
||||||
|
|
||||||
// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7
|
// Demo-Mieter: Whg. Hauptstraße 12 Nr. 7
|
||||||
export const DEMO_TENANT = 'Anna Berger'
|
export const DEMO_TENANT = 'Anna Berger'
|
||||||
export const DEMO_UNIT_ID = 'hs-7'
|
export const DEMO_UNIT_ID = 'hs-7'
|
||||||
|
|
||||||
let nameIdx = 0
|
// Maschinell erzeugte Bestandsdaten (beispieldaten.py -> generated.json)
|
||||||
function nextName(): string {
|
export const units = generated.units as Unit[]
|
||||||
const n = `${firstNames[nameIdx % firstNames.length]} ${lastNames[nameIdx % lastNames.length]}`
|
export const transactions = generated.transactions as Transaction[]
|
||||||
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
|
|
||||||
})
|
|
||||||
|
|
||||||
export const tickets: Ticket[] = [
|
export const tickets: Ticket[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -72,4 +72,18 @@
|
|||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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 { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter } from 'react-router'
|
import { BrowserRouter } from 'react-router'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import { initTheme } from './lib/theme'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
initTheme()
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ function TenantDashboard() {
|
|||||||
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={70} />
|
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={70} />
|
||||||
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
|
<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>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,7 +276,7 @@ function AdminDashboard() {
|
|||||||
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
<XAxis dataKey="m" tick={{ fontSize: 12 }} stroke="#94a3b8" />
|
||||||
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={80} />
|
<YAxis tick={{ fontSize: 12 }} stroke="#94a3b8" width={80} />
|
||||||
<Tooltip formatter={(v) => fmtEUR.format(Number(v))} />
|
<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>
|
</AreaChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,8 +291,8 @@ function AdminDashboard() {
|
|||||||
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#94a3b8" />
|
<XAxis type="number" tick={{ fontSize: 11 }} stroke="#94a3b8" />
|
||||||
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
|
<YAxis type="category" dataKey="name" tick={{ fontSize: 11 }} stroke="#94a3b8" width={95} />
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
<Bar dataKey="Vermietet" stackId="a" fill="#0d9488" radius={[0, 0, 0, 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]} />
|
<Bar dataKey="Frei" stackId="a" fill="#f59e0b" radius={[0, 6, 6, 0]} isAnimationActive={false} />
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user