v1.21: Live-Stammdaten aus Master-Data-CSV importiert, Sync verworfen, Demo-Reste entfernt
This commit is contained in:
@@ -33,5 +33,8 @@ tmp-*
|
|||||||
mp-dist.tar.gz
|
mp-dist.tar.gz
|
||||||
shots/
|
shots/
|
||||||
*.bak-v*
|
*.bak-v*
|
||||||
|
# Live-Stammdaten (PII) – nur lokal, nicht ins Git
|
||||||
|
Master Data V.csv
|
||||||
|
import-*.sql
|
||||||
.vite
|
.vite
|
||||||
_ref/
|
_ref/
|
||||||
|
|||||||
@@ -189,6 +189,34 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 8. Verlauf / offene Punkte
|
||||||
|
|
||||||
|
**Erledigt 04.10. (v1.21):** Live-Stammdaten aus SharePoint-Export-CSV
|
||||||
|
importiert (KEIN Dauersync – Nutzer-Entscheidung: einmaliger Import,
|
||||||
|
Pflege danach nur noch im Portal). CSV „Master Data V" (53 Zeilen,
|
||||||
|
37 Spalten, 12 Häuser in Krefeld, 46 Wohneinheiten inkl. Garagen/Zimmer,
|
||||||
|
43 Mietparteien) liegt NUR lokal im Workspace (PII, in .gitignore).
|
||||||
|
Import-Skript server/import/import-masterdata.py (stdin CSV → stdout SQL,
|
||||||
|
deterministische IDs slug(haus)-slug(we), deutsche Zahlenformate
|
||||||
|
'2.700'→2700 / '71,4'→71.4 / Baujahr '1.905'→1905, PLZ '47.829'→47829,
|
||||||
|
Etage aus 'Lage im Objekt' EG/1. OG/DG, WE01→'1'). buildings-NOT-NULL
|
||||||
|
(baujahr/stockwerke/etagenhoehe/wohneinheiten/quadratmeter) per ALTER
|
||||||
|
gelockert. Import ersetzt komplett: threads/tickets/transactions/costs/
|
||||||
|
belege/documents/meters/codes + Mieter-User + units + buildings;
|
||||||
|
Verwaltungs-User, Ankündigungen bleiben. Mieter bekommen Zufalls-Passwort-
|
||||||
|
Hash (unloggbar bis Passwort-Reset auf 0815 durch Verwaltung in /benutzer),
|
||||||
|
email='' (Mailversand überspringt leere Adressen, v1.20-Profil zum
|
||||||
|
Nachpflegen). Zähler NICHT importiert (CSV-Spalten nur '0').
|
||||||
|
Sync-Infrastruktur (v1.21-Vorentwicklung) komplett entfernt: mp-sync-cron
|
||||||
|
Container gelöscht, portal.env von SP_*/INTERN_SYNC_TOKEN bereinigt,
|
||||||
|
Code aus Arbeitsbaum entfernt (nie gepusht). WICHTIG seed.js-Fix:
|
||||||
|
Demo-Nachseeds (seedeZaehler/Haeuser/Kosten) prüften nur Tabellen-Leere
|
||||||
|
→ FK-Crashloop nach Import (hs-7 fehlt). Fix: demoBestandVorhanden()
|
||||||
|
gate auf units.id='hs-7'. Frontend: hartkodierte Demo-Zahlen entfernt –
|
||||||
|
AppLayout-Footer + Dashboard (Bestandszählung, Belegungsquote) jetzt
|
||||||
|
dynamisch aus buildings/units, Hauslisten in Payments/Zaehler/Benutzer-
|
||||||
|
Verwaltung dynamisch, Login-Demo-Zugang a.berger entfernt (existiert
|
||||||
|
nicht mehr), DEMO_UNIT_ID='' (Fallback = erste Einheit). E2E: Bootstrap
|
||||||
|
12 Häuser/46 Einheiten/43 Mieter, API healthy.
|
||||||
|
|
||||||
**Erledigt 04.10. (v1.20):** Stammdaten-Selbstpflege. users-Tabelle um
|
**Erledigt 04.10. (v1.20):** Stammdaten-Selbstpflege. users-Tabelle um
|
||||||
telefon/kontoinhaber/iban/bankname erweitert (ALTER ... ADD COLUMN IF NOT
|
telefon/kontoinhaber/iban/bankname erweitert (ALTER ... ADD COLUMN IF NOT
|
||||||
EXISTS). PATCH /api/auth/profil (auth, eigene Daten): E-Mail (Pflicht +
|
EXISTS). PATCH /api/auth/profil (auth, eigene Daten): E-Mail (Pflicht +
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Einmaliger Import der Mieter-Stammdaten aus der SharePoint-Export-CSV
|
||||||
|
(„Master Data V") in die PostgreSQL-Datenbank des Mieterportals.
|
||||||
|
|
||||||
|
Liest die CSV von stdin, schreibt SQL auf stdout:
|
||||||
|
python import-masterdata.py < \"Master Data V.csv\" | \\
|
||||||
|
docker exec -i mp-db psql -U mp -d mieterportal
|
||||||
|
|
||||||
|
ACHTUNG: Ersetzt die Demo-Daten (Häuser, Wohnungen, Mieter, Buchungen,
|
||||||
|
Meldungen, Unterhaltungen, Dokumente, Zähler, Kosten, Belege, Codes).
|
||||||
|
Verwaltungs-Konten, Ankündigungen und Sessions der Verwaltung bleiben.
|
||||||
|
|
||||||
|
IDs sind deterministisch (Slug aus Haus + WE) – das Skript ist damit
|
||||||
|
fehlerverträglich wiederholbar, solange die CSV unverändert bleibt.
|
||||||
|
Die CSV gehört NICHT ins Git (PII) – siehe .gitignore.
|
||||||
|
"""
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sys
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
HEUTE = f'{date.today().day}.{date.today().month}.{date.today().year}'
|
||||||
|
|
||||||
|
|
||||||
|
def q(wert):
|
||||||
|
"""SQL-Escaping für Textfelder."""
|
||||||
|
if wert is None:
|
||||||
|
return 'NULL'
|
||||||
|
return "'" + str(wert).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
def slug(text):
|
||||||
|
s = str(text or '').strip().lower()
|
||||||
|
s = (s.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
|
||||||
|
s = re.sub(r'[^a-z0-9]+', '-', s).strip('-')
|
||||||
|
return s or 'x'
|
||||||
|
|
||||||
|
|
||||||
|
def zahl(wert):
|
||||||
|
"""Deutsche Zahlenformate: '2.700' -> 2700, '71,4' -> 71.4, '' -> 0"""
|
||||||
|
s = str(wert or '').strip()
|
||||||
|
if not s:
|
||||||
|
return 0.0
|
||||||
|
s = s.replace('.', '').replace(',', '.')
|
||||||
|
try:
|
||||||
|
return float(s)
|
||||||
|
except ValueError:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def jahr(wert):
|
||||||
|
"""'1.905' -> 1905, leer/0 -> None"""
|
||||||
|
s = str(wert or '').strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
s = s.replace('.', '')
|
||||||
|
try:
|
||||||
|
j = int(s)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return j if 1500 < j < 2100 else None
|
||||||
|
|
||||||
|
|
||||||
|
def etage(lage):
|
||||||
|
s = (lage or '').strip().lower()
|
||||||
|
if s.startswith('eg') or 'hinterhof' in s:
|
||||||
|
return 0
|
||||||
|
m = re.search(r'(\d+)\.\s*og', s)
|
||||||
|
if m:
|
||||||
|
return int(m.group(1))
|
||||||
|
if s.startswith('dg'):
|
||||||
|
return 4
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def wohnungsnummer(we):
|
||||||
|
"""'WE01' -> '1', 'Garage 1' bleibt, 'Zimmer 2' bleibt."""
|
||||||
|
s = str(we or '').strip()
|
||||||
|
m = re.match(r'^WE0*(\d+)$', s, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return str(int(m.group(1)))
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
eingabe = io.TextIOWrapper(sys.stdin.buffer, encoding='utf-8-sig')
|
||||||
|
zeilen = list(csv.DictReader(eingabe))
|
||||||
|
if not zeilen:
|
||||||
|
sys.exit('CSV ist leer oder konnte nicht gelesen werden.')
|
||||||
|
|
||||||
|
sql = []
|
||||||
|
sql.append('BEGIN;')
|
||||||
|
# buildings: NOT NULL lockern – die CSV liefert nicht alle Pflichtfelder
|
||||||
|
for spalte in ('baujahr', 'stockwerke', 'etagenhoehe', 'wohneinheiten', 'quadratmeter'):
|
||||||
|
sql.append(f'ALTER TABLE buildings ALTER COLUMN {spalte} DROP NOT NULL;')
|
||||||
|
|
||||||
|
# ── Demo-Daten entfernen (Reihenfolge wegen Fremdschlüsseln) ──
|
||||||
|
sql += [
|
||||||
|
'DELETE FROM threads;',
|
||||||
|
'DELETE FROM tickets;',
|
||||||
|
'DELETE FROM transactions;',
|
||||||
|
'DELETE FROM costs;',
|
||||||
|
'DELETE FROM belege;',
|
||||||
|
'DELETE FROM documents;',
|
||||||
|
'DELETE FROM meters;',
|
||||||
|
'DELETE FROM registration_codes;',
|
||||||
|
"DELETE FROM sessions WHERE user_id IN (SELECT id FROM users WHERE rolle = 'mieter');",
|
||||||
|
"DELETE FROM users WHERE rolle = 'mieter';",
|
||||||
|
'DELETE FROM units;',
|
||||||
|
'DELETE FROM buildings;',
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Häuser ──
|
||||||
|
haeuser = []
|
||||||
|
for z in zeilen:
|
||||||
|
t = z['Titel'].strip()
|
||||||
|
if t and t not in haeuser:
|
||||||
|
haeuser.append(t)
|
||||||
|
for t in haeuser:
|
||||||
|
zeilen_haus = [z for z in zeilen if z['Titel'].strip() == t]
|
||||||
|
we_zeilen = [z for z in zeilen_haus if z['WE'].strip()]
|
||||||
|
strasse = zeilen_haus[0]['Straße'].strip()
|
||||||
|
plz = zeilen_haus[0]['PLZ'].strip().replace('.', '')
|
||||||
|
stadt = zeilen_haus[0]['Stadt'].strip()
|
||||||
|
adresse = ', '.join(x for x in (strasse, f'{plz} {stadt}'.strip()) if x)
|
||||||
|
bj = jahr(zeilen_haus[0]['Baujahr'])
|
||||||
|
qm = sum(zahl(z['m²']) for z in we_zeilen) or None
|
||||||
|
wohnungen = len(we_zeilen)
|
||||||
|
sql.append(
|
||||||
|
'INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)'
|
||||||
|
f' VALUES ({q(t)}, {q(adresse)}, {bj if bj else "NULL"}, NULL, NULL, {wohnungen}, {qm if qm else "NULL"});',
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Wohneinheiten + Mieter ──
|
||||||
|
warnungen = []
|
||||||
|
for z in zeilen:
|
||||||
|
we = z['WE'].strip()
|
||||||
|
if not we:
|
||||||
|
continue
|
||||||
|
titel = z['Titel'].strip()
|
||||||
|
einheit_id = f"{slug(titel)}-{slug(we)}"
|
||||||
|
nummer = wohnungsnummer(we)
|
||||||
|
mieter = z['Name des Mieters'].strip()
|
||||||
|
kalt = zahl(z['Nettokaltmiete'])
|
||||||
|
warm = kalt + zahl(z['Nebenkosten'])
|
||||||
|
sql.append(
|
||||||
|
'INSERT INTO units (id, building, number, floor, size, cold_rent, warm_rent, tenant, status)'
|
||||||
|
f' VALUES ({q(einheit_id)}, {q(titel)}, {q(nummer)}, {etage(z["Lage im Objekt"])},'
|
||||||
|
f' {zahl(z["m²"])}, {kalt}, {warm}, {q(mieter) if mieter else "NULL"},'
|
||||||
|
f" '{('vermietet' if mieter else 'frei')}');",
|
||||||
|
)
|
||||||
|
if mieter:
|
||||||
|
nr = z['Nr. Mietpartei'].strip() or slug(we)
|
||||||
|
benutzername = f"{slug(titel)}-{nr}"
|
||||||
|
# Zufälliger unbekannter Hash -> Mieter kann sich erst nach
|
||||||
|
# Passwort-Reset durch die Verwaltung (Button in /benutzer) anmelden.
|
||||||
|
passwort_hash = secrets.token_hex(32)
|
||||||
|
sql.append(
|
||||||
|
'INSERT INTO users (id, benutzername, name, passwort_hash, rolle, email, einheit_id, aktiv, erstellt_am)'
|
||||||
|
f" VALUES ({q('b-' + einheit_id)}, {q(benutzername)}, {q(mieter)}, {q(passwort_hash)},"
|
||||||
|
f" 'mieter', '', {q(einheit_id)}, TRUE, {q(HEUTE)});",
|
||||||
|
)
|
||||||
|
# Zähler werden NICHT importiert: die CSV-Zählerspalten sind leer/'0'
|
||||||
|
if z['Stromzähler'].strip() not in ('', '0'):
|
||||||
|
warnungen.append(f"Stromzähler in {titel}/{we}: {z['Stromzähler'].strip()} (nicht importiert)")
|
||||||
|
|
||||||
|
sql.append('COMMIT;')
|
||||||
|
|
||||||
|
sys.stdout.write('\n'.join(sql) + '\n')
|
||||||
|
print(f'-- {len(haeuser)} Häuser, '
|
||||||
|
f"{sum(1 for z in zeilen if z['WE'].strip())} Wohneinheiten, "
|
||||||
|
f"{sum(1 for z in zeilen if z['Name des Mieters'].strip())} Mieter", file=sys.stderr)
|
||||||
|
for w in warnungen:
|
||||||
|
print('-- HINWEIS: ' + w, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
+11
-3
@@ -107,12 +107,20 @@ async function seed() {
|
|||||||
|
|
||||||
module.exports = { seed }
|
module.exports = { seed }
|
||||||
|
|
||||||
|
/** Der Demo-Bestand (Einheit hs-7) muss existieren, sonst gehören die
|
||||||
|
* Demo-Nachseeds (Zähler/Häuser/Kosten) nicht in diese Datenbank – z. B. nach
|
||||||
|
* dem Live-Import (v1.21) darf nichts mehr nachgeseedet werden. */
|
||||||
|
async function demoBestandVorhanden() {
|
||||||
|
const { rows } = await pool.query(`SELECT 1 FROM units WHERE id = 'hs-7'`)
|
||||||
|
return rows.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
/** Zähler-Demo-Daten: idempotent – läuft bei jedem Start und füllt die
|
/** Zähler-Demo-Daten: idempotent – läuft bei jedem Start und füllt die
|
||||||
* Zähler-Tabelle nur, wenn sie leer ist (auch nachträglich auf bestehenden
|
* Zähler-Tabelle nur, wenn sie leer ist (auch nachträglich auf bestehenden
|
||||||
* Datenbanken, z. B. nach dem v1.9-Update). */
|
* Datenbanken, z. B. nach dem v1.9-Update). */
|
||||||
async function seedeZaehler() {
|
async function seedeZaehler() {
|
||||||
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM meters')
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM meters')
|
||||||
if (rows[0].n > 0) return false
|
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
|
||||||
for (const z of meters) {
|
for (const z of meters) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am)
|
`INSERT INTO meters (id, unit_id, building, nummer, art, stand, stand_am, geaendert_am)
|
||||||
@@ -129,7 +137,7 @@ module.exports = { seed, seedeZaehler }
|
|||||||
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.10). */
|
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.10). */
|
||||||
async function seedeHaeuser() {
|
async function seedeHaeuser() {
|
||||||
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings')
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM buildings')
|
||||||
if (rows[0].n > 0) return false
|
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
|
||||||
for (const h of buildings) {
|
for (const h of buildings) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)
|
`INSERT INTO buildings (name, adresse, baujahr, stockwerke, etagenhoehe, wohneinheiten, quadratmeter)
|
||||||
@@ -146,7 +154,7 @@ module.exports = { seed, seedeZaehler, seedeHaeuser }
|
|||||||
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.14). */
|
* leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.14). */
|
||||||
async function seedeKosten() {
|
async function seedeKosten() {
|
||||||
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM costs')
|
const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM costs')
|
||||||
if (rows[0].n > 0) return false
|
if (rows[0].n > 0 || !(await demoBestandVorhanden())) return false
|
||||||
for (const k of kosten) {
|
for (const k of kosten) {
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am)
|
`INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const navItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const { role, me, logout, threads } = useStore()
|
const { role, me, logout, threads, buildings, units } = useStore()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const unread = threads.filter((t) =>
|
const unread = threads.filter((t) =>
|
||||||
role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin,
|
role === 'tenant' ? t.unreadByTenant : t.unreadByAdmin,
|
||||||
@@ -96,7 +96,7 @@ export default function AppLayout() {
|
|||||||
<div className="border-t border-white/10 p-4">
|
<div className="border-t border-white/10 p-4">
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-slate-300/70">
|
<div className="mb-2 flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-slate-300/70">
|
||||||
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
<Building2 className="h-3.5 w-3.5 shrink-0" />
|
||||||
<span>3 Häuser · 50 Wohnungen</span>
|
<span>{buildings.length} {buildings.length === 1 ? 'Haus' : 'Häuser'} · {units.length} {units.length === 1 ? 'Wohnung' : 'Wohnungen'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function NeuerBenutzerDialog() {
|
|||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
|
const [rolle, setRolle] = useState<BenutzerRolle>('mieter')
|
||||||
const [pw, setPw] = useState('0815')
|
const [pw, setPw] = useState('0815')
|
||||||
const [einheitId, setEinheitId] = useState('hs-7')
|
const [einheitId, setEinheitId] = useState(units[0]?.id ?? '')
|
||||||
const [fehler, setFehler] = useState<string | null>(null)
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
const submit = async () => {
|
const submit = async () => {
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ function TenantDashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminDashboard() {
|
function AdminDashboard() {
|
||||||
const { units, transactions, tickets, announcements } = useStore()
|
const { units, transactions, tickets, announcements, buildings: gebaeude } = useStore()
|
||||||
const rented = units.filter((u) => u.status === 'vermietet')
|
const rented = units.filter((u) => u.status === 'vermietet')
|
||||||
const occupancy = Math.round((rented.length / units.length) * 100)
|
const occupancy = Math.round((rented.length / units.length) * 100)
|
||||||
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 0)
|
const monthlyIncome = rented.reduce((s, u) => s + u.warmRent, 0)
|
||||||
@@ -229,10 +229,10 @@ function AdminDashboard() {
|
|||||||
.reduce((s, t) => s + t.amount, 0),
|
.reduce((s, t) => s + t.amount, 0),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const buildings = ['Hauptstraße 12', 'Ringweg 3', 'Am Park 5'].map((b) => {
|
const buildings = gebaeude.map((g) => {
|
||||||
const us = units.filter((u) => u.building === b)
|
const us = units.filter((u) => u.building === g.name)
|
||||||
return {
|
return {
|
||||||
name: b,
|
name: g.name,
|
||||||
Vermietet: us.filter((u) => u.status === 'vermietet').length,
|
Vermietet: us.filter((u) => u.status === 'vermietet').length,
|
||||||
Frei: us.filter((u) => u.status === 'frei').length,
|
Frei: us.filter((u) => u.status === 'frei').length,
|
||||||
}
|
}
|
||||||
@@ -243,13 +243,13 @@ function AdminDashboard() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Verwaltungs-Dashboard</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Verwaltungs-Dashboard</h1>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Bestand: 3 Häuser · 50 Wohnungen · {rented.length} vermietet
|
Bestand: {buildings.length} {buildings.length === 1 ? 'Haus' : 'Häuser'} · {units.length} Wohneinheiten · {rented.length} vermietet
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<StatCard icon={Building2} label="Wohnungen gesamt" value="50" sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
|
<StatCard icon={Building2} label="Wohneinheiten gesamt" value={String(units.length)} sub={`${units.filter((u) => u.status === 'frei').length} aktuell frei`} />
|
||||||
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von 50 Wohnungen`} />
|
<StatCard icon={Users} label="Vermietungsquote" value={`${occupancy} %`} sub={`${rented.length} von ${units.length} Wohneinheiten`} />
|
||||||
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
|
<StatCard icon={Euro} label="Monatliche Warmmiete (Soll)" value={fmtEUR.format(monthlyIncome)} sub="Summe aller vermieteten Einheiten" />
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={CircleAlert}
|
icon={CircleAlert}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ function DokumentDialog({ dokument, einheiten }: { dokument?: DocumentItem; einh
|
|||||||
<Label htmlFor="d-titel">Titel</Label>
|
<Label htmlFor="d-titel">Titel</Label>
|
||||||
<Input
|
<Input
|
||||||
id="d-titel"
|
id="d-titel"
|
||||||
placeholder="z. B. Mietvertrag – Hauptstraße 12, Whg. 7"
|
placeholder="z. B. Mietvertrag – Blücherstr. 59, Whg. 1"
|
||||||
value={titel}
|
value={titel}
|
||||||
onChange={(e) => setTitel(e.target.value)}
|
onChange={(e) => setTitel(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+1
-2
@@ -17,7 +17,6 @@ const features = [
|
|||||||
|
|
||||||
const demoZugaenge = [
|
const demoZugaenge = [
|
||||||
{ benutzername: 'j.hinterstein', label: 'Joseph Hinterstein (Verwaltung)' },
|
{ benutzername: 'j.hinterstein', label: 'Joseph Hinterstein (Verwaltung)' },
|
||||||
{ benutzername: 'a.berger', label: 'Anna Berger (Mieterin)' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -84,7 +83,7 @@ export default function Login() {
|
|||||||
<div className="absolute inset-0 bg-gradient-to-t from-[#22333b]/85 via-transparent to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-[#22333b]/85 via-transparent to-transparent" />
|
||||||
<div className="absolute bottom-0 p-6 text-white">
|
<div className="absolute bottom-0 p-6 text-white">
|
||||||
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
|
<div className="text-lg font-semibold">Ihr Zuhause. Ihr Portal.</div>
|
||||||
<div className="text-sm text-slate-200">3 Wohnanlagen · 50 Wohnungen · persönliche Verwaltung</div>
|
<div className="text-sm text-slate-200">Ihr Bestand. Ihre Mieter. Ein Portal.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export default function Messages() {
|
|||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
{role === 'tenant'
|
{role === 'tenant'
|
||||||
? 'Direktdraht zur Verwaltung – Antwort meist innerhalb eines Werktags'
|
? 'Direktdraht zur Verwaltung – Antwort meist innerhalb eines Werktags'
|
||||||
: 'Mieter-Kommunikation über alle 50 Wohnungen'}
|
: 'Mieter-Kommunikation über den gesamten Bestand'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewMessageDialog onCreated={setActiveId} />
|
<NewMessageDialog onCreated={setActiveId} />
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ function TenantPayments() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AdminPayments() {
|
function AdminPayments() {
|
||||||
const { units, transactions } = useStore()
|
const { units, transactions, buildings } = useStore()
|
||||||
const [building, setBuilding] = useState('alle')
|
const [building, setBuilding] = useState('alle')
|
||||||
|
|
||||||
const list = units
|
const list = units
|
||||||
@@ -161,7 +161,7 @@ function AdminPayments() {
|
|||||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
|
<h1 className="text-2xl font-bold tracking-tight">Mietkonto & Zahlungen</h1>
|
||||||
<p className="text-sm text-slate-500">Forderungsmanagement für 50 Wohnungen</p>
|
<p className="text-sm text-slate-500">Forderungsmanagement für den gesamten Bestand</p>
|
||||||
</div>
|
</div>
|
||||||
<Select value={building} onValueChange={setBuilding}>
|
<Select value={building} onValueChange={setBuilding}>
|
||||||
<SelectTrigger className="w-56">
|
<SelectTrigger className="w-56">
|
||||||
@@ -169,9 +169,9 @@ function AdminPayments() {
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="alle">Alle Häuser</SelectItem>
|
<SelectItem value="alle">Alle Häuser</SelectItem>
|
||||||
<SelectItem value="Hauptstraße 12">Hauptstraße 12</SelectItem>
|
{buildings.map((b) => (
|
||||||
<SelectItem value="Ringweg 3">Ringweg 3</SelectItem>
|
<SelectItem key={b.name} value={b.name}>{b.name}</SelectItem>
|
||||||
<SelectItem value="Am Park 5">Am Park 5</SelectItem>
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,14 +46,14 @@ const fmtStand = (n: number) =>
|
|||||||
n.toLocaleString('de-DE', { maximumFractionDigits: 3 })
|
n.toLocaleString('de-DE', { maximumFractionDigits: 3 })
|
||||||
|
|
||||||
function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
function ZaehlerDialog({ meter }: { meter?: Meter }) {
|
||||||
const { addZaehler, updateZaehler, units } = useStore()
|
const { addZaehler, updateZaehler, units, buildings } = useStore()
|
||||||
const bearbeiten = Boolean(meter)
|
const bearbeiten = Boolean(meter)
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [nummer, setNummer] = useState(meter?.nummer ?? '')
|
const [nummer, setNummer] = useState(meter?.nummer ?? '')
|
||||||
const [art, setArt] = useState<ZaehlerArt>(meter?.art ?? 'Strom')
|
const [art, setArt] = useState<ZaehlerArt>(meter?.art ?? 'Strom')
|
||||||
const [stand, setStand] = useState(String(meter?.stand ?? '').replace('.', ','))
|
const [stand, setStand] = useState(String(meter?.stand ?? '').replace('.', ','))
|
||||||
const [unitId, setUnitId] = useState<string>(meter?.unitId ?? 'haus')
|
const [unitId, setUnitId] = useState<string>(meter?.unitId ?? 'haus')
|
||||||
const [building, setBuilding] = useState(meter?.building ?? 'Hauptstraße 12')
|
const [building, setBuilding] = useState(meter?.building ?? buildings[0]?.name ?? '')
|
||||||
const [sende, setSende] = useState(false)
|
const [sende, setSende] = useState(false)
|
||||||
const [fehler, setFehler] = useState<string | null>(null)
|
const [fehler, setFehler] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -38,9 +38,9 @@ import type {
|
|||||||
import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
|
import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api'
|
||||||
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
import { sha256, DEMO_PASSWORT } from '../lib/auth'
|
||||||
|
|
||||||
/** Demo-Einheit als Fallback, falls ein Mieter noch keine Wohnung zugewiesen hat. */
|
/** Fallback: Mieter ohne Wohnungszuweisung sehen die erste Einheit des Bestands. */
|
||||||
export const DEMO_TENANT = 'Anna Berger'
|
export const DEMO_TENANT = ''
|
||||||
export const DEMO_UNIT_ID = 'hs-7'
|
export const DEMO_UNIT_ID = ''
|
||||||
|
|
||||||
interface Bootstrap {
|
interface Bootstrap {
|
||||||
units: Unit[]
|
units: Unit[]
|
||||||
|
|||||||
Reference in New Issue
Block a user