diff --git a/kimi.md b/kimi.md index 4345425..c263c0a 100644 --- a/kimi.md +++ b/kimi.md @@ -189,6 +189,24 @@ tar -czf mp-dist.tar.gz -C dist . ## 8. Verlauf / offene Punkte +**Erledigt 04.10. (v1.14):** Steuermodul „Steuer & Kosten" (/kosten, Nav zwischen +Zählerstände und Dokumente, für beide Rollen sichtbar). Neue Tabelle `costs` +(building NOT NULL → buildings.name, unit_id NULL = hausweite Position, jahr, +kostenart, betrag, umlegbar BOOLEAN, notiz, erfasst_am), idempot vorbefüllt +mit 16 Demo-Positionen 2025/2026 (`seedeKosten()`). API (adminOnly): +POST/PATCH/DELETE /api/costs(/:id); Validierung: Kostenart aus KOSTEN_ARTEN +(15 Werte), jahr 2000..aktuell+1, betrag ≥ 0, Haus muss in buildings existieren +– bei gesetzter unitId folgt das Haus IMMER der Wohnung (eingereichtes Haus +wird überschrieben, vgl. meters). Bootstrap: Admin alle (jahr DESC), Mieter nur +eigene Wohnung. Frontend: Summenkarten (umlegbar/nicht umlegbar/Gesamt über die +Filterauswahl), Filterchips Umlegbarkeit + Selects Jahr/Haus (Admin), Tabelle +mit Kostenart/Notiz, Haus, Wohnung, Jahr, Badge, Betrag; Admin-Dialog +(Anlegen/Bearbeiten) mit Kostenart-, Umlegbarkeits-, Jahr-, Betrag-, Haus- +(disabled bei Wohnungswahl) und Wohnung-Select („Ganzes Haus" = Sentinel +'__haus'). Store: addKosten/updateKosten/deleteKosten. E2E verifiziert: +Hausableitung, Patch, Validierung 400 (Kostenart/Haus/Jahr/Betrag), Mieter +403, Löschen, Testdaten aufgeräumt. + **Erledigt 04.10. (v1.13):** Echter PDF-Upload für das Dokumentenmodul. Neue Dep multer (^2.0.2, server/package-lock.json liegt jetzt im Repo, Docker baut mit `npm ci`). DB: `documents` um `file_name`/`file_path` erweitert diff --git a/server/src/db.js b/server/src/db.js index ad4a897..cb31565 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -139,6 +139,20 @@ CREATE TABLE IF NOT EXISTS buildings ( wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll) quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m² ); + +CREATE TABLE IF NOT EXISTS costs ( + id TEXT PRIMARY KEY, + building TEXT NOT NULL REFERENCES buildings(name), -- Haus + unit_id TEXT REFERENCES units(id), -- NULL = hausweite Position + jahr INT NOT NULL, + kostenart TEXT NOT NULL, + betrag NUMERIC(10,2) NOT NULL, + umlegbar BOOLEAN NOT NULL DEFAULT TRUE, -- umlegbar / nicht umlegbar + notiz TEXT NOT NULL DEFAULT '', + erfasst_am TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_costs_building ON costs(building); +CREATE INDEX IF NOT EXISTS idx_costs_unit ON costs(unit_id); ` async function init() { diff --git a/server/src/index.js b/server/src/index.js index b0a23eb..627a600 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -12,7 +12,7 @@ const fs = require('fs') const path = require('path') const multer = require('multer') const { pool, init, sessionsAufraeumen } = require('./db') -const { seed, seedeZaehler, seedeHaeuser } = require('./seed') +const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed') const mail = require('./mail') const PORT = Number(process.env.PORT || 3000) @@ -177,6 +177,11 @@ const mapBuilding = (b) => ({ etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten, quadratmeter: Number(b.quadratmeter), }) +const mapKosten = (k) => ({ + id: k.id, building: k.building, unitId: k.unit_id, jahr: k.jahr, + kostenart: k.kostenart, betrag: Number(k.betrag), umlegbar: k.umlegbar, + notiz: k.notiz, erfasstAm: k.erfasst_am, +}) const deutschesDatum = () => new Date().toLocaleDateString('de-DE') const deutschesDatumZeit = () => new Date().toLocaleString('de-DE') @@ -280,7 +285,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { try { const istAdmin = req.user.rolle === 'verwaltung' const einheit = req.user.einheit_id - const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings] = + const [units, transactions, documents, tickets, threads, messages, announcements, users, codes, meters, buildings, costs] = await Promise.all([ pool.query('SELECT * FROM units ORDER BY id'), istAdmin @@ -311,6 +316,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { istAdmin ? pool.query('SELECT * FROM buildings ORDER BY name') : { rows: [] }, + istAdmin + ? pool.query('SELECT * FROM costs ORDER BY jahr DESC, building, kostenart') + : pool.query('SELECT * FROM costs WHERE unit_id = $1 ORDER BY jahr DESC, kostenart', [einheit]), ]) // Threads mit ihren Nachrichten zusammenführen const nachrichtenProThread = {} @@ -332,6 +340,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => { codes: codes.rows.map(mapCode), meters: meters.rows.map(mapMeter), buildings: buildings.rows.map(mapBuilding), + kosten: costs.rows.map(mapKosten), }) } catch (e) { next(e) } }) @@ -625,6 +634,84 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => { } catch (e) { next(e) } }) +// ── Steuer & Kosten (Verwaltung) ───────────────────────────── +const KOSTEN_ARTEN = [ + 'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege', + 'Hausreinigung', 'Hauswart', 'Aufzug', 'Versicherung', 'Grundsteuer', + 'Heizung/Warmwasser', 'Kabelanschluss', 'Verwaltung', 'Instandhaltung', + 'Mietausfall', 'Sonstiges', +] + +async function kostenGueltig(b, res) { + const kostenart = String(b.kostenart || '').trim() + if (!KOSTEN_ARTEN.includes(kostenart)) { res.status(400).json({ fehler: 'Ungültige Kostenart.' }); return null } + const jahr = Number(b.jahr) + if (!Number.isInteger(jahr) || jahr < 2000 || jahr > new Date().getFullYear() + 1) { res.status(400).json({ fehler: 'Jahr ungültig.' }); return null } + const betrag = Number(b.betrag) + if (!Number.isFinite(betrag) || betrag < 0) { res.status(400).json({ fehler: 'Betrag ungültig.' }); return null } + // Haus folgt der Wohnung: unitId gesetzt → Wohnung muss existieren, Haus wird übernommen + let einheitId = b.unitId !== undefined ? (b.unitId || null) : undefined + let haus = String(b.building || '').trim() + if (einheitId) { + const { rows: u } = await pool.query('SELECT building FROM units WHERE id = $1', [einheitId]) + if (!u.length) { res.status(400).json({ fehler: 'Wohnung unbekannt.' }); return null } + haus = u[0].building + } + if (!haus) { res.status(400).json({ fehler: 'Haus fehlt.' }); return null } + const { rows: h } = await pool.query('SELECT name FROM buildings WHERE name = $1', [haus]) + if (!h.length) { res.status(400).json({ fehler: 'Haus unbekannt.' }); return null } + return { kostenart, jahr, betrag, einheitId, haus, notiz: String(b.notiz || '').trim() } +} + +app.post('/api/costs', auth, adminOnly, async (req, res, next) => { + try { + const g = await kostenGueltig(req.body || {}, res) + if (!g) return + const { rows } = await pool.query( + `INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *`, + [`k-${Date.now()}`, g.haus, g.einheitId ?? null, g.jahr, g.kostenart, g.betrag, + req.body.umlegbar !== false, g.notiz, deutschesDatumZeit()], + ) + res.json({ kosten: mapKosten(rows[0]) }) + } catch (e) { next(e) } +}) + +app.patch('/api/costs/:id', auth, adminOnly, async (req, res, next) => { + try { + const { rows } = await pool.query('SELECT * FROM costs WHERE id = $1', [req.params.id]) + if (!rows.length) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' }) + const k = rows[0] + const b = req.body || {} + const kandidat = { + kostenart: b.kostenart !== undefined ? b.kostenart : k.kostenart, + jahr: b.jahr !== undefined ? b.jahr : k.jahr, + betrag: b.betrag !== undefined ? b.betrag : k.betrag, + unitId: b.unitId !== undefined ? b.unitId : k.unit_id, + building: b.building !== undefined ? b.building : k.building, + notiz: b.notiz !== undefined ? b.notiz : k.notiz, + } + const g = await kostenGueltig(kandidat, res) + if (!g) return + const einheitId = g.einheitId === undefined ? k.unit_id : g.einheitId + const { rows: aktualisiert } = await pool.query( + `UPDATE costs SET building = $1, unit_id = $2, jahr = $3, kostenart = $4, betrag = $5, umlegbar = $6, notiz = $7 + WHERE id = $8 RETURNING *`, + [g.haus, einheitId, g.jahr, g.kostenart, g.betrag, + b.umlegbar !== undefined ? Boolean(b.umlegbar) : k.umlegbar, g.notiz, req.params.id], + ) + res.json({ kosten: mapKosten(aktualisiert[0]) }) + } catch (e) { next(e) } +}) + +app.delete('/api/costs/:id', auth, adminOnly, async (req, res, next) => { + try { + const { rowCount } = await pool.query('DELETE FROM costs WHERE id = $1', [req.params.id]) + if (!rowCount) return res.status(404).json({ fehler: 'Kostenposition nicht gefunden.' }) + res.json({ ok: true }) + } catch (e) { next(e) } +}) + // ── Bestand: Häuser & Wohnungen (Verwaltung) ──────────────── function pruefeHaus(b) { const fehler = [] @@ -1006,6 +1093,8 @@ async function start() { if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt') const haeuserGeseedet = await seedeHaeuser() if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt') + const kostenGeseedet = await seedeKosten() + if (kostenGeseedet) console.log('[api] Kosten-Demo-Daten eingespielt') setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref() app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`)) } diff --git a/server/src/seed-data.js b/server/src/seed-data.js index 7cf6e26..0a2b94b 100644 --- a/server/src/seed-data.js +++ b/server/src/seed-data.js @@ -174,4 +174,30 @@ const buildings = [ { name: 'Am Park 5', adresse: 'Am Park 5', baujahr: 2019, stockwerke: 3, etagenhoehe: 2.60, wohneinheiten: 15, quadratmeter: 1110.0 }, ] -module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } +// Steuermodul: umlegbare und nicht umlegbare Kosten (Demo 2025/2026). +// unit_id null = hausweite Position, sonst Kosten der konkreten Wohnung. +const kosten = [ + // Hauptstraße 12 – hausweit + { id: 'k-1', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Wasser/Abwasser', betrag: 4860.00, umlegbar: true, notiz: 'Verbrauch abrechnungsreif nach Zählern', erfasst_am: '15.01.2026, 09:00' }, + { id: 'k-2', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Müllabfuhr', betrag: 2210.40, umlegbar: true, notiz: 'Stadtwerke, 4-Behälter-Service', erfasst_am: '15.01.2026, 09:02' }, + { id: 'k-3', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Hausreinigung', betrag: 3944.00, umlegbar: true, notiz: 'Treppenhaus wöchentlich', erfasst_am: '15.01.2026, 09:03' }, + { id: 'k-4', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Versicherung', betrag: 1872.00, umlegbar: true, notiz: 'Haus- und Grundbesitzerhaftpflicht', erfasst_am: '15.01.2026, 09:04' }, + { id: 'k-5', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Instandhaltung', betrag: 7380.00, umlegbar: false, notiz: 'Fassadensanierung Südseite (Verrechnung über Rücklage)', erfasst_am: '20.01.2026, 11:30' }, + { id: 'k-6', building: 'Hauptstraße 12', unit_id: null, jahr: 2025, kostenart: 'Verwaltung', betrag: 5400.00, umlegbar: false, notiz: 'Anteilige Verwaltungskosten (nicht umlegbarer Teil)', erfasst_am: '20.01.2026, 11:31' }, + // Hauptstraße 12 – Wohnung hs-7 (Anna Berger) + { id: 'k-7', building: 'Hauptstraße 12', unit_id: 'hs-7', jahr: 2025, kostenart: 'Heizung/Warmwasser', betrag: 912.55, umlegbar: true, notiz: 'Verbrauch 2025 laut Heizkostenrechnung', erfasst_am: '22.01.2026, 14:10' }, + // Ringweg 3 – hausweit + { id: 'k-8', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Straßenreinigung/Winterdienst', betrag: 1284.00, umlegbar: true, notiz: '', erfasst_am: '16.01.2026, 10:00' }, + { id: 'k-9', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Gartenpflege', betrag: 2160.00, umlegbar: true, notiz: 'Hof- und Vorgartenpflege April–Oktober', erfasst_am: '16.01.2026, 10:01' }, + { id: 'k-10', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Grundsteuer', betrag: 1490.00, umlegbar: true, notiz: '', erfasst_am: '16.01.2026, 10:02' }, + { id: 'k-11', building: 'Ringweg 3', unit_id: null, jahr: 2025, kostenart: 'Mietausfall', betrag: 2310.00, umlegbar: false, notiz: 'Leerstand Whg. 9 (März–Juli)', erfasst_am: '21.01.2026, 08:45' }, + // Ringweg 3 – Wohnung rw-4 + { id: 'k-12', building: 'Ringweg 3', unit_id: 'rw-4', jahr: 2025, kostenart: 'Heizung/Warmwasser', betrag: 764.20, umlegbar: true, notiz: '', erfasst_am: '22.01.2026, 14:12' }, + // Am Park 5 – hausweit + { id: 'k-13', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Aufzug', betrag: 1980.00, umlegbar: true, notiz: 'Wartung + TÜV, vierteljährlich', erfasst_am: '03.10.2026, 09:20' }, + { id: 'k-14', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Kabelanschluss', betrag: 960.00, umlegbar: true, notiz: 'Hausvertrag Kabelnetz', erfasst_am: '03.10.2026, 09:21' }, + { id: 'k-15', building: 'Am Park 5', unit_id: null, jahr: 2026, kostenart: 'Instandhaltung', betrag: 3150.00, umlegbar: false, notiz: 'Garagentor-Antrieb erneuert', erfasst_am: '03.10.2026, 09:22' }, + { id: 'k-16', building: 'Am Park 5', unit_id: 'ap-2', jahr: 2026, kostenart: 'Heizung/Warmwasser', betrag: 388.10, umlegbar: true, notiz: 'Abschlag 1. Halbjahr', erfasst_am: '03.10.2026, 09:25' }, +] + +module.exports = { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings, kosten } diff --git a/server/src/seed.js b/server/src/seed.js index 84bb716..7726200 100644 --- a/server/src/seed.js +++ b/server/src/seed.js @@ -5,7 +5,7 @@ */ const { pool } = require('./db') -const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings } = require('./seed-data') +const { users, registrationCodes, tickets, documents, threads, announcements, meters, buildings, kosten } = require('./seed-data') const generated = require('../seed/generated.json') async function seed() { @@ -141,3 +141,20 @@ async function seedeHaeuser() { } module.exports = { seed, seedeZaehler, seedeHaeuser } + +/** Kosten-Demo-Daten: idempotent – füllt die costs-Tabelle nur, wenn sie + * leer ist (auch nachträglich auf bestehenden Datenbanken, z. B. v1.14). */ +async function seedeKosten() { + const { rows } = await pool.query('SELECT COUNT(*)::int AS n FROM costs') + if (rows[0].n > 0) return false + for (const k of kosten) { + await pool.query( + `INSERT INTO costs (id, building, unit_id, jahr, kostenart, betrag, umlegbar, notiz, erfasst_am) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)`, + [k.id, k.building, k.unit_id, k.jahr, k.kostenart, k.betrag, k.umlegbar, k.notiz, k.erfasst_am], + ) + } + return true +} + +module.exports = { seed, seedeZaehler, seedeHaeuser, seedeKosten } diff --git a/src/App.tsx b/src/App.tsx index cbc01de..9938dce 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import Tickets from './pages/Tickets' import Documents from './pages/Documents' import Messages from './pages/Messages' import Zaehler from './pages/Zaehler' +import Kosten from './pages/Kosten' import Bestand from './pages/Bestand' import BenutzerVerwaltung from './pages/BenutzerVerwaltung' @@ -83,6 +84,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index c76762d..74f3a71 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -9,6 +9,7 @@ import { LogOut, Building2, Gauge, + Calculator, } from 'lucide-react' import { useStore } from '../store/store' import ThemeToggle from './ThemeToggle' @@ -19,6 +20,7 @@ const navItems = [ { to: '/mietkonto', label: 'Mietkonto & Zahlungen', icon: Wallet }, { to: '/schadensmeldungen', label: 'Schadensmeldungen', icon: Wrench }, { to: '/zaehler', label: 'Zählerstände', icon: Gauge }, + { to: '/kosten', label: 'Steuer & Kosten', icon: Calculator }, { to: '/dokumente', label: 'Dokumente', icon: FileText }, { to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare }, ] diff --git a/src/pages/Kosten.tsx b/src/pages/Kosten.tsx new file mode 100644 index 0000000..8011dc4 --- /dev/null +++ b/src/pages/Kosten.tsx @@ -0,0 +1,375 @@ +import { useMemo, useState } from 'react' +import { Calculator, Receipt, ReceiptText, PiggyBank, Plus, Pencil, Trash2, Coins } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, +} from '@/components/ui/dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useStore, fmtEUR } from '../store/store' +import type { KostenItem } from '../types' + +const KOSTEN_ARTEN = [ + 'Wasser/Abwasser', 'Müllabfuhr', 'Straßenreinigung/Winterdienst', 'Gartenpflege', + 'Hausreinigung', 'Hauswart', 'Aufzug', 'Versicherung', 'Grundsteuer', + 'Heizung/Warmwasser', 'Kabelanschluss', 'Verwaltung', 'Instandhaltung', + 'Mietausfall', 'Sonstiges', +] + +/** Komma oder Punkt als Dezimaltrenner akzeptieren. */ +const zahl = (s: string) => Number(s.replace(',', '.')) + +function KostenDialog({ position }: { position?: KostenItem }) { + const { addKosten, updateKosten, units, buildings } = useStore() + const bearbeiten = Boolean(position) + const [open, setOpen] = useState(false) + const [kostenart, setKostenart] = useState(position?.kostenart ?? 'Wasser/Abwasser') + const [jahr, setJahr] = useState(String(position?.jahr ?? new Date().getFullYear())) + const [betrag, setBetrag] = useState(position ? String(position.betrag).replace('.', ',') : '') + const [einheit, setEinheit] = useState(position?.unitId ?? '__haus') // '__haus' = ganzes Haus + const [haus, setHaus] = useState(position?.building ?? buildings[0]?.name ?? '') + const [umlegbar, setUmlegbar] = useState(position?.umlegbar ?? true) + const [notiz, setNotiz] = useState(position?.notiz ?? '') + const [sende, setSende] = useState(false) + const [fehler, setFehler] = useState(null) + + const submit = async () => { + if (sende) return + setSende(true) + setFehler(null) + const payload = { + kostenart, + jahr: zahl(jahr), + betrag: zahl(betrag), + unitId: einheit === '__haus' ? null : einheit, + building: haus, + umlegbar, + notiz: notiz.trim(), + } + const err = bearbeiten ? await updateKosten(position!.id, payload) : await addKosten(payload) + setSende(false) + if (err) return setFehler(err) + setOpen(false) + } + + return ( + + + {bearbeiten ? ( + + ) : ( + + )} + + + + {bearbeiten ? 'Kostenposition bearbeiten' : 'Neue Kostenposition erfassen'} + +
+
+
+ + +
+
+ + +
+
+
+
+ + setJahr(e.target.value)} /> +
+
+ + setBetrag(e.target.value)} /> +
+
+
+
+ + + {einheit !== '__haus' && ( +

Das Haus folgt automatisch der gewählten Wohnung.

+ )} +
+
+ + +
+
+
+ + setNotiz(e.target.value)} /> +
+ {fehler &&

{fehler}

} +
+ + + + +
+
+ ) +} + +export default function Kosten() { + const { kosten, role, units, buildings, deleteKosten } = useStore() + const [jahrFilter, setJahrFilter] = useState('alle') + const [hausFilter, setHausFilter] = useState('alle') + const [artFilter, setArtFilter] = useState<'alle' | 'umlegbar' | 'nicht'>('alle') + + const jahre = useMemo( + () => Array.from(new Set(kosten.map((k) => k.jahr))).sort((a, b) => b - a), + [kosten], + ) + + const filtered = useMemo(() => { + return kosten.filter( + (k) => + (jahrFilter === 'alle' || k.jahr === Number(jahrFilter)) && + (hausFilter === 'alle' || k.building === hausFilter) && + (artFilter === 'alle' || (artFilter === 'umlegbar' ? k.umlegbar : !k.umlegbar)), + ) + }, [kosten, jahrFilter, hausFilter, artFilter]) + + const summeUmlegbar = filtered.filter((k) => k.umlegbar).reduce((s, k) => s + k.betrag, 0) + const summeNicht = filtered.filter((k) => !k.umlegbar).reduce((s, k) => s + k.betrag, 0) + + const loeschen = async (k: KostenItem) => { + if (!window.confirm(`${k.kostenart} ${k.jahr} (${fmtEUR.format(k.betrag)}) wirklich löschen?`)) return + await deleteKosten(k.id) + } + + const einheitLabel = (k: KostenItem) => + k.unitId ? (() => { + const u = units.find((x) => x.id === k.unitId) + return u ? `Whg. ${u.number}` : k.unitId + })() : 'Ganzes Haus' + + return ( +
+
+
+

Steuer & Kosten

+

+ {role === 'tenant' + ? 'Umlegbare und nicht umlegbare Kosten Ihrer Wohnung' + : 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'} +

+
+ {role === 'admin' && } +
+ +
+
+
+ Umlegbar (Filterauswahl) +
+
{fmtEUR.format(summeUmlegbar)}
+
{filtered.filter((k) => k.umlegbar).length} Positionen
+
+
+
+ Nicht umlegbar (Eigentümer) +
+
{fmtEUR.format(summeNicht)}
+
{filtered.filter((k) => !k.umlegbar).length} Positionen
+
+
+
+ Gesamt +
+
{fmtEUR.format(summeUmlegbar + summeNicht)}
+
{filtered.length} Positionen gesamt
+
+
+ +
+
+ {(['alle', 'umlegbar', 'nicht'] as const).map((a) => ( + + ))} +
+ {role === 'admin' && ( + <> + + + + )} +
+ +
+ {filtered.length === 0 ? ( +
+ + Keine Kostenpositionen gefunden. +
+ ) : ( + + + + Kostenart + {role === 'admin' && Haus} + Wohnung + Jahr + Umlegbarkeit + Betrag + {role === 'admin' && Aktion} + + + + {filtered.map((k) => ( + + +
+ +
+ {k.kostenart} + {k.notiz &&
{k.notiz}
} +
+
+
+ {role === 'admin' && {k.building}} + {einheitLabel(k)} + {k.jahr} + + + {k.umlegbar ? 'Umlegbar' : 'Nicht umlegbar'} + + + {fmtEUR.format(k.betrag)} + {role === 'admin' && ( + +
+ + +
+
+ )} +
+ ))} +
+
+ )} +
+ +

+ Umlegbare Kosten werden nach den Vorgaben der Betriebskostenverordnung auf die Mieter + umgelegt; nicht umlegbare Positionen trägt die Eigentümerseite. Erfasst von der + Verwaltung, Grundlage der jährlichen Nebenkostenabrechnung. +

+
+ ) +} diff --git a/src/store/store.tsx b/src/store/store.tsx index ea9f0e6..fae9300 100644 --- a/src/store/store.tsx +++ b/src/store/store.tsx @@ -32,6 +32,7 @@ import type { Meter, ZaehlerArt, Building, + KostenItem, } from '../types' import { apiGet, apiPost, apiPatch, apiDelete, ApiFehler } from '../lib/api' import { sha256, DEMO_PASSWORT } from '../lib/auth' @@ -51,6 +52,7 @@ interface Bootstrap { codes: RegistrierungsCode[] meters: Meter[] buildings: Building[] + kosten: KostenItem[] } const LEER: Bootstrap = { @@ -64,6 +66,7 @@ const LEER: Bootstrap = { codes: [], meters: [], buildings: [], + kosten: [], } interface Store extends Bootstrap { @@ -139,6 +142,21 @@ interface Store extends Bootstrap { deleteDokument: (id: string) => Promise /** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */ syncDokument: (dokument: DocumentItem) => void + // ── Steuer & Kosten (Verwaltung pflegt, Mieter lesen eigene Wohnung) ── + addKosten: (k: { + building: string + unitId: string | null + jahr: number + kostenart: string + betrag: number + umlegbar: boolean + notiz: string + }) => Promise + updateKosten: ( + id: string, + k: Partial<{ building: string; unitId: string | null; jahr: number; kostenart: string; betrag: number; umlegbar: boolean; notiz: string }>, + ) => Promise + deleteKosten: (id: string) => Promise } const StoreContext = createContext(null) @@ -399,6 +417,34 @@ export function StoreProvider({ children }: { children: ReactNode }) { : [dokument, ...x.documents], })) }, + // ── Steuer & Kosten ───────────────────────────────────── + async addKosten(k) { + try { + const { kosten: position } = await apiPost<{ kosten: KostenItem }>('/api/costs', k) + setDaten((x) => ({ ...x, kosten: [position, ...x.kosten] })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async updateKosten(id, k) { + try { + const { kosten: position } = await apiPatch<{ kosten: KostenItem }>(`/api/costs/${id}`, k) + setDaten((x) => ({ ...x, kosten: x.kosten.map((m) => (m.id === id ? position : m)) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, + async deleteKosten(id) { + try { + await apiDelete(`/api/costs/${id}`) + setDaten((x) => ({ ...x, kosten: x.kosten.filter((m) => m.id !== id) })) + return null + } catch (e) { + return fehlerNachricht(e) + } + }, }), [daten, bereit, me, role, meineEinheit, ladeAlles]) return {children} diff --git a/src/types/index.ts b/src/types/index.ts index 7ba06de..b2534b0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -120,3 +120,16 @@ export interface Meter { standAm: string geaendertAm: string } + +/** Kostenposition des Steuermoduls (umlegbar / nicht umlegbar). */ +export interface KostenItem { + id: string + building: string // Haus + unitId: string | null // null = hausweite Kostenposition + jahr: number + kostenart: string + betrag: number // € + umlegbar: boolean + notiz: string + erfasstAm: string +}