v1.14: Steuermodul - umlegbare und nicht umlegbare Kosten nach Haus und Wohnung
This commit is contained in:
@@ -189,6 +189,24 @@ tar -czf mp-dist.tar.gz -C dist .
|
|||||||
|
|
||||||
## 8. Verlauf / offene Punkte
|
## 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
|
**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
|
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
|
baut mit `npm ci`). DB: `documents` um `file_name`/`file_path` erweitert
|
||||||
|
|||||||
@@ -139,6 +139,20 @@ CREATE TABLE IF NOT EXISTS buildings (
|
|||||||
wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll)
|
wohneinheiten INT NOT NULL, -- Anzahl Wohneinheiten (Soll)
|
||||||
quadratmeter NUMERIC(9,1) NOT NULL -- Gesamtwohnfläche in m²
|
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() {
|
async function init() {
|
||||||
|
|||||||
+91
-2
@@ -12,7 +12,7 @@ const fs = require('fs')
|
|||||||
const path = require('path')
|
const path = require('path')
|
||||||
const multer = require('multer')
|
const multer = require('multer')
|
||||||
const { pool, init, sessionsAufraeumen } = require('./db')
|
const { pool, init, sessionsAufraeumen } = require('./db')
|
||||||
const { seed, seedeZaehler, seedeHaeuser } = require('./seed')
|
const { seed, seedeZaehler, seedeHaeuser, seedeKosten } = require('./seed')
|
||||||
const mail = require('./mail')
|
const mail = require('./mail')
|
||||||
|
|
||||||
const PORT = Number(process.env.PORT || 3000)
|
const PORT = Number(process.env.PORT || 3000)
|
||||||
@@ -177,6 +177,11 @@ const mapBuilding = (b) => ({
|
|||||||
etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten,
|
etagenhoehe: Number(b.etagenhoehe), wohneinheiten: b.wohneinheiten,
|
||||||
quadratmeter: Number(b.quadratmeter),
|
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 deutschesDatum = () => new Date().toLocaleDateString('de-DE')
|
||||||
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
const deutschesDatumZeit = () => new Date().toLocaleString('de-DE')
|
||||||
@@ -280,7 +285,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const istAdmin = req.user.rolle === 'verwaltung'
|
const istAdmin = req.user.rolle === 'verwaltung'
|
||||||
const einheit = req.user.einheit_id
|
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([
|
await Promise.all([
|
||||||
pool.query('SELECT * FROM units ORDER BY id'),
|
pool.query('SELECT * FROM units ORDER BY id'),
|
||||||
istAdmin
|
istAdmin
|
||||||
@@ -311,6 +316,9 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
istAdmin
|
istAdmin
|
||||||
? pool.query('SELECT * FROM buildings ORDER BY name')
|
? pool.query('SELECT * FROM buildings ORDER BY name')
|
||||||
: { rows: [] },
|
: { 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
|
// Threads mit ihren Nachrichten zusammenführen
|
||||||
const nachrichtenProThread = {}
|
const nachrichtenProThread = {}
|
||||||
@@ -332,6 +340,7 @@ app.get('/api/bootstrap', auth, async (req, res, next) => {
|
|||||||
codes: codes.rows.map(mapCode),
|
codes: codes.rows.map(mapCode),
|
||||||
meters: meters.rows.map(mapMeter),
|
meters: meters.rows.map(mapMeter),
|
||||||
buildings: buildings.rows.map(mapBuilding),
|
buildings: buildings.rows.map(mapBuilding),
|
||||||
|
kosten: costs.rows.map(mapKosten),
|
||||||
})
|
})
|
||||||
} catch (e) { next(e) }
|
} catch (e) { next(e) }
|
||||||
})
|
})
|
||||||
@@ -625,6 +634,84 @@ app.get('/api/documents/:id/download', auth, async (req, res, next) => {
|
|||||||
} catch (e) { next(e) }
|
} 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) ────────────────
|
// ── Bestand: Häuser & Wohnungen (Verwaltung) ────────────────
|
||||||
function pruefeHaus(b) {
|
function pruefeHaus(b) {
|
||||||
const fehler = []
|
const fehler = []
|
||||||
@@ -1006,6 +1093,8 @@ async function start() {
|
|||||||
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
|
if (zaehlerGeseedet) console.log('[api] Zähler-Demo-Daten eingespielt')
|
||||||
const haeuserGeseedet = await seedeHaeuser()
|
const haeuserGeseedet = await seedeHaeuser()
|
||||||
if (haeuserGeseedet) console.log('[api] Haus-Demo-Daten eingespielt')
|
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()
|
setInterval(sessionsAufraeumen, 60 * 60 * 1000).unref()
|
||||||
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
app.listen(PORT, () => console.log(`[api] mp-api lauscht auf :${PORT}`))
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-1
@@ -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 },
|
{ 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 }
|
||||||
|
|||||||
+18
-1
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const { pool } = require('./db')
|
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')
|
const generated = require('../seed/generated.json')
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
@@ -141,3 +141,20 @@ async function seedeHaeuser() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { seed, seedeZaehler, 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 }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Tickets from './pages/Tickets'
|
|||||||
import Documents from './pages/Documents'
|
import Documents from './pages/Documents'
|
||||||
import Messages from './pages/Messages'
|
import Messages from './pages/Messages'
|
||||||
import Zaehler from './pages/Zaehler'
|
import Zaehler from './pages/Zaehler'
|
||||||
|
import Kosten from './pages/Kosten'
|
||||||
import Bestand from './pages/Bestand'
|
import Bestand from './pages/Bestand'
|
||||||
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
import BenutzerVerwaltung from './pages/BenutzerVerwaltung'
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export default function App() {
|
|||||||
<Route path="/mietkonto" element={<Payments />} />
|
<Route path="/mietkonto" element={<Payments />} />
|
||||||
<Route path="/schadensmeldungen" element={<Tickets />} />
|
<Route path="/schadensmeldungen" element={<Tickets />} />
|
||||||
<Route path="/zaehler" element={<Zaehler />} />
|
<Route path="/zaehler" element={<Zaehler />} />
|
||||||
|
<Route path="/kosten" element={<Kosten />} />
|
||||||
<Route path="/bestand" element={<Bestand />} />
|
<Route path="/bestand" element={<Bestand />} />
|
||||||
<Route path="/dokumente" element={<Documents />} />
|
<Route path="/dokumente" element={<Documents />} />
|
||||||
<Route path="/nachrichten" element={<Messages />} />
|
<Route path="/nachrichten" element={<Messages />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
Building2,
|
Building2,
|
||||||
Gauge,
|
Gauge,
|
||||||
|
Calculator,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useStore } from '../store/store'
|
import { useStore } from '../store/store'
|
||||||
import ThemeToggle from './ThemeToggle'
|
import ThemeToggle from './ThemeToggle'
|
||||||
@@ -19,6 +20,7 @@ const navItems = [
|
|||||||
{ to: '/mietkonto', label: 'Mietkonto & Zahlungen', icon: Wallet },
|
{ to: '/mietkonto', label: 'Mietkonto & Zahlungen', icon: Wallet },
|
||||||
{ to: '/schadensmeldungen', label: 'Schadensmeldungen', icon: Wrench },
|
{ to: '/schadensmeldungen', label: 'Schadensmeldungen', icon: Wrench },
|
||||||
{ to: '/zaehler', label: 'Zählerstände', icon: Gauge },
|
{ to: '/zaehler', label: 'Zählerstände', icon: Gauge },
|
||||||
|
{ to: '/kosten', label: 'Steuer & Kosten', icon: Calculator },
|
||||||
{ to: '/dokumente', label: 'Dokumente', icon: FileText },
|
{ to: '/dokumente', label: 'Dokumente', icon: FileText },
|
||||||
{ to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare },
|
{ to: '/nachrichten', label: 'Nachrichten', icon: MessagesSquare },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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<string | null>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{bearbeiten ? (
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5">
|
||||||
|
<Pencil className="h-3.5 w-3.5" /> Bearbeiten
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="gap-2">
|
||||||
|
<Plus className="h-4 w-4" /> Neue Kostenposition
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{bearbeiten ? 'Kostenposition bearbeiten' : 'Neue Kostenposition erfassen'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Kostenart</Label>
|
||||||
|
<Select value={kostenart} onValueChange={setKostenart}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{KOSTEN_ARTEN.map((k) => (
|
||||||
|
<SelectItem key={k} value={k}>
|
||||||
|
{k}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Umlegbarkeit</Label>
|
||||||
|
<Select value={umlegbar ? 'umlegbar' : 'nicht'} onValueChange={(v) => setUmlegbar(v === 'umlegbar')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="umlegbar">Umlegbar auf Mieter</SelectItem>
|
||||||
|
<SelectItem value="nicht">Nicht umlegbar (Eigentümer)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="k-jahr">Jahr</Label>
|
||||||
|
<Input id="k-jahr" inputMode="numeric" placeholder="z. B. 2026" value={jahr} onChange={(e) => setJahr(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="k-betrag">Betrag (€)</Label>
|
||||||
|
<Input id="k-betrag" inputMode="decimal" placeholder="z. B. 1.250,00" value={betrag} onChange={(e) => setBetrag(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Haus</Label>
|
||||||
|
<Select value={haus} onValueChange={setHaus} disabled={einheit !== '__haus'}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Haus wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{buildings.map((b) => (
|
||||||
|
<SelectItem key={b.name} value={b.name}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{einheit !== '__haus' && (
|
||||||
|
<p className="text-xs text-slate-400">Das Haus folgt automatisch der gewählten Wohnung.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>Wohnung</Label>
|
||||||
|
<Select value={einheit} onValueChange={setEinheit}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Ganzes Haus" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__haus">Ganzes Haus</SelectItem>
|
||||||
|
{units.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.building}, Whg. {u.number}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="k-notiz">Notiz (optional)</Label>
|
||||||
|
<Input id="k-notiz" placeholder="z. B. Rechnungs-Nr., Abrechnungszeitraum …" value={notiz} onChange={(e) => setNotiz(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
{fehler && <p className="text-sm font-medium text-red-600">{fehler}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
<Button onClick={submit} disabled={sende || !betrag.trim() || !jahr.trim()}>
|
||||||
|
{sende ? 'Speichere …' : bearbeiten ? 'Speichern' : 'Erfassen'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Steuer & Kosten</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{role === 'tenant'
|
||||||
|
? 'Umlegbare und nicht umlegbare Kosten Ihrer Wohnung'
|
||||||
|
: 'Umlegbare und nicht umlegbare Kosten nach Haus und Wohnung – Grundlage für die Nebenkostenabrechnung'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{role === 'admin' && <KostenDialog />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Receipt className="h-4 w-4 text-teal-600" /> Umlegbar (Filterauswahl)
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-teal-700">{fmtEUR.format(summeUmlegbar)}</div>
|
||||||
|
<div className="text-xs text-slate-400">{filtered.filter((k) => k.umlegbar).length} Positionen</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<PiggyBank className="h-4 w-4 text-slate-500" /> Nicht umlegbar (Eigentümer)
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold text-slate-700">{fmtEUR.format(summeNicht)}</div>
|
||||||
|
<div className="text-xs text-slate-400">{filtered.filter((k) => !k.umlegbar).length} Positionen</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Coins className="h-4 w-4 text-amber-500" /> Gesamt
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-bold">{fmtEUR.format(summeUmlegbar + summeNicht)}</div>
|
||||||
|
<div className="text-xs text-slate-400">{filtered.length} Positionen gesamt</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(['alle', 'umlegbar', 'nicht'] as const).map((a) => (
|
||||||
|
<button
|
||||||
|
key={a}
|
||||||
|
onClick={() => setArtFilter(a)}
|
||||||
|
className={`rounded-full px-4 py-1.5 text-sm font-medium transition ${
|
||||||
|
artFilter === a
|
||||||
|
? 'bg-teal-600 text-white'
|
||||||
|
: 'bg-white text-slate-600 ring-1 ring-slate-200 hover:ring-teal-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{a === 'alle' ? 'Alle' : a === 'umlegbar' ? 'Umlegbar' : 'Nicht umlegbar'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<>
|
||||||
|
<Select value={jahrFilter} onValueChange={setJahrFilter}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="alle">Alle Jahre</SelectItem>
|
||||||
|
{jahre.map((j) => (
|
||||||
|
<SelectItem key={j} value={String(j)}>
|
||||||
|
{j}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={hausFilter} onValueChange={setHausFilter}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="alle">Alle Häuser</SelectItem>
|
||||||
|
{buildings.map((b) => (
|
||||||
|
<SelectItem key={b.name} value={b.name}>
|
||||||
|
{b.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border bg-white p-5 shadow-sm">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="py-10 text-center text-sm text-slate-500">
|
||||||
|
<Calculator className="mx-auto mb-3 h-8 w-8 text-slate-300" />
|
||||||
|
Keine Kostenpositionen gefunden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Kostenart</TableHead>
|
||||||
|
{role === 'admin' && <TableHead>Haus</TableHead>}
|
||||||
|
<TableHead>Wohnung</TableHead>
|
||||||
|
<TableHead>Jahr</TableHead>
|
||||||
|
<TableHead>Umlegbarkeit</TableHead>
|
||||||
|
<TableHead className="text-right">Betrag</TableHead>
|
||||||
|
{role === 'admin' && <TableHead className="text-right">Aktion</TableHead>}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filtered.map((k) => (
|
||||||
|
<TableRow key={k.id}>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ReceiptText className="h-4 w-4 shrink-0 text-teal-600" />
|
||||||
|
<div>
|
||||||
|
{k.kostenart}
|
||||||
|
{k.notiz && <div className="text-xs font-normal text-slate-400">{k.notiz}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{role === 'admin' && <TableCell className="text-slate-500">{k.building}</TableCell>}
|
||||||
|
<TableCell className="text-slate-500">{einheitLabel(k)}</TableCell>
|
||||||
|
<TableCell>{k.jahr}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className={k.umlegbar ? 'bg-teal-50 text-teal-700' : 'bg-slate-100 text-slate-600'}
|
||||||
|
>
|
||||||
|
{k.umlegbar ? 'Umlegbar' : 'Nicht umlegbar'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-semibold">{fmtEUR.format(k.betrag)}</TableCell>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<KostenDialog position={k} />
|
||||||
|
<Button variant="outline" size="sm" className="gap-1.5 text-red-600 hover:text-red-700" onClick={() => loeschen(k)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" /> Löschen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ import type {
|
|||||||
Meter,
|
Meter,
|
||||||
ZaehlerArt,
|
ZaehlerArt,
|
||||||
Building,
|
Building,
|
||||||
|
KostenItem,
|
||||||
} from '../types'
|
} from '../types'
|
||||||
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'
|
||||||
@@ -51,6 +52,7 @@ interface Bootstrap {
|
|||||||
codes: RegistrierungsCode[]
|
codes: RegistrierungsCode[]
|
||||||
meters: Meter[]
|
meters: Meter[]
|
||||||
buildings: Building[]
|
buildings: Building[]
|
||||||
|
kosten: KostenItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LEER: Bootstrap = {
|
const LEER: Bootstrap = {
|
||||||
@@ -64,6 +66,7 @@ const LEER: Bootstrap = {
|
|||||||
codes: [],
|
codes: [],
|
||||||
meters: [],
|
meters: [],
|
||||||
buildings: [],
|
buildings: [],
|
||||||
|
kosten: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Store extends Bootstrap {
|
interface Store extends Bootstrap {
|
||||||
@@ -139,6 +142,21 @@ interface Store extends Bootstrap {
|
|||||||
deleteDokument: (id: string) => Promise<string | null>
|
deleteDokument: (id: string) => Promise<string | null>
|
||||||
/** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */
|
/** Ersetzt ein Dokument im State durch die Server-Fassung (z. B. nach PDF-Upload). */
|
||||||
syncDokument: (dokument: DocumentItem) => void
|
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<string | null>
|
||||||
|
updateKosten: (
|
||||||
|
id: string,
|
||||||
|
k: Partial<{ building: string; unitId: string | null; jahr: number; kostenart: string; betrag: number; umlegbar: boolean; notiz: string }>,
|
||||||
|
) => Promise<string | null>
|
||||||
|
deleteKosten: (id: string) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
const StoreContext = createContext<Store | null>(null)
|
const StoreContext = createContext<Store | null>(null)
|
||||||
@@ -399,6 +417,34 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|||||||
: [dokument, ...x.documents],
|
: [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])
|
}), [daten, bereit, me, role, meineEinheit, ladeAlles])
|
||||||
|
|
||||||
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>
|
||||||
|
|||||||
@@ -120,3 +120,16 @@ export interface Meter {
|
|||||||
standAm: string
|
standAm: string
|
||||||
geaendertAm: 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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user