Initial commit - Stand 26.04.2026
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
.env
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
dist
|
||||
build
|
||||
@@ -0,0 +1,15 @@
|
||||
# Umgebungsvariablen
|
||||
|
||||
# Datenbank
|
||||
DB_PATH=/app/data/steuer.db
|
||||
|
||||
# Upload
|
||||
UPLOAD_DIR=/app/uploads
|
||||
MAX_FILE_SIZE=10485760
|
||||
|
||||
# CORS (für Produktion anpassen)
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
|
||||
# Optional: Reverse Proxy Domain
|
||||
# VIRTUAL_HOST=steuerflow.dein-domain.de
|
||||
# LETSENCRYPT_HOST=steuerflow.dein-domain.de
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Backups
|
||||
*.backup*
|
||||
*.backup
|
||||
*.sql
|
||||
*.tar
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# Temp files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Excel locks
|
||||
.~lock.*
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
File diff suppressed because it is too large
Load Diff
+141
@@ -0,0 +1,141 @@
|
||||
# Finanz Flow by Taeger IT
|
||||
|
||||
## Versionsmanagement
|
||||
|
||||
### Aktuelle Version: 0.1.0-alpha
|
||||
|
||||
**Semantische Versionierung:**
|
||||
- MAJOR (X.0.0): Inkompatible API-Änderungen
|
||||
- MINOR (0.X.0): Neue Features (abwärtskompatibel)
|
||||
- PATCH (0.0.X): Bugfixes
|
||||
- -alpha: Entwicklungsversion
|
||||
- -beta: Testversion vor Release
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
### [0.1.0-alpha] - 2026-04-20
|
||||
|
||||
#### Features
|
||||
- ✅ Kredit-Verwaltung mit automatischer Zinsberechnung
|
||||
- ✅ Nebenkosten-Abrechnung für Mieter
|
||||
- ✅ Kostenplanung für mehrere Jahre
|
||||
- ✅ Dashboard mit Übersicht
|
||||
- ✅ PDF-Export für Nebenkosten
|
||||
- ✅ Multi-User Support (Admin/User)
|
||||
|
||||
#### Importiert
|
||||
- ✅ 5 Kredite aus Excel (DSL, PSD, Zingelstr. DSL/Sparkasse, PVCreditplus)
|
||||
- ✅ 10 Nebenkosten-Abrechnungen (2020-2024)
|
||||
|
||||
#### Bekannte Bugs
|
||||
- 🐛 PDF-Export Formatierung verbesserungswürdig
|
||||
- 🐛 Keine Kaltmieten in Nebenkosten hinterlegt
|
||||
- 🐛 Kein automatisches Backup
|
||||
- 🐛 Kein Lizenzmanagement
|
||||
|
||||
#### Geplant für 0.2.0
|
||||
- [ ] Kaltmieten ergänzen
|
||||
- [ ] Backup-Automatisierung
|
||||
- [ ] API-Dokumentation
|
||||
- [ ] Fehlerbehebung PDF-Export
|
||||
|
||||
---
|
||||
|
||||
## Lizenz & Verkauf
|
||||
|
||||
**Status:** Nicht marktreif
|
||||
|
||||
**Voraussetzungen für Verkauf:**
|
||||
- [ ] Versionsmanagement ✅ (heute angelegt)
|
||||
- [ ] Lizenzmanagement (Hardware-ID oder Key-basiert)
|
||||
- [ ] Update-Mechanismus (Auto-Update)
|
||||
- [ ] Backup/Restore-Funktion
|
||||
- [ ] Dokumentation (Benutzerhandbuch)
|
||||
- [ ] Support-System (Ticket-System oder Chat)
|
||||
- [ ] Demo-Version (limitiert)
|
||||
|
||||
**Preisvorstellung:**
|
||||
- Einmalkauf: 500-2.000 €
|
||||
- Abo: 20-50 €/Monat
|
||||
- Support: 80 €/h
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1: Stabilisierung (0.1.x)
|
||||
- Fehlerbehebung
|
||||
- Performance-Optimierung
|
||||
- Code-Cleanup
|
||||
|
||||
### Phase 2: Produktreife (0.2.x)
|
||||
- Lizenzmanagement
|
||||
- Update-Mechanismus
|
||||
- Dokumentation
|
||||
- Demo-Version
|
||||
|
||||
### Phase 3: Markteinführung (1.0.0)
|
||||
- Website mit Download
|
||||
- Erste Kunden
|
||||
- Feedback-System
|
||||
- Feature-Requests
|
||||
|
||||
---
|
||||
|
||||
## Feature-Request-Template
|
||||
|
||||
```
|
||||
## Feature-Request
|
||||
|
||||
**Beschreibung:**
|
||||
[Was soll das Feature tun?]
|
||||
|
||||
**Priorität:**
|
||||
[Low / Medium / High / Critical]
|
||||
|
||||
**Nutzer:**
|
||||
[Wer braucht das?]
|
||||
|
||||
**Akzeptanzkriterien:**
|
||||
- [ ] Kriterium 1
|
||||
- [ ] Kriterium 2
|
||||
|
||||
**Geschätzter Aufwand:**
|
||||
[Stunden / Tage]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug-Report-Template
|
||||
|
||||
```
|
||||
## Bug-Report
|
||||
|
||||
**Version:**
|
||||
[z.B. 0.1.0-alpha]
|
||||
|
||||
**Beschreibung:**
|
||||
[Was ist passiert?]
|
||||
|
||||
**Schritte zum Reproduzieren:**
|
||||
1. Schritt 1
|
||||
2. Schritt 2
|
||||
|
||||
**Erwartetes Verhalten:**
|
||||
[Was sollte passieren?]
|
||||
|
||||
**Tatsächliches Verhalten:**
|
||||
[Was ist passiert?]
|
||||
|
||||
**Screenshots:**
|
||||
[Falls vorhanden]
|
||||
|
||||
**Logs:**
|
||||
[Falls relevant]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-04-20
|
||||
@@ -0,0 +1,146 @@
|
||||
# Import-Protokoll: Niki-Kredit
|
||||
**Datum:** 2026-04-20
|
||||
**Datei:** Schulden Niki.xlsx
|
||||
|
||||
---
|
||||
|
||||
## Zusammenfassung
|
||||
|
||||
✅ **STATUS: KOMPLETT UND VERIFIZIERT**
|
||||
|
||||
Alle Zahlungen und Auslagen aus der Excel-Datei sind korrekt in die Datenbank importiert.
|
||||
|
||||
---
|
||||
|
||||
## Excel-Analyse
|
||||
|
||||
| Kategorie | Anzahl | Summe |
|
||||
|-----------|--------|-------|
|
||||
| Zahlungen (Abgezahlt) | 9 | 3.400,00 EUR |
|
||||
| Auslagen (Neue Kosten) | 4 | 505,00 EUR |
|
||||
| **Netto tilgt** | | **2.895,00 EUR** |
|
||||
|
||||
### Details der Zahlungen
|
||||
|
||||
| Datum | Betrag | Typ | Beschreibung |
|
||||
|-------|--------|-----|--------------|
|
||||
| 2024-04-01 | 500,00 EUR | zahlung_eingang | Zahlung |
|
||||
| 2024-05-01 | 500,00 EUR | zahlung_eingang | Zahlung |
|
||||
| 2024-06-01 | 150,00 EUR | zahlung_eingang | Kameras |
|
||||
| 2024-06-01 | 90,00 EUR | auslage | Kameras |
|
||||
| 2024-07-01 | 450,00 EUR | zahlung_eingang | Kameras |
|
||||
| 2024-07-01 | 45,00 EUR | auslage | Kameras |
|
||||
| 2024-08-01 | 500,00 EUR | zahlung_eingang | Zahlung |
|
||||
| 2024-09-01 | 300,00 EUR | zahlung_eingang | Zahlung |
|
||||
| 2024-10-01 | 300,00 EUR | zahlung_eingang | Videorekorder + Zubehör |
|
||||
| 2024-10-01 | 110,00 EUR | auslage | Videorekorder + Zubehör |
|
||||
| 2025-02-01 | 200,00 EUR | zahlung_eingang | Zahlung |
|
||||
| 2025-06-01 | 500,00 EUR | zahlung_eingang | Handyvertag |
|
||||
| 2025-06-01 | 260,00 EUR | auslage | Handyvertag |
|
||||
|
||||
---
|
||||
|
||||
## Datenbank-Zustand
|
||||
|
||||
### Kredit-Datensatz
|
||||
```sql
|
||||
SELECT * FROM kredite WHERE name ILIKE '%niki%';
|
||||
```
|
||||
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| ID | 4ad8826f-ecb4-443d-aef6-ce9162e5f078 |
|
||||
| Name | Niki Schulden (Forderung) |
|
||||
| Kreditgeber | Niki |
|
||||
| Person | Niki |
|
||||
| Ursprungsschuld | 7.000,00 EUR |
|
||||
| Restschuld | 4.105,00 EUR |
|
||||
| Zinssatz | 10% |
|
||||
| Start-Datum | 2023-07-01 |
|
||||
| Status | aktiv |
|
||||
|
||||
### Kredit-Zahlungen (13 Einträge)
|
||||
|
||||
Alle 13 Zahlungen/Auslagen korrekt vorhanden in `kredit_zahlungen`.
|
||||
|
||||
---
|
||||
|
||||
## Verifikation
|
||||
|
||||
### Excel vs. DB Vergleich
|
||||
|
||||
| Metrik | Excel | DB | Status |
|
||||
|--------|-------|-----|--------|
|
||||
| Startschuld | 7.000,00 EUR | 7.000,00 EUR | ✅ OK |
|
||||
| Gesamtzahlungen | 3.400,00 EUR | 3.400,00 EUR | ✅ OK |
|
||||
| Gesamtauslagen | 505,00 EUR | 505,00 EUR | ✅ OK |
|
||||
| Netto getilgt | 2.895,00 EUR | 2.895,00 EUR | ✅ OK |
|
||||
| Restschuld (ohne Zinsen) | - | 4.105,00 EUR | ✅ OK |
|
||||
|
||||
**Wichtiger Hinweis:**
|
||||
Die Excel-Datei berechnet Restschuld mit Zinsen (aktuell: 6.697,75 EUR).
|
||||
Die App berechnet Restschuld ohne Zinsen (4.105,00 EUR).
|
||||
Dies ist beabsichtigt - die App zeigt nur den tatsächlichen Schuldenstand ohne Zinsaufwuchs.
|
||||
|
||||
---
|
||||
|
||||
## Import-Template für weitere Schulden
|
||||
|
||||
### Format der Excel-Datei (Schulden [Name].xlsx)
|
||||
|
||||
**Spalten (ab Zeile 6):**
|
||||
|
||||
| Spalte | Feld | Bedeutung |
|
||||
|--------|------|-----------|
|
||||
| C | Datum | Monat/Jahr der Buchung |
|
||||
| D | Restschuld | Aktueller Stand (negativ = Schulden) |
|
||||
| E | Zinsen | Monatliche Zinsen (optional) |
|
||||
| F | Abgezahlt | Positive Werte = Zahlungen erhalten |
|
||||
| G | Neue Kosten | Negative Werte = Neue Auslagen/Ausgaben |
|
||||
| H | Für was | Beschreibung/Notiz |
|
||||
|
||||
### Regeln
|
||||
|
||||
1. **Zahlungen** (Spalte F): Positive Werte = Zahlung eingegangen
|
||||
- Import als `zahlung_eingang` in `kredit_zahlungen`
|
||||
|
||||
2. **Auslagen** (Spalte G): Negative Werte = Neue Ausgaben
|
||||
- Import als `auslage` in `kredit_zahlungen`
|
||||
- Betrag als positiven Wert speichern
|
||||
|
||||
3. **Zinsen** (Spalte E): Werden in der App nicht berücksichtigt
|
||||
- Nur zur Dokumentation
|
||||
|
||||
4. **Restschuld** (Spalte D): Referenzwert, nicht importieren
|
||||
- App berechnet Restschuld automatisch
|
||||
|
||||
---
|
||||
|
||||
## Checkliste für Import
|
||||
|
||||
- [ ] Excel-Datei vorliegend mit Format: "Schulden [Name].xlsx"
|
||||
- [ ] Spaltenreihenfolge prüfen (C=Datum, D=Restschuld, E=Zinsen, F=Abgezahlt, G=Neue Kosten, H=Beschreibung)
|
||||
- [ ] Zahlungen und Auslagen identifizieren
|
||||
- [ ] Kredit in DB anlegen (falls nicht vorhanden)
|
||||
- [ ] Alle Zahlungen als `zahlung_eingang` importieren
|
||||
- [ ] Alle Auslagen als `auslage` importieren
|
||||
- [ ] Verifikation durchführen (Summen vergleichen)
|
||||
- [ ] Restschuld prüfen
|
||||
|
||||
---
|
||||
|
||||
## Nächster Import: Schulden Kerstin
|
||||
|
||||
**Datei:** `Schulden Kerstin.xlsx`
|
||||
|
||||
### Vorgehen
|
||||
|
||||
1. Excel-Datei mit `read_excel_values.py` analysieren
|
||||
2. Zahlungen und Auslagen identifizieren
|
||||
3. Kredit-Datensatz prüfen/erstellen
|
||||
4. Import durchführen
|
||||
5. Verifikation (Summen vergleichen)
|
||||
|
||||
---
|
||||
|
||||
*Dokumentation erstellt am: 2026-04-20*
|
||||
Binary file not shown.
@@ -0,0 +1,98 @@
|
||||
# Buchhaltungs-App Migration zu Proxmox
|
||||
|
||||
## 1. Voraussetzungen auf Proxmox
|
||||
- LXC Container erstellt (Ubuntu/Debian empfohlen)
|
||||
- Docker + Docker Compose installiert
|
||||
- Netzwerkzugriff (für Zugriff über 192.168.0.x)
|
||||
|
||||
## 2. Dateien kopieren
|
||||
|
||||
Von deinem lokalen Rechner auf Proxmox übertragen:
|
||||
|
||||
```bash
|
||||
# Auf Proxmox LXC ausführen:
|
||||
mkdir -p /opt/buchhaltungs-app
|
||||
cd /opt/buchhaltungs-app
|
||||
|
||||
# Folgende Dateien müssen kopiert werden:
|
||||
# - docker-compose.yml
|
||||
# - nginx.conf
|
||||
# - backend/uploads/ (falls Dokumente vorhanden)
|
||||
# - backup_vor_umzug_*.sql (Datenbank-Backup)
|
||||
```
|
||||
|
||||
## 3. Frontend neu bauen
|
||||
|
||||
```bash
|
||||
cd /opt/buchhaltungs-app
|
||||
|
||||
# Falls Frontend-Source vorhanden:
|
||||
cd frontend
|
||||
npm install
|
||||
npm run build
|
||||
cd ..
|
||||
```
|
||||
|
||||
## 4. Container starten
|
||||
|
||||
```bash
|
||||
docker-compose up -d db
|
||||
|
||||
# Warte 10 Sekunden auf DB-Start
|
||||
sleep 10
|
||||
|
||||
# Backup einspielen
|
||||
docker exec -i buchhaltung-db psql -U postgres buchhaltung < backup_vor_umzug_*.sql
|
||||
|
||||
# Alle Container starten
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 5. Port ändern (optional)
|
||||
|
||||
Falls Port 3000/3001 schon belegt sind, in docker-compose.yml anpassen:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- "3000:80" # z.B. zu "8080:80" ändern
|
||||
- "3001:3001" # z.B. zu "8081:3001" ändern
|
||||
```
|
||||
|
||||
## 6. Reverse Proxy (empfohlen)
|
||||
|
||||
Für saubere URLs ohne Port:
|
||||
- Nginx Proxy Manager auf Proxmox
|
||||
- Oder Traefik im LXC
|
||||
- Domain: buchhaltung.lan → 192.168.0.xxx:3000
|
||||
|
||||
## 7. Automatische Backups einrichten
|
||||
|
||||
Cron-Job auf Proxmox:
|
||||
```bash
|
||||
0 2 * * * cd /opt/buchhaltungs-app && docker exec buchhaltung-db pg_dump -U postgres buchhaltung > backups/backup_$(date +\%Y\%m\%d).sql
|
||||
```
|
||||
|
||||
## Dateien die du brauchst
|
||||
|
||||
| Datei | Quelle | Ziel auf Proxmox |
|
||||
|-------|--------|------------------|
|
||||
| docker-compose.yml | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` |
|
||||
| nginx.conf | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` |
|
||||
| backend/uploads/ | `~/.openclaw/workspace/buchhaltungs-app/backend/` | `/opt/buchhaltungs-app/backend/uploads/` |
|
||||
| backup_*.sql | `~/.openclaw/workspace/buchhaltungs-app/` | `/opt/buchhaltungs-app/` |
|
||||
| frontend/dist/ | `~/.openclaw/workspace/buchhaltungs-app/frontend/` | `/opt/buchhaltungs-app/frontend/dist/` |
|
||||
|
||||
## Test nach Migration
|
||||
|
||||
1. http://192.168.0.xxx:3000 öffnen
|
||||
2. Mit admin/admin123 anmelden
|
||||
3. Stunden/Einträge prüfen
|
||||
4. Dokumente testen
|
||||
|
||||
## Rollback
|
||||
|
||||
Falls was schiefgeht:
|
||||
```bash
|
||||
docker-compose down
|
||||
# Lokal weiterarbeiten - Backup ist ja erstellt
|
||||
```
|
||||
@@ -0,0 +1,111 @@
|
||||
# Nebenkostenabrechnung - Implementation Complete
|
||||
|
||||
## Erstellte Dateien
|
||||
|
||||
### Datenbank (Migrationen)
|
||||
1. `backend/database/migrations/001_nebenkostenabrechnung.sql`
|
||||
- Tabellen: objekte, mieter, mietvertraege, objektkosten, vorauszahlungen, nebenkostenabrechnungen, abrechnungspositionen
|
||||
- Indizes und Trigger
|
||||
|
||||
2. `backend/database/migrations/002_nebenkosten_beispieldaten.sql`
|
||||
- 3 Beispiel-Objekte: Wilster, Segeberg, Zingelstraße 14
|
||||
- 5 Beispiel-Mieter
|
||||
- 3 Mietverträge
|
||||
- Beispielkosten und Vorauszahlungen für 2024
|
||||
|
||||
### Backend
|
||||
3. `backend/routes/nebenkosten.js`
|
||||
- API-Endpunkte für alle CRUD-Operationen
|
||||
- Berechnungslogik für Abrechnungen
|
||||
- PDF-Export mit Entwurf-Wasserzeichen
|
||||
|
||||
### Frontend
|
||||
4. `frontend/src/api-nebenkosten.js`
|
||||
- API-Client für Objekte, Mieter, Abrechnungen
|
||||
|
||||
5. `frontend/src/components/nebenkosten/Objekte.jsx`
|
||||
- Objekt-Verwaltung mit Formular
|
||||
- CRUD-Operationen
|
||||
|
||||
6. `frontend/src/components/nebenkosten/Mieter.jsx`
|
||||
- Mieter-Verwaltung
|
||||
|
||||
7. `frontend/src/components/nebenkosten/Nebenkostenabrechnung.jsx`
|
||||
- Abrechnungs-Erstellung
|
||||
- Vorschau mit Berechnungen
|
||||
- PDF-Download
|
||||
|
||||
### Integration
|
||||
8. `backend/server.js` (modifiziert)
|
||||
- Import: `const nebenkostenRoutes = require('./routes/nebenkosten');`
|
||||
- Routes: `nebenkostenRoutes(app);`
|
||||
|
||||
9. `frontend/src/App.jsx` (modifiziert)
|
||||
- Neue Tabs: "Objekte", "Mieter", "NK-Abrechnung"
|
||||
- Import der neuen Komponenten
|
||||
|
||||
## Features
|
||||
|
||||
### Objekt-Verwaltung
|
||||
- Name, Adresse, PLZ, Ort, Wohnfläche, Bemerkung
|
||||
- CRUD: Anlegen, Bearbeiten, Löschen
|
||||
|
||||
### Mieter-Verwaltung
|
||||
- Name, E-Mail, Telefon, Adresse
|
||||
- CRUD: Anlegen, Bearbeiten, Löschen
|
||||
|
||||
### Mietverträge (implizit in API)
|
||||
- Mieter-Zuordnung zu Objekt
|
||||
- Wohnfläche, Kaltmiete, Vorauszahlung
|
||||
- Vertragsbeginn/-ende
|
||||
|
||||
### Kosten pro Objekt
|
||||
- Kategorien: Grundsteuer, Heizung, Wohngeld, Handwerker, Wasser, Müll, Versicherung, Sonstiges
|
||||
- Verteilung nach qm/Personen/Zählern
|
||||
- CRUD über API
|
||||
|
||||
### Vorauszahlungen
|
||||
- Monatliche Vorauszahlungen pro Mieter
|
||||
- Bulk-Erstellung für ganze Jahr
|
||||
|
||||
### Abrechnung
|
||||
- Flexibler Zeitraum (Standard: 1.1. - 31.12.)
|
||||
- Pro-rata-Berechnung möglich
|
||||
- Berechnung: (Gesamtkosten / Gesamtfläche) × Mieterfläche
|
||||
- Abzug: Summe Vorauszahlungen
|
||||
- Ergebnis: Nachzahlung oder Gutschrift
|
||||
|
||||
### PDF Export
|
||||
- Firmenlogo und Daten (Täger IT)
|
||||
- Übersicht aller Kosten
|
||||
- Berechnung pro Mieter
|
||||
- Entwurf-Modus mit Wasserzeichen
|
||||
|
||||
## Verwendung
|
||||
|
||||
1. **Datenbank-Migrationen ausführen:**
|
||||
```bash
|
||||
docker-compose exec db psql -U postgres -d buchhaltung -f /migrations/001_nebenkostenabrechnung.sql
|
||||
docker-compose exec db psql -U postgres -d buchhaltung -f /migrations/002_nebenkosten_beispieldaten.sql
|
||||
```
|
||||
|
||||
2. **Frontend neu bauen:**
|
||||
```bash
|
||||
cd frontend && npm run build
|
||||
```
|
||||
|
||||
3. **Backend neu starten:**
|
||||
```bash
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
## Navigation
|
||||
- **Objekte**: Immobilien/Objekte verwalten
|
||||
- **Mieter**: Mieter verwalten
|
||||
- **NK-Abrechnung**: Neue Abrechnung erstellen, Vorschau berechnen, PDF exportieren
|
||||
|
||||
## Architektur
|
||||
- Objekte haben Mieter über Mietverträge
|
||||
- Kosten werden Objekten zugeordnet
|
||||
- Vorauszahlungen sind Mieter + Objekt + Jahr + Monat
|
||||
- Abrechnungen erstellen Positionen pro Mieter
|
||||
@@ -0,0 +1,143 @@
|
||||
# Nebenkostenabrechnung - Status Dokumentation
|
||||
|
||||
**Datum:** 21.04.2026
|
||||
**Status:** ✅ **VOLLSTÄNDIG IMPLEMENTIERT & DEPLOYED**
|
||||
|
||||
---
|
||||
|
||||
## ✅ IMPLEMENTIERT
|
||||
|
||||
### Backend (nebenkosten.js)
|
||||
- ✅ Objekte-CRUD (GET, POST, PUT, DELETE /api/objekte)
|
||||
- ✅ Mieter-CRUD (GET, POST, PUT, DELETE /api/mieter)
|
||||
- ✅ Mietvertraege-CRUD (GET, POST, PUT, DELETE /api/mietvertraege)
|
||||
- ✅ Objektkosten-CRUD (GET, POST, PUT, DELETE /api/objektkosten)
|
||||
- ✅ Vorauszahlungen-CRUD (GET, POST, PUT, DELETE /api/vorauszahlungen)
|
||||
- ✅ Vorauszahlungen Bulk-Erstellung (POST /api/vorauszahlungen/bulk)
|
||||
- ✅ Nebenkostenabrechnung Übersicht (GET /api/nebenkostenabrechnung/uebersicht)
|
||||
- ✅ Nebenkostenabrechnung Vorschau (POST /api/nebenkostenabrechnung/vorschau)
|
||||
- ✅ Nebenkostenabrechnung speichern (POST /api/nebenkostenabrechnung)
|
||||
- ✅ Alle Abrechnungen abrufen (GET /api/nebenkostenabrechnung)
|
||||
- ✅ Einzelne Abrechnung mit Positionen (GET /api/nebenkostenabrechnung/:id)
|
||||
- ✅ Status aktualisieren (PUT /api/nebenkostenabrechnung/:id/status)
|
||||
- ✅ PDF Export (POST /api/nebenkostenabrechnung/:id/pdf)
|
||||
|
||||
### Datenbank-Tabellen
|
||||
Alle erforderlichen Tabellen werden automatisch erstellt:
|
||||
- ✅ `objekte`
|
||||
- ✅ `mieter`
|
||||
- ✅ `mietvertraege`
|
||||
- ✅ `objektkosten`
|
||||
- ✅ `vorauszahlungen`
|
||||
- ✅ `nebenkostenabrechnungen`
|
||||
- ✅ `abrechnungspositionen`
|
||||
|
||||
### Frontend
|
||||
- ✅ Objekte.jsx - Komplette Objektverwaltung
|
||||
- ✅ Mieter.jsx - Komplette Mieterverwaltung
|
||||
- ✅ Nebenkostenabrechnung.jsx - Abrechnung erstellen & Berechnung
|
||||
- ✅ api-nebenkosten.js - Alle API-Calls mit Zusatzfunktionen
|
||||
- ✅ App.jsx - Routing eingebunden (Objekte, Mieter, NK-Abrechnung)
|
||||
|
||||
### Berechnungslogik
|
||||
- ✅ Pro-rata Verteilung der Kosten nach Wohnfläche
|
||||
- ✅ Vorauszahlungen werden verrechnet
|
||||
- ✅ Nachzahlung/Gutschrift automatisch berechnet
|
||||
- ✅ PDF-Generation mit Entwurf/Final-Modus
|
||||
|
||||
### Deployment
|
||||
- ✅ Backend Container erfolgreich neu gebaut
|
||||
- ✅ Neue Tabellen werden automatisch erstellt
|
||||
- ✅ Alle Routen aktiv
|
||||
|
||||
---
|
||||
|
||||
## 📦 VERFÜGBARE ENDPUNKTE
|
||||
|
||||
| Endpunkt | Methode | Beschreibung |
|
||||
|----------|-----------|--------------|
|
||||
| /api/objekte | GET/POST | Alle Objekte / Neues Objekt |
|
||||
| /api/objekte/:id | PUT/DELETE | Objekt aktualisieren/löschen |
|
||||
| /api/mieter | GET/POST | Alle Mieter / Neuer Mieter |
|
||||
| /api/mieter/:id | PUT/DELETE | Mieter aktualisieren/löschen |
|
||||
| /api/mietvertraege | GET/POST | Mietverträge |
|
||||
| /api/mietvertraege/:id | PUT/DELETE | Mietvertrag aktualisieren/löschen |
|
||||
| /api/objektkosten | GET/POST | Kosten |
|
||||
| /api/objektkosten/:id | PUT/DELETE | Kosten aktualisieren/löschen |
|
||||
| /api/vorauszahlungen | GET/POST | Vorauszahlungen |
|
||||
| /api/vorauszahlungen/bulk | POST | 12 Monate auf einmal erstellen |
|
||||
| /api/nebenkostenabrechnung/vorschau | POST | Berechnung durchführen |
|
||||
| /api/nebenkostenabrechnung | GET/POST | Abrechnungen abrufen/speichern |
|
||||
| /api/nebenkostenabrechnung/:id/pdf | POST | PDF erstellen |
|
||||
|
||||
---
|
||||
|
||||
## 🧪 TEST-ANLEITUNG
|
||||
|
||||
### Test 1: Objekt anlegen
|
||||
1. **Frontend öffnen:** `http://192.168.0.55:3000`
|
||||
2. **Tab "Objekte" auswählen**
|
||||
3. **"Neues Objekt" klicken**
|
||||
4. Daten eingeben:
|
||||
- Name: "Test Wohnung"
|
||||
- Adresse: "Musterstraße 1"
|
||||
- PLZ: "25554"
|
||||
- Ort: "Wilster"
|
||||
- Wohnfläche: "85"
|
||||
5. **"Speichern" klicken**
|
||||
|
||||
### Test 2: Mieter anlegen
|
||||
1. **Tab "Mieter" auswählen**
|
||||
2. **"Neuer Mieter" klicken**
|
||||
3. Daten eingeben:
|
||||
- Name: "Max Mustermann"
|
||||
- E-Mail: "max@test.de"
|
||||
4. **"Speichern"**
|
||||
|
||||
### Test 3: Abrechnung erstellen
|
||||
1. **Tab "NK-Abrechnung" auswählen**
|
||||
2. Objekt auswählen
|
||||
3. Jahr eingeben (z.B. 2026)
|
||||
4. **"Abrechnung berechnen" klicken**
|
||||
5. Ergebnis sollte leer sein (noch keine Kosten)
|
||||
6. **"Als Entwurf speichern"**
|
||||
|
||||
### Test 4: Kosten hinzufügen
|
||||
Kann über Backend/API erfolgen:
|
||||
```bash
|
||||
# Beispiel via curl (von Container aus)
|
||||
curl -X POST http://localhost:3001/api/objektkosten \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"objekt_id": "<OBJEKT_ID>", "kategorie": "Heizkosten", "bezeichnung": "Gas 2026", "betrag": 1200, "jahr": 2026}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 DATEIEN
|
||||
|
||||
### Backend
|
||||
- `backend/routes/nebenkosten.js` - Alle API-Routen (600+ Zeilen)
|
||||
- `backend/server.js` - InitDatabase erweitert
|
||||
|
||||
### Frontend
|
||||
- `frontend/src/components/nebenkosten/Objekte.jsx`
|
||||
- `frontend/src/components/nebenkosten/Mieter.jsx`
|
||||
- `frontend/src/components/nebenkosten/Nebenkostenabrechnung.jsx`
|
||||
- `frontend/src/api-nebenkosten.js`
|
||||
- `frontend/src/App.jsx` - Navigation erweitert
|
||||
|
||||
### Dokumentation
|
||||
- `NEBENKOSTEN_STATUS.md` - Diese Datei
|
||||
|
||||
---
|
||||
|
||||
## 🚀 DEPLOYMENT STATUS
|
||||
|
||||
```
|
||||
✅ Container gebaut: buchhaltungs-app-backend:latest
|
||||
✅ Container gestartet: buchhaltung-backend
|
||||
✅ Datenbank-Tabellen initialisiert
|
||||
✅ Backend läuft auf Port 3001
|
||||
```
|
||||
|
||||
**System ist betriebsbereit!**
|
||||
@@ -0,0 +1,62 @@
|
||||
# Niki Kredit - Import Ergebnis
|
||||
|
||||
## ✅ Erfolgreich abgeschlossen
|
||||
|
||||
### Was wurde gemacht:
|
||||
1. **Alten Kredit gelöscht** - Alle vorherigen Niki-Einträge entfernt
|
||||
2. **Excel analysiert** - "Schulden Niki.xlsx" komplett eingelesen
|
||||
3. **Korrekten Import durchgeführt** - Alle Zahlungen und Auslagen importiert
|
||||
4. **Restschuld korrigiert** - Auf korrekte 4105.00 € gesetzt
|
||||
|
||||
### Kredit-Details:
|
||||
| Feld | Wert |
|
||||
|------|------|
|
||||
| Name | Niki Schulden |
|
||||
| ID | 3c957cd3-e583-4596-a122-1253dd8956ef |
|
||||
| Ursprungsschuld | 7000.00 € |
|
||||
| **Restschuld** | **4105.00 €** |
|
||||
| Zinssatz | 10% |
|
||||
| Start | 2023-07-01 |
|
||||
|
||||
### Importierte Transaktionen (13 Stück):
|
||||
|
||||
**Zahlungen (9):**
|
||||
- 2024-04-01: +500.00 €
|
||||
- 2024-05-01: +500.00 €
|
||||
- 2024-06-01: +150.00 € (Kameras)
|
||||
- 2024-07-01: +450.00 € (Kameras)
|
||||
- 2024-08-01: +500.00 €
|
||||
- 2024-09-01: +300.00 €
|
||||
- 2024-10-01: +300.00 € (Videorekorder + Zubehör)
|
||||
- 2025-02-01: +200.00 €
|
||||
- 2025-06-01: +500.00 € (Handyvertag)
|
||||
|
||||
**Auslagen/Neue Kosten (4):**
|
||||
- 2024-06-01: -90.00 € (Kameras)
|
||||
- 2024-07-01: -45.00 € (Kameras)
|
||||
- 2024-10-01: -110.00 € (Videorekorder + Zubehör)
|
||||
- 2025-06-01: -260.00 € (Handyvertag)
|
||||
|
||||
### Berechnung:
|
||||
- Startschuld: 7000.00 €
|
||||
- Abzüglich Zahlungen: -3400.00 €
|
||||
- Plus Auslagen: +505.00 €
|
||||
- **= Restschuld: 4105.00 €** ✅
|
||||
|
||||
### Wichtige Erkenntnis für zukünftige Imports:
|
||||
Die Excel speichert "Neue Kosten" als **NEGATIVE** Zahlen (-90, -45, -110, -260)!
|
||||
Dies muss beim Import berücksichtigt werden.
|
||||
|
||||
### Für Kerstin-Import:
|
||||
Das gleiche Schema kann verwendet werden:
|
||||
1. Excel einlesen (Header Zeile 6, Daten ab Zeile 7)
|
||||
2. "Abgezahlt" > 0 = Zahlung
|
||||
3. "Neue Kosten" < 0 = Auslage (als negativer Wert speichern)
|
||||
4. Restschuld = Startschuld - Summe(Zahlungen) + Summe(|Auslagen|)
|
||||
|
||||
### Verwendete Scripts:
|
||||
- `import_niki_fixed.js` - Kompletter Import-Workflow
|
||||
- `fix_restschuld.js` - Restschuld-Korrektur
|
||||
- `verify_niki_final.js` - Finale Verifikation
|
||||
|
||||
Alle Scripts sind im Verzeichnis `C:\Users\renet\.openclaw\workspace\buchhaltungs-app\`
|
||||
Binary file not shown.
@@ -0,0 +1,77 @@
|
||||
# SteuerFlow - Buchhaltungs-App
|
||||
|
||||
Dockerisierte Buchhaltungsanwendung für Steuerunterlagen, Nebenkostenabrechnungen und Gewerbe-Abrechnungen.
|
||||
|
||||
## Features
|
||||
|
||||
- 📄 Dokumenten-Upload (PDF, Bilder)
|
||||
- 🏷️ Kategorien: Steuer, Nebenkosten, Kredit, Hausmeister, Gewerbe
|
||||
- 💰 Betrags-Erfassung pro Dokument
|
||||
- 🗄️ SQLite-Datenbank (persistiert via Docker-Volume)
|
||||
- 🔍 Schnelle Übersicht aller Dokumente
|
||||
|
||||
## Schnellstart
|
||||
|
||||
### Entwicklung
|
||||
|
||||
```bash
|
||||
cd buchhaltungs-app
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
- Frontend: http://localhost:5173
|
||||
- Backend API: http://localhost:3000
|
||||
|
||||
### Produktion
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.prod.yml up --build -d
|
||||
```
|
||||
|
||||
## Kategorien
|
||||
|
||||
| Kategorie | Verwendung |
|
||||
|-----------|-----------|
|
||||
| steuer | Elster-relevante Belege |
|
||||
| nebenkosten | Wohngeldabrechnungen |
|
||||
| kredit | Finanzierungsunterlagen |
|
||||
| hausmeister | Instandhaltung & Reparaturen |
|
||||
| gewerbe | Gewerbliche Ausgaben (ab Juli) |
|
||||
| sonstiges | Alles andere |
|
||||
|
||||
## Technologien
|
||||
|
||||
- **Frontend:** React + Vite + Tailwind CSS
|
||||
- **Backend:** Express + SQLite + Multer
|
||||
- **Container:** Docker + Docker Compose
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
buchhaltungs-app/
|
||||
├── backend/ # Express API
|
||||
├── frontend/ # React App
|
||||
├── docker-compose.yml # Dev-Konfiguration
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Reverse Proxy (Produktion)
|
||||
|
||||
Für HTTPS hinter einem Reverse Proxy (z.B. Traefik, Nginx Proxy Manager):
|
||||
|
||||
1. `docker-compose.prod.yml` anpassen
|
||||
2. Netzwerk `proxy` hinzufügen
|
||||
3. Labels für Traefik konfigurieren
|
||||
|
||||
## Datenbank
|
||||
|
||||
SQLite-Datenbank liegt unter `/app/data/steuer.db` im Container.
|
||||
Wird automatisch beim ersten Start erstellt.
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Kopiere `.env.example` zu `.env` und passe Werte an:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,67 @@
|
||||
# Test der Geschäftsplanung-Erweiterung
|
||||
|
||||
## Durchgeführte Änderungen
|
||||
|
||||
### 1. Datenbank-Migration (005_erweiterte_geschaeftsplanung.sql)
|
||||
- Neue Spalten: `typ`, `unterkategorie`, `ist_einnahme`
|
||||
- Views für Auswertungen: `geschaeftsplanung_auswertung`, `geschaeftsplanung_monatlich`, `miete_nebenkosten_vergleich`
|
||||
- Migration wurde erfolgreich ausgeführt
|
||||
|
||||
### 2. Backend-Erweiterungen (server.js)
|
||||
- API-Endpunkte hinzugefügt:
|
||||
- `GET /api/geschaeftsplanung/monatlich` - Monatliche Übersicht mit Ergebnis
|
||||
- `GET /api/geschaeftsplanung/kategorien` - Kategorie-Übersicht
|
||||
- `GET /api/geschaeftsplanung/objektvergleich/:objekt` - Miete vs Nebenkosten
|
||||
- `GET /api/geschaeftsplanung/umsatzvorschau` - Umsatzvorschau
|
||||
- Kostenplanung-API erweitert mit neuen Feldern (typ, unterkategorie, ist_einnahme)
|
||||
|
||||
### 3. Frontend
|
||||
- **Neue Komponente:** `Geschaeftsplanung.jsx` (ersetzt Kostenplanung.jsx)
|
||||
- Drei Bereiche: Einnahmen, Betriebskosten, Private Kosten
|
||||
- Schnellvorlagen für Standard-Kategorien
|
||||
- Tab-Navigation: Planung und Auswertung
|
||||
- Monatliche Detailtabelle mit Summen
|
||||
- **API-Service:** `geschaeftsplanungAPI` in `api.js` hinzugefügt
|
||||
- **Dashboard:** Neue Kacheln für Einnahmen und Betriebsergebnis
|
||||
|
||||
### 4. App.jsx aktualisiert
|
||||
- Import geändert von Kostenplanung zu Geschaeftsplanung
|
||||
- Tab-Name geändert zu "Geschäftsplanung"
|
||||
|
||||
## Features
|
||||
|
||||
### Einnahmen
|
||||
- Planungshonorare
|
||||
- Beratungsleistungen
|
||||
- Installationsleistungen
|
||||
- Mieteinnahmen
|
||||
|
||||
### Betriebskosten
|
||||
- Telefon/Internet (Vorschlag: 35 €)
|
||||
- Büromaterial (Vorschlag: 30 €)
|
||||
- Versicherungen (Vorschlag: 120 €)
|
||||
- Rechts/Beratung (Vorschlag: 50 €)
|
||||
- Kfz-Kosten (Vorschlag: 1.000 €)
|
||||
- Reisekosten (Vorschlag: 200 €)
|
||||
|
||||
### Private Kosten
|
||||
- Wohnung (Miete/Nebenkosten)
|
||||
- Essen/Trinken
|
||||
- Strom
|
||||
- Müll
|
||||
- Internet (privat)
|
||||
|
||||
### Auswertungen
|
||||
- Monatliche Übersicht: Einnahmen, Betriebskosten, Betriebsergebnis, Private Kosten, Cashflow
|
||||
- Umsatzvorschau nach Kategorien
|
||||
- Miete vs Nebenkosten pro Objekt
|
||||
|
||||
## Deployment
|
||||
|
||||
1. Backend wurde neugestartet (Container: buchhaltung-backend)
|
||||
2. Migration wurde in Datenbank ausgeführt
|
||||
3. Frontend wurde gebaut und deployed (Container: buchhaltung-frontend)
|
||||
|
||||
## Zugriff
|
||||
|
||||
Die Geschäftsplanung ist unter dem Reiter "Geschäftsplanung" im Finanz Flow erreichbar.
|
||||
@@ -0,0 +1,66 @@
|
||||
const fs = require('fs');
|
||||
|
||||
const filePath = 'C:\\Users\\renet\\.openclaw\\workspace\\buchhaltungs-app\\backend\\routes\\nebenkosten.js';
|
||||
let content = fs.readFileSync(filePath, 'utf8');
|
||||
|
||||
const kostenRoutes = `
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
|
||||
app.get('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { jahr } = req.query;
|
||||
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
|
||||
const params = [req.params.id];
|
||||
|
||||
if (jahr) {
|
||||
query += ' AND jahr = $2';
|
||||
params.push(jahr);
|
||||
}
|
||||
|
||||
query += ' ORDER BY jahr DESC, kategorie ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten hinzufügen
|
||||
app.post('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, betrag, jahr } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[req.params.id, kategorie, betrag, jahr]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten löschen
|
||||
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
// Finde das Ende der Objekt-DELETE Route und füge danach die Kosten-Routen ein
|
||||
const pattern = /(app\.delete\('\/api\/objekte\/:id',[\s\S]*?}\s*\);)(\s*\/\/ ========== MIETER)/;
|
||||
|
||||
if (content.match(pattern)) {
|
||||
content = content.replace(pattern, `$1${kostenRoutes}$2`);
|
||||
fs.writeFileSync(filePath, content);
|
||||
console.log('✅ Kosten-Routen erfolgreich hinzugefügt!');
|
||||
} else {
|
||||
console.error('❌ Pattern nicht gefunden');
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Durchsucht ALLE Sheets nach den 5 Krediten mit den korrekten Werten.
|
||||
"""
|
||||
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
# Excel-Datei laden
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
|
||||
# Target-Werte vom User
|
||||
target_kredite = {
|
||||
"DSL Bank": 64656.88,
|
||||
"PSD Nord": 50384.50,
|
||||
"Zingelstr. 14 DSL": 24382.38,
|
||||
"Zingelstr. 14 Sparkasse": 8140.11,
|
||||
"PVCreditplus": 1666.53
|
||||
}
|
||||
|
||||
print("SUCHE NACH KREDITEN MIT FOLGENDEN RESTSCHULDEN:")
|
||||
for name, betrag in target_kredite.items():
|
||||
print(f" {name}: {betrag:,.2f} EUR")
|
||||
|
||||
print("\n" + "="*80)
|
||||
|
||||
for sheet_name in wb.sheetnames:
|
||||
print(f"\n=== SHEET: {sheet_name} ===")
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Suche nach den Zielwerten
|
||||
for row in range(1, min(ws.max_row + 1, 100)):
|
||||
for col in range(1, min(ws.max_column + 1, 30)):
|
||||
val = ws.cell(row=row, column=col).value
|
||||
if val and isinstance(val, (int, float)):
|
||||
for kredit_name, target in target_kredite.items():
|
||||
# Toleranz von 100 EUR
|
||||
if abs(val - target) < 100:
|
||||
print(f" ZEILE {row}, Spalte {col}: {val:,.2f} = {kredit_name}!")
|
||||
# Kontext zeigen
|
||||
context = []
|
||||
for c in range(max(1, col-3), min(col+3, ws.max_column + 1)):
|
||||
v = ws.cell(row=row, column=c).value
|
||||
context.append(str(v)[:15] if v else "-")
|
||||
print(f" Kontext: {context}")
|
||||
# Header suchen
|
||||
headers = []
|
||||
for c in range(max(1, col-3), min(col+3, ws.max_column + 1)):
|
||||
h = ws.cell(row=1, column=c).value
|
||||
headers.append(str(h)[:15] if h else "-")
|
||||
print(f" Header: {headers}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("SUCHE NACH ZINSSÄTZEN (Text 'Zins' oder '%'):")
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
for row in range(1, min(20, ws.max_row + 1)):
|
||||
for col in range(1, min(30, ws.max_column + 1)):
|
||||
val = ws.cell(row=row, column=col).value
|
||||
if val:
|
||||
if isinstance(val, str) and ("zins" in val.lower() or "%" in val):
|
||||
print(f" [{sheet_name}] Zeile {row}, Spalte {col}: {val}")
|
||||
elif isinstance(val, (int, float)) and 0.001 < val < 0.2:
|
||||
# Möglicher Zinssatz als Dezimal
|
||||
label = ws.cell(row=row-1, column=col).value if row > 1 else None
|
||||
print(f" [{sheet_name}] Zeile {row}, Spalte {col}: {val} ({val*100:.2f}%) - {label}")
|
||||
@@ -0,0 +1,43 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx'
|
||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||
sheet = wb['Tilgung bei Gleichbleibenden Be']
|
||||
|
||||
# Alle Kredit-Header in Zeile 1
|
||||
credits = []
|
||||
print('=== ALLE KREDITE (Zeile 1 & 2) ===')
|
||||
for col in range(1, 30):
|
||||
h1 = sheet.cell(1, col).value
|
||||
h2 = sheet.cell(2, col).value
|
||||
if h1 is not None:
|
||||
print(f'Spalte {col}: Header1="{h1}", Header2="{h2}"')
|
||||
if 'Restschuld' in str(h2) or 'Rate' in str(h2) or h2 is None:
|
||||
credits.append({'col': col, 'name': h1, 'type': h2})
|
||||
|
||||
print('\n=== April 2026 suchen ===')
|
||||
target_date = datetime(2026, 4, 1)
|
||||
april_row = None
|
||||
for row in range(1, 200):
|
||||
cell_val = sheet.cell(row, 1).value
|
||||
if isinstance(cell_val, datetime):
|
||||
if cell_val.year == 2026 and cell_val.month == 4:
|
||||
print(f'April 2026 in Zeile {row}: {cell_val}')
|
||||
april_row = row
|
||||
break
|
||||
elif cell_val and '2026-04' in str(cell_val):
|
||||
print(f'April 2026 in Zeile {row}: {cell_val}')
|
||||
april_row = row
|
||||
break
|
||||
|
||||
print(f'\n=== Werte für April 2026 (Zeile {april_row}) ===')
|
||||
print('KREDITE mit Restschuld/Raten:')
|
||||
print(f'Spalte 2 (Targo Bank Restschuld): {sheet.cell(april_row, 2).value}')
|
||||
print(f'Spalte 6 (Käpke Restschuld): {sheet.cell(april_row, 6).value}')
|
||||
print(f'Spalte 9 (DSL Bank Restschuld): {sheet.cell(april_row, 9).value}')
|
||||
print(f'Spalte 13 (PSD Nord Restschuld): {sheet.cell(april_row, 13).value}')
|
||||
print(f'Spalte 20 (Zingelstr. 14 Restschuld): {sheet.cell(april_row, 20).value}')
|
||||
print(f'Spalte 21 (Sparkasse RATE): {sheet.cell(april_row, 21).value}')
|
||||
print(f'Spalte 22 (Sparkasse Zinsen): {sheet.cell(april_row, 22).value}')
|
||||
print(f'Spalte 24 (Gesamt Restschuld): {sheet.cell(april_row, 24).value}')
|
||||
@@ -0,0 +1,33 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx'
|
||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||
sheet = wb['Tilgung bei Gleichbleibenden Be']
|
||||
|
||||
print('=== Alle Header Zeile 1 & 2 ===')
|
||||
headers = []
|
||||
for col in range(1, 35):
|
||||
h1 = sheet.cell(1, col).value
|
||||
h2 = sheet.cell(2, col).value
|
||||
headers.append((col, h1, h2))
|
||||
if h1 is not None:
|
||||
print(f'Col {col:2d}: {h1:20s} | {h2}')
|
||||
|
||||
# April 2026
|
||||
april_row = 189
|
||||
|
||||
print(f'\n=== ALLE WERTE für April 2026 (Zeile {april_row}) ===')
|
||||
for col in range(1, 35):
|
||||
val = sheet.cell(april_row, col).value
|
||||
h1 = sheet.cell(1, col).value
|
||||
if val is not None and val != 0:
|
||||
print(f'Col {col:2d} ({h1}): {val}')
|
||||
|
||||
# Suche nach aktuellen Daten (letzte Zeile mit Daten)
|
||||
print('\n=== Suche aktuelle Daten (2025/2026) ===')
|
||||
for row in range(180, 200):
|
||||
val = sheet.cell(row, 1).value
|
||||
rest_gesamt = sheet.cell(row, 24).value
|
||||
if val:
|
||||
print(f'Row {row}: {val} -> Gesamt-Restschuld: {rest_gesamt}')
|
||||
@@ -0,0 +1,47 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx'
|
||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||
sheet = wb['Tilgung bei Gleichbleibenden Be']
|
||||
|
||||
# Prüfe Spalte 20 (Zingelstr.14) über mehrere Zeilen
|
||||
print('=== Spalte 20: Zingelstr. 14 (über Zeit) ===')
|
||||
for row in range(175, 195):
|
||||
date_val = sheet.cell(row, 1).value
|
||||
zingel = sheet.cell(row, 20).value
|
||||
if date_val and zingel is not None and zingel != 0:
|
||||
print(f'Row {row} ({date_val}): {zingel}')
|
||||
|
||||
# Prüfe Spalte 21 (Sparkasse) über mehrere Zeilen
|
||||
print('\n=== Spalte 21: Sparkasse (über Zeit) ===')
|
||||
for row in range(175, 195):
|
||||
date_val = sheet.cell(row, 1).value
|
||||
sparkasse = sheet.cell(row, 21).value
|
||||
if date_val and sparkasse is not None and sparkasse != 0:
|
||||
print(f'Row {row} ({date_val}): {sparkasse}')
|
||||
|
||||
# Prüfe Spalte 22 (Zinsen Sparkasse)
|
||||
print('\n=== Spalte 22: Sparkasse Zinsen (über Zeit) ===')
|
||||
for row in range(175, 195):
|
||||
date_val = sheet.cell(row, 1).value
|
||||
zinsen = sheet.cell(row, 22).value
|
||||
if date_val and zinsen is not None and zinsen != 0:
|
||||
print(f'Row {row} ({date_val}): {zinsen}')
|
||||
|
||||
# Gibt es vielleicht eine Restschuld für Sparkasse woanders?
|
||||
# Prüfe Spalten 28-30 (hatten Werte in April 2026!)
|
||||
print('\n=== Spalten 28-30 (unklare Daten) ===')
|
||||
for col in range(28, 32):
|
||||
h1 = sheet.cell(1, col).value
|
||||
h2 = sheet.cell(2, col).value
|
||||
val = sheet.cell(189, col).value
|
||||
print(f'Col {col}: {h1} / {h2} = {val}')
|
||||
|
||||
# Prüfe ob Spalte 28 Restschuld ist
|
||||
print('\n=== Spalte 28 (Restschuld Check) ===')
|
||||
for row in range(175, 195):
|
||||
date_val = sheet.cell(row, 1).value
|
||||
val28 = sheet.cell(row, 28).value
|
||||
if date_val and val28 is not None and val28 != 0:
|
||||
print(f'Row {row} ({date_val}): {val28}')
|
||||
@@ -0,0 +1,58 @@
|
||||
import openpyxl
|
||||
|
||||
file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx'
|
||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||
sheet = wb['Tilgung bei Gleichbleibenden Be']
|
||||
|
||||
print('=== DETAIL-ANALYSE aller Spalten ===')
|
||||
print('Format: Spalte | Header Zeile 1 | Header Zeile 2')
|
||||
print('-' * 60)
|
||||
|
||||
for col in range(1, 35):
|
||||
h1 = sheet.cell(1, col).value
|
||||
h2 = sheet.cell(2, col).value
|
||||
v = sheet.cell(189, col).value # April 2026
|
||||
|
||||
if h1 is not None or h2 is not None or v is not None:
|
||||
print(f'{col:2d} | {str(h1):20s} | {str(h2):15s} | April 2026: {v}')
|
||||
|
||||
print('\n=== WELCHES SIND KREDITE? ===')
|
||||
print('Kriterium: Hat Restschuld + Rate oder ist eindeutig benannt')
|
||||
|
||||
# Analyse der Gesamt-Spalte (24)
|
||||
print('\nGesamt-Spalte (24) prüfen:')
|
||||
for row in range(187, 192):
|
||||
date = sheet.cell(row, 1).value
|
||||
gesamt = sheet.cell(row, 24).value
|
||||
print(f'Row {row} ({date}): {gesamt}')
|
||||
|
||||
# Mathematische Prüfung
|
||||
print('\n=== MATHEMATISCHE PRÜFUNG April 2026 (Row 189) ===')
|
||||
dsl = sheet.cell(189, 9).value or 0
|
||||
psd = sheet.cell(189, 13).value or 0
|
||||
zingel20 = sheet.cell(189, 20).value or 0
|
||||
sparkasse28 = sheet.cell(189, 28).value or 0
|
||||
|
||||
print(f'DSL Bank (Col 9): {dsl}')
|
||||
print(f'PSD Nord (Col 13): {psd}')
|
||||
print(f'Zingelstr.14 (Col 20): {zingel20}')
|
||||
print(f'Unbekannt (Col 28): {sparkasse28}')
|
||||
print(f'Summe: {dsl + psd + zingel20 + sparkasse28}')
|
||||
print(f'Gesamt (Col 24): {sheet.cell(189, 24).value}')
|
||||
|
||||
print('\n=== IST SPALTE 28 SPARKASSE RESTSCHULD? ===')
|
||||
# Spalte 21 = Sparkasse RATE
|
||||
# Spalte 28 könnte Sparkasse Restschuld sein
|
||||
# Prüfe: Rate 350, Zinsen sinken wie bei Zingelstr.14
|
||||
|
||||
for row in range(189, 191):
|
||||
date = sheet.cell(row, 1).value
|
||||
rate21 = sheet.cell(row, 21).value
|
||||
rest28 = sheet.cell(row, 28).value
|
||||
zins30 = sheet.cell(row, 30).value
|
||||
zins22 = sheet.cell(row, 22).value
|
||||
print(f'{date}:')
|
||||
print(f' Spalte 21 (Sparkasse Rate): {rate21}')
|
||||
print(f' Spalte 22 (Sparkasse Zins): {zins22}')
|
||||
print(f' Spalte 28 (Restschuld?): {rest28}')
|
||||
print(f' Spalte 30 (Zinsen?): {zins30}')
|
||||
@@ -0,0 +1,96 @@
|
||||
import openpyxl
|
||||
import sys
|
||||
from datetime import datetime
|
||||
import re
|
||||
import json
|
||||
|
||||
# Excel-Datei laden
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
|
||||
print("\n=== Verfügbare Sheets ===")
|
||||
for name in wb.sheetnames:
|
||||
print(f" - {name}")
|
||||
|
||||
# Das Sheet "Tilgung bei Gleichbleibenden Be" laden
|
||||
sheet_name = "Tilgung bei Gleichbleibenden Be"
|
||||
if sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
print(f"\n=== Analyse: {sheet_name} ===")
|
||||
print(f"Bereich: {ws.dimensions}")
|
||||
print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}")
|
||||
else:
|
||||
print(f"Sheet '{sheet_name}' nicht gefunden!")
|
||||
print("Verfügbare Sheets:", wb.sheetnames)
|
||||
sys.exit(1)
|
||||
|
||||
# Ersten 50 Zeilen anzeigen um Struktur zu verstehen
|
||||
print("\n=== Erste 30 Zeilen (für Struktur) ===")
|
||||
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=30, values_only=True), 1):
|
||||
non_empty = [str(cell)[:30] if cell is not None else "" for cell in row[:10]]
|
||||
if any(non_empty):
|
||||
print(f"Zeile {row_idx:2}: {non_empty}")
|
||||
|
||||
# Nach Kreditnamen suchen - wir wissen die Kreditnamen
|
||||
kredit_namen = ["Targo Bank", "Köpke", "DSL Bank", "PSD Nord", "Zingelstr. 14",
|
||||
"Sparkasse", "Carola", "Kerstin", "PVCreditplus"]
|
||||
|
||||
print("\n=== Suche nach Krediten ===")
|
||||
kredite_gefunden = {}
|
||||
|
||||
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, values_only=True), 1):
|
||||
for cell in row:
|
||||
if cell and isinstance(cell, str):
|
||||
for kredit_name in kredit_namen:
|
||||
if kredit_name.lower() in cell.lower():
|
||||
# Zeile extrahieren
|
||||
row_data = list(row)
|
||||
print(f"\n🔍 Gefunden: '{kredit_name}' in Zeile {row_idx}")
|
||||
print(f" Zeile {row_idx}: {row_data[:15]}")
|
||||
kredite_gefunden[kredit_name] = {
|
||||
"zeile": row_idx,
|
||||
"daten": row_data
|
||||
}
|
||||
break
|
||||
|
||||
# Nun detaillierte Analyse für jeden gefundenen Kredit
|
||||
print("\n\n" + "="*80)
|
||||
print("DETAILLIERTE KREDIT-ANALYSE")
|
||||
print("="*80)
|
||||
|
||||
# Wir müssen die Struktur verstehen - oft sind Kredite untereinander
|
||||
# mit Header-Zeilen dazwischen
|
||||
|
||||
# Alternative: Suche nach Mustern wie "Restschuld", "Monatsrate", etc.
|
||||
print("\n=== Suche nach Schlüsselwörtern ===")
|
||||
keywords = ["restschuld", "monatsrate", "rate", "zinssatz", "zins", "laufzeit", "betrag", "start"]
|
||||
|
||||
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=100, values_only=True), 1):
|
||||
for cell in row:
|
||||
if cell and isinstance(cell, str):
|
||||
cell_lower = cell.lower()
|
||||
for kw in keywords:
|
||||
if kw in cell_lower:
|
||||
row_data = [str(c)[:25] if c is not None else "" for c in row[:12]]
|
||||
print(f"Zeile {row_idx:3} [{kw:12}]: {row_data}")
|
||||
break
|
||||
|
||||
# Versuche konkrete Werte zu finden
|
||||
print("\n=== Suche nach konkreten Werten (Zahlen > 1000) ===")
|
||||
for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=200, values_only=True), 1):
|
||||
numbers = []
|
||||
for col_idx, cell in enumerate(row[:20], 1):
|
||||
if isinstance(cell, (int, float)) and cell > 1000:
|
||||
numbers.append(f"Col{col_idx}:{cell}")
|
||||
if numbers:
|
||||
# Zeile auch mit Text anzeigen
|
||||
texts = [str(c)[:20] for c in row[:5] if c and isinstance(c, str)]
|
||||
print(f"Zeile {row_idx:3}: {texts} | Zahlen: {numbers[:5]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Fehler: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,136 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
# Alle Kredite mit ihren Spalten definieren
|
||||
# Format: (kredit_name, restschuld_col, tilgung_col, zinsen_col, start_datum_col)
|
||||
kredite_config = [
|
||||
("Targo Bank", 2, 3, 4, 1),
|
||||
("Köpke", 6, 7, None, 1),
|
||||
("DSL Bank", 9, 10, 11, 1),
|
||||
("PSD Nord", 13, 14, 15, 1),
|
||||
("Zingelstr. 14", 20, None, 22, 1), # Keine Tilgung-Spalte, aber Rate in 21?
|
||||
("Sparkasse", None, 21, 22, 1), # Sparkasse hat Rate statt Restschuld?
|
||||
("Gesamt", 24, 25, 26, 1),
|
||||
("Carola", 36, None, None, 1), # Nur Restschuld?
|
||||
("Kerstin", 39, None, None, 1), # Nur Restschuld?
|
||||
]
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("KOMPLETTE KREDIT-ANALYSE")
|
||||
print("="*80)
|
||||
|
||||
aktive_kredite = []
|
||||
abgezahlte_kredite = []
|
||||
|
||||
for name, rest_col, tilg_col, zins_col, datum_col in kredite_config:
|
||||
print(f"\n--- {name} ---")
|
||||
print(f" Spalten: Rest={rest_col}, Tilgung={tilg_col}, Zinsen={zins_col}")
|
||||
|
||||
if not rest_col:
|
||||
print(f" [KEINE RESTSCHULD-SPAlTE - Überspringe]")
|
||||
continue
|
||||
|
||||
# Suche die letzte Zeile mit Daten
|
||||
letzte_restschuld = None
|
||||
letztes_datum = None
|
||||
start_datum = None
|
||||
erster_wert = None
|
||||
monatsrate = None
|
||||
zinssatz = None
|
||||
|
||||
letzte_zeile_mit_datum = None
|
||||
|
||||
for row_idx in range(3, min(ws.max_row + 1, 400)):
|
||||
datum_cell = ws.cell(row=row_idx, column=datum_col).value
|
||||
rest_cell = ws.cell(row=row_idx, column=rest_col).value
|
||||
tilg_cell = ws.cell(row=row_idx, column=tilg_col).value if tilg_col else None
|
||||
|
||||
# Konvertiere Restschuld zu Zahl
|
||||
if rest_cell is not None:
|
||||
try:
|
||||
rest_val = float(rest_cell)
|
||||
except:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Startdatum = erstes Datum mit Restschuld > 0
|
||||
if start_datum is None and datum_cell and isinstance(datum_cell, datetime) and rest_val > 0:
|
||||
start_datum = datum_cell
|
||||
erster_wert = rest_val
|
||||
|
||||
# Monatsrate aus konstanter Tilgung (erster Wert)
|
||||
if tilg_cell and isinstance(tilg_cell, (int, float)):
|
||||
if tilg_cell > 0 and monatsrate is None:
|
||||
monatsrate = tilg_cell
|
||||
|
||||
# Letzter Wert
|
||||
letzte_restschuld = rest_val
|
||||
if datum_cell and isinstance(datum_cell, datetime):
|
||||
letztes_datum = datum_cell
|
||||
letzte_zeile_mit_datum = row_idx
|
||||
|
||||
# Status ermitteln
|
||||
if letzte_restschuld is not None:
|
||||
# Wenn Restschuld < 10 EUR oder negativ, gilt als abbezahlt
|
||||
status = "ABGEZAHLT" if letzte_restschuld < 10 else "AKTIV"
|
||||
|
||||
print(f" Ursprungsschuld: {erster_wert:,.2f} EUR" if erster_wert else " Ursprungsschuld: N/A")
|
||||
print(f" Aktuelle Restschuld: {letzte_restschuld:,.2f} EUR")
|
||||
print(f" Startdatum: {start_datum.strftime('%d.%m.%Y') if start_datum else 'N/A'}")
|
||||
print(f" Letztes Datum: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A")
|
||||
print(f" STATUS: {status}")
|
||||
|
||||
kredit_info = {
|
||||
"name": name,
|
||||
"ursprungsschuld": erster_wert if erster_wert else 0,
|
||||
"restschuld": letzte_restschuld,
|
||||
"start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None,
|
||||
"monatsrate": monatsrate,
|
||||
"zinssatz": zinssatz,
|
||||
"status": status
|
||||
}
|
||||
|
||||
if status == "AKTIV":
|
||||
aktive_kredite.append(kredit_info)
|
||||
else:
|
||||
abgezahlte_kredite.append(kredit_info)
|
||||
else:
|
||||
print(f" [Keine Daten gefunden]")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("ZUSAMMENFASSUNG")
|
||||
print("="*80)
|
||||
|
||||
print(f"\n>>> AKTIVE KREDITE ({len(aktive_kredite)}):")
|
||||
for k in aktive_kredite:
|
||||
print(f" - {k['name']}: {k['restschuld']:,.2f} EUR Restschuld")
|
||||
|
||||
print(f"\n>>> ABGEZAHLT ({len(abgezahlte_kredite)}):")
|
||||
for k in abgezahlte_kredite:
|
||||
print(f" - {k['name']}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("JSON für Import (nur AKTIVE):")
|
||||
print("="*80)
|
||||
print(json.dumps(aktive_kredite, indent=2, default=str))
|
||||
|
||||
# Speichere als Datei
|
||||
with open("kredite_analyse.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"aktiv": aktive_kredite,
|
||||
"abgezahlt": abgezahlte_kredite
|
||||
}, f, indent=2, default=str, ensure_ascii=False)
|
||||
|
||||
print("\n✅ Gespeichert in kredite_analyse.json")
|
||||
@@ -0,0 +1,51 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
EXCEL_FILE = r"C:\Users\renet\.openclaw\workspace\buchhaltungs-app\Schulden Kerstin.xlsx"
|
||||
|
||||
print(f"Lade: {EXCEL_FILE}")
|
||||
wb = openpyxl.load_workbook(EXCEL_FILE, data_only=True)
|
||||
ws = wb['Tabelle2']
|
||||
|
||||
print(f"\nSheet: {ws.title}")
|
||||
print(f"Zeilen: {ws.max_row}, Spalten: {ws.max_column}")
|
||||
print("\n" + "="*100)
|
||||
print("ALLE ZEILEN MIT INHALT:")
|
||||
print("="*100)
|
||||
|
||||
zahlungen = []
|
||||
for row_idx in range(1, min(ws.max_row + 1, 150)):
|
||||
row = [ws.cell(row=row_idx, column=c).value for c in range(1, 4)]
|
||||
|
||||
datum = row[0] if isinstance(row[0], datetime) else None
|
||||
notiz = row[1] if row[1] else None
|
||||
betrag = row[2] if isinstance(row[2], (int, float)) else None
|
||||
|
||||
if betrag is not None:
|
||||
print(f"Zeile {row_idx}: Datum={datum}, Notiz={notiz}, Betrag={betrag}")
|
||||
zahlungen.append({
|
||||
'zeile': row_idx,
|
||||
'datum': datum,
|
||||
'notiz': notiz,
|
||||
'betrag': betrag
|
||||
})
|
||||
|
||||
print(f"\n{'='*100}")
|
||||
print(f"INSGESAMT: {len(zahlungen)} Einträge mit Betrag")
|
||||
print(f"{'='*100}")
|
||||
|
||||
# Gruppiere nach Jahr
|
||||
by_year = {}
|
||||
for z in zahlungen:
|
||||
if z['datum']:
|
||||
year = z['datum'].year
|
||||
by_year[year] = by_year.get(year, 0) + 1
|
||||
|
||||
print("\nNach Jahr:")
|
||||
for year, count in sorted(by_year.items()):
|
||||
print(f" {year}: {count} Einträge")
|
||||
|
||||
print("\nOhne Datum:")
|
||||
ohne_datum = [z for z in zahlungen if z['datum'] is None]
|
||||
for z in ohne_datum:
|
||||
print(f" Zeile {z['zeile']}: {z['notiz']} - {z['betrag']}€")
|
||||
@@ -0,0 +1,150 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
# Kredite mit ihren Spalten - basierend auf der Header-Struktur
|
||||
# Format: (kredit_name, restschuld_col, tilgung_col, zinsen_col)
|
||||
# Spalten sind 1-basiert
|
||||
|
||||
kredite_config = [
|
||||
("Targo Bank", 2, 3, 4),
|
||||
("Köpke", 6, 7, 8),
|
||||
("DSL Bank", 9, 10, 11),
|
||||
("PSD Nord", 13, 14, 15),
|
||||
("Zingelstr. 14", 20, 21, 22), # Rate in 21
|
||||
("Carola", 36, 37, 38),
|
||||
("Kerstin", 39, 40, 41),
|
||||
("PVCreditplus", 42, 43, 44), # Gefunden in Zeile 2, Spalte 42
|
||||
("Sparkasse", 45, 46, 47),
|
||||
]
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("KORRIGIERTE KREDIT-ANALYSE")
|
||||
print("="*80)
|
||||
|
||||
aktive_kredite = []
|
||||
abgezahlte_kredite = []
|
||||
|
||||
# Aktuelles Datum für Vergleich
|
||||
heute = datetime(2026, 4, 20)
|
||||
|
||||
for name, rest_col, tilg_col, zins_col in kredite_config:
|
||||
print(f"\n--- {name} ---")
|
||||
print(f" Spalten: Rest={rest_col}, Tilgung={tilg_col}, Zinsen={zins_col}")
|
||||
|
||||
# Prüfe Header
|
||||
rest_header = row2[rest_col-1] if rest_col-1 < len(row2) else None
|
||||
print(f" Header Restschuld-Spalte: {rest_header}")
|
||||
|
||||
if rest_header != "Restschuld":
|
||||
print(f" [WARNUNG: Header ist '{rest_header}', nicht 'Restschuld']")
|
||||
|
||||
# Suche die relevanten Daten
|
||||
start_datum = None
|
||||
start_restschuld = None
|
||||
aktuelle_restschuld = None
|
||||
aktuelles_datum = None
|
||||
monatsrate = None
|
||||
zinssatz = None
|
||||
|
||||
# Finde erstes Datum mit Restschuld
|
||||
for row_idx in range(3, min(ws.max_row + 1, 500)):
|
||||
datum_cell = ws.cell(row=row_idx, column=1).value
|
||||
rest_cell = ws.cell(row=row_idx, column=rest_col).value
|
||||
|
||||
if datum_cell and isinstance(datum_cell, datetime):
|
||||
if rest_cell is not None:
|
||||
try:
|
||||
rest_val = float(rest_cell)
|
||||
if rest_val > 0 and start_datum is None:
|
||||
start_datum = datum_cell
|
||||
start_restschuld = rest_val
|
||||
print(f" Erster Wert: {rest_val:,.2f} EUR am {datum_cell.strftime('%d.%m.%Y')}")
|
||||
|
||||
# Monatsrate aus nächster Zeile
|
||||
next_tilg = ws.cell(row=row_idx+1, column=tilg_col).value if tilg_col else None
|
||||
if next_tilg and isinstance(next_tilg, (int, float)) and next_tilg > 0:
|
||||
monatsrate = next_tilg
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR")
|
||||
break
|
||||
except:
|
||||
continue
|
||||
|
||||
# Finde aktuellen Stand (April 2026 oder letzter Wert)
|
||||
for row_idx in range(3, ws.max_row + 1):
|
||||
datum_cell = ws.cell(row=row_idx, column=1).value
|
||||
rest_cell = ws.cell(row=row_idx, column=rest_col).value
|
||||
|
||||
if datum_cell and isinstance(datum_cell, datetime):
|
||||
if rest_cell is not None:
|
||||
try:
|
||||
rest_val = float(rest_cell)
|
||||
# Nimm den Wert für April 2026 oder den letzten vorhandenen
|
||||
if datum_cell.year == 2026 and datum_cell.month == 4:
|
||||
aktuelle_restschuld = rest_val
|
||||
aktuelles_datum = datum_cell
|
||||
break
|
||||
elif datum_cell < heute:
|
||||
aktuelle_restschuld = rest_val
|
||||
aktuelles_datum = datum_cell
|
||||
except:
|
||||
continue
|
||||
|
||||
if aktuelle_restschuld is not None:
|
||||
# Status ermitteln
|
||||
status = "ABGEZAHLT" if aktuelle_restschuld < 100 else "AKTIV"
|
||||
|
||||
print(f" Aktuelle Restschuld ({aktuelles_datum.strftime('%d.%m.%Y')}): {aktuelle_restschuld:,.2f} EUR")
|
||||
print(f" STATUS: {status}")
|
||||
|
||||
kredit_info = {
|
||||
"name": name,
|
||||
"ursprungsschuld": start_restschuld if start_restschuld else aktuelle_restschuld,
|
||||
"restschuld": aktuelle_restschuld,
|
||||
"start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None,
|
||||
"monatsrate": monatsrate,
|
||||
"zinssatz": zinssatz,
|
||||
"status": status
|
||||
}
|
||||
|
||||
if status == "AKTIV":
|
||||
aktive_kredite.append(kredit_info)
|
||||
else:
|
||||
abgezahlte_kredite.append(kredit_info)
|
||||
else:
|
||||
print(f" [Keine Daten gefunden]")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("ZUSAMMENFASSUNG")
|
||||
print("="*80)
|
||||
|
||||
print(f"\n>>> AKTIVE KREDITE ({len(aktive_kredite)}):")
|
||||
for k in aktive_kredite:
|
||||
print(f" - {k['name']}: {k['restschuld']:,.2f} EUR Restschuld")
|
||||
|
||||
print(f"\n>>> ABGEZAHLT ({len(abgezahlte_kredite)}):")
|
||||
for k in abgezahlte_kredite:
|
||||
print(f" - {k['name']}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("JSON für Import (nur AKTIVE):")
|
||||
print("="*80)
|
||||
print(json.dumps(aktive_kredite, indent=2, default=str))
|
||||
|
||||
# Speichere als Datei
|
||||
with open("kredite_analyse_korrigiert.json", "w", encoding="utf-8") as f:
|
||||
json.dump({
|
||||
"aktiv": aktive_kredite,
|
||||
"abgezahlt": abgezahlte_kredite
|
||||
}, f, indent=2, default=str, ensure_ascii=False)
|
||||
|
||||
print("\nGespeichert in kredite_analyse_korrigiert.json")
|
||||
@@ -0,0 +1,145 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
# Struktur: Kredite sind nebeneinander, jeweils 3-4 Spalten
|
||||
# Zeile 1: Kreditnamen
|
||||
# Zeile 2: Headers (Restschuld, Tilgung, Zinsen)
|
||||
# Zeile 3+: Monatliche Daten
|
||||
|
||||
# Lese Zeile 1 um Kreditnamen zu finden
|
||||
print("\n=== KREDITE IM SHEET ===")
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
|
||||
kredite = {}
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str) and cell.strip():
|
||||
name = cell.strip()
|
||||
# Finde Header für diesen Kredit (Zeile 2)
|
||||
headers = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
# Spalten für diesen Kredit
|
||||
restschuld_col = None
|
||||
tilgung_col = None
|
||||
zinsen_col = None
|
||||
|
||||
# Schaue die nächsten 3-4 Spalten für Headers
|
||||
for h_idx in range(col_idx-1, min(col_idx+3, len(headers))):
|
||||
if headers[h_idx] == "Restschuld":
|
||||
restschuld_col = h_idx + 1 # 1-based
|
||||
elif headers[h_idx] == "Tilgung":
|
||||
tilgung_col = h_idx + 1
|
||||
elif headers[h_idx] == "Zinsen":
|
||||
zinsen_col = h_idx + 1
|
||||
|
||||
if restschuld_col:
|
||||
kredite[name] = {
|
||||
"restschuld_col": restschuld_col,
|
||||
"tilgung_col": tilgung_col,
|
||||
"zinsen_col": zinsen_col,
|
||||
"start_col": col_idx
|
||||
}
|
||||
print(f" {name}: Restschuld in Spalte {restschuld_col}")
|
||||
|
||||
print(f"\nInsgesamt {len(kredite)} Kredite gefunden\n")
|
||||
|
||||
# Analysiere jeden Kredit
|
||||
print("="*80)
|
||||
print("ANALYSE JEDES KREDITS - Aktuelle Restschuld")
|
||||
print("="*80)
|
||||
|
||||
aktive_kredite = []
|
||||
abgezahlte_kredite = []
|
||||
|
||||
for name, cols in kredite.items():
|
||||
rest_col = cols["restschuld_col"]
|
||||
tilg_col = cols["tilgung_col"]
|
||||
|
||||
# Suche letzten Eintrag mit Restschuld
|
||||
letzte_restschuld = None
|
||||
letztes_datum = None
|
||||
start_datum = None
|
||||
monatsrate = None
|
||||
zinssatz = None
|
||||
|
||||
for row_idx in range(3, ws.max_row + 1):
|
||||
datum_cell = ws.cell(row=row_idx, column=1).value
|
||||
rest_cell = ws.cell(row=row_idx, column=rest_col).value
|
||||
tilg_cell = ws.cell(row=row_idx, column=tilg_col).value if tilg_col else None
|
||||
|
||||
# Startdatum = erstes Datum
|
||||
if start_datum is None and datum_cell and isinstance(datum_cell, datetime):
|
||||
start_datum = datum_cell
|
||||
|
||||
# Monatsrate aus Tilgung-Spalte (wenn konstant)
|
||||
if tilg_cell and isinstance(tilg_cell, (int, float)) and tilg_cell > 0:
|
||||
if monatsrate is None:
|
||||
monatsrate = tilg_cell
|
||||
|
||||
if rest_cell is not None and isinstance(rest_cell, (int, float)):
|
||||
if rest_cell > 0 or letzte_restschuld is None:
|
||||
letzte_restschuld = rest_cell
|
||||
letztes_datum = datum_cell
|
||||
|
||||
# Berechne Zinssatz aus den Daten
|
||||
if letzte_restschuld is not None and monatsrate:
|
||||
# Versuche Zinssatz zu extrahieren
|
||||
# Aus historischen Daten berechnen
|
||||
for row_idx in range(3, min(30, ws.max_row)):
|
||||
rest = ws.cell(row=row_idx, column=rest_col).value
|
||||
zins = ws.cell(row=row_idx, column=cols.get("zinsen_col", 1)).value if cols.get("zinsen_col") else None
|
||||
|
||||
if rest and zins and isinstance(rest, (int, float)) and isinstance(zins, (int, float)):
|
||||
if rest > 0 and zins > 0:
|
||||
# Monatlicher Zinssatz = Zinsen / Restschuld
|
||||
monatlicher_zins = zins / rest
|
||||
jaehrlicher_zins = monatlicher_zins * 12 * 100
|
||||
zinssatz = round(jaehrlicher_zins, 2)
|
||||
break
|
||||
|
||||
status = "ABGEZAHLT" if letzte_restschuld == 0 or letzte_restschuld is None else "AKTIV"
|
||||
|
||||
kredit_info = {
|
||||
"name": name,
|
||||
"restschuld": letzte_restschuld if letzte_restschuld else 0,
|
||||
"start_datum": start_datum.strftime("%Y-%m-%d") if start_datum else None,
|
||||
"monatsrate": monatsrate,
|
||||
"zinssatz": zinssatz,
|
||||
"letztes_datum": letztes_datum.strftime("%Y-%m-%d") if letztes_datum else None
|
||||
}
|
||||
|
||||
if status == "AKTIV":
|
||||
aktive_kredite.append(kredit_info)
|
||||
else:
|
||||
abgezahlte_kredite.append(kredit_info)
|
||||
|
||||
print(f"\n{name}:")
|
||||
print(f" Status: {status}")
|
||||
print(f" Aktuelle Restschuld: {letzte_restschuld:,.2f} EUR" if letzte_restschuld else " Aktuelle Restschuld: 0,00 EUR")
|
||||
print(f" Startdatum: {start_datum.strftime('%d.%m.%Y') if start_datum else 'N/A'}")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A")
|
||||
print(f" Geschätzter Zinssatz: {zinssatz}%" if zinssatz else " Zinssatz: N/A")
|
||||
print(f" Letzter Eintrag: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("ZUSAMMENFASSUNG")
|
||||
print("="*80)
|
||||
|
||||
print(f"\n🟢 AKTIVE KREDITE ({len(aktive_kredite)}):")
|
||||
for k in aktive_kredite:
|
||||
print(f" - {k['name']}: {k['restschuld']:,.2f} EUR")
|
||||
|
||||
print(f"\n⚫ ABGEZAHLT ({len(abgezahlte_kredite)}):")
|
||||
for k in abgezahlte_kredite:
|
||||
print(f" - {k['name']}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("JSON für Import:")
|
||||
print("="*80)
|
||||
import json
|
||||
print(json.dumps(aktive_kredite, indent=2, default=str))
|
||||
@@ -0,0 +1,127 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
# Zeile 1: Kreditnamen
|
||||
# Zeile 2: Headers
|
||||
print("\n=== KREDIT-STRUKTUR (Zeile 1 & 2) ===")
|
||||
|
||||
# Lese Zeile 1 und 2
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
# Zeige Spalten 1-30
|
||||
print("\nSpaltenübersicht:")
|
||||
for i in range(30):
|
||||
val1 = row1[i] if i < len(row1) and row1[i] else ""
|
||||
val2 = row2[i] if i < len(row2) and row2[i] else ""
|
||||
if val1 or val2:
|
||||
print(f" Spalte {i+1:2}: '{val1}' | '{val2}'")
|
||||
|
||||
# Suche nach Kreditnamen in Zeile 1
|
||||
kredit_positionen = []
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str) and cell.strip():
|
||||
name = cell.strip()
|
||||
if name not in ['', 'Monat']:
|
||||
kredit_positionen.append({
|
||||
'name': name,
|
||||
'spalte': col_idx,
|
||||
'header': row2[col_idx-1] if col_idx-1 < len(row2) else None
|
||||
})
|
||||
|
||||
print(f"\n=== Gefundene Kredite: {len(kredit_positionen)} ===")
|
||||
for kp in kredit_positionen:
|
||||
print(f" {kp['name']} bei Spalte {kp['spalte']}, Header: {kp['header']}")
|
||||
|
||||
# Für jeden Kredit: Finde Restschuld, Tilgung, Zinsen Spalten
|
||||
print("\n=== DETAILLIERTE ANALYSE ===")
|
||||
|
||||
for kp in kredit_positionen:
|
||||
name = kp['name']
|
||||
start_col = kp['spalte']
|
||||
|
||||
# Die nächsten 3 Spalten prüfen
|
||||
headers = []
|
||||
for i in range(start_col-1, min(start_col+2, len(row2))):
|
||||
headers.append((i+1, row2[i]))
|
||||
|
||||
print(f"\n{name} (Start bei Spalte {start_col}):")
|
||||
print(f" Headers: {headers}")
|
||||
|
||||
# Finde die relevanten Spalten
|
||||
restschuld_col = None
|
||||
tilgung_col = None
|
||||
zinsen_col = None
|
||||
|
||||
for col_num, header in headers:
|
||||
if header == "Restschuld":
|
||||
restschuld_col = col_num
|
||||
elif header == "Tilgung":
|
||||
tilgung_col = col_num
|
||||
elif header == "Zinsen":
|
||||
zinsen_col = col_num
|
||||
|
||||
print(f" Restschuld: Spalte {restschuld_col}")
|
||||
print(f" Tilgung: Spalte {tilgung_col}")
|
||||
print(f" Zinsen: Spalte {zinsen_col}")
|
||||
|
||||
if not restschuld_col:
|
||||
print(f" [Überspringe - keine Restschuld-Spalte]")
|
||||
continue
|
||||
|
||||
# Suche die letzte Zeile mit Daten
|
||||
letzte_restschuld = None
|
||||
letztes_datum = None
|
||||
erster_wert = None
|
||||
erster_wert_datum = None
|
||||
monatsrate = None
|
||||
start_datum = None
|
||||
|
||||
for row_idx in range(3, min(ws.max_row + 1, 400)):
|
||||
datum_cell = ws.cell(row=row_idx, column=1).value
|
||||
rest_cell = ws.cell(row=row_idx, column=restschuld_col).value
|
||||
tilg_cell = ws.cell(row=row_idx, column=tilgung_col).value if tilgung_col else None
|
||||
|
||||
# Konvertiere zu Zahl wenn möglich
|
||||
if rest_cell is not None:
|
||||
try:
|
||||
rest_val = float(rest_cell)
|
||||
except:
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
|
||||
# Startdatum = erstes Datum mit Restschuld
|
||||
if start_datum is None and datum_cell and isinstance(datum_cell, datetime):
|
||||
start_datum = datum_cell
|
||||
erster_wert = rest_val
|
||||
erster_wert_datum = datum_cell
|
||||
|
||||
# Monatsrate aus konstanter Tilgung
|
||||
if tilg_cell and isinstance(tilg_cell, (int, float)):
|
||||
if tilg_cell > 0:
|
||||
if monatsrate is None:
|
||||
monatsrate = tilg_cell
|
||||
|
||||
# Letzter Wert
|
||||
letzte_restschuld = rest_val
|
||||
if datum_cell and isinstance(datum_cell, datetime):
|
||||
letztes_datum = datum_cell
|
||||
|
||||
# Status ermitteln
|
||||
if letzte_restschuld is not None:
|
||||
status = "ABGEZAHLT" if letzte_restschuld < 10 else "AKTIV"
|
||||
|
||||
print(f" Erster Wert: {erster_wert:,.2f} EUR am {erster_wert_datum.strftime('%d.%m.%Y') if erster_wert_datum else 'N/A'}")
|
||||
print(f" Letzte Restschuld: {letzte_restschuld:,.2f} EUR")
|
||||
print(f" Letztes Datum: {letztes_datum.strftime('%d.%m.%Y') if letztes_datum else 'N/A'}")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR" if monatsrate else " Monatsrate: N/A")
|
||||
print(f" STATUS: {status}")
|
||||
else:
|
||||
print(f" [Keine Daten gefunden]")
|
||||
@@ -0,0 +1,78 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
# Zeile 1 vollständig lesen
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
print("\n=== ALLE SPALTEN (1-40) ===")
|
||||
for i in range(40):
|
||||
val1 = row1[i] if i < len(row1) and row1[i] else ""
|
||||
val2 = row2[i] if i < len(row2) and row2[i] else ""
|
||||
if val1 or val2:
|
||||
print(f" Spalte {i+1:2}: '{val1}' | '{val2}'")
|
||||
|
||||
# Suche nach allen Kredit-Namen (auch in späteren Spalten)
|
||||
suchbegriffe = ["Carola", "Kerstin", "PVCreditplus", "PV Creditplus", "Creditplus", "Zingelstr"]
|
||||
|
||||
print("\n=== SUCHE NACH FEHLENDEN KREDITEN ===")
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str):
|
||||
for suchwort in suchbegriffe:
|
||||
if suchwort.lower() in cell.lower():
|
||||
print(f" Gefunden '{cell}' bei Spalte {col_idx}, Header: {row2[col_idx-1] if col_idx-1 < len(row2) else 'N/A'}")
|
||||
|
||||
# Sparkasse genauer prüfen - vielleicht ist es in Spalte 23?
|
||||
print("\n=== SPARKASSE DETAILS ===")
|
||||
print("Spalte 21 Header:", row2[20] if len(row2) > 20 else "N/A")
|
||||
print("Spalte 22 Header:", row2[21] if len(row2) > 21 else "N/A")
|
||||
print("Spalte 23 Header:", row2[22] if len(row2) > 22 else "N/A")
|
||||
|
||||
# Prüfe Zeile 1 bei Spalte 23
|
||||
print("Spalte 21 Name:", row1[20] if len(row1) > 20 else "N/A")
|
||||
print("Spalte 22 Name:", row1[21] if len(row1) > 21 else "N/A")
|
||||
print("Spalte 23 Name:", row1[22] if len(row1) > 22 else "N/A")
|
||||
|
||||
# Suche nach "Sparkasse" in Zeile 1
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str) and "sparkasse" in cell.lower():
|
||||
print(f"\nSparkasse bei Spalte {col_idx}: '{cell}'")
|
||||
# Nächste 3 Spalten zeigen
|
||||
for i in range(col_idx-1, min(col_idx+2, len(row2))):
|
||||
print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'")
|
||||
|
||||
# Suche nach "Carola"
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str) and "carola" in cell.lower():
|
||||
print(f"\nCarola bei Spalte {col_idx}: '{cell}'")
|
||||
for i in range(col_idx-1, min(col_idx+3, len(row2))):
|
||||
print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'")
|
||||
|
||||
# Suche nach "Kerstin"
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str) and "kerstin" in cell.lower():
|
||||
print(f"\nKerstin bei Spalte {col_idx}: '{cell}'")
|
||||
for i in range(col_idx-1, min(col_idx+3, len(row2))):
|
||||
print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'")
|
||||
|
||||
# Suche nach "PVCreditplus" oder "Creditplus"
|
||||
for col_idx, cell in enumerate(row1, 1):
|
||||
if cell and isinstance(cell, str):
|
||||
if "credit" in cell.lower() or "pvc" in cell.lower() or "plus" in cell.lower():
|
||||
print(f"\nCredit/Plus bei Spalte {col_idx}: '{cell}'")
|
||||
for i in range(col_idx-1, min(col_idx+3, len(row2))):
|
||||
print(f" Spalte {i+1}: Name='{row1[i]}', Header='{row2[i]}'")
|
||||
|
||||
# Suche nach Zahlungen/Zinsen in Spalte 22-30
|
||||
print("\n=== SPALTEN 27-35 ===")
|
||||
for i in range(26, 35):
|
||||
if i < len(row1):
|
||||
val1 = row1[i] if row1[i] else ""
|
||||
val2 = row2[i] if i < len(row2) and row2[i] else ""
|
||||
print(f" Spalte {i+1:2}: '{val1}' | '{val2}'")
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Detaillierte Analyse des Niki-Kredits: Excel vs. DB
|
||||
"""
|
||||
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
# Excel-Daten (bereits extrahiert)
|
||||
excel_data = [
|
||||
# (Zeile, Datum, Restschuld, Zinsen, Abgezahlt, Neue_Kosten, Beschreibung)
|
||||
(7, datetime(2023, 7, 1), -7000, -58.33, None, None, "Start"),
|
||||
(8, datetime(2023, 8, 1), -7058.33, -58.82, None, None, None),
|
||||
(9, datetime(2023, 9, 1), -7117.15, -59.31, None, None, None),
|
||||
(10, datetime(2023, 10, 1), -7176.46, -59.80, None, None, None),
|
||||
(11, datetime(2023, 11, 1), -7236.27, -60.30, None, None, None),
|
||||
(12, datetime(2023, 12, 1), -7296.57, -60.80, None, None, None),
|
||||
(13, datetime(2024, 1, 1), -7357.37, -61.31, None, None, None),
|
||||
(14, datetime(2024, 2, 1), -7418.68, -61.82, None, None, None),
|
||||
(15, datetime(2024, 3, 1), -7480.51, -62.34, None, None, None),
|
||||
(16, datetime(2024, 4, 1), -7542.84, -62.86, 500, None, None),
|
||||
(17, datetime(2024, 5, 1), -7105.70, -59.21, 500, None, None), # Nach Zahlung
|
||||
(18, datetime(2024, 6, 1), -6664.92, -55.54, 150, -90, "Kameras"),
|
||||
(19, datetime(2024, 7, 1), -6660.46, -55.50, 450, -45, "Kameras"),
|
||||
(20, datetime(2024, 8, 1), -6310.96, -52.59, 500, None, None),
|
||||
(21, datetime(2024, 9, 1), -5863.55, -48.86, 300, None, None),
|
||||
(22, datetime(2024, 10, 1), -5612.41, -46.77, 300, -110, "Videorekorder + Zubehör"),
|
||||
(23, datetime(2024, 11, 1), -5469.18, -45.58, None, None, None),
|
||||
(24, datetime(2024, 12, 1), -5514.76, -45.96, None, None, None),
|
||||
(25, datetime(2025, 1, 1), -5560.72, -46.34, None, None, None),
|
||||
(26, datetime(2025, 2, 1), -5607.06, -46.73, 200, None, None),
|
||||
(27, datetime(2025, 3, 1), -5453.78, -45.45, None, None, None),
|
||||
(28, datetime(2025, 4, 1), -5499.23, -45.83, None, None, None),
|
||||
(29, datetime(2025, 5, 1), -5545.06, -46.21, None, None, None),
|
||||
(30, datetime(2025, 6, 1), -5591.27, -46.59, 500, -260, "Handyvertag"),
|
||||
(31, datetime(2025, 7, 1), -5397.86, -44.98, None, None, None),
|
||||
# ... bis Zeile 57
|
||||
]
|
||||
|
||||
# DB-Daten (bereits extrahiert)
|
||||
db_zahlungen = [
|
||||
{"datum": datetime(2024, 4, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"},
|
||||
{"datum": datetime(2024, 5, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"},
|
||||
{"datum": datetime(2024, 6, 1), "betrag": 150.00, "typ": "zahlung_eingang", "notiz": "Kameras"},
|
||||
{"datum": datetime(2024, 6, 1), "betrag": 90.00, "typ": "auslage", "notiz": "Kameras"},
|
||||
{"datum": datetime(2024, 7, 1), "betrag": 450.00, "typ": "zahlung_eingang", "notiz": "Kameras"},
|
||||
{"datum": datetime(2024, 7, 1), "betrag": 45.00, "typ": "auslage", "notiz": "Kameras"},
|
||||
{"datum": datetime(2024, 8, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"},
|
||||
{"datum": datetime(2024, 9, 1), "betrag": 300.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"},
|
||||
{"datum": datetime(2024, 10, 1), "betrag": 300.00, "typ": "zahlung_eingang", "notiz": "Videorekorder + Zubehör"},
|
||||
{"datum": datetime(2024, 10, 1), "betrag": 110.00, "typ": "auslage", "notiz": "Videorekorder + Zubehör"},
|
||||
{"datum": datetime(2025, 2, 1), "betrag": 200.00, "typ": "zahlung_eingang", "notiz": "Zahlung von Niki"},
|
||||
{"datum": datetime(2025, 6, 1), "betrag": 500.00, "typ": "zahlung_eingang", "notiz": "Handyvertag"},
|
||||
{"datum": datetime(2025, 6, 1), "betrag": 260.00, "typ": "auslage", "notiz": "Handyvertag"},
|
||||
]
|
||||
|
||||
print("=" * 80)
|
||||
print("ANALYSE: Niki-Kredit - Excel vs. DB")
|
||||
print("=" * 80)
|
||||
|
||||
# Berechne Zahlungen aus Excel
|
||||
print("\n## ZAHLUNGEN/AUSLAGEN aus Excel:")
|
||||
print("-" * 80)
|
||||
excel_zahlungen = []
|
||||
for row in excel_data:
|
||||
zeile, datum, restschuld, zinsen, abgezahlt, neue_kosten, beschreibung = row
|
||||
if abgezahlt:
|
||||
print(f"Zahlung: {datum.strftime('%Y-%m-%d')} | +{abgezahlt:6.2f} EUR | {beschreibung or 'Zahlung'}")
|
||||
excel_zahlungen.append({"datum": datum, "betrag": abgezahlt, "typ": "zahlung_eingang", "notiz": beschreibung or "Zahlung"})
|
||||
if neue_kosten:
|
||||
print(f"Auslage: {datum.strftime('%Y-%m-%d')} | {neue_kosten:7.2f} EUR | {beschreibung}")
|
||||
excel_zahlungen.append({"datum": datum, "betrag": abs(neue_kosten), "typ": "auslage", "notiz": beschreibung})
|
||||
|
||||
print("\n## VERGLEICH: Excel vs. DB")
|
||||
print("-" * 80)
|
||||
print(f"{'Datum':<12} {'Excel Zahlung':>15} {'DB Zahlung':>15} {'Status':<15}")
|
||||
print("-" * 80)
|
||||
|
||||
# Erstelle Mapping nach Datum
|
||||
from collections import defaultdict
|
||||
excel_by_date = defaultdict(list)
|
||||
db_by_date = defaultdict(list)
|
||||
|
||||
for z in excel_zahlungen:
|
||||
key = z["datum"].strftime("%Y-%m-%d")
|
||||
excel_by_date[key].append(z)
|
||||
|
||||
for z in db_zahlungen:
|
||||
key = z["datum"].strftime("%Y-%m-%d")
|
||||
db_by_date[key].append(z)
|
||||
|
||||
all_dates = sorted(set(list(excel_by_date.keys()) + list(db_by_date.keys())))
|
||||
|
||||
fehlend_in_db = []
|
||||
zusammenfassung = {"excel": 0, "db": 0, "fehlend": 0}
|
||||
|
||||
for date in all_dates:
|
||||
excel_list = excel_by_date.get(date, [])
|
||||
db_list = db_by_date.get(date, [])
|
||||
|
||||
excel_sum = sum(z["betrag"] for z in excel_list)
|
||||
db_sum = sum(z["betrag"] for z in db_list)
|
||||
|
||||
zusammenfassung["excel"] += excel_sum
|
||||
zusammenfassung["db"] += db_sum
|
||||
|
||||
status = "OK" if abs(excel_sum - db_sum) < 0.01 else "DIFFERENZ"
|
||||
if excel_sum > 0 and db_sum == 0:
|
||||
status = "FEHLT IN DB"
|
||||
fehlend_in_db.extend(excel_list)
|
||||
zusammenfassung["fehlend"] += excel_sum
|
||||
|
||||
print(f"{date:<12} {excel_sum:>15.2f} EUR {db_sum:>15.2f} EUR {status}")
|
||||
|
||||
print("-" * 80)
|
||||
print(f"{'GESAMT':<12} {zusammenfassung['excel']:>15.2f} EUR {zusammenfassung['db']:>15.2f} EUR")
|
||||
print(f"\nFehlend in DB: {zusammenfassung['fehlend']:.2f} EUR")
|
||||
|
||||
if fehlend_in_db:
|
||||
print("\n## FEHLENDE EINTRÄGE IN DB:")
|
||||
print("-" * 80)
|
||||
for z in fehlend_in_db:
|
||||
print(f"INSERT INTO kredit_zahlungen (kredit_id, betrag, datum, typ, notiz) VALUES ('4ad8826f-ecb4-443d-aef6-ce9162e5f078', {z['betrag']:.2f}, '{z['datum'].strftime('%Y-%m-%d')}', '{z['typ']}', '{z['notiz']}');")
|
||||
|
||||
# Analyse Restschuld
|
||||
print("\n" + "=" * 80)
|
||||
print("RESTSCHULD-ANALYSE")
|
||||
print("=" * 80)
|
||||
print(f"Excel (Stand 2023-07-01): -7000.00 EUR")
|
||||
print(f"DB Restschuld: 4105.00 EUR")
|
||||
print(f"DB Ursprungsschuld: 7000.00 EUR")
|
||||
print(f"\nBerechnung: 7000 - Summe(Zahlungen) + Summe(Auslagen) = ?")
|
||||
print(f"Gesamtzahlungen DB: {zusammenfassung['db']:.2f} EUR")
|
||||
print(f"Erwartete Restschuld: 7000 - {zusammenfassung['db']:.2f} = {7000 - zusammenfassung['db']:.2f} EUR")
|
||||
print(f"\nHinweis: Die App scheint die Restschuld anders zu berechnen als Excel.")
|
||||
print(f"Excel fügt Zinsen hinzu, DB vermutlich ohne/korrigierte Zinsberechnung.")
|
||||
@@ -0,0 +1,28 @@
|
||||
import openpyxl
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
row1 = list(ws.iter_rows(min_row=1, max_row=1, values_only=True))[0]
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
|
||||
print("=== SPALTENÜBERSICHT (alle mit Inhalt) ===")
|
||||
for i in range(len(row1)):
|
||||
val1 = row1[i] if i < len(row1) and row1[i] else ""
|
||||
val2 = row2[i] if i < len(row2) and row2[i] else ""
|
||||
if val1 or val2:
|
||||
print(f" Spalte {i+1:2}: Zeile1='{val1}' | Zeile2='{val2}'")
|
||||
|
||||
print("\n=== SUCHE NACH KREDIT-NAMEN ===")
|
||||
suchbegriffe = ["sparkasse", "carola", "kerstin", "credit", "pvc"]
|
||||
|
||||
for i, (val1, val2) in enumerate(zip(row1, row2)):
|
||||
if val1 and isinstance(val1, str):
|
||||
for suchwort in suchbegriffe:
|
||||
if suchwort.lower() in val1.lower():
|
||||
print(f" Gefunden '{val1}' in Spalte {i+1}")
|
||||
if val2 and isinstance(val2, str):
|
||||
for suchwort in suchbegriffe:
|
||||
if suchwort.lower() in val2.lower():
|
||||
print(f" Gefunden '{val2}' in Spalte {i+1} (Zeile 2)")
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analysiert Sheet 'Tabelle1' - wahrscheinlich das Haupt-Sheet mit aktuellen Daten.
|
||||
"""
|
||||
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
|
||||
sheet_name = "Tabelle1"
|
||||
ws = wb[sheet_name]
|
||||
|
||||
print(f"=== SHEET: {sheet_name} ===")
|
||||
print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}")
|
||||
|
||||
# Zeile 1-10: Headers
|
||||
print("\n=== HEADER (Zeile 1-10) ===")
|
||||
for row_idx in range(1, 11):
|
||||
row_data = []
|
||||
for col_idx in range(1, 20):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
val = cell.value
|
||||
if val:
|
||||
if isinstance(val, str):
|
||||
row_data.append((col_idx, val[:40]))
|
||||
elif isinstance(val, (int, float)):
|
||||
row_data.append((col_idx, f"{val:,.2f}"))
|
||||
elif isinstance(val, datetime):
|
||||
row_data.append((col_idx, val.strftime("%Y-%m")))
|
||||
if row_data:
|
||||
print(f"Zeile {row_idx}: {row_data}")
|
||||
|
||||
# Suche nach spezifischen Kreditnamen
|
||||
print("\n=== SUCHE NACH KREDITNAMEN ===")
|
||||
kredit_namen = ["DSL Bank", "PSD Nord", "Zingelstr", "PVCreditplus", "Sparkasse",
|
||||
"Targo", "Köpke", "Kopke", "Restschuld", "Kredit"]
|
||||
|
||||
for row_idx in range(1, min(ws.max_row + 1, 200)):
|
||||
for col_idx in range(1, min(ws.max_column + 1, 30)):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
val = cell.value
|
||||
if val and isinstance(val, str):
|
||||
for kredit in kredit_namen:
|
||||
if kredit.lower() in val.lower():
|
||||
# Zeige die Zeile
|
||||
row_context = []
|
||||
for c in range(1, 15):
|
||||
v = ws.cell(row=row_idx, column=c).value
|
||||
if v:
|
||||
if isinstance(v, str):
|
||||
row_context.append(f"{v[:20]}")
|
||||
elif isinstance(v, (int, float)):
|
||||
row_context.append(f"{v:,.0f}")
|
||||
print(f"\nZeile {row_idx}, Spalte {col_idx}: '{val}'")
|
||||
print(f" Kontext: {row_context[:8]}")
|
||||
|
||||
# Suche nach großen Geldbeträgen (Restschulden > 1000)
|
||||
print("\n=== GROSSE BETRÄGE (> 10000) IN ERSTEN 100 ZEILEN ===")
|
||||
gross_betraege = []
|
||||
for row_idx in range(1, min(ws.max_row + 1, 100)):
|
||||
for col_idx in range(1, min(ws.max_column + 1, 30)):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
val = cell.value
|
||||
if val and isinstance(val, (int, float)) and val > 10000:
|
||||
# Suche nach Label
|
||||
label = None
|
||||
for c in range(1, min(30, ws.max_column + 1)):
|
||||
v = ws.cell(row=row_idx, column=c).value
|
||||
if v and isinstance(v, str):
|
||||
label = v
|
||||
break
|
||||
gross_betraege.append((row_idx, col_idx, val, label))
|
||||
|
||||
for row, col, val, label in gross_betraege[:20]:
|
||||
print(f" Zeile {row}, Spalte {col}: {val:>15,.2f} EUR - Label: {label}")
|
||||
|
||||
# Suche nach den genauen Zielwerten
|
||||
print("\n=== SUCHE NACH ZIELWERTEN (Toleranz 500 EUR) ===")
|
||||
targets = [
|
||||
("DSL Bank", 64656.88),
|
||||
("PSD Nord", 50384.50),
|
||||
("Zingelstr. 14 DSL", 24382.38),
|
||||
("Zingelstr. 14 Sparkasse", 8140.11),
|
||||
("PVCreditplus", 1666.53)
|
||||
]
|
||||
|
||||
for row_idx in range(1, ws.max_row + 1):
|
||||
for col_idx in range(1, min(ws.max_column + 1, 50)):
|
||||
val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if val and isinstance(val, (int, float)):
|
||||
for name, target in targets:
|
||||
if abs(val - target) < 500:
|
||||
print(f"\n {name}: {val:,.2f} bei Zeile {row_idx}, Spalte {col_idx}")
|
||||
# Zeige die Zeile
|
||||
row_vals = []
|
||||
for c in range(1, 20):
|
||||
v = ws.cell(row=row_idx, column=c).value
|
||||
if v:
|
||||
if isinstance(v, str):
|
||||
row_vals.append(f"S{c}:{v[:15]}")
|
||||
elif isinstance(v, (int, float)):
|
||||
row_vals.append(f"S{c}:{v:,.0f}")
|
||||
print(f" Zeile: {row_vals}")
|
||||
# Zeige Header
|
||||
headers = []
|
||||
for c in range(1, 20):
|
||||
h = ws.cell(row=1, column=c).value
|
||||
if h:
|
||||
headers.append(f"S{c}:{str(h)[:15]}")
|
||||
print(f" Header: {headers}")
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analysiert das 'Tilgung bei Gleichbleibenden Be' Sheet genauer.
|
||||
Sucht nach den Raten: 513,80 / 237,35 / 350 / 1666.53
|
||||
"""
|
||||
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
|
||||
sheet_name = "Tilgung bei Gleichbleibenden Be"
|
||||
ws = wb[sheet_name]
|
||||
|
||||
print(f"=== SHEET: {sheet_name} ===")
|
||||
print(f"Max Zeile: {ws.max_row}, Max Spalte: {ws.max_column}")
|
||||
|
||||
# Header (Zeile 1-5)
|
||||
print("\n=== ZEILE 1-5 (HEADER) ===")
|
||||
for row_idx in range(1, 6):
|
||||
row_data = []
|
||||
for col_idx in range(1, 30):
|
||||
cell = ws.cell(row=row_idx, column=col_idx)
|
||||
val = cell.value
|
||||
if val:
|
||||
if isinstance(val, str):
|
||||
row_data.append(f"S{col_idx}:{val[:15]}")
|
||||
elif isinstance(val, (int, float)):
|
||||
row_data.append(f"S{col_idx}:{val:,.2f}")
|
||||
elif isinstance(val, datetime):
|
||||
row_data.append(f"S{col_idx}:{val.strftime('%Y-%m')}")
|
||||
if row_data:
|
||||
print(f"Zeile {row_idx}: {row_data}")
|
||||
|
||||
# Suche nach den Raten
|
||||
print("\n=== SUCHE NACH RATEN (513.80, 237.35, 350, 1666.53) ===")
|
||||
target_rates = [513.80, 237.35, 350, 1666.53, 427]
|
||||
|
||||
for target in target_rates:
|
||||
print(f"\nSuche nach Rate {target}:")
|
||||
found = False
|
||||
for row_idx in range(1, min(ws.max_row + 1, 200)):
|
||||
for col_idx in range(1, min(ws.max_column + 1, 50)):
|
||||
val = ws.cell(row=row_idx, column=col_idx).value
|
||||
if val and isinstance(val, (int, float)):
|
||||
if abs(val - target) < 1: # Toleranz 1 EUR
|
||||
bank = ws.cell(row=1, column=col_idx).value
|
||||
label = ws.cell(row=2, column=col_idx).value
|
||||
print(f" GEFUNDEN: Zeile {row_idx}, Spalte {col_idx}: {val}")
|
||||
print(f" Bank: {bank}, Label: {label}")
|
||||
# Zeige Kontext
|
||||
context = []
|
||||
for c in range(col_idx-2, col_idx+3):
|
||||
if c > 0:
|
||||
v = ws.cell(row=row_idx, column=c).value
|
||||
context.append(f"S{c}:{str(v)[:10] if v else '-'}")
|
||||
print(f" Kontext: {context}")
|
||||
found = True
|
||||
if not found:
|
||||
print(f" NICHT GEFUNDEN")
|
||||
|
||||
# Lese aktuelle Werte aus den letzten Zeilen
|
||||
print("\n=== LETZTE ZEILEN MIT DATEN (für aktuelle Restschulden) ===")
|
||||
|
||||
# Finde die letzte Zeile mit Datum
|
||||
last_date_row = None
|
||||
for row in range(ws.max_row, 5, -1):
|
||||
val = ws.cell(row=row, column=1).value
|
||||
if val and isinstance(val, datetime):
|
||||
last_date_row = row
|
||||
break
|
||||
|
||||
print(f"Letzte Zeile mit Datum: {last_date_row} ({ws.cell(row=last_date_row, column=1).value})")
|
||||
|
||||
if last_date_row:
|
||||
# Lese alle Werte aus der letzten Zeile
|
||||
print(f"\nWerte in Zeile {last_date_row}:")
|
||||
for col_idx in range(1, min(ws.max_column + 1, 30)):
|
||||
val = ws.cell(row=last_date_row, column=col_idx).value
|
||||
if val and isinstance(val, (int, float)) and val > 100:
|
||||
bank = ws.cell(row=1, column=col_idx).value
|
||||
label = ws.cell(row=2, column=col_idx).value
|
||||
print(f" Spalte {col_idx}: {val:,>12,.2f} - Bank: {bank}, Label: {label}")
|
||||
|
||||
# Zeige auch Zeile 1 Headers
|
||||
print("\n=== BANKNAMEN IN ZEILE 1 ===")
|
||||
for col_idx in range(1, 30):
|
||||
val = ws.cell(row=1, column=col_idx).value
|
||||
if val:
|
||||
print(f" Spalte {col_idx:2}: {val}")
|
||||
@@ -0,0 +1,21 @@
|
||||
# OCR Backend Service
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Installiere Tesseract OCR
|
||||
RUN apk add --no-cache tesseract-ocr tesseract-ocr-data-deu
|
||||
|
||||
# Dependencies
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
|
||||
# App Code
|
||||
COPY . .
|
||||
|
||||
# Stelle sicher dass public-Verzeichnis existiert
|
||||
RUN mkdir -p public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g nodemon
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["nodemon", "--legacy-watch", "server.js"]
|
||||
@@ -0,0 +1,5 @@
|
||||
const fs = require('fs');
|
||||
let content = fs.readFileSync('server.js', 'utf8');
|
||||
content = content.replace(/\r\n/g, '\n');
|
||||
fs.writeFileSync('server.js', content, 'utf8');
|
||||
console.log('Done');
|
||||
@@ -0,0 +1,108 @@
|
||||
// Import über REST-API statt direkt zu PostgreSQL
|
||||
|
||||
const API_URL = 'http://192.168.0.141:3001/api';
|
||||
|
||||
// Test-Einheit: Brandt 2023
|
||||
const testData = {
|
||||
jahr: 2023,
|
||||
wohnung: 'Zingelstr. 14',
|
||||
mieter: 'Yvonne Brandt',
|
||||
kaltmiete: 0,
|
||||
nebenkosten: 501.48, // Summe aus Excel
|
||||
versicherung: 129.58, // Geb. Vers./Haftpflichtv.
|
||||
heizkosten: 102.53, // Wartung Heizung
|
||||
wasser: 0,
|
||||
muell: 153.85, // Müllabfuhr
|
||||
sonstiges: 115.53 // Grundsteuer (40.17) + Niederschlagwasser (75.36)
|
||||
};
|
||||
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
if (options.body && typeof options.body === 'object') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function importTest() {
|
||||
console.log('=== TEST-IMPORT: Yvonne Brandt 2023 ===\n');
|
||||
console.log('=== ZU IMPORTIERENDE DATEN ===');
|
||||
console.log(JSON.stringify(testData, null, 2));
|
||||
|
||||
console.log('\n=== FÜHRE IMPORT AUS ===');
|
||||
|
||||
try {
|
||||
// Erst prüfen, ob Eintrag existiert
|
||||
const existing = await apiCall('/nebenkosten');
|
||||
const brandtEntries = existing.filter(e => e.mieter && e.mieter.includes('Brandt'));
|
||||
console.log(`Gefunden: ${brandtEntries.length} Einträge für Brandt`);
|
||||
brandtEntries.forEach(e => {
|
||||
console.log(` - ${e.jahr}: ${e.mieter} (${e.nebenkosten} €)`);
|
||||
});
|
||||
|
||||
// Import
|
||||
const result = await apiCall('/nebenkosten', {
|
||||
method: 'POST',
|
||||
body: testData
|
||||
});
|
||||
|
||||
console.log('\n✓ ERFOLGREICH IMPORTIERT:');
|
||||
console.log(' ID:', result.id);
|
||||
console.log(' Jahr:', result.jahr);
|
||||
console.log(' Wohnung:', result.wohnung);
|
||||
console.log(' Mieter:', result.mieter);
|
||||
console.log(' Nebenkosten:', result.nebenkosten, '€');
|
||||
console.log(' Versicherung:', result.versicherung, '€');
|
||||
console.log(' Heizkosten:', result.heizkosten, '€');
|
||||
console.log(' Müll:', result.muell, '€');
|
||||
console.log(' Sonstiges:', result.sonstiges, '€');
|
||||
|
||||
// Verifizieren
|
||||
const verify = await apiCall('/nebenkosten');
|
||||
const imported = verify.find(e => e.id === result.id);
|
||||
|
||||
console.log('\n=== VERIFIZIERUNG ===');
|
||||
if (imported) {
|
||||
console.log('✓ Datensatz in Datenbank bestätigt');
|
||||
console.log(' ID:', imported.id);
|
||||
console.log(' Mieter:', imported.mieter);
|
||||
console.log(' Jahr:', imported.jahr);
|
||||
console.log(' Gesamtkosten:', imported.nebenkosten, '€');
|
||||
} else {
|
||||
console.log('✗ Datensatz nicht gefunden!');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ FEHLER beim Import:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Führe Import aus
|
||||
importTest().catch(err => {
|
||||
console.error('\nImport fehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
|
||||
// Middleware: Prüft ob User eingeloggt ist
|
||||
function authRequired(req, res, next) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Token ungültig' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token abgelaufen' });
|
||||
}
|
||||
console.error('Auth Middleware Error:', error);
|
||||
return res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
}
|
||||
|
||||
// Middleware: Prüft ob User Admin ist
|
||||
function adminRequired(req, res, next) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Nicht eingeloggt' });
|
||||
}
|
||||
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Zugriff verweigert. Admin-Rechte erforderlich.' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authRequired,
|
||||
adminRequired
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "buchhaltung-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "OCR Backend für SteuerFlow",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pg": "^8.11.3",
|
||||
"sharp": "^0.33.2",
|
||||
"tesseract.js": "^5.0.5",
|
||||
"uuid": "^9.0.1",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,315 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Pool } = require('pg');
|
||||
const router = express.Router();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const BCRYPT_ROUNDS = 11;
|
||||
|
||||
// Helper: Check if any users exist
|
||||
async function hasUsers() {
|
||||
const result = await pool.query('SELECT COUNT(*) FROM users');
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
}
|
||||
|
||||
// POST /api/auth/setup - Erste Anmeldung (Admin erstellen)
|
||||
router.post('/setup', async (req, res) => {
|
||||
try {
|
||||
// Prüfen ob schon User existieren
|
||||
const existingUsers = await hasUsers();
|
||||
if (existingUsers) {
|
||||
return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, 'admin']
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Admin-Account erstellt',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login - JWT-Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
// Update last_login
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login Error:', error);
|
||||
res.status(500).json({ error: 'Server-Fehler beim Login' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung)
|
||||
router.post('/logout', async (req, res) => {
|
||||
res.json({ success: true, message: 'Logout erfolgreich' });
|
||||
});
|
||||
|
||||
// GET /api/auth/me - Aktueller User
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth/me Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/users - Neuen User anlegen (nur Admin)
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User anlegen' });
|
||||
}
|
||||
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, role]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User erstellt',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create User Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users - Alle User auflisten (nur Admin)
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List Users Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/users/:id - User löschen (nur Admin)
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User löschen' });
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (req.params.id === decoded.userId) {
|
||||
return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User gelöscht'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete User Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin)
|
||||
router.put('/users/:id/password', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Reset Password Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,315 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { Pool } = require('pg');
|
||||
const router = express.Router();
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
|
||||
const BCRYPT_ROUNDS = 11;
|
||||
|
||||
// Helper: Check if any users exist
|
||||
async function hasUsers() {
|
||||
const result = await pool.query('SELECT COUNT(*) FROM users');
|
||||
return parseInt(result.rows[0].count) > 0;
|
||||
}
|
||||
|
||||
// POST /api/auth/setup - Erste Anmeldung (Admin erstellen)
|
||||
router.post('/setup', async (req, res) => {
|
||||
try {
|
||||
// Prüfen ob schon User existieren
|
||||
const existingUsers = await hasUsers();
|
||||
if (existingUsers) {
|
||||
return res.status(400).json({ error: 'Setup bereits abgeschlossen. Bitte einloggen.' });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, 'admin']
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Admin-Account erstellt',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Setup Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler bei der Einrichtung' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/login - JWT-Login
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM users WHERE username = $1 AND is_active = true',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
// Update last_login
|
||||
await pool.query('UPDATE users SET last_login = NOW() WHERE id = $1', [user.id]);
|
||||
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login erfolgreich',
|
||||
user: { id: user.id, username: user.username, role: user.role },
|
||||
token
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login Error:', error);
|
||||
res.status(500).json({ error: 'Server-Fehler beim Login' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/logout - Logout (Client-seitig Token entfernen, hier nur Bestätigung)
|
||||
router.post('/logout', async (req, res) => {
|
||||
res.json({ success: true, message: 'Logout erfolgreich' });
|
||||
});
|
||||
|
||||
// GET /api/auth/me - Aktueller User
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, created_at, last_login FROM users WHERE id = $1 AND is_active = true',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'User nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth/me Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/auth/users - Neuen User anlegen (nur Admin)
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User anlegen' });
|
||||
}
|
||||
|
||||
const { username, password, role = 'user' } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Username und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_active)
|
||||
VALUES ($1, $2, $3, true)
|
||||
RETURNING id, username, role, created_at`,
|
||||
[username, passwordHash, role]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User erstellt',
|
||||
user: result.rows[0]
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create User Error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Username bereits vergeben' });
|
||||
}
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/users - Alle User auflisten (nur Admin)
|
||||
router.get('/users', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User-Liste sehen' });
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, role, is_active, created_at, last_login FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: result.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('List Users Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/users/:id - User löschen (nur Admin)
|
||||
router.delete('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können User löschen' });
|
||||
}
|
||||
|
||||
// Cannot delete yourself
|
||||
if (req.params.id === decoded.userId) {
|
||||
return res.status(400).json({ error: 'Kann eigenen Account nicht löschen' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User gelöscht'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete User Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/auth/users/:id/password - Passwort zurücksetzen (nur Admin)
|
||||
router.put('/users/:id/password', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Kein Token vorhanden' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const decoded = jwt.verify(token, JWT_SECRET);
|
||||
|
||||
if (decoded.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Nur Admins können Passwörter zurücksetzen' });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword || newPassword.length < 6) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 6 Zeichen lang sein' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS);
|
||||
|
||||
await pool.query('UPDATE users SET password_hash = $1 WHERE id = $2', [passwordHash, req.params.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Passwort zurückgesetzt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Reset Password Error:', error);
|
||||
if (error.name === 'JsonWebTokenError' || error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
res.status(500).json({ error: 'Server-Fehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
const fs = require('fs');
|
||||
|
||||
// Read file
|
||||
let content = fs.readFileSync(process.argv[1] || 'nebenkosten.js', 'utf8');
|
||||
|
||||
// Convert to Unix line endings
|
||||
content = content.replace(/\r\n/g, '\n');
|
||||
|
||||
// Save
|
||||
fs.writeFileSync(process.argv[1] || 'nebenkosten.js', content, 'utf8');
|
||||
|
||||
console.log('Lines:', content.split('\n').length);
|
||||
console.log('Has vermietung/bilanz:', content.includes("app.get('/api/vermietung/bilanz'"));
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,962 @@
|
||||
// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== OBJEKTE ==========
|
||||
|
||||
// Alle Objekte
|
||||
app.get('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelnes Objekt
|
||||
app.get('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt erstellen
|
||||
app.post('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt aktualisieren
|
||||
app.put('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt löschen
|
||||
app.delete('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
|
||||
app.get('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { jahr } = req.query;
|
||||
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
|
||||
const params = [req.params.id];
|
||||
|
||||
if (jahr) {
|
||||
query += ' AND jahr = $2';
|
||||
params.push(jahr);
|
||||
}
|
||||
|
||||
query += ' ORDER BY jahr DESC, kategorie ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten hinzufügen
|
||||
app.post('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, betrag, jahr } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[req.params.id, kategorie, betrag, jahr]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten löschen
|
||||
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mieter eines Objekts
|
||||
app.get('/api/objekte/:id/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete,
|
||||
mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell
|
||||
FROM mieter m
|
||||
JOIN mietvertraege mv ON mv.mieter_id = m.id
|
||||
WHERE mv.objekt_id = $1
|
||||
ORDER BY m.name`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETER ==========
|
||||
|
||||
app.get('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, email, telefon, adresse]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *',
|
||||
[name, email, telefon, adresse, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mietverträge eines Mieters
|
||||
app.get('/api/mieter/:id/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
WHERE mv.mieter_id = $1
|
||||
ORDER BY mv.vertragsbeginn DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, m.name as mieter_name, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
ORDER BY mv.vertragsbeginn DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, ist_aktuell } = req.query;
|
||||
let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort,
|
||||
m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
JOIN mieter m ON m.id = mv.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`mv.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (ist_aktuell !== undefined) {
|
||||
conditions.push(`mv.ist_aktuell = $${values.length + 1}`);
|
||||
values.push(ist_aktuell === 'true');
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY mv.vertragsbeginn DESC';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3,
|
||||
vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`,
|
||||
[wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege/:id/beenden', async (req, res) => {
|
||||
try {
|
||||
const { vertragsende } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`,
|
||||
[vertragsende, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
app.get('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, jahr } = req.query;
|
||||
let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id';
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`ok.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`ok.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== VORAUSZAHLUNGEN ==========
|
||||
|
||||
app.get('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr } = req.query;
|
||||
let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name
|
||||
FROM vorauszahlungen v
|
||||
JOIN objekte o ON o.id = v.objekt_id
|
||||
JOIN mieter m ON m.id = v.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`v.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (mieter_id) {
|
||||
conditions.push(`v.mieter_id = $${values.length + 1}`);
|
||||
values.push(mieter_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`v.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY v.jahr DESC, v.monat';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk: Vorauszahlungen für Jahr erstellen
|
||||
app.post('/api/vorauszahlungen/bulk', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { objektId, mieterId, jahr, monatlicherBetrag } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (let monat = 1; monat <= 12; monat++) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (objekt_id, mieter_id, jahr, monat)
|
||||
DO UPDATE SET betrag = $5
|
||||
RETURNING *`,
|
||||
[objektId, mieterId, jahr, monat, monatlicherBetrag]
|
||||
);
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, created: results.length, vorauszahlungen: results });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`,
|
||||
[betrag, bezahlt_am, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== NEBENKOSTENABRECHNUNG ==========
|
||||
|
||||
// Übersicht
|
||||
app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => {
|
||||
try {
|
||||
const { jahr = new Date().getFullYear() } = req.query;
|
||||
|
||||
// Alle Objekte mit Summen
|
||||
const result = await pool.query(`
|
||||
SELECT o.*,
|
||||
COALESCE(k.gesamt_kosten, 0) as gesamt_kosten,
|
||||
COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen,
|
||||
COALESCE(m.anzahl_mieter, 0) as anzahl_mieter
|
||||
FROM objekte o
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_kosten
|
||||
FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id
|
||||
) k ON k.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen
|
||||
FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id
|
||||
) v ON v.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, COUNT(*) as anzahl_mieter
|
||||
FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id
|
||||
) m ON m.objekt_id = o.id
|
||||
ORDER BY o.name
|
||||
`, [jahr]);
|
||||
|
||||
res.json({ jahr: parseInt(jahr), objekte: result.rows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung Vorschau (Berechnung)
|
||||
app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => {
|
||||
try {
|
||||
const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body;
|
||||
|
||||
// Objekt-Daten
|
||||
const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]);
|
||||
if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
const objekt = objektResult.rows[0];
|
||||
|
||||
// Alle Kosten des Objekts im Jahr
|
||||
const kostenResult = await pool.query(
|
||||
'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie',
|
||||
[objektId, jahr]
|
||||
);
|
||||
|
||||
// Aktuelle Mieter mit Mietverträgen
|
||||
const mieterResult = await pool.query(`
|
||||
SELECT mv.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true
|
||||
`, [objektId]);
|
||||
|
||||
// Berechnung
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0);
|
||||
|
||||
// Tage im Abrechnungszeitraum
|
||||
const vonDatum = new Date(zeitraumVon);
|
||||
const bisDatum = new Date(zeitraumBis);
|
||||
const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// Pro Mieter berechnen
|
||||
const berechnungen = [];
|
||||
|
||||
for (const mietvertrag of mieterResult.rows) {
|
||||
// Vorauszahlungen dieses Mieters
|
||||
const vorauszahlungenResult = await pool.query(
|
||||
'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3',
|
||||
[objektId, mietvertrag.mieter_id, jahr]
|
||||
);
|
||||
const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0);
|
||||
|
||||
// Anteil berechnen (pro-rata bei Mieterwechsel möglich)
|
||||
const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm);
|
||||
const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen
|
||||
const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche);
|
||||
|
||||
const ergebnis = anteilKosten - summeVorauszahlungen;
|
||||
|
||||
berechnungen.push({
|
||||
mietvertrag_id: mietvertrag.id,
|
||||
mieter_id: mietvertrag.mieter_id,
|
||||
mieter_name: mietvertrag.mieter_name,
|
||||
anteil_qm: anteilQm,
|
||||
anteil_tage: anteilTage,
|
||||
anteil_kosten: Math.round(anteilKosten * 100) / 100,
|
||||
summe_vorauszahlungen: summeVorauszahlungen,
|
||||
ergebnis: Math.round(ergebnis * 100) / 100,
|
||||
ist_nachzahlung: ergebnis > 0,
|
||||
betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0,
|
||||
betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
objekt,
|
||||
jahr,
|
||||
zeitraum_von: zeitraumVon,
|
||||
zeitraum_bis: zeitraumBis,
|
||||
tage_gesamt: tageGesamt,
|
||||
gesamt_kosten: gesamtKosten,
|
||||
gesamt_flaeche: gesamtFlaeche,
|
||||
kosten: kostenResult.rows,
|
||||
mieter: mieterResult.rows,
|
||||
berechnungen
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung speichern
|
||||
app.post('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body;
|
||||
|
||||
// Bestehende Abrechnung prüfen/löschen
|
||||
await client.query(
|
||||
'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2',
|
||||
[objekt_id, jahr]
|
||||
);
|
||||
|
||||
// Neue Abrechnung erstellen
|
||||
const abrechnungResult = await client.query(
|
||||
`INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false]
|
||||
);
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// Positionen speichern
|
||||
for (const pos of berechnungen) {
|
||||
await client.query(
|
||||
`INSERT INTO abrechnungspositionen
|
||||
(abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage,
|
||||
pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, abrechnung: abrechnungResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Abrechnungen
|
||||
app.get('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alias für Frontend (Plural)
|
||||
app.get('/api/nebenkostenabrechnungen', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelne Abrechnung mit Positionen
|
||||
app.get('/api/nebenkostenabrechnung/:id', async (req, res) => {
|
||||
try {
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2
|
||||
`, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]);
|
||||
|
||||
res.json({
|
||||
abrechnung: abrechnungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
kosten: kostenResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Status aktualisieren (Entwurf/Final)
|
||||
app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { ist_entwurf } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *',
|
||||
[ist_entwurf, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,962 @@
|
||||
// API Routes für Nebenkostenabrechnung (Objekte, Mieter, Mietverträge, Kosten, Vorauszahlungen, Abrechnungen)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== OBJEKTE ==========
|
||||
|
||||
// Alle Objekte
|
||||
app.get('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelnes Objekt
|
||||
app.get('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM objekte WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt erstellen
|
||||
app.post('/api/objekte', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objekte (name, adresse, plz, ort, wohnflaeche_qm, bemerkung) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm || 0, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt aktualisieren
|
||||
app.put('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, adresse, plz, ort, wohnflaeche_qm, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE objekte SET name = $1, adresse = $2, plz = $3, ort = $4, wohnflaeche_qm = $5, bemerkung = $6 WHERE id = $7 RETURNING *',
|
||||
[name, adresse, plz, ort, wohnflaeche_qm, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Objekt löschen
|
||||
app.delete('/api/objekte/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objekte WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
// Kosten für ein Objekt laden (optional gefiltert nach Jahr)
|
||||
app.get('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { jahr } = req.query;
|
||||
let query = 'SELECT * FROM objektkosten WHERE objekt_id = $1';
|
||||
const params = [req.params.id];
|
||||
|
||||
if (jahr) {
|
||||
query += ' AND jahr = $2';
|
||||
params.push(jahr);
|
||||
}
|
||||
|
||||
query += ' ORDER BY jahr DESC, kategorie ASC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten hinzufügen
|
||||
app.post('/api/objekte/:id/kosten', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, betrag, jahr } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO objektkosten (objekt_id, kategorie, betrag, jahr) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[req.params.id, kategorie, betrag, jahr]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Kosten löschen
|
||||
app.delete('/api/objekte/:id/kosten/:kostenId', async (req, res) => {
|
||||
try {
|
||||
await pool.query('DELETE FROM objektkosten WHERE id = $1 AND objekt_id = $2', [req.params.kostenId, req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mieter eines Objekts
|
||||
app.get('/api/objekte/:id/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT m.*, mv.id as mietvertrag_id, mv.wohnflaeche_qm, mv.kaltmiete,
|
||||
mv.nebenkosten_vorauszahlung, mv.vertragsbeginn, mv.vertragsende, mv.ist_aktuell
|
||||
FROM mieter m
|
||||
JOIN mietvertraege mv ON mv.mieter_id = m.id
|
||||
WHERE mv.objekt_id = $1
|
||||
ORDER BY m.name`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETER ==========
|
||||
|
||||
app.get('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM mieter WHERE id = $1', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mieter', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'INSERT INTO mieter (name, email, telefon, adresse) VALUES ($1, $2, $3, $4) RETURNING *',
|
||||
[name, email, telefon, adresse]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const { name, email, telefon, adresse } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE mieter SET name = $1, email = $2, telefon = $3, adresse = $4 WHERE id = $5 RETURNING *',
|
||||
[name, email, telefon, adresse, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mieter/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mieter WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mieter nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Mietverträge eines Mieters
|
||||
app.get('/api/mieter/:id/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
WHERE mv.mieter_id = $1
|
||||
ORDER BY mv.vertragsbeginn DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT mv.*, m.name as mieter_name, o.name as objekt_name
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
ORDER BY mv.vertragsbeginn DESC`
|
||||
);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== MIETVERTRÄGE ==========
|
||||
|
||||
app.get('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, ist_aktuell } = req.query;
|
||||
let query = `SELECT mv.*, o.name as objekt_name, o.adresse, o.plz, o.ort,
|
||||
m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN objekte o ON o.id = mv.objekt_id
|
||||
JOIN mieter m ON m.id = mv.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`mv.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (ist_aktuell !== undefined) {
|
||||
conditions.push(`mv.ist_aktuell = $${values.length + 1}`);
|
||||
values.push(ist_aktuell === 'true');
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY mv.vertragsbeginn DESC';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO mietvertraege (objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsbeginn)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[objekt_id, mieter_id, wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung || 0, vertragsbeginn]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const { wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET wohnflaeche_qm = $1, kaltmiete = $2, nebenkosten_vorauszahlung = $3,
|
||||
vertragsende = $4, ist_aktuell = $5 WHERE id = $6 RETURNING *`,
|
||||
[wohnflaeche_qm, kaltmiete, nebenkosten_vorauszahlung, vertragsende, ist_aktuell !== undefined ? ist_aktuell : true, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/mietvertraege/:id/beenden', async (req, res) => {
|
||||
try {
|
||||
const { vertragsende } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE mietvertraege SET vertragsende = $1, ist_aktuell = false WHERE id = $2 RETURNING *`,
|
||||
[vertragsende, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/mietvertraege/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM mietvertraege WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Mietvertrag nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== OBJEKTKOSTEN ==========
|
||||
|
||||
app.get('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, jahr } = req.query;
|
||||
let query = 'SELECT ok.*, o.name as objekt_name FROM objektkosten ok JOIN objekte o ON o.id = ok.objekt_id';
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`ok.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`ok.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY ok.jahr DESC, ok.kategorie, ok.datum';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/objektkosten', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO objektkosten (objekt_id, kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[objekt_id, kategorie, bezeichnung, betrag, datum, jahr || new Date().getFullYear(), verteilung || 'qm', bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE objektkosten SET kategorie = $1, bezeichnung = $2, betrag = $3, datum = $4, jahr = $5, verteilung = $6, bemerkung = $7
|
||||
WHERE id = $8 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, datum, jahr, verteilung, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/objektkosten/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM objektkosten WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Kosten nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== VORAUSZAHLUNGEN ==========
|
||||
|
||||
app.get('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr } = req.query;
|
||||
let query = `SELECT v.*, o.name as objekt_name, m.name as mieter_name
|
||||
FROM vorauszahlungen v
|
||||
JOIN objekte o ON o.id = v.objekt_id
|
||||
JOIN mieter m ON m.id = v.mieter_id`;
|
||||
const values = [];
|
||||
const conditions = [];
|
||||
|
||||
if (objekt_id) {
|
||||
conditions.push(`v.objekt_id = $${values.length + 1}`);
|
||||
values.push(objekt_id);
|
||||
}
|
||||
if (mieter_id) {
|
||||
conditions.push(`v.mieter_id = $${values.length + 1}`);
|
||||
values.push(mieter_id);
|
||||
}
|
||||
if (jahr) {
|
||||
conditions.push(`v.jahr = $${values.length + 1}`);
|
||||
values.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query += ' WHERE ' + conditions.join(' AND ');
|
||||
}
|
||||
|
||||
query += ' ORDER BY v.jahr DESC, v.monat';
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/vorauszahlungen', async (req, res) => {
|
||||
try {
|
||||
const { objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||
[objekt_id, mieter_id, jahr, monat, betrag, bezahlt_am, bemerkung]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk: Vorauszahlungen für Jahr erstellen
|
||||
app.post('/api/vorauszahlungen/bulk', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { objektId, mieterId, jahr, monatlicherBetrag } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (let monat = 1; monat <= 12; monat++) {
|
||||
const result = await client.query(
|
||||
`INSERT INTO vorauszahlungen (objekt_id, mieter_id, jahr, monat, betrag)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (objekt_id, mieter_id, jahr, monat)
|
||||
DO UPDATE SET betrag = $5
|
||||
RETURNING *`,
|
||||
[objektId, mieterId, jahr, monat, monatlicherBetrag]
|
||||
);
|
||||
results.push(result.rows[0]);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, created: results.length, vorauszahlungen: results });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.put('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const { betrag, bezahlt_am, bemerkung } = req.body;
|
||||
const result = await pool.query(
|
||||
`UPDATE vorauszahlungen SET betrag = $1, bezahlt_am = $2, bemerkung = $3 WHERE id = $4 RETURNING *`,
|
||||
[betrag, bezahlt_am, bemerkung, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/vorauszahlungen/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM vorauszahlungen WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Vorauszahlung nicht gefunden' });
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== NEBENKOSTENABRECHNUNG ==========
|
||||
|
||||
// Übersicht
|
||||
app.get('/api/nebenkostenabrechnung/uebersicht', async (req, res) => {
|
||||
try {
|
||||
const { jahr = new Date().getFullYear() } = req.query;
|
||||
|
||||
// Alle Objekte mit Summen
|
||||
const result = await pool.query(`
|
||||
SELECT o.*,
|
||||
COALESCE(k.gesamt_kosten, 0) as gesamt_kosten,
|
||||
COALESCE(v.gesamt_vorauszahlungen, 0) as gesamt_vorauszahlungen,
|
||||
COALESCE(m.anzahl_mieter, 0) as anzahl_mieter
|
||||
FROM objekte o
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_kosten
|
||||
FROM objektkosten WHERE jahr = $1 GROUP BY objekt_id
|
||||
) k ON k.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, SUM(betrag) as gesamt_vorauszahlungen
|
||||
FROM vorauszahlungen WHERE jahr = $1 GROUP BY objekt_id
|
||||
) v ON v.objekt_id = o.id
|
||||
LEFT JOIN (
|
||||
SELECT objekt_id, COUNT(*) as anzahl_mieter
|
||||
FROM mietvertraege WHERE ist_aktuell = true GROUP BY objekt_id
|
||||
) m ON m.objekt_id = o.id
|
||||
ORDER BY o.name
|
||||
`, [jahr]);
|
||||
|
||||
res.json({ jahr: parseInt(jahr), objekte: result.rows });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung Vorschau (Berechnung)
|
||||
app.post('/api/nebenkostenabrechnung/vorschau', async (req, res) => {
|
||||
try {
|
||||
const { objektId, jahr, zeitraumVon, zeitraumBis } = req.body;
|
||||
|
||||
// Objekt-Daten
|
||||
const objektResult = await pool.query('SELECT * FROM objekte WHERE id = $1', [objektId]);
|
||||
if (objektResult.rows.length === 0) return res.status(404).json({ error: 'Objekt nicht gefunden' });
|
||||
const objekt = objektResult.rows[0];
|
||||
|
||||
// Alle Kosten des Objekts im Jahr
|
||||
const kostenResult = await pool.query(
|
||||
'SELECT * FROM objektkosten WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie',
|
||||
[objektId, jahr]
|
||||
);
|
||||
|
||||
// Aktuelle Mieter mit Mietverträgen
|
||||
const mieterResult = await pool.query(`
|
||||
SELECT mv.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM mietvertraege mv
|
||||
JOIN mieter m ON m.id = mv.mieter_id
|
||||
WHERE mv.objekt_id = $1 AND mv.ist_aktuell = true
|
||||
`, [objektId]);
|
||||
|
||||
// Berechnung
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = mieterResult.rows.reduce((sum, m) => sum + parseFloat(m.wohnflaeche_qm), 0);
|
||||
|
||||
// Tage im Abrechnungszeitraum
|
||||
const vonDatum = new Date(zeitraumVon);
|
||||
const bisDatum = new Date(zeitraumBis);
|
||||
const tageGesamt = Math.ceil((bisDatum - vonDatum) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// Pro Mieter berechnen
|
||||
const berechnungen = [];
|
||||
|
||||
for (const mietvertrag of mieterResult.rows) {
|
||||
// Vorauszahlungen dieses Mieters
|
||||
const vorauszahlungenResult = await pool.query(
|
||||
'SELECT SUM(betrag) as summe FROM vorauszahlungen WHERE objekt_id = $1 AND mieter_id = $2 AND jahr = $3',
|
||||
[objektId, mietvertrag.mieter_id, jahr]
|
||||
);
|
||||
const summeVorauszahlungen = parseFloat(vorauszahlungenResult.rows[0]?.summe || 0);
|
||||
|
||||
// Anteil berechnen (pro-rata bei Mieterwechsel möglich)
|
||||
const anteilQm = parseFloat(mietvertrag.wohnflaeche_qm);
|
||||
const anteilTage = tageGesamt; // Hier könnte differenzierte Logik für Ein-/Auszug stehen
|
||||
const anteilKosten = gesamtKosten * (anteilQm / gesamtFlaeche);
|
||||
|
||||
const ergebnis = anteilKosten - summeVorauszahlungen;
|
||||
|
||||
berechnungen.push({
|
||||
mietvertrag_id: mietvertrag.id,
|
||||
mieter_id: mietvertrag.mieter_id,
|
||||
mieter_name: mietvertrag.mieter_name,
|
||||
anteil_qm: anteilQm,
|
||||
anteil_tage: anteilTage,
|
||||
anteil_kosten: Math.round(anteilKosten * 100) / 100,
|
||||
summe_vorauszahlungen: summeVorauszahlungen,
|
||||
ergebnis: Math.round(ergebnis * 100) / 100,
|
||||
ist_nachzahlung: ergebnis > 0,
|
||||
betrag_nachzahlung: ergebnis > 0 ? ergebnis : 0,
|
||||
betrag_gutschrift: ergebnis < 0 ? Math.abs(ergebnis) : 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
objekt,
|
||||
jahr,
|
||||
zeitraum_von: zeitraumVon,
|
||||
zeitraum_bis: zeitraumBis,
|
||||
tage_gesamt: tageGesamt,
|
||||
gesamt_kosten: gesamtKosten,
|
||||
gesamt_flaeche: gesamtFlaeche,
|
||||
kosten: kostenResult.rows,
|
||||
mieter: mieterResult.rows,
|
||||
berechnungen
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Abrechnung speichern
|
||||
app.post('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const { objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf, berechnungen } = req.body;
|
||||
|
||||
// Bestehende Abrechnung prüfen/löschen
|
||||
await client.query(
|
||||
'DELETE FROM nebenkostenabrechnungen WHERE objekt_id = $1 AND jahr = $2',
|
||||
[objekt_id, jahr]
|
||||
);
|
||||
|
||||
// Neue Abrechnung erstellen
|
||||
const abrechnungResult = await client.query(
|
||||
`INSERT INTO nebenkostenabrechnungen (objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[objekt_id, jahr, zeitraum_von, zeitraum_bis, ist_entwurf !== false]
|
||||
);
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// Positionen speichern
|
||||
for (const pos of berechnungen) {
|
||||
await client.query(
|
||||
`INSERT INTO abrechnungspositionen
|
||||
(abrechnung_id, mieter_id, mietvertrag_id, anteil_qm, anteil_tage, anteil_kosten, summe_vorauszahlungen, ergebnis)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[abrechnung.id, pos.mieter_id, pos.mietvertrag_id, pos.anteil_qm, pos.anteil_tage,
|
||||
pos.anteil_kosten, pos.summe_vorauszahlungen, pos.ergebnis]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true, abrechnung: abrechnungResult.rows[0] });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
res.status(500).json({ error: error.message });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Abrechnungen
|
||||
app.get('/api/nebenkostenabrechnung', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alias für Frontend (Plural)
|
||||
app.get('/api/nebenkostenabrechnungen', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
ORDER BY na.jahr DESC, o.name
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Einzelne Abrechnung mit Positionen
|
||||
app.get('/api/nebenkostenabrechnung/:id', async (req, res) => {
|
||||
try {
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2
|
||||
`, [abrechnungResult.rows[0].objekt_id, abrechnungResult.rows[0].jahr]);
|
||||
|
||||
res.json({
|
||||
abrechnung: abrechnungResult.rows[0],
|
||||
positionen: positionenResult.rows,
|
||||
kosten: kostenResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Status aktualisieren (Entwurf/Final)
|
||||
app.put('/api/nebenkostenabrechnung/:id/status', async (req, res) => {
|
||||
try {
|
||||
const { ist_entwurf } = req.body;
|
||||
const result = await pool.query(
|
||||
'UPDATE nebenkostenabrechnungen SET ist_entwurf = $1 WHERE id = $2 RETURNING *',
|
||||
[ist_entwurf, req.params.id]
|
||||
);
|
||||
if (result.rows.length === 0) return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
};
|
||||
@@ -0,0 +1,202 @@
|
||||
// PDF Export - Privat (René Täger)
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (Mieter) ==========
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
// PDF Export - Privat (René Täger)
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (Mieter) ==========
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
|
||||
// Trennlinie
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: y + 8 },
|
||||
end: { x: width - margin, y: y + 8 },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(` Wohnfläche: ${pos.anteil_qm} qm | Zeitraum: ${pos.anteil_tage} Tage`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Ihr Anteil an den Kosten:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Nachzahlung:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y },
|
||||
end: { x: width - margin, y },
|
||||
thickness: 1,
|
||||
color: rgb(0.7, 0.7, 0.7)
|
||||
});
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
// PDF Export - Privat (René Täger) - EINE PDF pro Mieter
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
|
||||
// ALLE Positionen laden
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// Berechnungsgrundlagen
|
||||
const gesamtKosten = kostenResult.rows.reduce((sum, k) => sum + parseFloat(k.betrag), 0);
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
const margin = 50;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(page, yPos) {
|
||||
page.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== FÜR JEDEN MIETER EINE SEITE ==========
|
||||
for (const pos of positionenResult.rows) {
|
||||
const mieterName = pos.mieter_name;
|
||||
const mieterAdresse = pos.mieter_adresse || '';
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
|
||||
// Neue Seite für diesen Mieter
|
||||
const page = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
|
||||
// ========== ABSENDER (oben links) ==========
|
||||
page.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(page, y);
|
||||
y -= 30;
|
||||
|
||||
// ========== EMPFÄNGER (nur dieser Mieter) ==========
|
||||
page.drawText(mieterName, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (mieterAdresse) {
|
||||
const adressZeilen = mieterAdresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
page.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// ========== DATUM + BETREFF ==========
|
||||
page.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
page.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
page.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
page.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
page.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
page.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
page.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
page.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
page.drawText('im Folgenden erhalten Sie Ihre persönliche Nebenkostenabrechnung.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText('Die Kosten wurden nach Ihrem im Mietvertrag vereinbarten Anteil umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== 1. KOSTENÜBERSICHT (alle Kosten des Objekts) ==========
|
||||
page.drawText('1. Kostenübersicht des Objekts:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
page.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
page.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
page.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
page.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
page.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
page.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
page.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 2. BERECHNUNGSGRUNDLAGE ==========
|
||||
page.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
page.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== 3. IHRE PERSÖNLICHE BERECHNUNG ==========
|
||||
page.drawText('3. Ihre persönliche Berechnung:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
page.drawText(`Ihr Name:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(mieterName, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 14;
|
||||
page.drawText(`Ihre Wohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${mieterFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Ihr Anteil an den Kosten:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
page.drawText(`Geleistete Vorauszahlungen:`, { x: margin + 20, y, size: 9, font });
|
||||
page.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 25;
|
||||
|
||||
// Ergebnis hervorgehoben
|
||||
drawLine(page, y + 8);
|
||||
y -= 5;
|
||||
|
||||
if (ergebnis > 0) {
|
||||
page.drawText(`Nachzahlung:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Bitte überweisen Sie den Betrag innerhalb von 14 Tagen.`, { x: margin + 20, y, size: 9, font, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
page.drawText(`Gutschrift:`, { x: margin + 20, y, size: 11, font: fontBold });
|
||||
page.drawText(`${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € = ${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 11, font: fontBold });
|
||||
y -= 20;
|
||||
page.drawText(`Der Betrag wird mit der nächsten Miete verrechnet.`, { x: margin + 20, y, size: 9, font, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(page, y);
|
||||
y -= 20;
|
||||
page.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
page.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
}
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// PDF Export - Privat (René Täger) mit Rechenweg
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(yPos) {
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ==========
|
||||
|
||||
// Absender (klein, oben links)
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(y);
|
||||
y -= 30;
|
||||
|
||||
// Empfänger (Mieter)
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// Datum + Betreff
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== RECHENWEG (Der Weg ist das Ziel!) ==========
|
||||
currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Gesamtfläche berechnen
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
|
||||
// Rechenweg
|
||||
currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
// Ergebnis
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(y);
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
// PDF Export - Privat (René Täger) mit Rechenweg
|
||||
app.post('/api/nebenkostenabrechnung/:id/pdf', async (req, res) => {
|
||||
try {
|
||||
const { PDFDocument, rgb, StandardFonts } = require('pdf-lib');
|
||||
|
||||
const abrechnungResult = await pool.query(`
|
||||
SELECT na.*, o.name as objekt_name, o.adresse, o.plz, o.ort, o.wohnflaeche_qm as objekt_flaeche
|
||||
FROM nebenkostenabrechnungen na
|
||||
JOIN objekte o ON o.id = na.objekt_id
|
||||
WHERE na.id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
if (abrechnungResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Abrechnung nicht gefunden' });
|
||||
}
|
||||
|
||||
const abrechnung = abrechnungResult.rows[0];
|
||||
const positionenResult = await pool.query(`
|
||||
SELECT ap.*, m.name as mieter_name, m.email as mieter_email, m.adresse as mieter_adresse
|
||||
FROM abrechnungspositionen ap
|
||||
JOIN mieter m ON m.id = ap.mieter_id
|
||||
WHERE ap.abrechnung_id = $1
|
||||
`, [req.params.id]);
|
||||
|
||||
const kostenResult = await pool.query(`
|
||||
SELECT * FROM objektkosten
|
||||
WHERE objekt_id = $1 AND jahr = $2 ORDER BY kategorie
|
||||
`, [abrechnung.objekt_id, abrechnung.jahr]);
|
||||
|
||||
// PDF erstellen
|
||||
const pdfDoc = await PDFDocument.create();
|
||||
const width = 595.28;
|
||||
const height = 841.89;
|
||||
|
||||
let currentPage = pdfDoc.addPage([width, height]);
|
||||
let y = height - 50;
|
||||
const margin = 50;
|
||||
const contentWidth = width - 2 * margin;
|
||||
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
||||
|
||||
const istEntwurf = req.query.entwurf === 'true';
|
||||
|
||||
// Hilfsfunktion für Trennlinie
|
||||
function drawLine(yPos) {
|
||||
currentPage.drawLine({
|
||||
start: { x: margin, y: yPos },
|
||||
end: { x: width - margin, y: yPos },
|
||||
thickness: 0.5,
|
||||
color: rgb(0.8, 0.8, 0.8)
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SEITE 1: ABSENDER + EMPFÄNGER + BETREFF ==========
|
||||
|
||||
// Absender (klein, oben links)
|
||||
currentPage.drawText('René Täger', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 14;
|
||||
currentPage.drawText('Zingelstraße 7', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('25554 Wilster', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// Trennlinie
|
||||
drawLine(y);
|
||||
y -= 30;
|
||||
|
||||
// Empfänger (Mieter)
|
||||
const empfaenger = positionenResult.rows[0];
|
||||
if (empfaenger) {
|
||||
currentPage.drawText(empfaenger.mieter_name || '', { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
if (empfaenger.mieter_adresse) {
|
||||
const adressZeilen = empfaenger.mieter_adresse.split('\n');
|
||||
for (const zeile of adressZeilen) {
|
||||
currentPage.drawText(zeile, { x: margin, y, size: 10, font });
|
||||
y -= 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
y -= 40;
|
||||
|
||||
// Datum + Betreff
|
||||
const heute = new Date().toLocaleDateString('de-DE');
|
||||
currentPage.drawText(`Wilster, den ${heute}`, { x: width - margin - 150, y: height - 120, size: 9, font });
|
||||
|
||||
currentPage.drawText('Nebenkostenabrechnung', { x: margin, y, size: 16, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 25;
|
||||
|
||||
if (istEntwurf) {
|
||||
currentPage.drawText('ENTWURF', { x: width - 140, y: y + 15, size: 20, font: fontBold, color: rgb(0.8, 0.3, 0.3) });
|
||||
}
|
||||
|
||||
currentPage.drawText(`für das Jahr ${abrechnung.jahr}`, { x: margin, y, size: 12, font });
|
||||
y -= 20;
|
||||
currentPage.drawText(`Objekt: ${abrechnung.objekt_name}`, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
currentPage.drawText(`${abrechnung.adresse}, ${abrechnung.plz} ${abrechnung.ort}`, { x: margin, y, size: 10, font });
|
||||
y -= 16;
|
||||
currentPage.drawText(`Abrechnungszeitraum: ${new Date(abrechnung.zeitraum_von).toLocaleDateString('de-DE')} - ${new Date(abrechnung.zeitraum_bis).toLocaleDateString('de-DE')}`, { x: margin, y, size: 10, font });
|
||||
y -= 35;
|
||||
|
||||
// ========== BEGLEITTEXT ==========
|
||||
currentPage.drawText('Sehr geehrte(r) Mieter(in),', { x: margin, y, size: 10, font: fontBold });
|
||||
y -= 18;
|
||||
currentPage.drawText('im Folgenden erhalten Sie die Nebenkostenabrechnung für das abgelaufene Jahr.', { x: margin, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText('Die Kosten wurden nach den im Mietvertrag vereinbarten Anteilen umgelegt.', { x: margin, y, size: 9, font });
|
||||
y -= 30;
|
||||
|
||||
// ========== KOSTENÜBERSICHT ==========
|
||||
currentPage.drawText('1. Kostenübersicht:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Tabellenkopf
|
||||
currentPage.drawText('Kategorie', { x: margin, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Bezeichnung', { x: margin + 100, y, size: 9, font: fontBold });
|
||||
currentPage.drawText('Betrag', { x: margin + 350, y, size: 9, font: fontBold });
|
||||
y -= 12;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
|
||||
let gesamtKosten = 0;
|
||||
for (const k of kostenResult.rows) {
|
||||
const betrag = parseFloat(k.betrag);
|
||||
gesamtKosten += betrag;
|
||||
currentPage.drawText(k.kategorie, { x: margin, y, size: 9, font });
|
||||
currentPage.drawText(k.bezeichnung, { x: margin + 100, y, size: 9, font });
|
||||
currentPage.drawText(`${betrag.toFixed(2)} €`, { x: margin + 350, y, size: 9, font });
|
||||
y -= 14;
|
||||
}
|
||||
|
||||
y -= 5;
|
||||
drawLine(y + 8);
|
||||
y -= 5;
|
||||
currentPage.drawText('Gesamtkosten:', { x: margin + 100, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 350, y, size: 10, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== RECHENWEG (Der Weg ist das Ziel!) ==========
|
||||
currentPage.drawText('2. Berechnungsgrundlage:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
// Gesamtfläche berechnen
|
||||
const gesamtFlaeche = parseFloat(abrechnung.objekt_flaeche || 95);
|
||||
const kostenProQm = gesamtKosten / gesamtFlaeche;
|
||||
|
||||
currentPage.drawText(`Gesamtkosten:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Gesamtwohnfläche:`, { x: margin + 20, y, size: 9, font });
|
||||
currentPage.drawText(`${gesamtFlaeche.toFixed(2)} qm`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(`Kosten pro qm:`, { x: margin + 20, y, size: 9, font: fontBold });
|
||||
currentPage.drawText(`${gesamtKosten.toFixed(2)} € / ${gesamtFlaeche.toFixed(2)} qm = ${kostenProQm.toFixed(2)} €/qm`, { x: margin + 250, y, size: 9, font: fontBold });
|
||||
y -= 30;
|
||||
|
||||
// ========== VERTEILUNG AUF MIETER ==========
|
||||
currentPage.drawText('3. Verteilung auf Mieter:', { x: margin, y, size: 12, font: fontBold, color: rgb(0.2, 0.4, 0.6) });
|
||||
y -= 20;
|
||||
|
||||
for (const pos of positionenResult.rows) {
|
||||
const anteilKosten = parseFloat(pos.anteil_kosten);
|
||||
const vorauszahlungen = parseFloat(pos.summe_vorauszahlungen);
|
||||
const ergebnis = parseFloat(pos.ergebnis);
|
||||
const mieterFlaeche = parseFloat(pos.anteil_qm);
|
||||
|
||||
currentPage.drawText(pos.mieter_name, { x: margin, y, size: 11, font: fontBold });
|
||||
y -= 16;
|
||||
|
||||
// Rechenweg
|
||||
currentPage.drawText(` Wohnfläche: ${mieterFlaeche.toFixed(2)} qm`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Berechnung: ${kostenProQm.toFixed(2)} €/qm × ${mieterFlaeche.toFixed(2)} qm = ${anteilKosten.toFixed(2)} €`, { x: margin + 15, y, size: 9, font });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Geleistete Vorauszahlungen:`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${vorauszahlungen.toFixed(2)} €`, { x: margin + 250, y, size: 9, font });
|
||||
y -= 16;
|
||||
|
||||
// Ergebnis
|
||||
if (ergebnis > 0) {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Nachzahlung erforderlich:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${ergebnis.toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.8, 0.2, 0.2) });
|
||||
} else {
|
||||
currentPage.drawText(` Ergebnis: ${anteilKosten.toFixed(2)} € - ${vorauszahlungen.toFixed(2)} € =`, { x: margin + 15, y, size: 9, font });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
y -= 14;
|
||||
currentPage.drawText(` Gutschrift:`, { x: margin + 15, y, size: 10, font: fontBold });
|
||||
currentPage.drawText(`${Math.abs(ergebnis).toFixed(2)} €`, { x: margin + 250, y, size: 10, font: fontBold, color: rgb(0.2, 0.6, 0.2) });
|
||||
}
|
||||
y -= 25;
|
||||
}
|
||||
|
||||
// ========== FUSSZEILE ==========
|
||||
y -= 20;
|
||||
drawLine(y);
|
||||
y -= 20;
|
||||
currentPage.drawText('Diese Abrechnung wurde maschinell erstellt und ist ohne Unterschrift gültig.', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
y -= 12;
|
||||
currentPage.drawText('Bei Rückfragen: René Täger | Tel: +49 15563 717612 | E-Mail: info@taeger-it.de', { x: margin, y, size: 8, font, color: rgb(0.5, 0.5, 0.5) });
|
||||
|
||||
// PDF speichern
|
||||
const pdfBytes = await pdfDoc.save();
|
||||
const pdfBuffer = Buffer.from(pdfBytes.buffer || pdfBytes);
|
||||
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Length', pdfBuffer.length);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="nebenkostenabrechnung-${abrechnung.jahr}-${abrechnung.objekt_name.replace(/\s+/g, '_')}.pdf"`);
|
||||
res.setHeader('Access-Control-Expose-Headers', 'Content-Disposition');
|
||||
res.end(pdfBuffer);
|
||||
} catch (error) {
|
||||
console.error('PDF Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,151 @@
|
||||
// API Routes für Private Finanzen (Monatliche Ausgaben, etc.)
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'buchhaltung-db',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
database: process.env.DB_NAME || 'buchhaltung',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
});
|
||||
|
||||
module.exports = (app) => {
|
||||
|
||||
// ========== PRIVAT AUSGABEN ==========
|
||||
|
||||
// Tabelle erstellen falls nicht existiert
|
||||
app.post('/api/privat/init', async (req, res) => {
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privat_ausgaben (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kategorie VARCHAR(100) NOT NULL,
|
||||
bezeichnung VARCHAR(255),
|
||||
betrag DECIMAL(10,2) NOT NULL,
|
||||
jahr INTEGER NOT NULL,
|
||||
monat INTEGER NOT NULL, -- 0-11 für Jan-Dez
|
||||
typ VARCHAR(20) DEFAULT 'einmalig', -- 'einmalig' oder 'wiederkehrend'
|
||||
wiederkehrend_bis INTEGER, -- Monat bis zu dem es wiederkehrt (optional)
|
||||
wiederkehrend_bis_jahr INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
res.json({ success: true, message: 'Tabelle privat_ausgaben erstellt' });
|
||||
} catch (error) {
|
||||
console.error('Init Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Alle Ausgaben für Jahr/Monat laden
|
||||
app.get('/api/privat/ausgaben', async (req, res) => {
|
||||
try {
|
||||
const { jahr, monat } = req.query;
|
||||
|
||||
let query = 'SELECT * FROM privat_ausgaben WHERE 1=1';
|
||||
const params = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (jahr) {
|
||||
paramCount++;
|
||||
query += ` AND (jahr = $${paramCount} OR (typ = 'wiederkehrend' AND jahr <= $${paramCount}))`;
|
||||
params.push(parseInt(jahr));
|
||||
}
|
||||
|
||||
if (monat !== undefined) {
|
||||
paramCount++;
|
||||
query += ` AND (monat = $${paramCount} OR typ = 'wiederkehrend')`;
|
||||
params.push(parseInt(monat));
|
||||
}
|
||||
|
||||
query += ' ORDER BY created_at DESC';
|
||||
|
||||
const result = await pool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
// Tabelle existiert nicht - leeres Array zurückgeben
|
||||
if (error.message.includes('relation "privat_ausgaben" does not exist')) {
|
||||
return res.json([]);
|
||||
}
|
||||
console.error('Load Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe erstellen
|
||||
app.post('/api/privat/ausgaben', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body;
|
||||
|
||||
// Tabelle erstellen falls nicht existiert
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS privat_ausgaben (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
kategorie VARCHAR(100) NOT NULL,
|
||||
bezeichnung VARCHAR(255),
|
||||
betrag DECIMAL(10,2) NOT NULL,
|
||||
jahr INTEGER NOT NULL,
|
||||
monat INTEGER NOT NULL,
|
||||
typ VARCHAR(20) DEFAULT 'einmalig',
|
||||
wiederkehrend_bis INTEGER,
|
||||
wiederkehrend_bis_jahr INTEGER,
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
)
|
||||
`);
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO privat_ausgaben (kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, jahr, monat, typ || 'einmalig', wiederkehrend_bis, wiederkehrend_bis_jahr]
|
||||
);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Create Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe aktualisieren
|
||||
app.put('/api/privat/ausgaben/:id', async (req, res) => {
|
||||
try {
|
||||
const { kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE privat_ausgaben
|
||||
SET kategorie = $1, bezeichnung = $2, betrag = $3, jahr = $4, monat = $5,
|
||||
typ = $6, wiederkehrend_bis = $7, wiederkehrend_bis_jahr = $8, updated_at = NOW()
|
||||
WHERE id = $9 RETURNING *`,
|
||||
[kategorie, bezeichnung, betrag, jahr, monat, typ, wiederkehrend_bis, wiederkehrend_bis_jahr, req.params.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Update Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Ausgabe löschen
|
||||
app.delete('/api/privat/ausgaben/:id', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('DELETE FROM privat_ausgaben WHERE id = $1 RETURNING *', [req.params.id]);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Ausgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({ success: true, deleted: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Delete Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
};
|
||||
+1583
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
const Tesseract = require('tesseract.js');
|
||||
const path = require('path');
|
||||
|
||||
class OCRService {
|
||||
async processDocument(filePath) {
|
||||
try {
|
||||
console.log(`[OCR] Verarbeite: ${filePath}`);
|
||||
|
||||
// Erkenne Dateityp
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
|
||||
if (ext === '.pdf') {
|
||||
// Für PDFs: Text-Extraktion (ohne OCR, wenn möglich)
|
||||
// Hier könnte pdf-parse verwendet werden
|
||||
return {
|
||||
success: true,
|
||||
extracted: { text: 'PDF Text extrahiert (Platzhalter)', type: 'pdf' }
|
||||
};
|
||||
}
|
||||
|
||||
// Für Bilder: Tesseract OCR
|
||||
if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) {
|
||||
const result = await Tesseract.recognize(
|
||||
filePath,
|
||||
'deu', // Deutsche Sprache
|
||||
{
|
||||
logger: m => console.log(`[OCR] ${m.status}: ${Math.round(m.progress * 100)}%`)
|
||||
}
|
||||
);
|
||||
|
||||
// Extrahiere potenzielle Beträge (einfache Regex)
|
||||
const amounts = this.extractAmounts(result.data.text);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
extracted: {
|
||||
text: result.data.text,
|
||||
confidence: result.data.confidence,
|
||||
amounts: amounts,
|
||||
type: 'image'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Nicht unterstütztes Dateiformat'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[OCR] Fehler:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
extractAmounts(text) {
|
||||
// Deutsche Beträge erkennen (z.B. 1.234,56 oder 1234,56)
|
||||
const patterns = [
|
||||
/(\d{1,3}(?:\.\d{3})*,\d{2})\s*[€$]?/g, // 1.234,56 €
|
||||
/(\d+,\d{2})\s*[€$]?/g, // 1234,56 €
|
||||
/[€$]\s*(\d{1,3}(?:,\d{3})*\.\d{2})/g, // € 1,234.56
|
||||
/[€$]\s*(\d+\.\d{2})/g // € 1234.56
|
||||
];
|
||||
|
||||
const amounts = [];
|
||||
patterns.forEach(pattern => {
|
||||
const matches = text.match(pattern);
|
||||
if (matches) {
|
||||
amounts.push(...matches.map(m => m.replace(/[^\d,]/g, '').replace(',', '.')));
|
||||
}
|
||||
});
|
||||
|
||||
// Eindeutige Beträge zurückgeben
|
||||
return [...new Set(amounts)].map(a => parseFloat(a)).filter(a => a > 0);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OCRService();
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,40 @@
|
||||
[
|
||||
{
|
||||
"id": "d7922be9-4de9-442e-a0e6-a9545faccc37",
|
||||
"name": "Kerstin Schulden",
|
||||
"kreditgeber": "Kerstin",
|
||||
"person": "Kerstin",
|
||||
"ursprungsschuld": "34036.98",
|
||||
"restschuld": "3412.09",
|
||||
"monatsrate": "350.00",
|
||||
"zinssatz": "0.00",
|
||||
"start_datum": "2018-01-01T00:00:00.000Z",
|
||||
"end_datum": null,
|
||||
"laufzeit_monate": null,
|
||||
"faelligkeit_tag": 1,
|
||||
"status": "aktiv",
|
||||
"notizen": "Importiert aus Excel - Start 01.01.2018, 0% Zinsen, Startbetrag 34.036,98 EUR",
|
||||
"created_at": "2026-04-20T13:30:48.262Z",
|
||||
"updated_at": "2026-04-20T13:30:48.262Z",
|
||||
"richtung": "ausgehend"
|
||||
},
|
||||
{
|
||||
"id": "3c957cd3-e583-4596-a122-1253dd8956ef",
|
||||
"name": "Niki Schulden",
|
||||
"kreditgeber": "Niki",
|
||||
"person": "Niki",
|
||||
"ursprungsschuld": "7000.00",
|
||||
"restschuld": "3095.00",
|
||||
"monatsrate": "0.00",
|
||||
"zinssatz": "10.00",
|
||||
"start_datum": "2023-07-01T00:00:00.000Z",
|
||||
"end_datum": null,
|
||||
"laufzeit_monate": null,
|
||||
"faelligkeit_tag": null,
|
||||
"status": "aktiv",
|
||||
"notizen": "Importiert aus Excel (Schulden Niki.xlsx)",
|
||||
"created_at": "2026-04-20T08:02:02.750Z",
|
||||
"updated_at": "2026-04-20T12:27:30.254Z",
|
||||
"richtung": "eingehend"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
+28
@@ -0,0 +1,28 @@
|
||||
import psycopg2
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="buchhaltung",
|
||||
user="postgres",
|
||||
password="postgres"
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Pruefe Schema
|
||||
cursor.execute("SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'kredit_zahlungen'")
|
||||
columns = cursor.fetchall()
|
||||
print("Spalten in kredit_zahlungen:")
|
||||
for col in columns:
|
||||
print(f" {col[0]}: {col[1]}")
|
||||
|
||||
# Zeige einen Beispiel-Datensatz
|
||||
cursor.execute("SELECT * FROM kredit_zahlungen LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
print(f"\nBeispiel-Datensatz:")
|
||||
for i, col in enumerate(columns):
|
||||
if i < len(row):
|
||||
print(f" [{i}] {col[0]} = {row[i]} (Typ: {type(row[i])})")
|
||||
|
||||
cursor.close()
|
||||
conn.close()
|
||||
@@ -0,0 +1,17 @@
|
||||
import pandas as pd
|
||||
|
||||
# Excel-Datei lesen - Tabelle2
|
||||
df = pd.read_excel('C:\\Users\\renet\\.openclaw\\workspace\\buchhaltungs-app\\Schulden Kerstin.xlsx', sheet_name='Tabelle2', header=0)
|
||||
|
||||
print("Erste 30 Zeilen der Excel-Tabelle:")
|
||||
print("="*80)
|
||||
for idx, row in df.head(30).iterrows():
|
||||
datum = row.iloc[0]
|
||||
beschreibung = row.iloc[1]
|
||||
betrag = row.iloc[2]
|
||||
|
||||
print(f"Zeile {idx}: Datum='{datum}' (Typ: {type(datum)}), Betrag='{betrag}', Beschreibung='{beschreibung}'")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("Rohdaten (erste 30 Zeilen):")
|
||||
print(df.head(30).to_string())
|
||||
@@ -0,0 +1,74 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
# Prüfe DSL Bank (Spalte 9) - die ersten und letzten 10 Einträge
|
||||
print("=== DSL BANK (Spalte 9) ===")
|
||||
print("Erste 10 Einträge:")
|
||||
for row_idx in range(3, 13):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
rest = ws.cell(row=row_idx, column=9).value
|
||||
tilg = ws.cell(row=row_idx, column=10).value
|
||||
zins = ws.cell(row=row_idx, column=11).value
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Rest={rest}, Tilg={tilg}, Zins={zins}")
|
||||
|
||||
print("\nLetzte 10 Einträge:")
|
||||
for row_idx in range(ws.max_row-10, ws.max_row+1):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
rest = ws.cell(row=row_idx, column=9).value
|
||||
tilg = ws.cell(row=row_idx, column=10).value
|
||||
zins = ws.cell(row=row_idx, column=11).value
|
||||
if rest is not None:
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Rest={rest}, Tilg={tilg}, Zins={zins}")
|
||||
|
||||
# Prüfe Zingelstr. 14 (Spalte 20)
|
||||
print("\n=== ZINGELSTR. 14 (Spalte 20) ===")
|
||||
print("Erste 10 Einträge:")
|
||||
for row_idx in range(3, 13):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
rest = ws.cell(row=row_idx, column=20).value
|
||||
rate = ws.cell(row=row_idx, column=21).value
|
||||
zins = ws.cell(row=row_idx, column=22).value
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Rest={rest}, Rate={rate}, Zins={zins}")
|
||||
|
||||
print("\nLetzte 10 Einträge:")
|
||||
for row_idx in range(ws.max_row-10, ws.max_row+1):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
rest = ws.cell(row=row_idx, column=20).value
|
||||
rate = ws.cell(row=row_idx, column=21).value
|
||||
zins = ws.cell(row=row_idx, column=22).value
|
||||
if rest is not None:
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Rest={rest}, Rate={rate}, Zins={zins}")
|
||||
|
||||
# Suche nach aktuellem Datum (2026)
|
||||
print("\n=== SUCHE NACH 2024-2026 ===")
|
||||
for row_idx in range(3, ws.max_row+1):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
if datum and isinstance(datum, datetime):
|
||||
if datum.year >= 2024:
|
||||
rest_dsl = ws.cell(row=row_idx, column=9).value
|
||||
rest_psd = ws.cell(row=row_idx, column=13).value
|
||||
rest_zing = ws.cell(row=row_idx, column=20).value
|
||||
print(f"{datum.strftime('%Y-%m')}: DSL={rest_dsl}, PSD={rest_psd}, Zing={rest_zing}")
|
||||
if datum.year >= 2026 and datum.month >= 4:
|
||||
break
|
||||
|
||||
# Finde Sparkasse - suchen in allen Zeilen nach "Sparkasse"
|
||||
print("\n=== SUCHE SPARKASSE ===")
|
||||
for row_idx in range(1, min(ws.max_row+1, 50)):
|
||||
for col_idx in range(1, 50):
|
||||
cell = ws.cell(row=row_idx, column=col_idx).value
|
||||
if cell and isinstance(cell, str) and "sparkasse" in cell.lower():
|
||||
print(f" Gefunden in Zeile {row_idx}, Spalte {col_idx}: '{cell}'")
|
||||
|
||||
# Suche PVCreditplus
|
||||
print("\n=== SUCHE PVCreditplus ===")
|
||||
for row_idx in range(1, min(ws.max_row+1, 50)):
|
||||
for col_idx in range(1, 50):
|
||||
cell = ws.cell(row=row_idx, column=col_idx).value
|
||||
if cell and isinstance(cell, str):
|
||||
if "credit" in cell.lower() or "pvc" in cell.lower():
|
||||
print(f" Gefunden in Zeile {row_idx}, Spalte {col_idx}: '{cell}'")
|
||||
@@ -0,0 +1,82 @@
|
||||
import openpyxl
|
||||
from datetime import datetime
|
||||
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
ws = wb["Tilgung bei Gleichbleibenden Be"]
|
||||
|
||||
# Prüfe Zeile 2 für Carola, Kerstin, PVCreditplus
|
||||
print("=== ZEILE 2 (Carola, Kerstin, PVCreditplus) ===")
|
||||
row2 = list(ws.iter_rows(min_row=2, max_row=2, values_only=True))[0]
|
||||
for i in range(35, min(len(row2), 50)):
|
||||
if row2[i]:
|
||||
print(f" Spalte {i+1}: '{row2[i]}'")
|
||||
|
||||
# Prüfe Zeile 3-13 für Carola (Spalte 36)
|
||||
print("\n=== CAROLA (Spalte 36) - Erste 10 Einträge ===")
|
||||
for row_idx in range(3, 13):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
carola = ws.cell(row=row_idx, column=36).value
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Carola={carola}")
|
||||
|
||||
# Letzte Einträge Carola
|
||||
print("\n=== CAROLA (Spalte 36) - Letzte 10 Einträge ===")
|
||||
for row_idx in range(ws.max_row-10, ws.max_row+1):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
carola = ws.cell(row=row_idx, column=36).value
|
||||
if carola is not None:
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Carola={carola}")
|
||||
|
||||
# Prüfe Kerstin (Spalte 39)
|
||||
print("\n=== KERSTIN (Spalte 39) - Erste und letzte Einträge ===")
|
||||
for row_idx in [3, 4, 5, ws.max_row-5, ws.max_row-4, ws.max_row-3, ws.max_row-2, ws.max_row-1, ws.max_row]:
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
kerstin = ws.cell(row=row_idx, column=39).value
|
||||
print(f" Zeile {row_idx}: Datum={datum}, Kerstin={kerstin}")
|
||||
|
||||
# Prüfe PVCreditplus (Spalte 42)
|
||||
print("\n=== PVCREDITPLUS (Spalte 42) - Erste und letzte Einträge ===")
|
||||
for row_idx in [3, 4, 5, ws.max_row-5, ws.max_row-4, ws.max_row-3, ws.max_row-2, ws.max_row-1, ws.max_row]:
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
pvc = ws.cell(row=row_idx, column=42).value
|
||||
print(f" Zeile {row_idx}: Datum={datum}, PVCreditplus={pvc}")
|
||||
|
||||
# Prüfe Sparkasse - hat "Rate" in Zeile 2, Spalte 21
|
||||
# Aber wo ist Restschuld? Vielleicht in Spalte 20?
|
||||
print("\n=== SPARKASSE - Umgebung Spalte 20-23 ===")
|
||||
for row_idx in [1, 2, 3, 4, 5, 20, 21, 22]:
|
||||
vals = []
|
||||
for col in range(19, 24):
|
||||
val = ws.cell(row=row_idx, column=col).value
|
||||
vals.append(str(val)[:15] if val else "")
|
||||
print(f" Zeile {row_idx}: {vals}")
|
||||
|
||||
# Aktuelle Werte (2026)
|
||||
print("\n=== AKTUELLE WERTE (April 2026) ===")
|
||||
heute = datetime(2026, 4, 1)
|
||||
for row_idx in range(3, ws.max_row+1):
|
||||
datum = ws.cell(row=row_idx, column=1).value
|
||||
if datum and datum.year == 2026 and datum.month == 4:
|
||||
print(f"Zeile {row_idx} ({datum}):")
|
||||
# DSL
|
||||
dsl = ws.cell(row=row_idx, column=9).value
|
||||
print(f" DSL Bank (9): {dsl}")
|
||||
# PSD
|
||||
psd = ws.cell(row=row_idx, column=13).value
|
||||
print(f" PSD Nord (13): {psd}")
|
||||
# Zingelstr
|
||||
zing = ws.cell(row=row_idx, column=20).value
|
||||
print(f" Zingelstr. 14 (20): {zing}")
|
||||
# Sparkasse
|
||||
spark = ws.cell(row=row_idx, column=20).value
|
||||
print(f" Sparkasse - Spalte 20: {spark}")
|
||||
# Carola
|
||||
caro = ws.cell(row=row_idx, column=36).value
|
||||
print(f" Carola (36): {caro}")
|
||||
# Kerstin
|
||||
ker = ws.cell(row=row_idx, column=39).value
|
||||
print(f" Kerstin (39): {ker}")
|
||||
# PVCreditplus
|
||||
pvc = ws.cell(row=row_idx, column=42).value
|
||||
print(f" PVCreditplus (42): {pvc}")
|
||||
break
|
||||
@@ -0,0 +1,15 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
# Loesche den alten falschen Kredit
|
||||
kredit_id = 'c3342fa9-4499-48fe-bc25-592299d9c9cf'
|
||||
|
||||
url = f'http://localhost:3001/api/kredite/{kredit_id}'
|
||||
req = urllib.request.Request(url, method='DELETE')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
print(f'[OK] Alter Kredit geloescht: {response.read().decode()}')
|
||||
except Exception as e:
|
||||
print(f'[WARN] Konnte alten Kredit nicht loeschen: {e}')
|
||||
print('Versuche neuen Import trotzdem...')
|
||||
@@ -0,0 +1,35 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
- db_data:/app/data
|
||||
networks:
|
||||
- app-net
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "80:80"
|
||||
networks:
|
||||
- app-net
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
uploads:
|
||||
db_data:
|
||||
|
||||
networks:
|
||||
app-net:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,65 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: buchhaltung-db
|
||||
environment:
|
||||
POSTGRES_DB: buchhaltung
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- buchhaltung-net
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build: ./backend
|
||||
container_name: buchhaltung-backend
|
||||
ports:
|
||||
- "3001:3001"
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_NAME: buchhaltung
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
PORT: 3001
|
||||
NODE_ENV: production
|
||||
JWT_SECRET: change-this-secret-in-production-min-32-chars
|
||||
volumes:
|
||||
- ./backend/uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- buchhaltung-net
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: nginx:alpine
|
||||
container_name: buchhaltung-frontend
|
||||
ports:
|
||||
- "3000:80"
|
||||
volumes:
|
||||
- ./frontend/dist:/usr/share/nginx/html:ro
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- buchhaltung-net
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
buchhaltung-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
+1994
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
import psycopg2
|
||||
|
||||
# Verbindung zur Datenbank
|
||||
conn = psycopg2.connect(
|
||||
host='localhost',
|
||||
port=5432,
|
||||
database='buchhaltung',
|
||||
user='postgres',
|
||||
password='postgres'
|
||||
)
|
||||
|
||||
cur = conn.cursor()
|
||||
|
||||
print('=== NEBENKOSTEN IMPORT ===\n')
|
||||
|
||||
# Prüfe vorhandene Einträge
|
||||
cur.execute("SELECT COUNT(*) FROM nebenkosten WHERE jahr BETWEEN 2020 AND 2024")
|
||||
count = cur.fetchone()[0]
|
||||
print(f'Vorhandene Einträge 2020-2024: {count}')
|
||||
|
||||
if count > 0:
|
||||
print('Lösche vorhandene Einträge...')
|
||||
cur.execute("DELETE FROM nebenkosten WHERE jahr BETWEEN 2020 AND 2024")
|
||||
conn.commit()
|
||||
print('Gelöscht.\n')
|
||||
|
||||
# Alle Abrechnungen
|
||||
abrechnungen = [
|
||||
(2020, 'Zingelstr. 14', 'Kevin Körger', 28.34, 16.73, None, 2.86, 7.23, 1.52),
|
||||
(2020, 'Zingelstr. 14', 'Johanna Krohn / Tobias Welling', 34.19, 16.73, None, 6.67, 7.23, 3.56),
|
||||
(2021, 'Zingelstr. 14', 'Kevin Körger', 471.91, 214.35, 75.29, 33.68, 130.64, 17.95),
|
||||
(2021, 'Zingelstr. 14', 'Johanna Krohn / Tobias Welling', 530.10, 203.70, 75.29, 78.59, 130.64, 41.89),
|
||||
(2022, 'Ahornweg 6', 'Kevin Körger', 487.72, 230.00, 52.73, 33.68, 153.36, 17.95),
|
||||
(2022, 'Ahornweg 6', 'Johanna Krohn / Tobias Welling', 556.56, 230.00, 52.73, 78.59, 153.36, 41.89),
|
||||
(2023, 'Ahornweg 6', 'Kevin Körger', 148.20, 63.47, 17.34, 11.07, 50.42, 5.90),
|
||||
(2023, 'Ahornweg 6', 'Johanna Krohn / Tobias Welling', 170.83, 63.47, 17.34, 25.84, 50.42, 13.77),
|
||||
(2023, 'Zingelstr. 14', 'Yvonne Brandt', 501.48, 129.58, 102.53, 75.36, 153.85, 40.17),
|
||||
(2024, 'Zingelstr. 14', 'Yvonne Brandt', 675.56, 115.41, 175.76, 112.27, 212.28, 59.84),
|
||||
]
|
||||
|
||||
query = """
|
||||
INSERT INTO nebenkosten
|
||||
(jahr, wohnung, mieter, nebenkosten, versicherung, heizkosten, wasser, muell, sonstiges)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
imported = 0
|
||||
for data in abrechnungen:
|
||||
try:
|
||||
cur.execute(query, data)
|
||||
imported += 1
|
||||
print(f'OK {data[2]} {data[0]}: {data[3]} EUR')
|
||||
except Exception as e:
|
||||
print(f'FEHLER bei {data[2]} {data[0]}: {e}')
|
||||
|
||||
conn.commit()
|
||||
|
||||
print(f'\n=== VERIFIZIERUNG ===')
|
||||
print(f'Importiert: {imported} von {len(abrechnungen)}')
|
||||
|
||||
# Zeige alle Einträge
|
||||
cur.execute("""
|
||||
SELECT jahr, mieter, wohnung, nebenkosten
|
||||
FROM nebenkosten
|
||||
WHERE jahr BETWEEN 2020 AND 2024
|
||||
ORDER BY jahr, mieter
|
||||
""")
|
||||
|
||||
print('\nAlle Einträge:')
|
||||
for row in cur.fetchall():
|
||||
print(f' {row[0]}: {row[1]} ({row[2]}) - {row[3]} €')
|
||||
|
||||
# Summen pro Jahr
|
||||
cur.execute("""
|
||||
SELECT jahr, COUNT(*), SUM(nebenkosten)
|
||||
FROM nebenkosten
|
||||
WHERE jahr BETWEEN 2020 AND 2024
|
||||
GROUP BY jahr
|
||||
ORDER BY jahr
|
||||
""")
|
||||
|
||||
print('\nSummen pro Jahr:')
|
||||
for row in cur.fetchall():
|
||||
print(f' {row[0]}: {row[1]} Abrechnungen, {row[2]:.2f} €')
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
print('\n✅ IMPORT ABGESCHLOSSEN')
|
||||
@@ -0,0 +1,80 @@
|
||||
import openpyxl
|
||||
import json
|
||||
|
||||
wb = openpyxl.load_workbook('Nebenkosten 2020.xlsx', data_only=True)
|
||||
|
||||
all_data = []
|
||||
|
||||
for sheet in wb.sheetnames:
|
||||
ws = wb[sheet]
|
||||
|
||||
year = None
|
||||
for y in ['2020', '2021', '2022', '2023', '2024']:
|
||||
if y in sheet:
|
||||
year = int(y)
|
||||
break
|
||||
|
||||
if not year:
|
||||
continue
|
||||
|
||||
mieter = None
|
||||
address = None
|
||||
|
||||
for row in ws.iter_rows(min_row=1, max_row=15, values_only=True):
|
||||
for cell in row:
|
||||
if cell and isinstance(cell, str):
|
||||
if 'Zingelstr. 14' in cell:
|
||||
address = 'Zingelstr. 14'
|
||||
elif 'Zingelstr. 7' in cell or 'Zingelstr.7' in cell:
|
||||
address = 'Zingelstr. 7'
|
||||
elif 'Ahornweg 6' in cell:
|
||||
address = 'Ahornweg 6'
|
||||
|
||||
if 'Krohn' in cell and 'Welling' in cell:
|
||||
mieter = 'Johanna Krohn / Tobias Welling'
|
||||
elif 'Körger' in cell or 'K�rger' in cell:
|
||||
mieter = 'Kevin Körger'
|
||||
elif 'Brandt' in cell and 'Yvonne' in cell:
|
||||
mieter = 'Yvonne Brandt'
|
||||
|
||||
if not mieter or not address:
|
||||
continue
|
||||
|
||||
positions = {}
|
||||
for row in ws.iter_rows(min_row=7, max_row=30, values_only=True):
|
||||
if not row or not row[0]:
|
||||
continue
|
||||
|
||||
pos_name = str(row[0]).strip()
|
||||
|
||||
betrag = None
|
||||
if len(row) > 10 and row[10] and isinstance(row[10], (int, float)):
|
||||
betrag = round(row[10], 2)
|
||||
elif len(row) > 5 and row[5] and isinstance(row[5], (int, float)):
|
||||
betrag = round(row[5], 2)
|
||||
|
||||
if betrag:
|
||||
positions[pos_name] = betrag
|
||||
|
||||
all_data.append({
|
||||
'sheet': sheet,
|
||||
'year': year,
|
||||
'mieter': mieter,
|
||||
'address': address,
|
||||
'positions': positions
|
||||
})
|
||||
|
||||
zingelstr14 = [d for d in all_data if d['address'] == 'Zingelstr. 14']
|
||||
|
||||
print('=== DATEN FUER ZINGELSTR. 14 ===')
|
||||
for d in zingelstr14:
|
||||
print(f"\n{d['sheet']}: {d['mieter']} ({d['year']})")
|
||||
print(f" Positionen: {len(d['positions'])}")
|
||||
for pos, val in d['positions'].items():
|
||||
print(f" - {pos}: {val}")
|
||||
|
||||
with open('nebenkosten_zingelstr14.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(zingelstr14, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print(f"\nGesamt: {len(zingelstr14)} Eintraege")
|
||||
print('Gespeichert in nebenkosten_zingelstr14.json')
|
||||
@@ -0,0 +1,33 @@
|
||||
import openpyxl
|
||||
from openpyxl import load_workbook
|
||||
|
||||
file_path = r"C:\Users\renet\.openclaw\workspace\buchhaltungs-app\Nebenkosten 2020.xlsx"
|
||||
wb = load_workbook(file_path, data_only=True)
|
||||
|
||||
sheets = ['Körger 2020', 'KrohnWelling 2020', 'Körger 2021', 'KrohnWelling 2021',
|
||||
'Körger 2022', 'KrohnWelling 2022', 'KrohnWelling 2023', 'Körger 2023)', 'Brandt 2023', 'Brandt 2024']
|
||||
|
||||
print("Heizkosten und Müll aus allen Sheets:\n")
|
||||
|
||||
for sheet_name in sheets:
|
||||
if sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
print(f"=== {sheet_name} ===")
|
||||
|
||||
# Zeilen 13-25 nach Heizung und Müll suchen
|
||||
for row in range(13, 26):
|
||||
cell_a = ws.cell(row=row, column=1).value
|
||||
cell_b = ws.cell(row=row, column=2).value # Gesamtkosten
|
||||
cell_k = ws.cell(row=row, column=11).value # Ihr Anteil (Spalte K)
|
||||
|
||||
if cell_a and ('heiz' in str(cell_a).lower() or 'Heiz' in str(cell_a)):
|
||||
print(f" Zeile {row}: {cell_a}")
|
||||
print(f" Gesamtkosten: {cell_b}")
|
||||
print(f" Anteil: {cell_k}")
|
||||
|
||||
if cell_a and ('müll' in str(cell_a).lower() or 'Müll' in str(cell_a) or 'muell' in str(cell_a).lower()):
|
||||
print(f" Zeile {row}: {cell_a}")
|
||||
print(f" Gesamtkosten: {cell_b}")
|
||||
print(f" Anteil: {cell_k}")
|
||||
|
||||
print()
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extrahiert alle 5 Kredite mit vollständigen Daten.
|
||||
"""
|
||||
|
||||
import openpyxl
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
# Excel-Datei laden
|
||||
datei = "Kopie von Kostenrechnung der Nächsten jahre (3).xlsx"
|
||||
print(f"Lade {datei}...")
|
||||
|
||||
wb = openpyxl.load_workbook(datei, data_only=True)
|
||||
|
||||
# Sheet "Tilgung bei Gleichbleibenden Be" laden
|
||||
sheet_name = "Tilgung bei Gleichbleibenden Be"
|
||||
ws = wb[sheet_name]
|
||||
|
||||
# Kredit-Definitionen
|
||||
kredite = {
|
||||
"Targo Bank": {"col_base": 2}, # Spalte 2 = Restschuld, 3=Tilgung, 4=Zinsen
|
||||
"Köpke": {"col_base": 6}, # Spalte 6 = Restschuld, 7=Tilgung
|
||||
"DSL Bank": {"col_base": 9}, # Spalte 9 = Restschuld, 10=Tilgung, 11=Zinsen
|
||||
"PSD Nord": {"col_base": 13}, # Spalte 13 = Restschuld, 14=Tilgung, 15=Zinsen
|
||||
"Zingelstr. 14": {"col_base": 20}, # Spalte 20 = Restschuld, 21=Rate, 22=Zinsen
|
||||
}
|
||||
|
||||
# Meine 5 Ziel-Kredite
|
||||
target_kredite = ["DSL Bank", "PSD Nord", "Zingelstr. 14", "PVCreditplus", "Sparkasse"]
|
||||
|
||||
# Für Zingelstr. 14 müssen wir nach Sparkasse suchen (die haben beide Zingelstr. 14)
|
||||
# Zingelstr. 14 DSL und Zingelstr. 14 Sparkasse sind wahrscheinlich unter "Zingelstr. 14" zusammen
|
||||
|
||||
print("\n=== DATENEXTRAKTION FUER 5 KREDITE ===\n")
|
||||
|
||||
def get_last_value(ws, col, start_row=6):
|
||||
"""Holt den letzten numerischen Wert aus einer Spalte"""
|
||||
for row in range(ws.max_row, start_row-1, -1):
|
||||
val = ws.cell(row=row, column=col).value
|
||||
if val is not None and isinstance(val, (int, float)) and val != 0:
|
||||
return val, row
|
||||
return None, None
|
||||
|
||||
def get_first_date(ws, col=1, start_row=6):
|
||||
"""Holt das erste Datum aus Spalte 1 (Monat)"""
|
||||
val = ws.cell(row=start_row, column=col).value
|
||||
if val and isinstance(val, datetime):
|
||||
return val
|
||||
return None
|
||||
|
||||
def get_monthly_rate(ws, tilgung_col, start_row=6):
|
||||
"""Ermittelt die monatliche Rate (häufigster Tilgungswert)"""
|
||||
rates = []
|
||||
for row in range(start_row, min(start_row + 24, ws.max_row)):
|
||||
val = ws.cell(row=row, column=tilgung_col).value
|
||||
if val and isinstance(val, (int, float)) and val > 0:
|
||||
rates.append(val)
|
||||
if rates:
|
||||
from collections import Counter
|
||||
return Counter(rates).most_common(1)[0][0]
|
||||
return 0
|
||||
|
||||
def get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col, start_row=6):
|
||||
"""Berechnet Zinssatz aus Zinsen/Restschuld * 12"""
|
||||
for row in range(start_row, min(start_row + 5, ws.max_row)):
|
||||
zinsen = ws.cell(row=row, column=zinsen_col).value
|
||||
restschuld = ws.cell(row=row, column=restschuld_col).value
|
||||
if zinsen and restschuld and isinstance(zinsen, (int, float)) and isinstance(restschuld, (int, float)) and restschuld > 0:
|
||||
zinssatz = (zinsen / restschuld) * 12 * 100 # Jahreszins in %
|
||||
if 0 < zinssatz < 20: # Plausibilitätscheck
|
||||
return round(zinssatz, 2)
|
||||
return None
|
||||
|
||||
# DSL Bank
|
||||
print("1. DSL BANK")
|
||||
print("-" * 40)
|
||||
col_base = 9
|
||||
restschuld_col = col_base
|
||||
tilgung_col = col_base + 1
|
||||
zinsen_col = col_base + 2
|
||||
|
||||
restschuld, last_row = get_last_value(ws, restschuld_col)
|
||||
start_datum = get_first_date(ws)
|
||||
monatsrate = get_monthly_rate(ws, tilgung_col)
|
||||
zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col)
|
||||
|
||||
print(f" Restschuld: {restschuld:,.2f} EUR")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR")
|
||||
print(f" Zinssatz: {zinssatz}% (berechnet)")
|
||||
print(f" Startdatum: {start_datum}")
|
||||
print(f" Letzte Zeile mit Daten: {last_row}")
|
||||
|
||||
# PSD Nord
|
||||
print("\n2. PSD NORD")
|
||||
print("-" * 40)
|
||||
col_base = 13
|
||||
restschuld_col = col_base
|
||||
tilgung_col = col_base + 1
|
||||
zinsen_col = col_base + 2
|
||||
|
||||
restschuld, last_row = get_last_value(ws, restschuld_col)
|
||||
monatsrate = get_monthly_rate(ws, tilgung_col)
|
||||
zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col)
|
||||
|
||||
print(f" Restschuld: {restschuld:,.2f} EUR")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR")
|
||||
print(f" Zinssatz: {zinssatz}% (berechnet)")
|
||||
print(f" Startdatum: {start_datum}")
|
||||
|
||||
# Zingelstr. 14 (kombiniert DSL + Sparkasse?)
|
||||
print("\n3. ZINGELSTR. 14 (Gesamt)")
|
||||
print("-" * 40)
|
||||
col_base = 20
|
||||
restschuld_col = col_base
|
||||
rate_col = col_base + 1
|
||||
zinsen_col = col_base + 2
|
||||
|
||||
restschuld, last_row = get_last_value(ws, restschuld_col)
|
||||
monatsrate = get_monthly_rate(ws, rate_col)
|
||||
zinssatz = get_zinssatz_from_berechnung(ws, zinsen_col, restschuld_col)
|
||||
|
||||
print(f" Restschuld: {restschuld:,.2f} EUR")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR")
|
||||
print(f" Zinssatz: {zinssatz}% (berechnet)")
|
||||
print(f" Startdatum: {start_datum}")
|
||||
|
||||
# Zeige einige Zeilen der Zingelstr. 14 Spalten
|
||||
print("\n Erste 5 Tilgungszeilen für Zingelstr. 14:")
|
||||
for row in range(6, 11):
|
||||
monat = ws.cell(row=row, column=1).value
|
||||
rest = ws.cell(row=row, column=20).value
|
||||
rate = ws.cell(row=row, column=21).value
|
||||
zins = ws.cell(row=row, column=22).value
|
||||
print(f" {row}: Monat={monat}, Rest={rest}, Rate={rate}, Zins={zins}")
|
||||
|
||||
print("\n4. SUCHE NACH PVCreditplus...")
|
||||
print("-" * 40)
|
||||
# Suche nach PVCreditplus in Zeile 1
|
||||
pvc_col = None
|
||||
for col in range(1, ws.max_column + 1):
|
||||
val = ws.cell(row=1, column=col).value
|
||||
if val and "pvc" in str(val).lower():
|
||||
pvc_col = col
|
||||
print(f" GEFUNDEN in Spalte {col}: {val}")
|
||||
break
|
||||
|
||||
if not pvc_col:
|
||||
print(" PVCreditplus nicht im Sheet 'Tilgung bei Gleichbleibenden Be' gefunden!")
|
||||
print(" Suche in anderen Sheets...")
|
||||
for sheet_name in wb.sheetnames:
|
||||
print(f" Checking {sheet_name}...")
|
||||
temp_ws = wb[sheet_name]
|
||||
for row in range(1, min(5, temp_ws.max_row + 1)):
|
||||
for col in range(1, min(20, temp_ws.max_column + 1)):
|
||||
val = temp_ws.cell(row=row, column=col).value
|
||||
if val and "pvc" in str(val).lower():
|
||||
print(f" GEFUNDEN in Sheet '{sheet_name}', Zeile {row}, Spalte {col}: {val}")
|
||||
|
||||
print("\n5. SUCHE NACH SPARKASSE...")
|
||||
print("-" * 40)
|
||||
# Suche nach Sparkasse in Zeile 1
|
||||
sparkasse_col = None
|
||||
for col in range(1, ws.max_column + 1):
|
||||
val = ws.cell(row=1, column=col).value
|
||||
if val and "sparkasse" in str(val).lower():
|
||||
sparkasse_col = col
|
||||
print(f" GEFUNDEN in Spalte {col}: {val}")
|
||||
# Zeige die zugehörigen Labels
|
||||
label = ws.cell(row=2, column=col).value
|
||||
label2 = ws.cell(row=2, column=col+1).value
|
||||
print(f" Labels: {label}, {label2}")
|
||||
break
|
||||
|
||||
# Jetzt die Restschuld und Rate holen
|
||||
if sparkasse_col:
|
||||
restschuld, last_row = get_last_value(ws, sparkasse_col)
|
||||
monatsrate = get_monthly_rate(ws, sparkasse_col + 1)
|
||||
zinssatz = get_zinssatz_from_berechnung(ws, sparkasse_col + 2, sparkasse_col)
|
||||
print(f" Restschuld: {restschuld:,.2f} EUR")
|
||||
print(f" Monatsrate: {monatsrate:,.2f} EUR")
|
||||
print(f" Zinssatz: {zinssatz}% (berechnet)")
|
||||
|
||||
print("\n=== ZUSAMMENFASSUNG ===")
|
||||
print("""
|
||||
Die 5 Kredite aus dem Task:
|
||||
1. DSL Bank -> Spalte 9 in Sheet 'Tilgung bei Gleichbleibenden Be'
|
||||
2. PSD Nord -> Spalte 13 in Sheet 'Tilgung bei Gleichbleibenden Be'
|
||||
3. Zingelstr.14 DSL -> Teil von Zingelstr. 14 (Spalte 20) oder separat?
|
||||
4. Zingelstr.14 Sparkasse -> Gefunden in Spalte 21!
|
||||
5. PVCreditplus -> NICHT im Tilgung-Sheet gefunden!
|
||||
|
||||
WICHTIG: Die Werte für Zingelstr. 14 scheinen KOMBINIERT zu sein (DSL + Sparkasse)
|
||||
Für separate Importe müssen wir die Einzelwerte finden!
|
||||
""")
|
||||
@@ -0,0 +1,26 @@
|
||||
import openpyxl
|
||||
import json
|
||||
import os
|
||||
|
||||
os.chdir(r'C:\Users\renet\.openclaw\workspace\buchhaltungs-app')
|
||||
|
||||
# Excel-Datei laden
|
||||
wb = openpyxl.load_workbook('Nebenkosten 2020.xlsx', data_only=True)
|
||||
|
||||
print(f"Sheets gefunden: {wb.sheetnames}")
|
||||
print()
|
||||
|
||||
# Alle Sheets analysieren
|
||||
for sheet_name in wb.sheetnames:
|
||||
ws = wb[sheet_name]
|
||||
print(f"\n=== {sheet_name} ===")
|
||||
|
||||
# Zeilen 1-25 für Header und Struktur
|
||||
for row in range(1, 26):
|
||||
values = []
|
||||
for col in range(1, 15): # Spalten A-O
|
||||
cell = ws.cell(row=row, column=col)
|
||||
val = cell.value
|
||||
values.append(str(val) if val is not None else "")
|
||||
if any(values):
|
||||
print(f"Zeile {row}: {values}")
|
||||
@@ -0,0 +1,85 @@
|
||||
import openpyxl
|
||||
|
||||
file_path = 'Kopie von Kostenrechnung der Nächsten jahre (3).xlsx'
|
||||
wb = openpyxl.load_workbook(file_path, data_only=True)
|
||||
sheet = wb['Tilgung bei Gleichbleibenden Be']
|
||||
|
||||
print('=== ENDGÜLTIGE KREDIT-ANALYSE APRIL 2026 ===')
|
||||
print()
|
||||
|
||||
# KREDIT 1: DSL Bank (Spalte 9)
|
||||
dsl_rest = sheet.cell(189, 9).value
|
||||
dsl_rate = sheet.cell(189, 10).value
|
||||
print('KREDIT 1: DSL Bank')
|
||||
print(f' Spalte: 9 (Restschuld), 10 (Rate)')
|
||||
print(f' Restschuld Apr 2026: {dsl_rest:,.2f} €' if dsl_rest else ' Restschuld: None')
|
||||
print(f' Monatsrate: {dsl_rate} €' if dsl_rate else ' Rate: None')
|
||||
print()
|
||||
|
||||
# KREDIT 2: PSD Nord (Spalte 13)
|
||||
psd_rest = sheet.cell(189, 13).value
|
||||
psd_rate = sheet.cell(189, 14).value
|
||||
print('KREDIT 2: PSD Nord')
|
||||
print(f' Spalte: 13 (Restschuld), 14 (Rate)')
|
||||
print(f' Restschuld Apr 2026: {psd_rest:,.2f} €' if psd_rest else ' Restschuld: None')
|
||||
print(f' Monatsrate: {psd_rate} €' if psd_rate else ' Rate: None')
|
||||
print()
|
||||
|
||||
# KREDIT 3: Zingelstr. 14 - Sparkasse (Spalten 20, 21, 28)
|
||||
print('=== ZINGELSTR. 14 ANAYLSE ===')
|
||||
print()
|
||||
|
||||
# Wie interpretieren wir die Daten?
|
||||
# Option 1: Zingelstr. 14 = 1 Kredit, Sparkasse = Header
|
||||
# Option 2: Zingelstr. 14 ist ein Überbegriff, Sparkasse und DSL sind 2 Kredite
|
||||
|
||||
zingel_rest = sheet.cell(189, 20).value
|
||||
sparkasse_rate = sheet.cell(189, 21).value
|
||||
sparkasse_zins = sheet.cell(189, 22).value
|
||||
|
||||
print('Spalte 20 (Zingelstr. 14):')
|
||||
print(f' Restschuld: {zingel_rest:,.2f} €' if zingel_rest else ' Restschuld: None')
|
||||
print()
|
||||
|
||||
print('Spalte 21 (Sparkasse):')
|
||||
print(f' Rate: {sparkasse_rate} €')
|
||||
print()
|
||||
|
||||
print('Spalte 28 (kein Header):')
|
||||
val28 = sheet.cell(189, 28).value
|
||||
print(f' Wert: {val28:,.2f} €' if val28 else ' Wert: None')
|
||||
print()
|
||||
|
||||
# PRÜFUNG: Ist Spalte 28 = Zingelstr.14 - Sparkasse Restschuld?
|
||||
print('=== MATHEMATISCHE PRÜFUNG ===')
|
||||
sum_check = (dsl_rest or 0) + (psd_rest or 0) + (zingel_rest or 0)
|
||||
gesamt = sheet.cell(189, 24).value
|
||||
|
||||
print(f'DSL (9) + PSD (13) + Zingel (20) = {sum_check:,.2f} €')
|
||||
print(f'Gesamt (24) = {gesamt:,.2f} €')
|
||||
print(f'Differenz = {gesamt - sum_check:,.2f} €')
|
||||
|
||||
# Zingelstr.14 + Spalte 28
|
||||
sum_check2 = (dsl_rest or 0) + (psd_rest or 0) + (zingel_rest or 0) + (val28 or 0)
|
||||
print(f'\\nDSL (9) + PSD (13) + Zingel (20) + Col28 = {sum_check2:,.2f} €')
|
||||
print(f'Das passt zu Gesamt (24)!' if abs(sum_check2 - gesamt) < 0.01 else f'Passt nicht: Differenz {sum_check2 - gesamt}')
|
||||
print()
|
||||
|
||||
print('=== ERGEBNIS ===')
|
||||
print('Es gibt 4 aktive Kredite im April 2026:')
|
||||
print()
|
||||
print('1. DSL Bank (Spalte 9)')
|
||||
print(f' Restschuld: {dsl_rest:,.2f} €')
|
||||
print(f' Rate: {dsl_rate} €')
|
||||
print()
|
||||
print('2. PSD Nord (Spalte 13)')
|
||||
print(f' Restschuld: {psd_rest:,.2f} €')
|
||||
print(f' Rate: {psd_rate} €')
|
||||
print()
|
||||
print('3. Zingelstr. 14 - DSL (Spalte 20) - oder Gesamt für Zingelstr?')
|
||||
print(f' Restschuld: {zingel_rest:,.2f} €')
|
||||
print(f' Rate: unbekannt (nicht in Spalten 21/22)')
|
||||
print()
|
||||
print('4. Sparkasse (Spalte 28 - Restschuld, Spalte 21 - Rate)')
|
||||
print(f' Restschuld: {val28:,.2f} €')
|
||||
print(f' Rate: {sparkasse_rate} €')
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Restschuld für Niki korrigieren
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
function apiCall(method, endpoint, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: `/api${endpoint}`,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseData = '';
|
||||
res.on('data', (chunk) => responseData += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(responseData);
|
||||
resolve(parsed);
|
||||
} catch (e) {
|
||||
resolve(responseData);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (data) req.write(JSON.stringify(data));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Korrigiere Restschuld für Niki...\n');
|
||||
|
||||
// Alle Kredite holen
|
||||
const kredite = await apiCall('GET', '/kredite');
|
||||
const nikiKredit = kredite.find(k => k.name?.toLowerCase().includes('niki'));
|
||||
|
||||
if (!nikiKredit) {
|
||||
console.log('Kredit nicht gefunden');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Gefunden: ${nikiKredit.name} (${nikiKredit.id})`);
|
||||
console.log(`Aktuelle Restschuld: ${nikiKredit.restschuld} €`);
|
||||
console.log(`Ursprungsschuld: ${nikiKredit.ursprungsschuld} €`);
|
||||
|
||||
// Zahlungen holen
|
||||
const zahlungen = await apiCall('GET', `/kredite/${nikiKredit.id}/zahlungen`);
|
||||
|
||||
const sumZahlungen = zahlungen
|
||||
.filter(z => z.typ === 'zahlung')
|
||||
.reduce((sum, z) => sum + parseFloat(z.betrag), 0);
|
||||
const sumAuslagen = zahlungen
|
||||
.filter(z => z.typ === 'auslage')
|
||||
.reduce((sum, z) => sum + parseFloat(z.betrag), 0);
|
||||
|
||||
console.log(`\nSumme Zahlungen: ${sumZahlungen} €`);
|
||||
console.log(`Summe Auslagen: ${sumAuslagen} €`);
|
||||
|
||||
// Korrekte Restschuld berechnen
|
||||
const korrekteRestschuld = 7000 - sumZahlungen + Math.abs(sumAuslagen);
|
||||
console.log(`\nBerechnete Restschuld: ${korrekteRestschuld} €`);
|
||||
|
||||
// Patch via PUT
|
||||
const updateResult = await apiCall('PUT', `/kredite/${nikiKredit.id}`, {
|
||||
...nikiKredit,
|
||||
restschuld: korrekteRestschuld
|
||||
});
|
||||
|
||||
console.log(`\n✅ Restschuld korrigiert: ${updateResult.restschuld} €`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,39 @@
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
kredit_id = '4ad8826f-ecb4-443d-aef6-ce9162e5f078'
|
||||
|
||||
# Berechne korrekte Restschuld fuer Forderung
|
||||
# Start: 7000
|
||||
# Zahlungen von Niki: +3400 (erhoeht die Forderung)
|
||||
# Auslagen (Rene gibt Geld): -505 (verringert die Forderung)
|
||||
# Erwartet: 7000 - 2895 = 4105
|
||||
|
||||
correct_restschuld = 4105.00
|
||||
|
||||
print("=" * 60)
|
||||
print("KORREKTUR: Restschuld fuer Niki Forderung")
|
||||
print("=" * 60)
|
||||
|
||||
# Update Restschuld
|
||||
url = f'http://localhost:3001/api/kredite/{kredit_id}'
|
||||
data = json.dumps({
|
||||
'restschuld': correct_restschuld,
|
||||
'notizen': 'Korrigierte Forderung - Rene bekommt Geld von Niki'
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json'}, method='PUT')
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req) as response:
|
||||
result = json.loads(response.read().decode())
|
||||
print(f"[OK] Restschuld aktualisiert: {result['restschuld']} EUR")
|
||||
print(f"[OK] Richtung: {result.get('richtung', 'N/A')}")
|
||||
except Exception as e:
|
||||
print(f"[ERR] Fehler beim Update: {e}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("KORREKTUR ABGESCHLOSSEN")
|
||||
print("=" * 60)
|
||||
print(f"\nKorrigierte Restschuld: {correct_restschuld} EUR")
|
||||
print("Berechnung: 7000 (Start) - 2895 (Netto-Zahlungen) = 4105 EUR")
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Restschuld für Niki korrigieren
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
|
||||
function apiCall(method, endpoint, data = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 3001,
|
||||
path: `/api${endpoint}`,
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
let responseData = '';
|
||||
res.on('data', (chunk) => responseData += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(responseData);
|
||||
resolve(parsed);
|
||||
} catch (e) {
|
||||
resolve(responseData);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
if (data) req.write(JSON.stringify(data));
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('Korrigiere Restschuld für Niki...\n');
|
||||
|
||||
// Alle Kredite holen
|
||||
const kredite = await apiCall('GET', '/kredite');
|
||||
const nikiKredit = kredite.find(k => k.name?.toLowerCase().includes('niki'));
|
||||
|
||||
if (!nikiKredit) {
|
||||
console.log('Kredit nicht gefunden');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Gefunden: ${nikiKredit.name} (${nikiKredit.id})`);
|
||||
console.log(`Aktuelle Restschuld: ${nikiKredit.restschuld} €`);
|
||||
console.log(`Ursprungsschuld: ${nikiKredit.ursprungsschuld} €`);
|
||||
|
||||
// Zahlungen holen
|
||||
const zahlungen = await apiCall('GET', `/kredite/${nikiKredit.id}/zahlungen`);
|
||||
|
||||
const sumZahlungen = zahlungen
|
||||
.filter(z => z.typ === 'zahlung')
|
||||
.reduce((sum, z) => sum + parseFloat(z.betrag), 0);
|
||||
const sumAuslagen = zahlungen
|
||||
.filter(z => z.typ === 'auslage')
|
||||
.reduce((sum, z) => sum + parseFloat(z.betrag), 0);
|
||||
|
||||
console.log(`\nSumme Zahlungen: ${sumZahlungen} €`);
|
||||
console.log(`Summe Auslagen: ${sumAuslagen} €`);
|
||||
|
||||
// Korrekte Restschuld berechnen
|
||||
const korrekteRestschuld = 7000 - sumZahlungen + Math.abs(sumAuslagen);
|
||||
console.log(`\nBerechnete Restschuld: ${korrekteRestschuld} €`);
|
||||
|
||||
// Patch via PUT
|
||||
const updateResult = await apiCall('PUT', `/kredite/${nikiKredit.id}`, {
|
||||
...nikiKredit,
|
||||
restschuld: korrekteRestschuld
|
||||
});
|
||||
|
||||
console.log(`\n✅ Restschuld korrigiert: ${updateResult.restschuld} €`);
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,18 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN npm install -g nodemon
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
EXPOSE 5173
|
||||
CMD ["npm", "run", "dev", "--", "--host"]
|
||||
@@ -0,0 +1,13 @@
|
||||
import { build } from 'vite';
|
||||
try {
|
||||
const result = await build();
|
||||
console.log('BUILD OK');
|
||||
if (result?.output) {
|
||||
result.output.forEach(o => console.log(' →', o.fileName, o.type, o.code?.length || ''));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('BUILD ERROR:', e.message);
|
||||
if (e.loc) console.error('Location:', JSON.stringify(e.loc));
|
||||
if (e.frame) console.error(e.frame);
|
||||
console.error(e.stack);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SteuerFlow - Buchhaltung</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
location /uploads {
|
||||
proxy_pass http://backend:3000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "buchhaltungs-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"lucide-react": "^0.294.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^5.0.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,346 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
LayoutDashboard, FileText, Clock, CreditCard, Home, TrendingUp, LogOut, Users,
|
||||
ClipboardCheck, Building2, Calculator, ChevronRight, ChevronDown, Settings,
|
||||
Briefcase, DollarSign, BarChart3
|
||||
} from 'lucide-react';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import Stunden from './components/Stunden';
|
||||
import Kredite from './components/Kredite';
|
||||
import PrivateKosten from './components/PrivateKosten';
|
||||
import Dokumente from './components/Dokumente';
|
||||
import Geschaeftsplanung from './components/Geschaeftsplanung';
|
||||
import Auftragsnachweis from './components/Auftragsnachweis';
|
||||
import Objekte from './components/nebenkosten/Objekte';
|
||||
import Mieter from './components/nebenkosten/Mieter';
|
||||
import Nebenkostenabrechnung from './components/nebenkosten/Nebenkostenabrechnung';
|
||||
import VermietungsBilanz from './components/nebenkosten/VermietungsBilanz';
|
||||
import { AuthProvider, useAuth, ProtectedRoute } from './contexts/AuthContext';
|
||||
import Login from './components/auth/Login';
|
||||
import SetupWizard from './components/auth/SetupWizard';
|
||||
import UserManagement from './components/auth/user-management/UserManagement';
|
||||
|
||||
const API_URL = '/api';
|
||||
|
||||
function AppContent() {
|
||||
const { isAuthenticated, loading, needsSetup, logout, user, isAdmin } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState('dashboard');
|
||||
const [apiStatus, setApiStatus] = useState('loading');
|
||||
const [expandedFolders, setExpandedFolders] = useState(['privat', 'geschaeftlich', 'vermietung', 'steuer', 'auftragsnachweis']);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetch(`${API_URL}/health`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
console.log('API OK:', data);
|
||||
setApiStatus('ok');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('API Error:', err);
|
||||
setApiStatus('error');
|
||||
});
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const toggleFolder = (folderId) => {
|
||||
setExpandedFolders(prev =>
|
||||
prev.includes(folderId)
|
||||
? prev.filter(id => id !== folderId)
|
||||
: [...prev, folderId]
|
||||
);
|
||||
};
|
||||
|
||||
// Dashboard separat ganz oben
|
||||
const dashboardItem = { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard };
|
||||
|
||||
const menuStructure = [
|
||||
{
|
||||
id: 'privat',
|
||||
label: 'Privat',
|
||||
icon: Home,
|
||||
items: [
|
||||
{ id: 'privat-kredite', label: 'Kredit-Übersicht', icon: CreditCard },
|
||||
{ id: 'privat-kosten', label: 'Private Kosten', icon: DollarSign },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'geschaeftlich',
|
||||
label: 'Geschäftlich',
|
||||
icon: Briefcase,
|
||||
items: [
|
||||
{ id: 'stunden', label: 'Stunden & Pauschalen', icon: Clock },
|
||||
{ id: 'auftragsnachweis', label: 'Auftragsnachweis', icon: ClipboardCheck },
|
||||
{ id: 'planung', label: 'Geschäftsplanung', icon: TrendingUp },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'vermietung',
|
||||
label: 'Vermietung',
|
||||
icon: Building2,
|
||||
items: [
|
||||
{ id: 'objekte', label: 'Objekte', icon: Home },
|
||||
{ id: 'mieter', label: 'Mieter', icon: Users },
|
||||
{ id: 'nk-abrechnung', label: 'NK-Abrechnung', icon: Calculator },
|
||||
{ id: 'vermietungs-bilanz', label: 'Vermietungs-Bilanz', icon: BarChart3 },
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'steuer',
|
||||
label: 'Steuer',
|
||||
icon: FileText,
|
||||
items: [
|
||||
{ id: 'docs', label: 'Dokumente', icon: FileText },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
// Admin separat
|
||||
const adminItem = { id: 'users', label: 'Benutzerverwaltung', icon: Settings };
|
||||
|
||||
// Setup Wizard anzeigen wenn keine User existieren
|
||||
if (needsSetup) {
|
||||
return <SetupWizard />;
|
||||
}
|
||||
|
||||
// Login anzeigen wenn nicht eingeloggt
|
||||
if (!isAuthenticated && !loading) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
if (apiStatus === 'loading' && isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-xl">⏳ Lade SteuerFlow...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (apiStatus === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="bg-red-100 text-red-700 p-6 rounded-lg">
|
||||
<h2 className="font-bold text-lg mb-2">❌ Backend nicht erreichbar</h2>
|
||||
<p>Bitte überprüfe ob Docker läuft.</p>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-4 px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex flex-col">
|
||||
{/* Header oben */}
|
||||
<header className="bg-white shadow-md h-20 flex-shrink-0">
|
||||
<div className="px-6 h-full flex items-center justify-between">
|
||||
{/* Links: Logo + Titel */}
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Finanz Flow Logo"
|
||||
className="h-12 w-auto"
|
||||
onError={(e) => { e.target.style.display = 'none'; }}
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Finanz Flow</h1>
|
||||
<span className="text-sm text-blue-600">by Taeger IT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rechts: Status + User Info */}
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></span>
|
||||
✅ Online
|
||||
</span>
|
||||
<span className="text-sm text-gray-700">{user?.username}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content-Bereich mit Sidebar */}
|
||||
<div className="flex flex-1">
|
||||
{/* Sidebar links */}
|
||||
<aside className="w-72 bg-white shadow-md flex-shrink-0 flex flex-col">
|
||||
<div className="pl-4 pr-4 py-4 flex-1">
|
||||
{/* Navigation */}
|
||||
<nav className="space-y-1">
|
||||
{/* Dashboard - ganz oben, separat */}
|
||||
<button
|
||||
onClick={() => setActiveTab(dashboardItem.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === dashboardItem.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<dashboardItem.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium">{dashboardItem.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Ordnerstruktur */}
|
||||
{menuStructure.map(folder => {
|
||||
const FolderIcon = folder.icon;
|
||||
const isExpanded = expandedFolders.includes(folder.id);
|
||||
const hasActiveItem = folder.items.some(item => item.id === activeTab);
|
||||
|
||||
return (
|
||||
<div key={folder.id} className="mt-2">
|
||||
{/* Ordner Header */}
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
||||
hasActiveItem
|
||||
? 'text-blue-600 bg-blue-50'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
)}
|
||||
<FolderIcon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium text-left flex-1">{folder.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Unterpunkte */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 ml-6 pl-4 border-l-2 border-gray-200">
|
||||
{folder.items.map(item => {
|
||||
const ItemIcon = item.icon;
|
||||
const hasChildren = item.children && item.children.length > 0;
|
||||
const isChildrenExpanded = expandedFolders.includes(item.id);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (hasChildren) {
|
||||
toggleFolder(item.id);
|
||||
} else {
|
||||
setActiveTab(item.id);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === item.id || item.children?.some(c => c.id === activeTab)
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{hasChildren && (
|
||||
isChildrenExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
)
|
||||
)}
|
||||
<ItemIcon className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
|
||||
{/* Sub-children */}
|
||||
{hasChildren && isChildrenExpanded && (
|
||||
<div className="ml-6 pl-2 border-l border-gray-200 mt-1">
|
||||
{item.children.map(child => {
|
||||
const ChildIcon = child.icon;
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
onClick={() => setActiveTab(child.id)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-sm ${
|
||||
activeTab === child.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<ChildIcon className="w-3 h-3 flex-shrink-0" />
|
||||
<span>{child.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Admin-Bereich - nur für Admins */}
|
||||
{isAdmin && (
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={() => setActiveTab(adminItem.id)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
|
||||
activeTab === adminItem.id
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<adminItem.icon className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="font-medium">{adminItem.label}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* User Info & Logout - unten in Sidebar */}
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-gray-600 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
<span>Logout</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 p-6 overflow-y-auto">
|
||||
<ProtectedRoute>
|
||||
{activeTab === 'dashboard' && <Dashboard />}
|
||||
{activeTab === 'stunden' && <Stunden />}
|
||||
{activeTab === 'auftragsnachweis' && <Auftragsnachweis />}
|
||||
{activeTab === 'privat-kredite' && <Kredite />}
|
||||
{activeTab === 'privat-kosten' && <PrivateKosten />}
|
||||
|
||||
{activeTab === 'docs' && <Dokumente API_URL={API_URL} />}
|
||||
{activeTab === 'planung' && <Geschaeftsplanung />}
|
||||
{activeTab === 'objekte' && <Objekte />}
|
||||
{activeTab === 'mieter' && <Mieter />}
|
||||
{activeTab === 'nk-abrechnung' && <Nebenkostenabrechnung />}
|
||||
{activeTab === 'vermietungs-bilanz' && <VermietungsBilanz />}
|
||||
{activeTab === 'users' && isAdmin && <UserManagement />}
|
||||
{!['dashboard', 'stunden', 'auftragsnachweis', 'kredite', 'docs', 'planung', 'objekte', 'mieter', 'nk-abrechnung', 'users', 'privat-kredite', 'privat-kosten', 'vermietungs-bilanz'].includes(activeTab) && (
|
||||
<div className="bg-white rounded-lg shadow p-8">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
⏳ Lade...
|
||||
</h2>
|
||||
<p className="text-gray-600">Diese Komponente wird noch geladen...</p>
|
||||
</div>
|
||||
)}
|
||||
</ProtectedRoute>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// API Service für Nebenkosten-Module
|
||||
const API_URL = '/api';
|
||||
|
||||
// Objekte API
|
||||
export const objekteAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/objekte`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Objekte');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`);
|
||||
if (!res.ok) throw new Error('Objekt nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/objekte`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/objekte/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getKosten(objektId, jahr) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async addKosten(objektId, data) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Hinzufügen der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteKosten(objektId, kostenId) {
|
||||
const res = await fetch(`${API_URL}/objekte/${objektId}/kosten/${kostenId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen der Kosten');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Mieter API
|
||||
export const mieterAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/mieter`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Mieter');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`);
|
||||
if (!res.ok) throw new Error('Mieter nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/mieter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async createWithContract(data) {
|
||||
const res = await fetch(`${API_URL}/mieter-mit-vertrag`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async update(id, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async updateWithContract(id, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}/mit-vertrag`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Aktualisieren');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/mieter/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getMietvertraege(mieterId) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/mietvertraege`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Mietverträge');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getVorauszahlungen(mieterId, jahr) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Vorauszahlungen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async addVorauszahlung(mieterId, data) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Hinzufügen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async deleteVorauszahlung(mieterId, vorauszahlungId) {
|
||||
const res = await fetch(`${API_URL}/mieter/${mieterId}/vorauszahlungen/${vorauszahlungId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
|
||||
// Nebenkostenabrechnung API
|
||||
export const nebenkostenabrechnungAPI = {
|
||||
async getAll() {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Abrechnungen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async getById(id) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`);
|
||||
if (!res.ok) throw new Error('Abrechnung nicht gefunden');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async vorschau(data) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnung/vorschau`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler bei der Vorschau');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async create(data) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnung`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Erstellen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
const res = await fetch(`${API_URL}/nebenkostenabrechnungen/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!res.ok) throw new Error('Fehler beim Löschen');
|
||||
return res.json();
|
||||
},
|
||||
|
||||
async downloadPDF(id, entwurf = false) {
|
||||
const url = `${API_URL}/nebenkostenabrechnung/${id}/pdf${entwurf ? '?entwurf=true' : ''}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
if (!res.ok) throw new Error('Fehler beim PDF-Download');
|
||||
return res.blob();
|
||||
},
|
||||
};
|
||||
|
||||
// Vermietungsbilanz API
|
||||
export const vermietungsbilanzAPI = {
|
||||
async getBilanz(jahr) {
|
||||
const res = await fetch(`${API_URL}/vermietungsbilanz?jahr=${jahr}`);
|
||||
if (!res.ok) throw new Error('Fehler beim Laden der Bilanz');
|
||||
return res.json();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,314 @@
|
||||
// API Service für alle Backend-Calls
|
||||
// Docker: Frontend (nginx:80) -> Backend (3001) via nginx proxy
|
||||
const API_URL = '/api';
|
||||
|
||||
// Helper für API Calls
|
||||
async function apiCall(endpoint, options = {}) {
|
||||
const url = `${API_URL}${endpoint}`;
|
||||
const config = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
};
|
||||
|
||||
if (options.body && typeof options.body === 'object') {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||
throw new Error(error.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error(`API Error (${endpoint}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Kredite API
|
||||
export const krediteAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/kredite${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/kredite', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kredite/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
// PATCH für partielle Updates (z.B. nur Restschuld)
|
||||
patch: (id, data) => apiCall(`/kredite/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kredite/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
getBuchungen: (id) => apiCall(`/kredite/${id}/buchungen`),
|
||||
|
||||
addBuchung: (id, data) => apiCall(`/kredite/${id}/buchungen`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
deleteBuchung: (id, buchungId) => apiCall(`/kredite/${id}/buchungen/${buchungId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// Zahlungen API (Alias für Buchungen)
|
||||
getZahlungen: (id) => apiCall(`/kredite/${id}/zahlungen`),
|
||||
|
||||
addZahlung: (id, data) => apiCall(`/kredite/${id}/zahlungen`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
deleteZahlung: (id, zahlungId) => apiCall(`/kredite/${id}/zahlungen/${zahlungId}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Stunden API
|
||||
export const stundenAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/stunden${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/stunden', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/stunden/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/stunden/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Nebenkosten API
|
||||
export const nebenkostenAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/nebenkosten${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/nebenkosten', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/nebenkosten/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/nebenkosten/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Kostenplanung API
|
||||
export const kostenplanungAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/kostenplanung${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
create: (data) => apiCall('/kostenplanung', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kostenplanung/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kostenplanung/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Geschäftsplanung API (neu)
|
||||
export const geschaeftsplanungAPI = {
|
||||
// Monatliche Übersicht mit Ergebnis und Cashflow
|
||||
getMonatlich: (jahr) => apiCall(`/geschaeftsplanung/monatlich?jahr=${jahr}`),
|
||||
|
||||
// Kategorie-Übersicht
|
||||
getKategorien: (jahr) => apiCall(`/geschaeftsplanung/kategorien?jahr=${jahr}`),
|
||||
|
||||
// Miete vs Nebenkosten pro Objekt
|
||||
getObjektVergleich: (objekt, jahr) => apiCall(`/geschaeftsplanung/objektvergleich/${objekt}?jahr=${jahr}`),
|
||||
|
||||
// Umsatzvorschau (nur Einnahmen)
|
||||
getUmsatzVorschau: (jahr) => apiCall(`/geschaeftsplanung/umsatzvorschau?jahr=${jahr}`),
|
||||
};
|
||||
|
||||
// Kunden API (für Auftragsnachweise)
|
||||
export const kundenAPI = {
|
||||
getAll: () => apiCall('/kunden'),
|
||||
|
||||
create: (data) => apiCall('/kunden', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
update: (id, data) => apiCall(`/kunden/${id}`, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
}),
|
||||
|
||||
delete: (id) => apiCall(`/kunden/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
};
|
||||
|
||||
// Auftragsnachweise API
|
||||
export const auftragsnachweisAPI = {
|
||||
getAll: (params = {}) => {
|
||||
const queryParams = new URLSearchParams(params).toString();
|
||||
return apiCall(`/auftragsnachweise${queryParams ? '?' + queryParams : ''}`);
|
||||
},
|
||||
|
||||
getById: (id) => apiCall(`/auftragsnachweise/${id}`),
|
||||
|
||||
create: (data) => {
|
||||
// Stelle sicher, dass art_der_arbeit ein Array ist
|
||||
const sanitizedData = {
|
||||
...data,
|
||||
art_der_arbeit: Array.isArray(data.art_der_arbeit)
|
||||
? data.art_der_arbeit
|
||||
: (data.art_der_arbeit ? [data.art_der_arbeit] : []),
|
||||
stunden_ids: Array.isArray(data.stunden_ids)
|
||||
? data.stunden_ids
|
||||
: (data.stunden_ids ? [data.stunden_ids] : []),
|
||||
};
|
||||
return apiCall('/auftragsnachweise', {
|
||||
method: 'POST',
|
||||
body: sanitizedData,
|
||||
});
|
||||
},
|
||||
|
||||
delete: (id) => apiCall(`/auftragsnachweise/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
|
||||
// PDF generieren und herunterladen
|
||||
generatePDF: async (id, firmenname) => {
|
||||
const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firmenname }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Bei Fehler erst als Text versuchen, dann als JSON
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
let errorMessage = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorText;
|
||||
} catch (e) {
|
||||
// Nicht JSON, Text verwenden
|
||||
}
|
||||
throw new Error(errorMessage || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// Blob für Download
|
||||
const blob = await response.blob();
|
||||
if (blob.size === 0) {
|
||||
throw new Error('PDF ist leer');
|
||||
}
|
||||
|
||||
// Download-Dateiname aus Header extrahieren oder Default verwenden
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let filename = `auftragsnachweis-${id}.pdf`;
|
||||
if (disposition && disposition.includes('filename=')) {
|
||||
const match = disposition.match(/filename="([^"]+)"/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
// Vorhandenes PDF herunterladen
|
||||
downloadPDF: async (id) => {
|
||||
const response = await fetch(`${API_URL}/auftragsnachweise/${id}/pdf`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
let errorMessage = errorText;
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText);
|
||||
errorMessage = errorJson.error || errorText;
|
||||
} catch (e) {
|
||||
// Nicht JSON, Text verwenden
|
||||
}
|
||||
throw new Error(errorMessage || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
if (blob.size === 0) {
|
||||
throw new Error('PDF ist leer');
|
||||
}
|
||||
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let filename = `auftragsnachweis-${id}.pdf`;
|
||||
if (disposition && disposition.includes('filename=')) {
|
||||
const match = disposition.match(/filename="([^"]+)"/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
// Health Check
|
||||
export const healthCheck = () => apiCall('/health');
|
||||
|
||||
export default {
|
||||
kredite: krediteAPI,
|
||||
stunden: stundenAPI,
|
||||
nebenkosten: nebenkostenAPI,
|
||||
kostenplanung: kostenplanungAPI,
|
||||
geschaeftsplanung: geschaeftsplanungAPI,
|
||||
health: healthCheck,
|
||||
};
|
||||
@@ -0,0 +1,778 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Plus, Trash2, Download, UserPlus, CheckSquare, X, AlertCircle, Clock, Users, FileCheck, Filter, RotateCcw, Calendar } from 'lucide-react';
|
||||
import { auftragsnachweisAPI, kundenAPI, stundenAPI } from '../api';
|
||||
|
||||
// Art der Arbeit Optionen
|
||||
const ARBEIT_ARTEN = [
|
||||
'Wartung',
|
||||
'Regiebericht',
|
||||
'Abnahme',
|
||||
'Lieferschein',
|
||||
'Installation',
|
||||
'Instandsetzung',
|
||||
'Systempflege',
|
||||
'Schulung',
|
||||
];
|
||||
|
||||
export default function Auftragsnachweis() {
|
||||
const [kunden, setKunden] = useState([]);
|
||||
const [stunden, setStunden] = useState([]);
|
||||
const [auftragsnachweise, setAuftragsnachweise] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Form States
|
||||
const [showKundenForm, setShowKundenForm] = useState(false);
|
||||
const [showAuftragsnachweisForm, setShowAuftragsnachweisForm] = useState(false);
|
||||
const [selectedStunden, setSelectedStunden] = useState([]);
|
||||
|
||||
// Filter States
|
||||
const [filterKunde, setFilterKunde] = useState('');
|
||||
const [filterDatumVon, setFilterDatumVon] = useState('');
|
||||
const [filterDatumBis, setFilterDatumBis] = useState('');
|
||||
const [filterArbeitArten, setFilterArbeitArten] = useState([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
|
||||
// Kunden Form
|
||||
const [kundeForm, setKundeForm] = useState({
|
||||
name: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
});
|
||||
|
||||
// Auftragsnachweis Form
|
||||
const [auftragsnachweisForm, setAuftragsnachweisForm] = useState({
|
||||
kunde_id: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
art_der_arbeit: [],
|
||||
software_version: '',
|
||||
durchgefuehrte_arbeiten: '',
|
||||
geliefertes_material: '',
|
||||
anlage_betriebsbereit: 'voll',
|
||||
anfahrt_km: '',
|
||||
abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.',
|
||||
});
|
||||
|
||||
// Daten laden
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [kundenData, stundenData, auftraegeData] = await Promise.all([
|
||||
kundenAPI.getAll(),
|
||||
stundenAPI.getAll({ status: 'offen' }),
|
||||
auftragsnachweisAPI.getAll(),
|
||||
]);
|
||||
setKunden(kundenData);
|
||||
setStunden(stundenData);
|
||||
setAuftragsnachweise(auftraegeData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Kunden CRUD
|
||||
const addKunde = async () => {
|
||||
if (!kundeForm.name) return;
|
||||
|
||||
try {
|
||||
await kundenAPI.create(kundeForm);
|
||||
await loadData();
|
||||
setKundeForm({
|
||||
name: '',
|
||||
strasse: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
telefon: '',
|
||||
email: '',
|
||||
});
|
||||
setShowKundenForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteKunde = async (id) => {
|
||||
if (confirm('Kunde wirklich löschen?')) {
|
||||
try {
|
||||
await kundenAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Stunden Auswahl
|
||||
const toggleStunde = (id) => {
|
||||
setSelectedStunden(prev =>
|
||||
prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
|
||||
);
|
||||
};
|
||||
|
||||
// Auftragsnachweis erstellen
|
||||
const createAuftragsnachweis = async () => {
|
||||
if (!auftragsnachweisForm.kunde_id) {
|
||||
setError('Bitte einen Kunden auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedStunden.length === 0) {
|
||||
setError('Bitte mindestens eine Stunde auswählen');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...auftragsnachweisForm,
|
||||
stunden_ids: selectedStunden,
|
||||
};
|
||||
|
||||
const result = await auftragsnachweisAPI.create(data);
|
||||
|
||||
// PDF generieren und herunterladen
|
||||
await auftragsnachweisAPI.generatePDF(result.id, 'Täger IT & Gebäude-Systeme');
|
||||
|
||||
await loadData();
|
||||
setShowAuftragsnachweisForm(false);
|
||||
setSelectedStunden([]);
|
||||
setAuftragsnachweisForm({
|
||||
kunde_id: '',
|
||||
datum: new Date().toISOString().split('T')[0],
|
||||
art_der_arbeit: [],
|
||||
software_version: '',
|
||||
durchgefuehrte_arbeiten: '',
|
||||
geliefertes_material: '',
|
||||
anlage_betriebsbereit: 'voll',
|
||||
anfahrt_km: '',
|
||||
abnahmebestaetigung_text: 'Hiermit bestätige ich, dass die Arbeiten durch Täger IT & Gebäude-Systeme ordnungsgemäß durchgeführt wurden.',
|
||||
});
|
||||
} catch (err) {
|
||||
setError('Fehler beim Erstellen: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAuftragsnachweis = async (id) => {
|
||||
if (confirm('Auftragsnachweis wirklich löschen?')) {
|
||||
try {
|
||||
await auftragsnachweisAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const downloadPDF = async (id) => {
|
||||
try {
|
||||
await auftragsnachweisAPI.downloadPDF(id);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Download: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Berechne Summe der ausgewählten Stunden
|
||||
const selectedStundenDetails = stunden.filter(s => selectedStunden.includes(s.id));
|
||||
const totalStunden = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.stunden), 0);
|
||||
const totalBetrag = selectedStundenDetails.reduce((sum, s) => sum + parseFloat(s.betrag), 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kundenverwaltung */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="text-blue-600" size={24} />
|
||||
<h2 className="text-xl font-bold">Adressbuch</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKundenForm(!showKundenForm)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
<UserPlus size={18} />
|
||||
{showKundenForm ? 'Abbrechen' : 'Neuer Kunde'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKundenForm && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.name}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Firmenname / Kunde"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Straße</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.strasse}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, strasse: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Musterstraße 123"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">PLZ</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.plz}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, plz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="25554"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ort</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.ort}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, ort: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Wilster"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
|
||||
<input
|
||||
type="text"
|
||||
value={kundeForm.telefon}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, telefon: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="+49..."
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
value={kundeForm.email}
|
||||
onChange={(e) => setKundeForm({ ...kundeForm, email: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="info@kunde.de"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKunde}
|
||||
className="mt-4 w-full md:w-auto px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
💾 Kunde speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kundenliste */}
|
||||
{kunden.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4">Name</th>
|
||||
<th className="text-left py-2 px-4">Adresse</th>
|
||||
<th className="text-left py-2 px-4">Kontakt</th>
|
||||
<th className="py-2 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kunden.map(kunde => (
|
||||
<tr key={kunde.id} className="border-b border-gray-100">
|
||||
<td className="py-2 px-4 font-medium">{kunde.name}</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{kunde.strasse && <div>{kunde.strasse}</div>}
|
||||
{(kunde.plz || kunde.ort) && (
|
||||
<div>{kunde.plz} {kunde.ort}</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{kunde.telefon && <div>📞 {kunde.telefon}</div>}
|
||||
{kunde.email && <div>✉️ {kunde.email}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<button
|
||||
onClick={() => deleteKunde(kunde.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">Noch keine Kunden angelegt.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auftragsnachweis erstellen */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck className="text-green-600" size={24} />
|
||||
<h2 className="text-xl font-bold">Auftragsnachweis erstellen</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAuftragsnachweisForm(!showAuftragsnachweisForm)}
|
||||
disabled={kunden.length === 0 || stunden.length === 0}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileText size={18} />
|
||||
{showAuftragsnachweisForm ? 'Abbrechen' : 'Neuen Auftragsnachweis'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(kunden.length === 0 || stunden.length === 0) && (
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-yellow-700 mb-4">
|
||||
<AlertCircle size={18} className="inline mr-2" />
|
||||
{kunden.length === 0 && 'Bitte zuerst einen Kunden anlegen. '}
|
||||
{stunden.length === 0 && 'Bitte zuerst Stunden erfassen.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAuftragsnachweisForm && (
|
||||
<div className="space-y-6">
|
||||
{/* Stunden Auswahl */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
||||
<Clock size={18} />
|
||||
Stunden auswählen
|
||||
</h3>
|
||||
|
||||
{stunden.length > 0 ? (
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{stunden.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(s => (
|
||||
<label key={s.id} className="flex items-center gap-3 p-2 bg-white rounded border hover:bg-blue-50 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedStunden.includes(s.id)}
|
||||
onChange={() => toggleStunde(s.id)}
|
||||
className="w-5 h-5 text-blue-600"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{s.kunde}</div>
|
||||
<div className="text-sm text-gray-600">{s.beschreibung}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold">{new Date(s.datum).toLocaleDateString('de-DE')}</div>
|
||||
<div className="text-sm">{s.stunden}h × {s.stundensatz}€ = {parseFloat(s.betrag).toFixed(2)}€</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">Keine offenen Stunden vorhanden.</p>
|
||||
)}
|
||||
|
||||
{selectedStunden.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-blue-100 rounded-lg text-blue-800">
|
||||
<strong>Ausgewählt:</strong> {selectedStunden.length} Einträge,
|
||||
{' '}{totalStunden.toFixed(1)} Stunden,
|
||||
{' '}{totalBetrag.toFixed(2)}€
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auftragsnachweis Formular */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold">Auftragsdetails</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde *</label>
|
||||
<select
|
||||
value={auftragsnachweisForm.kunde_id}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, kunde_id: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
<option value="">Bitte wählen...</option>
|
||||
{kunden.map(k => (
|
||||
<option key={k.id} value={k.id}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datum</label>
|
||||
<input
|
||||
type="date"
|
||||
value={auftragsnachweisForm.datum}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, datum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art der Arbeit */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Art der Arbeit</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ARBEIT_ARTEN.map(art => (
|
||||
<label key={art} className="flex items-center gap-1 px-3 py-1 bg-white border rounded-full cursor-pointer hover:bg-blue-50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={auftragsnachweisForm.art_der_arbeit.includes(art)}
|
||||
onChange={(e) => {
|
||||
const newArbeit = e.target.checked
|
||||
? [...auftragsnachweisForm.art_der_arbeit, art]
|
||||
: auftragsnachweisForm.art_der_arbeit.filter(a => a !== art);
|
||||
setAuftragsnachweisForm({ ...auftragsnachweisForm, art_der_arbeit: newArbeit });
|
||||
}}
|
||||
className="mr-1"
|
||||
/>
|
||||
{art}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Software-Version</label>
|
||||
<input
|
||||
type="text"
|
||||
value={auftragsnachweisForm.software_version}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, software_version: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. AEOS 4.2.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Durchgeführte Arbeiten</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.durchgefuehrte_arbeiten}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, durchgefuehrte_arbeiten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-24"
|
||||
placeholder="Beschreibung der durchgeführten Arbeiten..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Geliefertes Material</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.geliefertes_material}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, geliefertes_material: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-20"
|
||||
placeholder="z.B. 2x Kamera XYZ, 1x Switch..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anlage betriebsbereit</label>
|
||||
<div className="flex gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="voll"
|
||||
checked={auftragsnachweisForm.anlage_betriebsbereit === 'voll'}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anlage_betriebsbereit: e.target.value })}
|
||||
/>
|
||||
Vollständig
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
value="eingeschrankt"
|
||||
checked={auftragsnachweisForm.anlage_betriebsbereit === 'eingeschrankt'}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anlage_betriebsbereit: e.target.value })}
|
||||
/>
|
||||
Eingeschränkt
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Anfahrt (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={auftragsnachweisForm.anfahrt_km}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, anfahrt_km: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. 45"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Abnahmebestätigung Text</label>
|
||||
<textarea
|
||||
value={auftragsnachweisForm.abnahmebestaetigung_text}
|
||||
onChange={(e) => setAuftragsnachweisForm({ ...auftragsnachweisForm, abnahmebestaetigung_text: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 h-20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={createAuftragsnachweis}
|
||||
className="w-full py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 font-semibold flex items-center justify-center gap-2"
|
||||
>
|
||||
<FileCheck size={20} />
|
||||
Auftragsnachweis erstellen & PDF downloaden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bestehende Auftragsnachweise */}
|
||||
{auftragsnachweise.length > 0 && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<FileText className="text-blue-600" size={24} />
|
||||
Erstellte Auftragsnachweise
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowFilter(!showFilter)}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||
showFilter || filterKunde || filterDatumVon || filterDatumBis || filterArbeitArten.length > 0
|
||||
? 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
<Filter size={18} />
|
||||
Filter
|
||||
{(filterKunde || filterDatumVon || filterDatumBis || filterArbeitArten.length > 0) && (
|
||||
<span className="bg-blue-600 text-white text-xs px-2 py-0.5 rounded-full">
|
||||
Aktiv
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter-Leiste */}
|
||||
{showFilter && (
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-700 flex items-center gap-2">
|
||||
<Filter size={18} />
|
||||
Filter Optionen
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilterKunde('');
|
||||
setFilterDatumVon('');
|
||||
setFilterDatumBis('');
|
||||
setFilterArbeitArten([]);
|
||||
}}
|
||||
className="flex items-center gap-1 text-sm text-red-600 hover:text-red-800"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
Filter zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Kunde Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kunde</label>
|
||||
<select
|
||||
value={filterKunde}
|
||||
onChange={(e) => setFilterKunde(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Alle Kunden</option>
|
||||
{kunden.map(k => (
|
||||
<option key={k.id} value={k.id}>{k.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Datum von */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
Datum von
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDatumVon}
|
||||
onChange={(e) => setFilterDatumVon(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Datum bis */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1 flex items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
Datum bis
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterDatumBis}
|
||||
onChange={(e) => setFilterDatumBis(e.target.value)}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Anzahl Ergebnisse */}
|
||||
<div className="flex items-end">
|
||||
<div className="text-sm text-gray-600">
|
||||
Gefiltert: <span className="font-semibold text-blue-600">
|
||||
{auftragsnachweise.filter(an => {
|
||||
// Kunde Filter
|
||||
if (filterKunde && an.kunde_id !== parseInt(filterKunde) && an.kunde_name !== kunden.find(k => k.id === parseInt(filterKunde))?.name) return false;
|
||||
|
||||
// Datum Filter
|
||||
const anDatum = new Date(an.datum);
|
||||
if (filterDatumVon && anDatum < new Date(filterDatumVon)) return false;
|
||||
if (filterDatumBis) {
|
||||
const bisDatum = new Date(filterDatumBis);
|
||||
bisDatum.setHours(23, 59, 59);
|
||||
if (anDatum > bisDatum) return false;
|
||||
}
|
||||
|
||||
// Art der Arbeit Filter
|
||||
if (filterArbeitArten.length > 0) {
|
||||
const anArbeiten = Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit : (an.art_der_arbeit ? [an.art_der_arbeit] : []);
|
||||
if (!filterArbeitArten.some(art => anArbeiten.includes(art))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}).length}
|
||||
</span> von {auftragsnachweise.length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Art der Arbeit Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Art der Arbeit</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ARBEIT_ARTEN.map(art => (
|
||||
<label key={art} className={`flex items-center gap-1 px-3 py-1 border rounded-full cursor-pointer transition-colors text-sm ${
|
||||
filterArbeitArten.includes(art)
|
||||
? 'bg-blue-100 border-blue-300 text-blue-800'
|
||||
: 'bg-white border-gray-300 hover:bg-gray-50'
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filterArbeitArten.includes(art)}
|
||||
onChange={(e) => {
|
||||
setFilterArbeitArten(prev =>
|
||||
e.target.checked
|
||||
? [...prev, art]
|
||||
: prev.filter(a => a !== art)
|
||||
);
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{art}
|
||||
{filterArbeitArten.includes(art) && <CheckSquare size={14} className="ml-1" />}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-4">Datum</th>
|
||||
<th className="text-left py-2 px-4">Kunde</th>
|
||||
<th className="text-left py-2 px-4">Art der Arbeit</th>
|
||||
<th className="text-center py-2 px-4">PDF</th>
|
||||
<th className="py-2 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{auftragsnachweise
|
||||
.filter(an => {
|
||||
// Kunde Filter
|
||||
if (filterKunde && an.kunde_id !== parseInt(filterKunde) && an.kunde_name !== kunden.find(k => k.id === parseInt(filterKunde))?.name) return false;
|
||||
|
||||
// Datum Filter
|
||||
const anDatum = new Date(an.datum);
|
||||
if (filterDatumVon && anDatum < new Date(filterDatumVon)) return false;
|
||||
if (filterDatumBis) {
|
||||
const bisDatum = new Date(filterDatumBis);
|
||||
bisDatum.setHours(23, 59, 59);
|
||||
if (anDatum > bisDatum) return false;
|
||||
}
|
||||
|
||||
// Art der Arbeit Filter
|
||||
if (filterArbeitArten.length > 0) {
|
||||
const anArbeiten = Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit : (an.art_der_arbeit ? [an.art_der_arbeit] : []);
|
||||
if (!filterArbeitArten.some(art => anArbeiten.includes(art))) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.sort((a, b) => new Date(b.datum) - new Date(a.datum))
|
||||
.map(an => (
|
||||
<tr key={an.id} className="border-b border-gray-100">
|
||||
<td className="py-2 px-4">{new Date(an.datum).toLocaleDateString('de-DE')}</td>
|
||||
<td className="py-2 px-4 font-medium">{an.kunde_name || 'Unbekannt'}</td>
|
||||
<td className="py-2 px-4 text-gray-600">
|
||||
{Array.isArray(an.art_der_arbeit) ? an.art_der_arbeit.join(', ') : (an.art_der_arbeit || '-')}
|
||||
</td>
|
||||
<td className="text-center py-2 px-4">
|
||||
{an.pdf_pfad ? (
|
||||
<button
|
||||
onClick={() => downloadPDF(an.id)}
|
||||
className="text-blue-600 hover:text-blue-800"
|
||||
title="PDF herunterladen"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-gray-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 px-4">
|
||||
<button
|
||||
onClick={() => deleteAuftragsnachweis(an.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { LayoutDashboard, Clock, CreditCard, Home, TrendingUp, DollarSign, FileText, AlertCircle, Wallet, BarChart3, Building2, Briefcase, Users, Calculator } from 'lucide-react';
|
||||
import { krediteAPI, stundenAPI, kostenplanungAPI, geschaeftsplanungAPI } from '../api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [stunden, setStunden] = useState([]);
|
||||
const [kredite, setKredite] = useState([]);
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [geschaeftsDaten, setGeschaeftsDaten] = useState(null);
|
||||
const [zahlungen, setZahlungen] = useState({});
|
||||
const [buchungen, setBuchungen] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const [stundenData, krediteData, planungData, geschaeftsDaten] = await Promise.all([
|
||||
stundenAPI.getAll(),
|
||||
krediteAPI.getAll(),
|
||||
kostenplanungAPI.getAll(),
|
||||
geschaeftsplanungAPI.getMonatlich(currentYear).catch(() => null)
|
||||
]);
|
||||
|
||||
setStunden(stundenData);
|
||||
setKredite(krediteData);
|
||||
setPlanung(planungData);
|
||||
setGeschaeftsDaten(geschaeftsDaten);
|
||||
|
||||
// Lade Zahlungen für alle Kredite
|
||||
const zahlungenPromises = krediteData.map(k =>
|
||||
krediteAPI.getZahlungen(k.id).catch(() => [])
|
||||
);
|
||||
const zahlungenResults = await Promise.all(zahlungenPromises);
|
||||
const zahlungenMap = {};
|
||||
krediteData.forEach((k, idx) => {
|
||||
zahlungenMap[k.id] = zahlungenResults[idx];
|
||||
});
|
||||
setZahlungen(zahlungenMap);
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden der Daten: ' + err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback: stelle sicher, dass zahlungen immer definiert ist
|
||||
const zahlungenData = zahlungen || {};
|
||||
|
||||
// EINFACHE Restschuld-Berechnung: Direkt aus DB nehmen
|
||||
const berechneRestschuld = (kredit) => {
|
||||
if (!kredit) return 0;
|
||||
// Verwende den gespeicherten Restschuld-Wert aus der Datenbank
|
||||
// (wie in Kredite.jsx vor der dynamischen Berechnung)
|
||||
return parseFloat(kredit.restschuld) || parseFloat(kredit.ursprungsschuld) || 0;
|
||||
};
|
||||
|
||||
// Berechne aktuelle monatliche Rate basierend auf der Restschuld
|
||||
const berechneAktuelleRate = (kredit) => {
|
||||
if (!kredit || !kredit.monatsrate) return 0;
|
||||
|
||||
const restschuld = berechneRestschuld(kredit);
|
||||
if (restschuld <= 0) return 0;
|
||||
|
||||
// Wenn die Restschuld kleiner als die Rate ist, zeige die Restschuld als Rate
|
||||
return Math.min(restschuld, parseFloat(kredit.monatsrate));
|
||||
};
|
||||
|
||||
// Berechne nächste Fälligkeit
|
||||
const berechneNaechsteFaelligkeit = (kredit) => {
|
||||
if (!kredit || !kredit.faelligkeit_tag) return null;
|
||||
|
||||
const today = new Date();
|
||||
const currentMonth = today.getMonth();
|
||||
const currentYear = today.getFullYear();
|
||||
const day = kredit.faelligkeit_tag;
|
||||
|
||||
// Versuche diesen Monat
|
||||
let faelligkeit = new Date(currentYear, currentMonth, day);
|
||||
if (faelligkeit < today) {
|
||||
// Nächster Monat
|
||||
faelligkeit = new Date(currentYear, currentMonth + 1, day);
|
||||
}
|
||||
|
||||
return faelligkeit;
|
||||
};
|
||||
|
||||
// Formatiere Fälligkeit für Anzeige
|
||||
const formatiereFaelligkeit = (datum) => {
|
||||
if (!datum) return '';
|
||||
const heute = new Date();
|
||||
const diffTage = Math.ceil((datum - heute) / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffTage === 0) return 'Heute';
|
||||
if (diffTage === 1) return 'Morgen';
|
||||
if (diffTage <= 7) return `in ${diffTage} Tagen`;
|
||||
return datum.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||||
};
|
||||
|
||||
// Berechnungen
|
||||
const offeneStunden = stunden.filter(s => s.status === 'offen');
|
||||
const abgerechneteStunden = stunden.filter(s => s.status === 'abgerechnet');
|
||||
const totalOffen = offeneStunden.reduce((sum, s) => sum + parseFloat(s.betrag || 0), 0);
|
||||
const totalAbgerechnet = abgerechneteStunden.reduce((sum, s) => sum + parseFloat(s.betrag || 0), 0);
|
||||
|
||||
// Berechne Gesamtschuld MIT Zinsen - unterscheide nach Richtung
|
||||
// WICHTIG: richtung kann 'eingehend' (Forderung) oder 'ausgehend' (Schulden) sein
|
||||
const { schulden, forderungen } = kredite.reduce((acc, k) => {
|
||||
const restschuld = berechneRestschuld(k);
|
||||
const richtung = k.richtung || 'ausgehend'; // Default: ausgehend (Schulden)
|
||||
|
||||
if (richtung === 'eingehend') {
|
||||
// Eingehend = Forderung/Guthaben (jemand schuldet mir)
|
||||
acc.forderungen += restschuld;
|
||||
} else {
|
||||
// Ausgehend = Schulden (ich schulde jemandem) oder Default
|
||||
acc.schulden += restschuld;
|
||||
}
|
||||
return acc;
|
||||
}, { schulden: 0, forderungen: 0 });
|
||||
|
||||
// Kredit-Dashboard-Daten
|
||||
const krediteAktiv = kredite.filter(k => berechneRestschuld(k) > 0);
|
||||
const kreditRestschuldTotal = krediteAktiv.reduce((sum, k) => {
|
||||
const richtung = k.richtung || 'ausgehend';
|
||||
const restschuld = berechneRestschuld(k);
|
||||
// Nur ausgehende Kredite (Schulden) zählen
|
||||
if (richtung !== 'eingehend') {
|
||||
return sum + restschuld;
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Monatliche Kredit-Belastung (nur Schulden)
|
||||
const monatlicheKreditBelastung = krediteAktiv.reduce((sum, k) => {
|
||||
const richtung = k.richtung || 'ausgehend';
|
||||
if (richtung !== 'eingehend') {
|
||||
return sum + berechneAktuelleRate(k);
|
||||
}
|
||||
return sum;
|
||||
}, 0);
|
||||
|
||||
// Nächste Fälligkeit finden
|
||||
const naechsteFaelligkeit = krediteAktiv
|
||||
.filter(k => (k.richtung || 'ausgehend') !== 'eingehend')
|
||||
.map(k => ({
|
||||
kredit: k,
|
||||
faelligkeit: berechneNaechsteFaelligkeit(k),
|
||||
rate: berechneAktuelleRate(k)
|
||||
}))
|
||||
.filter(k => k.faelligkeit && k.rate > 0)
|
||||
.sort((a, b) => a.faelligkeit - b.faelligkeit)[0];
|
||||
|
||||
// Debug-Log für Entwicklung
|
||||
console.log('Dashboard Kredite:', kredite.map(k => ({
|
||||
name: k.name,
|
||||
richtung: k.richtung,
|
||||
restschuld: berechneRestschuld(k)
|
||||
})));
|
||||
console.log('Berechnet:', { schulden, forderungen });
|
||||
|
||||
// Für Abwärtskompatibilität: totalSchulden = Schulden - Forderungen (Nettoschuld)
|
||||
const totalSchulden = schulden - forderungen;
|
||||
|
||||
// Für die Anzeige: Betrag (immer positiv) und ob Schulden oder Forderungen
|
||||
const isNettoSchulden = totalSchulden > 0;
|
||||
const anzeigeBetrag = Math.abs(totalSchulden);
|
||||
|
||||
const aktuellesJahr = new Date().getFullYear();
|
||||
const aktuellePlanung = planung.filter(p => p.jahr === aktuellesJahr);
|
||||
|
||||
// Berechne monatliche Summen
|
||||
const monatlicheSummen = Array(12).fill(0);
|
||||
aktuellePlanung.forEach(p => {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) monatlicheSummen[idx] += parseFloat(val) || 0;
|
||||
});
|
||||
});
|
||||
const durchschnittlichePlanung = monatlicheSummen.reduce((a, b) => a + b, 0) / 12;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Dashboard...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Anzahl Kredite nach Richtung (mit Fallback auf 'ausgehend')
|
||||
const anzahlSchulden = kredite.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length;
|
||||
const anzahlForderungen = kredite.filter(k => (k.richtung || 'ausgehend') === 'eingehend').length;
|
||||
|
||||
// Monatliche Raten nur für ausgehende Kredite (Schulden)
|
||||
const monatlicheRaten = kredite
|
||||
.filter(k => (k.richtung || 'ausgehend') !== 'eingehend')
|
||||
.reduce((sum, k) => sum + berechneAktuelleRate(k), 0);
|
||||
|
||||
const karten = [
|
||||
{
|
||||
icon: FileText,
|
||||
title: 'Dokumente',
|
||||
value: 'OCR + Upload',
|
||||
color: 'bg-blue-500',
|
||||
link: 'docs'
|
||||
},
|
||||
{
|
||||
icon: CreditCard,
|
||||
title: 'Kredit-Restschuld',
|
||||
value: kreditRestschuldTotal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||
subtitle: `${krediteAktiv.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length} aktive Kredite`,
|
||||
color: 'bg-red-500',
|
||||
link: 'kredite'
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: 'Offene Stunden',
|
||||
value: totalOffen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }),
|
||||
subtitle: `${offeneStunden.length} Einträge`,
|
||||
color: 'bg-orange-500',
|
||||
link: 'stunden'
|
||||
},
|
||||
{
|
||||
icon: Home,
|
||||
title: 'Planung',
|
||||
value: aktuellePlanung.length + ' Positionen',
|
||||
color: 'bg-green-500',
|
||||
link: 'planung'
|
||||
}
|
||||
];
|
||||
|
||||
// Geschäftsplanung-Daten für Dashboard
|
||||
const geschaeftsKarten = geschaeftsDaten?.summen ? [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: 'Einnahmen (Plan)',
|
||||
value: (geschaeftsDaten.summen.einnahmen || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }),
|
||||
color: 'bg-emerald-500',
|
||||
link: 'planung'
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
title: 'Betriebsergebnis',
|
||||
value: (geschaeftsDaten.summen.betriebsergebnis || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 }),
|
||||
color: (geschaeftsDaten.summen.betriebsergebnis || 0) >= 0 ? 'bg-blue-500' : 'bg-red-500',
|
||||
link: 'planung'
|
||||
}
|
||||
] : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={() => { setError(null); loadDashboardData(); }}
|
||||
className="ml-auto px-3 py-1 bg-red-600 text-white rounded hover:bg-red-700"
|
||||
>
|
||||
Neu laden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Karten */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{karten.map((karte, idx) => {
|
||||
const Icon = karte.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className={`${karte.color} w-12 h-12 rounded-lg flex items-center justify-center mb-4`}>
|
||||
<Icon className="text-white" size={24} />
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">{karte.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{karte.value}</p>
|
||||
{karte.subtitle && <p className="text-sm text-gray-500">{karte.subtitle}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Übersichten */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Kredite Übersicht - NEU */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<CreditCard size={20} />
|
||||
Kredit-Status
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Kredit-Restschuld */}
|
||||
<div className="flex justify-between items-center p-3 bg-red-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Kredit-Restschuld (aktuell)</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{krediteAktiv.filter(k => (k.richtung || 'ausgehend') !== 'eingehend').length} aktive Kredite
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-red-600">
|
||||
{kreditRestschuldTotal.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Monatliche Kredit-Belastung */}
|
||||
<div className="flex justify-between items-center p-3 bg-orange-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Monatliche Kredit-Belastung</span>
|
||||
<span className="text-xs text-gray-500">Summe aller Raten diesen Monat</span>
|
||||
</div>
|
||||
<span className="font-bold text-xl text-orange-600">
|
||||
{monatlicheKreditBelastung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Nächste Fälligkeit */}
|
||||
{naechsteFaelligkeit && naechsteFaelligkeit.kredit && (
|
||||
<div className="flex justify-between items-center p-3 bg-blue-50 rounded-lg">
|
||||
<div>
|
||||
<span className="text-sm text-gray-600 block">Nächste Fälligkeit</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{naechsteFaelligkeit.kredit?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="font-bold text-lg text-blue-600 block">
|
||||
{(naechsteFaelligkeit.rate || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
<span className="text-xs text-blue-500">
|
||||
{formatiereFaelligkeit(naechsteFaelligkeit.faelligkeit)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forderungen anzeigen falls vorhanden */}
|
||||
{forderungen > 0 && (
|
||||
<div className="flex justify-between items-center p-3 bg-green-50 rounded-lg">
|
||||
<span className="text-sm text-gray-600">Meine Forderungen</span>
|
||||
<span className="font-bold text-green-600">
|
||||
{forderungen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stunden Übersicht */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock size={20} />
|
||||
Stunden Übersicht
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Abgerechnet</span>
|
||||
<span className="font-semibold text-green-600">{totalAbgerechnet.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Offen</span>
|
||||
<span className="font-semibold text-orange-600">{totalOffen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Gesamt</span>
|
||||
<span className="font-semibold">{(totalAbgerechnet + totalOffen).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geschäftsplanung Dashboard-Kacheln */}
|
||||
{geschaeftsKarten.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-6">
|
||||
{geschaeftsKarten.map((karte, idx) => {
|
||||
const Icon = karte.icon;
|
||||
return (
|
||||
<div
|
||||
key={`gp-${idx}`}
|
||||
className="bg-white rounded-lg shadow p-6 cursor-pointer hover:shadow-lg transition-shadow border-l-4 border-blue-500"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${karte.color} w-12 h-12 rounded-lg flex items-center justify-center`}>
|
||||
<Icon className="text-white" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600 text-sm">{karte.title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{karte.value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kostenplanung Vorschau */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<TrendingUp size={20} />
|
||||
Geschäftsplanung {aktuellesJahr}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{geschaeftsDaten?.summen ? (
|
||||
<>
|
||||
<div className="bg-emerald-50 rounded p-3">
|
||||
<p className="text-sm text-emerald-600">Einnahmen</p>
|
||||
<p className="text-xl font-bold text-emerald-700">
|
||||
{(geschaeftsDaten.summen.einnahmen || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-rose-50 rounded p-3">
|
||||
<p className="text-sm text-rose-600">Betriebskosten</p>
|
||||
<p className="text-xl font-bold text-rose-700">
|
||||
{(geschaeftsDaten.summen.betriebskosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-blue-50 rounded p-3">
|
||||
<p className="text-sm text-blue-600">Betriebsergebnis</p>
|
||||
<p className={`text-xl font-bold ${(geschaeftsDaten.summen.betriebsergebnis || 0) >= 0 ? 'text-blue-700' : 'text-red-700'}`}>
|
||||
{(geschaeftsDaten.summen.betriebsergebnis || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 rounded p-3">
|
||||
<p className="text-sm text-amber-600">Cashflow (nach privat)</p>
|
||||
<p className={`text-xl font-bold ${(geschaeftsDaten.summen.cashflow || 0) >= 0 ? 'text-amber-700' : 'text-red-700'}`}>
|
||||
{(geschaeftsDaten.summen.cashflow || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-gray-500 col-span-4">Keine Geschäftsplanung vorhanden</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Upload, Eye, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function Dokumente({ API_URL }) {
|
||||
const [docs, setDocs] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
year: new Date().getFullYear(),
|
||||
category: 'steuer',
|
||||
amount: '',
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const categories = [
|
||||
{ id: 'steuer', name: 'Steuer (Elster)', color: 'bg-blue-100 text-blue-800' },
|
||||
{ id: 'nebenkosten', name: 'Nebenkosten (Wohngeld)', color: 'bg-green-100 text-green-800' },
|
||||
{ id: 'kredit', name: 'Kredit/Finanzierung', color: 'bg-purple-100 text-purple-800' },
|
||||
{ id: 'hausmeister', name: 'Hausmeister/Instandhaltung', color: 'bg-orange-100 text-orange-800' },
|
||||
{ id: 'gewerbe', name: 'Gewerbe', color: 'bg-red-100 text-red-800' },
|
||||
{ id: 'sonstiges', name: 'Sonstiges', color: 'bg-gray-100 text-gray-800' }
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocs();
|
||||
}, []);
|
||||
|
||||
const fetchDocs = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/api/docs`);
|
||||
const data = await res.json();
|
||||
setDocs(data);
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
// Upload-Logik hier
|
||||
};
|
||||
|
||||
const deleteDoc = async (id) => {
|
||||
if (!confirm('Dokument wirklich löschen?')) return;
|
||||
try {
|
||||
await fetch(`${API_URL}/api/docs/${id}`, { method: 'DELETE' });
|
||||
fetchDocs();
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Löschen:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Formular */}
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Upload size={20} />
|
||||
Dokument hochladen
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Jahr</label>
|
||||
<select
|
||||
value={form.year}
|
||||
onChange={(e) => setForm({ ...form, year: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{[2024, 2025, 2026, 2027].map(y => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(e) => setForm({ ...form, category: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{categories.map(cat => (
|
||||
<option key={cat.id} value={cat.id}>{cat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Betrag (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.amount}
|
||||
onChange={(e) => setForm({ ...form, amount: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Datei</label>
|
||||
<input
|
||||
type="file"
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
rows="2"
|
||||
placeholder="Optionale Notizen..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Wird hochgeladen...' : '📤 Hochladen'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Dokumenten-Liste */}
|
||||
{docs.length > 0 ? (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4">Datei</th>
|
||||
<th className="text-left py-3 px-4">Kategorie</th>
|
||||
<th className="text-left py-3 px-4">Jahr</th>
|
||||
<th className="text-right py-3 px-4">Betrag</th>
|
||||
<th className="py-3 px-4"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{docs.map(doc => {
|
||||
const cat = categories.find(c => c.id === doc.category);
|
||||
return (
|
||||
<tr key={doc.id} className="border-b border-gray-100">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={16} />
|
||||
{doc.filename}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className={`text-xs px-2 py-1 rounded-full ${cat?.color || 'bg-gray-100'}`}>
|
||||
{cat?.name || doc.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">{doc.year}</td>
|
||||
<td className="text-right py-3 px-4">
|
||||
{doc.amount > 0 && `${parseFloat(doc.amount).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}`}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex gap-2">
|
||||
<button className="text-blue-400 hover:text-blue-600">
|
||||
<Eye size={18} />
|
||||
</button>
|
||||
<button onClick={() => deleteDoc(doc.id)} className="text-red-400 hover:text-red-600">
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Dokumente hochgeladen.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, AlertCircle, X, Clock } from 'lucide-react';
|
||||
import { kostenplanungAPI, stundenAPI } from '../api';
|
||||
|
||||
const MONATE = [
|
||||
{ key: 0, name: 'Jan' },
|
||||
{ key: 1, name: 'Feb' },
|
||||
{ key: 2, name: 'Mär' },
|
||||
{ key: 3, name: 'Apr' },
|
||||
{ key: 4, name: 'Mai' },
|
||||
{ key: 5, name: 'Jun' },
|
||||
{ key: 6, name: 'Jul' },
|
||||
{ key: 7, name: 'Aug' },
|
||||
{ key: 8, name: 'Sep' },
|
||||
{ key: 9, name: 'Okt' },
|
||||
{ key: 10, name: 'Nov' },
|
||||
{ key: 11, name: 'Dez' }
|
||||
];
|
||||
|
||||
const KATEGORIEN = ['Einnahmen', 'Betriebskosten'];
|
||||
const OBJEKTE = ['Firma']; // Nur noch Firma, keine Vermietungsobjekte
|
||||
|
||||
export default function Geschaeftsplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [stundenEinnahmen, setStundenEinnahmen] = useState(Array(12).fill(0));
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Firma');
|
||||
const [selectedJahr, setSelectedJahr] = useState(2026);
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
typ: 'gewerbe',
|
||||
istEinnahme: false,
|
||||
monate: Array(12).fill(''),
|
||||
objekt: 'Firma'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [selectedObjekt, selectedJahr]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Lade Geschäftsplanung
|
||||
const data = await kostenplanungAPI.getAll({ objekt: selectedObjekt, jahr: selectedJahr });
|
||||
|
||||
// Lade abgerechnete Stunden für automatische Einnahmen
|
||||
const stundenData = await stundenAPI.getAll({
|
||||
jahr: selectedJahr,
|
||||
status: 'abgerechnet'
|
||||
});
|
||||
|
||||
// Berechne Stundeneinnahmen pro Monat
|
||||
const monatlicheStunden = Array(12).fill(0);
|
||||
stundenData.forEach(s => {
|
||||
const stundenDatum = new Date(s.datum);
|
||||
if (stundenDatum.getFullYear() === selectedJahr) {
|
||||
const monat = stundenDatum.getMonth();
|
||||
const stunden = parseFloat(s.stunden) || 0;
|
||||
const stundensatz = parseFloat(s.stundensatz) || 80; // Default 80€
|
||||
monatlicheStunden[monat] += stunden * stundensatz;
|
||||
}
|
||||
});
|
||||
|
||||
setStundenEinnahmen(monatlicheStunden);
|
||||
setPlanung(data.map(p => ({
|
||||
...p,
|
||||
monate: p.monate || Array(12).fill(0)
|
||||
})));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const berechneSumme = (monate) => {
|
||||
return (monate || []).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
typ: newRow.typ,
|
||||
ist_einnahme: newRow.istEinnahme,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: selectedJahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadData();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
typ: 'gewerbe',
|
||||
istEinnahme: false,
|
||||
monate: Array(12).fill(''),
|
||||
objekt: selectedObjekt
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanungByKategorie = (kategorieName) => {
|
||||
return planung.filter(p => p.kategorie === kategorieName);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Geschäftsplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-600 rounded-lg p-6 text-white">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Geschäftsplanung</h2>
|
||||
<p className="text-blue-200 mt-1">Firma · Einnahmen · Betriebskosten</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedJahr}
|
||||
onChange={(e) => setSelectedJahr(parseInt(e.target.value))}
|
||||
className="bg-white/10 border border-white/20 rounded px-3 py-2 text-white"
|
||||
>
|
||||
<option value={2025} className="text-gray-900">2025</option>
|
||||
<option value={2026} className="text-gray-900">2026</option>
|
||||
<option value={2027} className="text-gray-900">2027</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Einrichtung"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Objekt</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.objekt}
|
||||
onChange={(e) => setNewRow({ ...newRow, objekt: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Firma, Wilster, Segeberg..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{MONATE.map((m) => (
|
||||
<div key={m.key} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{m.name}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[m.key]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[m.key] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-4 font-semibold">Name</th>
|
||||
<th className="text-left py-3 px-4 font-semibold">Objekt</th>
|
||||
{MONATE.map(m => (
|
||||
<th key={m.key} className="text-center py-3 px-1 font-semibold">{m.name}</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-4 font-semibold">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{KATEGORIEN.map(kategorie => {
|
||||
const positionen = getPlanungByKategorie(kategorie);
|
||||
|
||||
// Automatische Stunden-Einnahmen für Kategorie "Einnahmen"
|
||||
const zeigeStundenEinnahmen = kategorie === 'Einnahmen' && stundenEinnahmen.some(e => e > 0);
|
||||
|
||||
if (positionen.length === 0 && !zeigeStundenEinnahmen) return null;
|
||||
|
||||
return (
|
||||
<Fragment key={kategorie}>
|
||||
<tr className="bg-gray-50 border-t border-gray-200">
|
||||
<td colSpan={15} className="py-2 px-4 font-bold text-gray-700">
|
||||
{kategorie}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Automatische Stunden-Einnahmen */}
|
||||
{zeigeStundenEinnahmen && (
|
||||
<tr className="border-b border-gray-100 bg-emerald-50">
|
||||
<td className="py-2 px-4 font-medium flex items-center gap-2">
|
||||
<Clock size={16} className="text-emerald-600" />
|
||||
Abgerechnete Stunden (auto)
|
||||
</td>
|
||||
<td className="py-2 px-4 text-gray-600">-</td>
|
||||
{MONATE.map((m) => (
|
||||
<td key={m.key} className="text-center py-1 px-1 text-emerald-600 font-semibold">
|
||||
{stundenEinnahmen[m.key] > 0
|
||||
? stundenEinnahmen[m.key].toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })
|
||||
: '-'
|
||||
}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-4 font-bold text-emerald-600">
|
||||
{stundenEinnahmen.reduce((a, b) => a + b, 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<span className="text-xs text-gray-400">Auto</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{positionen.map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-4 font-medium">{p.name}</td>
|
||||
<td className="py-2 px-4 text-gray-600">{p.objekt || '-'}</td>
|
||||
{MONATE.map((m) => (
|
||||
<td key={m.key} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={(p.monate && p.monate[m.key]) || ''}
|
||||
onChange={(e) => updateMonat(p.id, m.key, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-4 font-semibold">
|
||||
{berechneSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{planung.length === 0 && (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
Keine Einträge vorhanden. Klicke auf "Neue Position hinzufügen".
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, Calculator, Building2, AlertCircle, X } from 'lucide-react';
|
||||
import { kostenplanungAPI } from '../api';
|
||||
|
||||
export default function Kostenplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Wilster');
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
|
||||
const objekte = ['Wilster', 'Segeberg', 'Allgemein'];
|
||||
|
||||
const kategorien = [
|
||||
{ id: 'Einnahmen', name: 'Einnahmen', color: 'bg-green-50', textColor: 'text-green-700' },
|
||||
{ id: 'Betriebskosten', name: 'Betriebskosten', color: 'bg-red-50', textColor: 'text-red-700' },
|
||||
{ id: 'Ergebnis', name: 'Ergebnis', color: 'bg-blue-50', textColor: 'text-blue-700' }
|
||||
];
|
||||
|
||||
const monateNamen = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
// Lade Planung beim Start
|
||||
useEffect(() => {
|
||||
loadPlanung();
|
||||
}, []);
|
||||
|
||||
const loadPlanung = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await kostenplanungAPI.getAll();
|
||||
// Transformiere Daten für Frontend
|
||||
const transformedData = data.map(p => ({
|
||||
id: p.id,
|
||||
objekt: p.objekt,
|
||||
name: p.name,
|
||||
kategorie: p.kategorie,
|
||||
monate: p.monate || Array(12).fill(0),
|
||||
jahr: p.jahr
|
||||
}));
|
||||
setPlanung(transformedData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtere Planung nach ausgewähltem Objekt
|
||||
const gefiltertePlanung = planung.filter(p => p.objekt === selectedObjekt);
|
||||
|
||||
// Gruppiere nach Kategorien
|
||||
const gruppiertePlanung = kategorien.reduce((gruppen, kat) => {
|
||||
gruppen[kat.id] = gefiltertePlanung.filter(p => p.kategorie === kat.id);
|
||||
return gruppen;
|
||||
}, {});
|
||||
|
||||
// Berechne Summen pro Zeile
|
||||
const berechneZeilenSumme = (monate) => {
|
||||
return (monate || Array(12).fill(0)).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
// Berechne Summen pro Monat
|
||||
const berechneMonatsSummen = () => {
|
||||
const summen = {};
|
||||
|
||||
kategorien.forEach(kat => {
|
||||
summen[kat.id] = Array(12).fill(0);
|
||||
});
|
||||
|
||||
gefiltertePlanung.forEach(p => {
|
||||
if (p.kategorie !== 'Ergebnis' && summen[p.kategorie]) {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) {
|
||||
summen[p.kategorie][idx] += parseFloat(val) || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summen['Ergebnis'] = summen['Einnahmen'].map((einnahme, idx) =>
|
||||
einnahme - summen['Betriebskosten'][idx]
|
||||
);
|
||||
|
||||
return summen;
|
||||
};
|
||||
|
||||
const monatsSummen = berechneMonatsSummen();
|
||||
|
||||
const gesamtSummen = kategorien.reduce((summen, kat) => {
|
||||
summen[kat.id] = (monatsSummen[kat.id] || []).reduce((sum, val) => sum + (val || 0), 0);
|
||||
return summen;
|
||||
}, {});
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: new Date().getFullYear()
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadPlanung();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRow = async (id, updates) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const data = {
|
||||
objekt: updates.objekt || p.objekt,
|
||||
name: updates.name !== undefined ? updates.name : p.name,
|
||||
kategorie: updates.kategorie || p.kategorie,
|
||||
monate: updates.monate || p.monate,
|
||||
jahr: updates.jahr || p.jahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.update(id, data);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToAllMonths = async (id, wert) => {
|
||||
try {
|
||||
const val = parseFloat(wert) || 0;
|
||||
await updateRow(id, { monate: Array(12).fill(val) });
|
||||
} catch (err) {
|
||||
setError('Fehler beim Kopieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Kostenplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header mit Objekt-Auswahl */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
Kostenplanung
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{selectedObjekt} • Jahr {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedObjekt}
|
||||
onChange={(e) => setSelectedObjekt(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 bg-white"
|
||||
>
|
||||
{objekte.map(obj => (
|
||||
<option key={obj} value={obj}>{obj}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gesamtübersicht */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600">Gesamteinnahmen</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{(gesamtSummen['Einnahmen'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">Gesamtkosten</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{(gesamtSummen['Betriebskosten'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-4 ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<p className={`text-sm ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-600' : 'text-orange-600'}`}>Jahresergebnis</p>
|
||||
<p className={`text-2xl font-bold ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-700' : 'text-orange-700'}`}>
|
||||
{(gesamtSummen['Ergebnis'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neue Position Button */}
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neues Position Formular */}
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Mieteinnahmen, Strom, Versicherung..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={newRow.kategorie}
|
||||
onChange={(e) => setNewRow({ ...newRow, kategorie: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{kategorien.filter(k => k.id !== 'Ergebnis').map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{monat}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[idx]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[idx] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const firstValue = newRow.monate[0];
|
||||
setNewRow({ ...newRow, monate: Array(12).fill(firstValue) });
|
||||
}}
|
||||
className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<Calculator size={16} /> Überall übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-2 font-semibold min-w-[200px]">Position</th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-3 px-1 font-semibold min-w-[70px]">
|
||||
{idx + 1}.
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-2 font-semibold min-w-[100px]">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 text-xs">
|
||||
<th className="py-2 px-2 text-gray-500"></th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-2 px-1 text-gray-500">
|
||||
{monat}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 px-2"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kategorien.map(kat => (
|
||||
<Fragment key={kat.id}>
|
||||
{/* Kategorie-Header */}
|
||||
<tr className={`${kat.color} border-b border-gray-200`}>
|
||||
<td colSpan="15" className="py-2 px-2 font-bold">
|
||||
<span className={kat.textColor}>{kat.name}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Positionen */}
|
||||
{kat.id === 'Ergebnis' ? (
|
||||
<tr className="bg-blue-100 font-bold border-b-2 border-blue-300">
|
||||
<td className="py-3 px-2">Ergebnis (Einnahmen - Kosten)</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-3 px-1 ${(summe || 0) >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-3 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{(gruppiertePlanung[kat.id] || []).map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={p.name}
|
||||
onChange={(e) => updateRow(p.id, { name: e.target.value })}
|
||||
className="w-full bg-transparent border-none px-0 py-1 focus:ring-0 font-medium"
|
||||
/>
|
||||
</td>
|
||||
{(p.monate || []).map((wert, idx) => (
|
||||
<td key={idx} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={wert || ''}
|
||||
onChange={(e) => updateMonat(p.id, idx, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{berechneZeilenSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Summe der Kategorie */}
|
||||
{(gruppiertePlanung[kat.id] || []).length > 0 && (
|
||||
<tr className={`${kat.color} border-b border-gray-300 font-semibold`}>
|
||||
<td className="py-2 px-2 text-gray-700">Summe {kat.name}</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-2 px-1 ${kat.textColor}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(gruppiertePlanung[kat.id] || []).length === 0 && (
|
||||
<tr className="border-b border-gray-100">
|
||||
<td colSpan="15" className="py-2 px-4 text-gray-400 text-sm italic">
|
||||
Keine Einträge in dieser Kategorie
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Hilfe / Hinweise */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
|
||||
<h4 className="font-semibold mb-2">Hinweise:</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Wählen Sie zuerst das Objekt aus (Wilster, Segeberg, etc.)</li>
|
||||
<li>Neue Positionen können über den Button "Neue Position hinzufügen" erstellt werden</li>
|
||||
<li>Die Ergebniszeile wird automatisch berechnet (Einnahmen - Betriebskosten)</li>
|
||||
<li>Alle Werte werden automatisch in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { Plus, Trash2, Save, Calculator, Building2, AlertCircle, X } from 'lucide-react';
|
||||
import { kostenplanungAPI } from '../api';
|
||||
|
||||
export default function Kostenplanung() {
|
||||
const [planung, setPlanung] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedObjekt, setSelectedObjekt] = useState('Wilster');
|
||||
const [showAddRow, setShowAddRow] = useState(false);
|
||||
const [newRow, setNewRow] = useState({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
|
||||
const objekte = ['Wilster', 'Segeberg', 'Allgemein'];
|
||||
|
||||
const kategorien = [
|
||||
{ id: 'Einnahmen', name: 'Einnahmen', color: 'bg-green-50', textColor: 'text-green-700' },
|
||||
{ id: 'Betriebskosten', name: 'Betriebskosten', color: 'bg-red-50', textColor: 'text-red-700' },
|
||||
{ id: 'Ergebnis', name: 'Ergebnis', color: 'bg-blue-50', textColor: 'text-blue-700' }
|
||||
];
|
||||
|
||||
const monateNamen = ['Jan', 'Feb', 'Mär', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez'];
|
||||
|
||||
// Lade Planung beim Start
|
||||
useEffect(() => {
|
||||
loadPlanung();
|
||||
}, []);
|
||||
|
||||
const loadPlanung = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await kostenplanungAPI.getAll();
|
||||
// Transformiere Daten für Frontend
|
||||
const transformedData = data.map(p => ({
|
||||
id: p.id,
|
||||
objekt: p.objekt,
|
||||
name: p.name,
|
||||
kategorie: p.kategorie,
|
||||
monate: p.monate || Array(12).fill(0),
|
||||
jahr: p.jahr
|
||||
}));
|
||||
setPlanung(transformedData);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filtere Planung nach ausgewähltem Objekt
|
||||
const gefiltertePlanung = planung.filter(p => p.objekt === selectedObjekt);
|
||||
|
||||
// Gruppiere nach Kategorien
|
||||
const gruppiertePlanung = kategorien.reduce((gruppen, kat) => {
|
||||
gruppen[kat.id] = gefiltertePlanung.filter(p => p.kategorie === kat.id);
|
||||
return gruppen;
|
||||
}, {});
|
||||
|
||||
// Berechne Summen pro Zeile
|
||||
const berechneZeilenSumme = (monate) => {
|
||||
return (monate || Array(12).fill(0)).reduce((sum, val) => sum + (parseFloat(val) || 0), 0);
|
||||
};
|
||||
|
||||
// Berechne Summen pro Monat
|
||||
const berechneMonatsSummen = () => {
|
||||
const summen = {};
|
||||
|
||||
kategorien.forEach(kat => {
|
||||
summen[kat.id] = Array(12).fill(0);
|
||||
});
|
||||
|
||||
gefiltertePlanung.forEach(p => {
|
||||
if (p.kategorie !== 'Ergebnis' && summen[p.kategorie]) {
|
||||
(p.monate || []).forEach((val, idx) => {
|
||||
if (idx < 12) {
|
||||
summen[p.kategorie][idx] += parseFloat(val) || 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
summen['Ergebnis'] = summen['Einnahmen'].map((einnahme, idx) =>
|
||||
einnahme - summen['Betriebskosten'][idx]
|
||||
);
|
||||
|
||||
return summen;
|
||||
};
|
||||
|
||||
const monatsSummen = berechneMonatsSummen();
|
||||
|
||||
const gesamtSummen = kategorien.reduce((summen, kat) => {
|
||||
summen[kat.id] = (monatsSummen[kat.id] || []).reduce((sum, val) => sum + (val || 0), 0);
|
||||
return summen;
|
||||
}, {});
|
||||
|
||||
const addRow = async () => {
|
||||
if (!newRow.name) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
objekt: selectedObjekt,
|
||||
name: newRow.name,
|
||||
kategorie: newRow.kategorie,
|
||||
monate: newRow.monate.map(v => parseFloat(v) || 0),
|
||||
jahr: new Date().getFullYear()
|
||||
};
|
||||
|
||||
await kostenplanungAPI.create(data);
|
||||
await loadPlanung();
|
||||
|
||||
setNewRow({
|
||||
name: '',
|
||||
kategorie: 'Betriebskosten',
|
||||
monate: Array(12).fill('')
|
||||
});
|
||||
setShowAddRow(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateRow = async (id, updates) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const data = {
|
||||
objekt: updates.objekt || p.objekt,
|
||||
name: updates.name !== undefined ? updates.name : p.name,
|
||||
kategorie: updates.kategorie || p.kategorie,
|
||||
monate: updates.monate || p.monate,
|
||||
jahr: updates.jahr || p.jahr
|
||||
};
|
||||
|
||||
await kostenplanungAPI.update(id, data);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateMonat = async (id, monatIndex, wert) => {
|
||||
try {
|
||||
const p = planung.find(item => item.id === id);
|
||||
const neueMonate = [...(p.monate || Array(12).fill(0))];
|
||||
neueMonate[monatIndex] = parseFloat(wert) || 0;
|
||||
|
||||
await kostenplanungAPI.update(id, { monate: neueMonate });
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRow = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await kostenplanungAPI.delete(id);
|
||||
await loadPlanung();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyToAllMonths = async (id, wert) => {
|
||||
try {
|
||||
const val = parseFloat(wert) || 0;
|
||||
await updateRow(id, { monate: Array(12).fill(val) });
|
||||
} catch (err) {
|
||||
setError('Fehler beim Kopieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Kostenplanung...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header mit Objekt-Auswahl */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-800 flex items-center gap-2">
|
||||
<Building2 size={20} />
|
||||
Kostenplanung
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600">
|
||||
{selectedObjekt} • Jahr {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<select
|
||||
value={selectedObjekt}
|
||||
onChange={(e) => setSelectedObjekt(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-2 bg-white"
|
||||
>
|
||||
{objekte.map(obj => (
|
||||
<option key={obj} value={obj}>{obj}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gesamtübersicht */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<p className="text-sm text-green-600">Gesamteinnahmen</p>
|
||||
<p className="text-2xl font-bold text-green-700">
|
||||
{(gesamtSummen['Einnahmen'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p className="text-sm text-red-600">Gesamtkosten</p>
|
||||
<p className="text-2xl font-bold text-red-700">
|
||||
{(gesamtSummen['Betriebskosten'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`border rounded-lg p-4 ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'bg-blue-50 border-blue-200' : 'bg-orange-50 border-orange-200'}`}>
|
||||
<p className={`text-sm ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-600' : 'text-orange-600'}`}>Jahresergebnis</p>
|
||||
<p className={`text-2xl font-bold ${(gesamtSummen['Ergebnis'] || 0) >= 0 ? 'text-blue-700' : 'text-orange-700'}`}>
|
||||
{(gesamtSummen['Ergebnis'] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neue Position Button */}
|
||||
<button
|
||||
onClick={() => setShowAddRow(!showAddRow)}
|
||||
className="w-full bg-blue-600 text-white py-3 rounded-lg hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddRow ? 'Abbrechen' : 'Neue Position hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neues Position Formular */}
|
||||
{showAddRow && (
|
||||
<div className="bg-white rounded-lg shadow p-6 border-2 border-blue-200">
|
||||
<h3 className="text-lg font-semibold mb-4">Neue Position</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Bezeichnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newRow.name}
|
||||
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Mieteinnahmen, Strom, Versicherung..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kategorie</label>
|
||||
<select
|
||||
value={newRow.kategorie}
|
||||
onChange={(e) => setNewRow({ ...newRow, kategorie: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
>
|
||||
{kategorien.filter(k => k.id !== 'Ergebnis').map(kat => (
|
||||
<option key={kat.id} value={kat.id}>{kat.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-6 md:grid-cols-12 gap-2 mb-4">
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<div key={idx} className="text-center">
|
||||
<label className="block text-xs text-gray-500 mb-1">{monat}</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={newRow.monate[idx]}
|
||||
onChange={(e) => {
|
||||
const neueMonate = [...newRow.monate];
|
||||
neueMonate[idx] = e.target.value;
|
||||
setNewRow({ ...newRow, monate: neueMonate });
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={addRow}
|
||||
className="flex-1 bg-blue-600 text-white py-2 rounded hover:bg-blue-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const firstValue = newRow.monate[0];
|
||||
setNewRow({ ...newRow, monate: Array(12).fill(firstValue) });
|
||||
}}
|
||||
className="bg-gray-200 text-gray-700 px-4 py-2 rounded hover:bg-gray-300 flex items-center gap-2"
|
||||
>
|
||||
<Calculator size={16} /> Überall übernehmen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabelle */}
|
||||
<div className="bg-white rounded-lg shadow overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-100 border-b-2 border-gray-300">
|
||||
<tr>
|
||||
<th className="text-left py-3 px-2 font-semibold min-w-[200px]">Position</th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-3 px-1 font-semibold min-w-[70px]">
|
||||
{idx + 1}.
|
||||
</th>
|
||||
))}
|
||||
<th className="text-right py-3 px-2 font-semibold min-w-[100px]">Summe</th>
|
||||
<th className="py-3 px-2 w-10"></th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50 text-xs">
|
||||
<th className="py-2 px-2 text-gray-500"></th>
|
||||
{monateNamen.map((monat, idx) => (
|
||||
<th key={idx} className="text-center py-2 px-1 text-gray-500">
|
||||
{monat}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2 px-2"></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{kategorien.map(kat => (
|
||||
<Fragment key={kat.id}>
|
||||
{/* Kategorie-Header */}
|
||||
<tr className={`${kat.color} border-b border-gray-200`}>
|
||||
<td colSpan="15" className="py-2 px-2 font-bold">
|
||||
<span className={kat.textColor}>{kat.name}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Positionen */}
|
||||
{kat.id === 'Ergebnis' ? (
|
||||
<tr className="bg-blue-100 font-bold border-b-2 border-blue-300">
|
||||
<td className="py-3 px-2">Ergebnis (Einnahmen - Kosten)</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-3 px-1 ${(summe || 0) >= 0 ? 'text-blue-700' : 'text-red-600'}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-3 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
) : (
|
||||
<>
|
||||
{(gruppiertePlanung[kat.id] || []).map(p => (
|
||||
<tr key={p.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-2 px-2">
|
||||
<input
|
||||
type="text"
|
||||
value={p.name}
|
||||
onChange={(e) => updateRow(p.id, { name: e.target.value })}
|
||||
className="w-full bg-transparent border-none px-0 py-1 focus:ring-0 font-medium"
|
||||
/>
|
||||
</td>
|
||||
{(p.monate || []).map((wert, idx) => (
|
||||
<td key={idx} className="text-center py-1 px-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={wert || ''}
|
||||
onChange={(e) => updateMonat(p.id, idx, e.target.value)}
|
||||
className="w-full border border-gray-200 rounded px-1 py-1 text-center text-xs"
|
||||
placeholder="-"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{berechneZeilenSumme(p.monate).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
<td className="py-2 px-2">
|
||||
<button
|
||||
onClick={() => deleteRow(p.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Summe der Kategorie */}
|
||||
{(gruppiertePlanung[kat.id] || []).length > 0 && (
|
||||
<tr className={`${kat.color} border-b border-gray-300 font-semibold`}>
|
||||
<td className="py-2 px-2 text-gray-700">Summe {kat.name}</td>
|
||||
{(monatsSummen[kat.id] || []).map((summe, idx) => (
|
||||
<td key={idx} className={`text-center py-2 px-1 ${kat.textColor}`}>
|
||||
{(summe || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 })}
|
||||
</td>
|
||||
))}
|
||||
<td className="text-right py-2 px-2">
|
||||
{(gesamtSummen[kat.id] || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{(gruppiertePlanung[kat.id] || []).length === 0 && (
|
||||
<tr className="border-b border-gray-100">
|
||||
<td colSpan="15" className="py-2 px-4 text-gray-400 text-sm italic">
|
||||
Keine Einträge in dieser Kategorie
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Hilfe / Hinweise */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600">
|
||||
<h4 className="font-semibold mb-2">Hinweise:</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>Wählen Sie zuerst das Objekt aus (Wilster, Segeberg, etc.)</li>
|
||||
<li>Neue Positionen können über den Button "Neue Position hinzufügen" erstellt werden</li>
|
||||
<li>Die Ergebniszeile wird automatisch berechnet (Einnahmen - Betriebskosten)</li>
|
||||
<li>Alle Werte werden automatisch in der Datenbank gespeichert</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,500 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { CreditCard, Trash2, Edit2, Plus, X, Save, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
export default function Kredite({ kredite, setKredite }) {
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [expandedKredit, setExpandedKredit] = useState(null);
|
||||
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
betrag: '',
|
||||
zinssatz: '',
|
||||
startDatum: '',
|
||||
notizen: ''
|
||||
});
|
||||
|
||||
// Monatliche Berechnung für einen Kredit
|
||||
const berechneMonatlicheWerte = (kredit, bisDatum = new Date()) => {
|
||||
if (!kredit.startDatum) return [];
|
||||
|
||||
const start = new Date(kredit.startDatum + '-01');
|
||||
const ende = new Date(bisDatum);
|
||||
const monate = [];
|
||||
|
||||
let restschuld = kredit.betrag;
|
||||
let aktuellesDatum = new Date(start);
|
||||
|
||||
// Alle Zahlungen chronologisch sortieren
|
||||
const zahlungen = [...(kredit.zahlungen || [])].sort((a, b) => new Date(a.datum) - new Date(b.datum));
|
||||
let zahlungsIndex = 0;
|
||||
|
||||
while (aktuellesDatum <= ende) {
|
||||
const jahr = aktuellesDatum.getFullYear();
|
||||
const monat = aktuellesDatum.getMonth();
|
||||
|
||||
// Zinsen für diesen Monat
|
||||
const monatlicheZinsen = restschuld * (kredit.zinssatz / 100) / 12;
|
||||
|
||||
// Zahlungen für diesen Monat finden
|
||||
const monatsZahlungen = [];
|
||||
while (zahlungenIndex < zahlungen.length) {
|
||||
const z = zahlungen[zahlungsIndex];
|
||||
const zDatum = new Date(z.datum);
|
||||
if (zDatum.getFullYear() === jahr && zDatum.getMonth() === monat) {
|
||||
monatsZahlungen.push(z);
|
||||
zahlungsIndex++;
|
||||
} else if (zDatum > aktuellesDatum) {
|
||||
break;
|
||||
} else {
|
||||
zahlungsIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
const zahlungsSumme = monatsZahlungen.reduce((sum, z) => sum + z.betrag, 0);
|
||||
|
||||
// Abzug: Zinsen zuerst, dann Tilgung
|
||||
let zahlungFuerZinsen = Math.min(zahlungsSumme, monatlicheZinsen);
|
||||
let zahlungFuerTilgung = zahlungsSumme - zahlungFuerZinsen;
|
||||
|
||||
const alteRestschuld = restschuld;
|
||||
restschuld = restschuld + monatlicheZinsen - zahlungsSumme;
|
||||
if (restschuld < 0) restschuld = 0;
|
||||
|
||||
monate.push({
|
||||
datum: new Date(aktuellesDatum),
|
||||
jahr,
|
||||
monat: monat + 1,
|
||||
restschuldVorher: alteRestschuld,
|
||||
zinsen: monatlicheZinsen,
|
||||
zahlungen: monatsZahlungen,
|
||||
zahlungFuerZinsen,
|
||||
zahlungFuerTilgung,
|
||||
restschuldNachher: restschuld,
|
||||
tilgung: alteRestschuld + monatlicheZinsen - restschuld - monatlicheZinsen + zahlungFuerTilgung
|
||||
});
|
||||
|
||||
// Nächster Monat
|
||||
aktuellesDatum.setMonth(aktuellesDatum.getMonth() + 1);
|
||||
}
|
||||
|
||||
return monate;
|
||||
};
|
||||
|
||||
const addKredit = () => {
|
||||
if (!form.name || !form.betrag || !form.startDatum) return;
|
||||
|
||||
const k = {
|
||||
id: Date.now(),
|
||||
name: form.name,
|
||||
betrag: parseFloat(form.betrag),
|
||||
zinssatz: parseFloat(form.zinssatz || 0),
|
||||
startDatum: form.startDatum,
|
||||
notizen: form.notizen,
|
||||
zahlungen: []
|
||||
};
|
||||
|
||||
setKredite([...kredite, k]);
|
||||
setForm({ name: '', betrag: '', zinssatz: '', startDatum: '', notizen: '' });
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const updateKredit = (id, updates) => {
|
||||
setKredite(kredite.map(k => k.id === id ? { ...k, ...updates } : k));
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const deleteKredit = (id) => {
|
||||
if (confirm('Kredit wirklich löschen?')) {
|
||||
setKredite(kredite.filter(k => k.id !== id));
|
||||
}
|
||||
};
|
||||
|
||||
const addZahlung = (kreditId, betrag, datum, notiz = '') => {
|
||||
setKredite(kredite.map(k => {
|
||||
if (k.id === kreditId) {
|
||||
return {
|
||||
...k,
|
||||
zahlungen: [...(k.zahlungen || []), {
|
||||
id: Date.now(),
|
||||
betrag: parseFloat(betrag),
|
||||
datum,
|
||||
notiz
|
||||
}]
|
||||
};
|
||||
}
|
||||
return k;
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteZahlung = (kreditId, zahlungId) => {
|
||||
setKredite(kredite.map(k => {
|
||||
if (k.id === kreditId) {
|
||||
return {
|
||||
...k,
|
||||
zahlungen: k.zahlungen.filter(z => z.id !== zahlungId)
|
||||
};
|
||||
}
|
||||
return k;
|
||||
}));
|
||||
};
|
||||
|
||||
const totalRestschuld = kredite.reduce((sum, k) => {
|
||||
const monate = berechneMonatlicheWerte(k);
|
||||
const letzterMonat = monate[monate.length - 1];
|
||||
return sum + (letzterMonat ? letzterMonat.restschuldNachher : k.betrag);
|
||||
}, 0);
|
||||
|
||||
const totalKreditsumme = kredite.reduce((sum, k) => sum + k.betrag, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Übersicht */}
|
||||
<div className="bg-purple-50 border border-purple-200 rounded-lg p-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-center">
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Kredite</p>
|
||||
<p className="text-xl font-bold text-purple-800">{kredite.length}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Gesamtschuld</p>
|
||||
<p className="text-xl font-bold text-purple-800">{totalKreditsumme.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Restschuld</p>
|
||||
<p className="text-xl font-bold text-red-600">{totalRestschuld.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-purple-600">Getilgt</p>
|
||||
<p className="text-xl font-bold text-green-600">{(totalKreditsumme - totalRestschuld).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Neuer Kredit Button */}
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="w-full bg-purple-600 text-white py-3 rounded-lg hover:bg-purple-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddForm ? 'Abbrechen' : 'Neuen Kredit hinzufügen'}
|
||||
</button>
|
||||
|
||||
{/* Neuer Kredit Formular */}
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Neuer Kredit</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Autokredit Niki"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kreditsumme (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.betrag}
|
||||
onChange={(e) => setForm({ ...form, betrag: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="7000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Zinssatz (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.zinssatz}
|
||||
onChange={(e) => setForm({ ...form, zinssatz: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="10"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Start (Monat)</label>
|
||||
<input
|
||||
type="month"
|
||||
value={form.startDatum}
|
||||
onChange={(e) => setForm({ ...form, startDatum: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Notizen</label>
|
||||
<textarea
|
||||
value={form.notizen}
|
||||
onChange={(e) => setForm({ ...form, notizen: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Optional: Details zum Kredit..."
|
||||
rows="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKredit}
|
||||
className="mt-4 w-full bg-purple-600 text-white py-2 rounded hover:bg-purple-700"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kredit-Liste */}
|
||||
{kredite.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{kredite.map(k => {
|
||||
const monate = berechneMonatlicheWerte(k);
|
||||
const aktuellerStand = monate[monate.length - 1];
|
||||
const isExpanded = expandedKredit === k.id;
|
||||
const isEditing = editingId === k.id;
|
||||
|
||||
// Jahresfilter für Verlauf
|
||||
const jahresMonate = monate.filter(m => m.jahr === selectedYear);
|
||||
|
||||
return (
|
||||
<div key={k.id} className="bg-white rounded-lg shadow p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={k.name}
|
||||
onBlur={(e) => updateKredit(k.id, { name: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Name"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={k.betrag}
|
||||
onBlur={(e) => updateKredit(k.id, { betrag: parseFloat(e.target.value) })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Betrag"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
defaultValue={k.zinssatz}
|
||||
onBlur={(e) => updateKredit(k.id, { zinssatz: parseFloat(e.target.value) })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Zinssatz"
|
||||
/>
|
||||
<input
|
||||
type="month"
|
||||
defaultValue={k.startDatum}
|
||||
onBlur={(e) => updateKredit(k.id, { startDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="flex items-center gap-2 bg-green-600 text-white px-4 py-2 rounded"
|
||||
>
|
||||
<Save size={16} /> Fertig
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{k.name}</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
Start: {new Date(k.startDatum + '-01').toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })} |
|
||||
Zinssatz: {k.zinssatz}%
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingId(k.id)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteKredit(k.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-sm mb-3">
|
||||
<div>
|
||||
<p className="text-gray-500">Kreditsumme</p>
|
||||
<p className="font-semibold">{k.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Aktuelle Restschuld</p>
|
||||
<p className="font-semibold text-purple-700">
|
||||
{aktuellerStand ? aktuellerStand.restschuldNachher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }) : k.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Monatl. Zinsen (aktuell)</p>
|
||||
<p className="font-semibold text-red-600">
|
||||
{aktuellerStand ? aktuellerStand.zinsen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 }) : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zahlung hinzufügen */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<h4 className="text-sm font-semibold mb-2">➕ Zahlung hinzufügen</h4>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<input
|
||||
type="date"
|
||||
id={`zahlung-datum-${k.id}`}
|
||||
defaultValue={new Date().toISOString().split('T')[0]}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
id={`zahlung-betrag-${k.id}`}
|
||||
placeholder="Betrag"
|
||||
className="border rounded px-2 py-1 text-sm w-24"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
id={`zahlung-notiz-${k.id}`}
|
||||
placeholder="Notiz (optional)"
|
||||
className="border rounded px-2 py-1 text-sm flex-1 min-w-[120px]"
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
const datum = document.getElementById(`zahlung-datum-${k.id}`).value;
|
||||
const betrag = document.getElementById(`zahlung-betrag-${k.id}`).value;
|
||||
const notiz = document.getElementById(`zahlung-notiz-${k.id}`).value;
|
||||
if (betrag && datum) {
|
||||
addZahlung(k.id, betrag, datum, notiz);
|
||||
document.getElementById(`zahlung-betrag-${k.id}`).value = '';
|
||||
document.getElementById(`zahlung-notiz-${k.id}`).value = '';
|
||||
}
|
||||
}}
|
||||
className="bg-blue-500 text-white px-3 py-1 rounded text-sm"
|
||||
>
|
||||
Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zahlungsliste */}
|
||||
{(k.zahlungen || []).length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{k.zahlungen.sort((a, b) => new Date(b.datum) - new Date(a.datum)).map(z => (
|
||||
<div key={z.id} className="flex justify-between items-center text-sm px-3 py-2 bg-blue-50 rounded">
|
||||
<span>{z.datum} {z.notiz && `- ${z.notiz}`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-green-700">
|
||||
-{z.betrag.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => deleteZahlung(k.id, z.id)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Monatlicher Verlauf (aufklappbar) */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setExpandedKredit(isExpanded ? null : k.id)}
|
||||
className="flex items-center gap-2 text-purple-600 hover:text-purple-800 font-medium"
|
||||
>
|
||||
{isExpanded ? <ChevronUp size={18} /> : <ChevronDown size={18} />}
|
||||
{isExpanded ? 'Verlauf ausblenden' : 'Monatlicher Verlauf anzeigen'}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
{/* Jahresauswahl */}
|
||||
<div className="mb-4">
|
||||
<label className="text-sm text-gray-600 mr-2">Jahr:</label>
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => setSelectedYear(parseInt(e.target.value))}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
{[...new Set(monate.map(m => m.jahr))].sort().map(j => (
|
||||
<option key={j} value={j}>{j}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Verlaufstabelle */}
|
||||
{jahresMonate.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-2">Monat</th>
|
||||
<th className="text-right py-2 px-2">Restschuld (vorher)</th>
|
||||
<th className="text-right py-2 px-2">Zinsen</th>
|
||||
<th className="text-right py-2 px-2">Zahlungen</th>
|
||||
<th className="text-right py-2 px-2">Tilgung</th>
|
||||
<th className="text-right py-2 px-2">Restschuld (nachher)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{jahresMonate.map((m, idx) => (
|
||||
<tr key={idx} className="border-b border-gray-100">
|
||||
<td className="py-2 px-2">
|
||||
{m.jahr}-{String(m.monat).padStart(2, '0')}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2">
|
||||
{m.restschuldVorher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-red-600">
|
||||
+{m.zinsen.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-green-600">
|
||||
{m.zahlungen.length > 0 ? (
|
||||
<span title={m.zahlungen.map(z => `${z.datum}: ${z.betrag}€`).join('\n')}>
|
||||
-{m.zahlungen.reduce((s, z) => s + z.betrag, 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</span>
|
||||
) : '-'}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 text-blue-600">
|
||||
{m.zahlungFuerTilgung > 0 ? `-${m.zahlungFuerTilgung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}` : '-'}
|
||||
</td>
|
||||
<td className="text-right py-2 px-2 font-semibold">
|
||||
{m.restschuldNachher.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-center py-4">Keine Daten für {selectedYear}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Kredite erfasst. Füge deinen ersten Kredit hinzu.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Home, Plus, Trash2, Edit2, Save, X, Calculator, FileDown, AlertCircle } from 'lucide-react';
|
||||
import { nebenkostenAPI } from '../api';
|
||||
|
||||
export default function Nebenkosten() {
|
||||
const [nebenkosten, setNebenkosten] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
|
||||
const [form, setForm] = useState({
|
||||
jahr: new Date().getFullYear(),
|
||||
wohnung: '',
|
||||
mieter: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten: '',
|
||||
heizkosten: '',
|
||||
wasser: '',
|
||||
muell: '',
|
||||
versicherung: '',
|
||||
sonstiges: ''
|
||||
});
|
||||
|
||||
// Lade Nebenkosten beim Start
|
||||
useEffect(() => {
|
||||
loadNebenkosten();
|
||||
}, []);
|
||||
|
||||
const loadNebenkosten = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await nebenkostenAPI.getAll();
|
||||
setNebenkosten(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden: ' + err.message);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addNebenkosten = async () => {
|
||||
if (!form.jahr || !form.wohnung) return;
|
||||
|
||||
try {
|
||||
const data = {
|
||||
jahr: parseInt(form.jahr),
|
||||
wohnung: form.wohnung,
|
||||
mieter: form.mieter,
|
||||
kaltmiete: parseFloat(form.kaltmiete) || 0,
|
||||
nebenkosten: parseFloat(form.nebenkosten) || 0,
|
||||
heizkosten: parseFloat(form.heizkosten) || 0,
|
||||
wasser: parseFloat(form.wasser) || 0,
|
||||
muell: parseFloat(form.muell) || 0,
|
||||
versicherung: parseFloat(form.versicherung) || 0,
|
||||
sonstiges: parseFloat(form.sonstiges) || 0
|
||||
};
|
||||
|
||||
await nebenkostenAPI.create(data);
|
||||
await loadNebenkosten();
|
||||
|
||||
setForm({
|
||||
jahr: new Date().getFullYear(),
|
||||
wohnung: '',
|
||||
mieter: '',
|
||||
kaltmiete: '',
|
||||
nebenkosten: '',
|
||||
heizkosten: '',
|
||||
wasser: '',
|
||||
muell: '',
|
||||
versicherung: '',
|
||||
sonstiges: ''
|
||||
});
|
||||
setShowAddForm(false);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Speichern: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateNebenkosten = async (id, updates) => {
|
||||
try {
|
||||
const nk = nebenkosten.find(n => n.id === id);
|
||||
const data = {
|
||||
jahr: updates.jahr !== undefined ? parseInt(updates.jahr) : nk.jahr,
|
||||
wohnung: updates.wohnung || nk.wohnung,
|
||||
mieter: updates.mieter !== undefined ? updates.mieter : nk.mieter,
|
||||
kaltmiete: updates.kaltmiete !== undefined ? parseFloat(updates.kaltmiete) : nk.kaltmiete,
|
||||
nebenkosten: updates.nebenkosten !== undefined ? parseFloat(updates.nebenkosten) : nk.nebenkosten,
|
||||
heizkosten: updates.heizkosten !== undefined ? parseFloat(updates.heizkosten) : nk.heizkosten,
|
||||
wasser: updates.wasser !== undefined ? parseFloat(updates.wasser) : nk.wasser,
|
||||
muell: updates.muell !== undefined ? parseFloat(updates.muell) : nk.muell,
|
||||
versicherung: updates.versicherung !== undefined ? parseFloat(updates.versicherung) : nk.versicherung,
|
||||
sonstiges: updates.sonstiges !== undefined ? parseFloat(updates.sonstiges) : nk.sonstiges
|
||||
};
|
||||
|
||||
await nebenkostenAPI.update(id, data);
|
||||
await loadNebenkosten();
|
||||
setEditingId(null);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Aktualisieren: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteNebenkosten = async (id) => {
|
||||
if (confirm('Position wirklich löschen?')) {
|
||||
try {
|
||||
await nebenkostenAPI.delete(id);
|
||||
await loadNebenkosten();
|
||||
} catch (err) {
|
||||
setError('Fehler beim Löschen: ' + err.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const jahre = [...new Set(nebenkosten.map(nk => nk.jahr))].sort((a, b) => b - a);
|
||||
|
||||
const gesamtNebenkosten = (nk) => {
|
||||
return (parseFloat(nk.nebenkosten) || 0) +
|
||||
(parseFloat(nk.heizkosten) || 0) +
|
||||
(parseFloat(nk.wasser) || 0) +
|
||||
(parseFloat(nk.muell) || 0) +
|
||||
(parseFloat(nk.versicherung) || 0) +
|
||||
(parseFloat(nk.sonstiges) || 0);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-gray-500">Lade Nebenkosten...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 flex items-center gap-2 text-red-700">
|
||||
<AlertCircle size={20} />
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)} className="ml-auto">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Übersicht pro Jahr */}
|
||||
{jahre.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{jahre.map(jahr => {
|
||||
const jahresDaten = nebenkosten.filter(nk => nk.jahr === jahr);
|
||||
const gesamtKosten = jahresDaten.reduce((sum, nk) => sum + gesamtNebenkosten(nk), 0);
|
||||
const gesamtKaltmiete = jahresDaten.reduce((sum, nk) => sum + (parseFloat(nk.kaltmiete) || 0), 0);
|
||||
|
||||
return (
|
||||
<div key={jahr} className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-sm font-medium">{jahr} – {jahresDaten.length} Einträge</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-600">Kaltmieten:</p>
|
||||
<p className="text-lg font-semibold">{gesamtKaltmiete.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-600">Nebenkosten:</p>
|
||||
<p className="text-lg font-semibold text-red-600">{gesamtKosten.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Neuer Eintrag Button */}
|
||||
<button
|
||||
onClick={() => setShowAddForm(!showAddForm)}
|
||||
className="w-full bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Plus size={20} />
|
||||
{showAddForm ? 'Abbrechen' : 'Neue Nebenkosten-Position'}
|
||||
</button>
|
||||
|
||||
{/* Neuer Eintrag Formular */}
|
||||
{showAddForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6 space-y-6">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Home size={20} />
|
||||
Neue Nebenkosten-Position
|
||||
</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Jahr</label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.jahr}
|
||||
onChange={(e) => setForm({ ...form, jahr: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wohnung</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.wohnung}
|
||||
onChange={(e) => setForm({ ...form, wohnung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="z.B. Wilster Wohnung 1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Mieter</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.mieter}
|
||||
onChange={(e) => setForm({ ...form, mieter: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="Name des Mieters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Kaltmiete (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.kaltmiete}
|
||||
onChange={(e) => setForm({ ...form, kaltmiete: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Nebenkosten (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.nebenkosten}
|
||||
onChange={(e) => setForm({ ...form, nebenkosten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Heizkosten (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.heizkosten}
|
||||
onChange={(e) => setForm({ ...form, heizkosten: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Wasser (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.wasser}
|
||||
onChange={(e) => setForm({ ...form, wasser: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Müll (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.muell}
|
||||
onChange={(e) => setForm({ ...form, muell: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Versicherung (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.versicherung}
|
||||
onChange={(e) => setForm({ ...form, versicherung: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Sonstiges (€)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.sonstiges}
|
||||
onChange={(e) => setForm({ ...form, sonstiges: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={addNebenkosten}
|
||||
className="w-full bg-green-600 text-white py-3 rounded-lg hover:bg-green-700 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Save size={20} />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
{nebenkosten.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{nebenkosten
|
||||
.sort((a, b) => b.jahr - a.jahr)
|
||||
.map(nk => {
|
||||
const isEditing = editingId === nk.id;
|
||||
const gesamt = gesamtNebenkosten(nk);
|
||||
|
||||
return (
|
||||
<div key={nk.id} className="bg-white rounded-lg shadow p-5">
|
||||
{isEditing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={nk.wohnung}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { wohnung: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Wohnung"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
defaultValue={nk.mieter}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { mieter: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Mieter"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={nk.kaltmiete}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { kaltmiete: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Kaltmiete"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
defaultValue={nk.nebenkosten}
|
||||
onBlur={(e) => updateNebenkosten(nk.id, { nebenkosten: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
placeholder="Nebenkosten"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingId(null)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded flex items-center gap-2"
|
||||
>
|
||||
<Save size={16} /> Fertig
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">{nk.wohnung}</h3>
|
||||
<p className="text-sm text-gray-600">{nk.mieter}</p>
|
||||
<p className="text-sm text-gray-500">Jahr: {nk.jahr}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setEditingId(nk.id)}
|
||||
className="text-blue-400 hover:text-blue-600"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteNebenkosten(nk.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Kaltmiete</p>
|
||||
<p className="font-semibold">{(nk.kaltmiete || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Nebenkosten</p>
|
||||
<p className="font-semibold">{(nk.nebenkosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Heizkosten</p>
|
||||
<p className="font-semibold">{(nk.heizkosten || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Gesamt</p>
|
||||
<p className="font-bold text-red-600">{gesamt.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
|
||||
Noch keine Nebenkosten erfasst.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { useState } from 'react';
|
||||
import { Home, Plus, Trash2, Edit2, Save, X, Calculator, Building, Users, FileDown } from 'lucide-react';
|
||||
|
||||
// NEUE STRUKTUR:
|
||||
// Objekte -> Jahre -> Positionen + Mieter
|
||||
|
||||
export default function NebenkostenV2({
|
||||
objekte, setObjekte,
|
||||
nebenkostenJahre, setNebenkostenJahre,
|
||||
mieter, setMieter
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState('objekte'); // 'objekte', 'kosten', 'mieter', 'abrechnung'
|
||||
const [selectedObjekt, setSelectedObjekt] = useState(null);
|
||||
const [selectedJahr, setSelectedJahr] = useState(new Date().getFullYear());
|
||||
|
||||
// Form States
|
||||
const [showObjektForm, setShowObjektForm] = useState(false);
|
||||
const [showKostenForm, setShowKostenForm] = useState(false);
|
||||
const [showMieterForm, setShowMieterForm] = useState(false);
|
||||
|
||||
const [objektForm, setObjektForm] = useState({
|
||||
name: '',
|
||||
strasse: '',
|
||||
hausnummer: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
einheiten: 1,
|
||||
wohnflaeche: 0
|
||||
});
|
||||
|
||||
const [kostenForm, setKostenForm] = useState({
|
||||
position: '',
|
||||
betrag: '',
|
||||
splitTyp: '30',
|
||||
gesamtbetrag: '',
|
||||
anteileGesamt: '',
|
||||
meineAnteile: ''
|
||||
});
|
||||
|
||||
const [mieterForm, setMieterForm] = useState({
|
||||
vorname: '',
|
||||
nachname: '',
|
||||
strasse: '',
|
||||
hausnummer: '',
|
||||
plz: '',
|
||||
ort: '',
|
||||
einzugDatum: '',
|
||||
auszugDatum: '',
|
||||
vorauszahlungMonatlich: ''
|
||||
});
|
||||
|
||||
// Hilfsfunktionen
|
||||
const addObjekt = () => {
|
||||
if (!objektForm.name) return;
|
||||
const newObjekt = {
|
||||
id: Date.now(),
|
||||
...objektForm
|
||||
};
|
||||
setObjekte([...objekte, newObjekt]);
|
||||
setObjektForm({
|
||||
name: '', strasse: '', hausnummer: '', plz: '', ort: '',
|
||||
einheiten: 1, wohnflaeche: 0
|
||||
});
|
||||
setShowObjektForm(false);
|
||||
};
|
||||
|
||||
const addKostenPosition = () => {
|
||||
if (!kostenForm.position || !kostenForm.betrag || !selectedObjekt) return;
|
||||
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const existing = nebenkostenJahre[jahrKey] || { positionen: [], mieter: [] };
|
||||
|
||||
const neuePosition = {
|
||||
id: Date.now(),
|
||||
...kostenForm,
|
||||
betrag: parseFloat(kostenForm.betrag),
|
||||
jahr: selectedJahr,
|
||||
objektId: selectedObjekt.id
|
||||
};
|
||||
|
||||
setNebenkostenJahre({
|
||||
...nebenkostenJahre,
|
||||
[jahrKey]: {
|
||||
...existing,
|
||||
positionen: [...existing.positionen, neuePosition]
|
||||
}
|
||||
});
|
||||
|
||||
setKostenForm({
|
||||
position: '', betrag: '', splitTyp: '30',
|
||||
gesamtbetrag: '', anteileGesamt: '', meineAnteile: ''
|
||||
});
|
||||
setShowKostenForm(false);
|
||||
};
|
||||
|
||||
const addMieter = () => {
|
||||
if (!mieterForm.vorname || !mieterForm.nachname || !selectedObjekt) return;
|
||||
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const existing = nebenkostenJahre[jahrKey] || { positionen: [], mieter: [] };
|
||||
|
||||
const neuerMieter = {
|
||||
id: Date.now(),
|
||||
...mieterForm,
|
||||
vorauszahlungMonatlich: parseFloat(mieterForm.vorauszahlungMonatlich || 0),
|
||||
jahr: selectedJahr,
|
||||
objektId: selectedObjekt.id
|
||||
};
|
||||
|
||||
setNebenkostenJahre({
|
||||
...nebenkostenJahre,
|
||||
[jahrKey]: {
|
||||
...existing,
|
||||
mieter: [...existing.mieter, neuerMieter]
|
||||
}
|
||||
});
|
||||
|
||||
setMieterForm({
|
||||
vorname: '', nachname: '', strasse: '', hausnummer: '',
|
||||
plz: '', ort: '', einzugDatum: '', auszugDatum: '', vorauszahlungMonatlich: ''
|
||||
});
|
||||
setShowMieterForm(false);
|
||||
};
|
||||
|
||||
// === OBJEKT BEARBEITEN / LÖSCHEN ===
|
||||
const [editingObjekt, setEditingObjekt] = useState(null);
|
||||
|
||||
const startEditObjekt = (objekt) => {
|
||||
setEditingObjekt(objekt);
|
||||
setObjektForm({
|
||||
name: objekt.name || '',
|
||||
strasse: objekt.strasse || '',
|
||||
hausnummer: objekt.hausnummer || '',
|
||||
plz: objekt.plz || '',
|
||||
ort: objekt.ort || '',
|
||||
einheiten: objekt.einheiten || 1,
|
||||
wohnflaeche: objekt.wohnflaeche || 0
|
||||
});
|
||||
setShowObjektForm(true);
|
||||
};
|
||||
|
||||
const updateObjekt = () => {
|
||||
if (!editingObjekt || !objektForm.name) return;
|
||||
setObjekte(objekte.map(o => o.id === editingObjekt.id ? { ...objektForm, id: o.id } : o));
|
||||
setEditingObjekt(null);
|
||||
setObjektForm({ name: '', strasse: '', hausnummer: '', plz: '', ort: '', einheiten: 1, wohnflaeche: 0 });
|
||||
setShowObjektForm(false);
|
||||
};
|
||||
|
||||
const deleteObjekt = (id) => {
|
||||
if (confirm('Objekt wirklich löschen? Alle zugehörigen Daten werden entfernt.')) {
|
||||
setObjekte(objekte.filter(o => o.id !== id));
|
||||
if (selectedObjekt?.id === id) {
|
||||
setSelectedObjekt(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const berechneTage = (einzg, auszg) => {
|
||||
if (!einzg || !auszg) return 365;
|
||||
const start = new Date(einzg);
|
||||
const end = new Date(auszug);
|
||||
return Math.ceil(Math.abs(end - start) / (1000 * 60 * 60 * 24));
|
||||
};
|
||||
|
||||
const berechneMieterAnteil = (position, mieterData) => {
|
||||
const tage = berechneTage(mieterData.einzugDatum, mieterData.auszugDatum);
|
||||
const tagesFaktor = tage / 365;
|
||||
|
||||
let anteil = 0;
|
||||
if (position.splitTyp === 'eigentuemer' && position.gesamtbetrag) {
|
||||
const anteilProzent = parseFloat(position.meineAnteile) / parseFloat(position.anteileGesamt);
|
||||
anteil = parseFloat(position.gesamtbetrag) * anteilProzent * tagesFaktor;
|
||||
} else {
|
||||
const splitFaktor = parseInt(position.splitTyp) / 100;
|
||||
anteil = position.betrag * splitFaktor * tagesFaktor;
|
||||
}
|
||||
|
||||
return anteil;
|
||||
};
|
||||
|
||||
// UI
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-gray-200 pb-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('objekte')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'objekte' ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Building className="inline w-4 h-4 mr-1" />
|
||||
Objekte
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('kosten')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'kosten' ? 'bg-green-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
💰 Kosten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('mieter')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'mieter' ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<Users className="inline w-4 h-4 mr-1" />
|
||||
Mieter
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('abrechnung')}
|
||||
className={`px-4 py-2 rounded-t-lg font-medium ${
|
||||
activeTab === 'abrechnung' ? 'bg-orange-600 text-white' : 'bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<FileDown className="inline w-4 h-4 mr-1" />
|
||||
Abrechnung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OBJEKTE TAB */}
|
||||
{activeTab === 'objekte' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Building />
|
||||
Meine Objekte
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowObjektForm(!showObjektForm)}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neues Objekt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showObjektForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">{editingObjekt ? '✏️ Objekt bearbeiten' : '➕ Neues Objekt'}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name (z.B. Musterstraße 7)"
|
||||
value={objektForm.name}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, name: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Straße"
|
||||
value={objektForm.strasse}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, strasse: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Hausnummer"
|
||||
value={objektForm.hausnummer}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, hausnummer: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="PLZ"
|
||||
value={objektForm.plz}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, plz: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ort"
|
||||
value={objektForm.ort}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, ort: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Wohnfläche (m²)"
|
||||
value={objektForm.wohnflaeche}
|
||||
onChange={(e) => setObjektForm({ ...objektForm, wohnflaeche: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={editingObjekt ? updateObjekt : addObjekt}
|
||||
className="mt-4 w-full bg-blue-600 text-white py-2 rounded"
|
||||
>
|
||||
{editingObjekt ? '💾 Änderungen speichern' : '💾 Speichern'}
|
||||
</button>
|
||||
{editingObjekt && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingObjekt(null);
|
||||
setObjektForm({ name: '', strasse: '', hausnummer: '', plz: '', ort: '', einheiten: 1, wohnflaeche: 0 });
|
||||
setShowObjektForm(false);
|
||||
}}
|
||||
className="mt-2 w-full bg-gray-300 text-gray-700 py-2 rounded"
|
||||
>
|
||||
❌ Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{objekte.map(objekt => (
|
||||
<div
|
||||
key={objekt.id}
|
||||
className={`bg-white rounded-lg shadow p-4 hover:shadow-lg transition-shadow ${
|
||||
selectedObjekt?.id === objekt.id ? 'ring-2 ring-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div
|
||||
onClick={() => {
|
||||
setSelectedObjekt(objekt);
|
||||
setActiveTab('kosten');
|
||||
}}
|
||||
className="flex-1 cursor-pointer"
|
||||
>
|
||||
<h3 className="font-semibold text-lg">{objekt.name}</h3>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{objekt.strasse} {objekt.hausnummer}, {objekt.plz} {objekt.ort}
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
{objekt.wohnflaeche} m² Wohnfläche
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEditObjekt(objekt);
|
||||
}}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded"
|
||||
title="Bearbeiten"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteObjekt(objekt.id);
|
||||
}}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded"
|
||||
title="Löschen"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KOSTEN TAB */}
|
||||
{activeTab === 'kosten' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt im Tab "Objekte" aus.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">💰 Kosten für {selectedObjekt.name}</h2>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{[2024, 2025, 2026, 2027].map(jahr => (
|
||||
<button
|
||||
key={jahr}
|
||||
onClick={() => setSelectedJahr(jahr)}
|
||||
className={`px-3 py-1 rounded ${
|
||||
selectedJahr === jahr ? 'bg-green-600 text-white' : 'bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{jahr}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowKostenForm(!showKostenForm)}
|
||||
className="bg-green-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neue Position
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showKostenForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">Neue Kostenposition für {selectedJahr}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Position (z.B. Heizung)"
|
||||
value={kostenForm.position}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, position: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Gesamtkosten (€)"
|
||||
value={kostenForm.betrag}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, betrag: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<select
|
||||
value={kostenForm.splitTyp}
|
||||
onChange={(e) => setKostenForm({ ...kostenForm, splitTyp: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
>
|
||||
<option value="30">30% (Mietfläche)</option>
|
||||
<option value="70">70% (Eigentumsanteil)</option>
|
||||
<option value="100">100% (Voll)</option>
|
||||
<option value="eigentuemer">Eigentümer (nach Anteilen)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
onClick={addKostenPosition}
|
||||
className="mt-4 w-full bg-green-600 text-white py-2 rounded"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste der Positionen */}
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
if (!data || data.positionen.length === 0) {
|
||||
return <div className="text-gray-500 text-center py-8">Noch keine Kosten erfasst.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{data.positionen.map(pos => (
|
||||
<div key={pos.id} className="bg-white rounded-lg shadow p-4 flex justify-between">
|
||||
<div>
|
||||
<p className="font-semibold">{pos.position}</p>
|
||||
<p className="text-sm text-gray-600">{pos.splitTyp}% Aufteilung</p>
|
||||
</div>
|
||||
<p className="font-bold text-green-700">
|
||||
{parseFloat(pos.betrag).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MIETER TAB */}
|
||||
{activeTab === 'mieter' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt im Tab "Objekte" aus.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<Users />
|
||||
Mieter für {selectedObjekt.name}
|
||||
</h2>
|
||||
<p className="text-gray-600">Jahr: {selectedJahr}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMieterForm(!showMieterForm)}
|
||||
className="bg-purple-600 text-white px-4 py-2 rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Neuer Mieter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMieterForm && (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold mb-4">Neuer Mieter für {selectedJahr}</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Vorname"
|
||||
value={mieterForm.vorname}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, vorname: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Nachname"
|
||||
value={mieterForm.nachname}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, nachname: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Einzug"
|
||||
value={mieterForm.einzugDatum}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, einzugDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
placeholder="Auszug"
|
||||
value={mieterForm.auszugDatum}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, auszugDatum: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
placeholder="Vorauszahlung/Monat (€)"
|
||||
value={mieterForm.vorauszahlungMonatlich}
|
||||
onChange={(e) => setMieterForm({ ...mieterForm, vorauszahlungMonatlich: e.target.value })}
|
||||
className="border rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={addMieter}
|
||||
className="mt-4 w-full bg-purple-600 text-white py-2 rounded"
|
||||
>
|
||||
💾 Speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste der Mieter */}
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
if (!data || data.mieter.length === 0) {
|
||||
return <div className="text-gray-500 text-center py-8">Noch keine Mieter erfasst.</div>;
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{data.mieter.map(m => (
|
||||
<div key={m.id} className="bg-white rounded-lg shadow p-4">
|
||||
<p className="font-semibold">{m.vorname} {m.nachname}</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{m.einzugDatum} - {m.auszugDatum || 'laufend'}
|
||||
</p>
|
||||
<p className="text-sm text-purple-600 mt-2">
|
||||
Vorauszahlung: {(m.vorauszahlungMonatlich || 0).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}/Monat
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ABRECHNUNG TAB */}
|
||||
{activeTab === 'abrechnung' && (
|
||||
<div className="space-y-4">
|
||||
{!selectedObjekt ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
Bitte wähle zuerst ein Objekt und erfasse Kosten sowie Mieter.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="text-xl font-semibold">📋 Abrechnung für {selectedObjekt.name}</h2>
|
||||
|
||||
{(() => {
|
||||
const jahrKey = `${selectedObjekt.id}-${selectedJahr}`;
|
||||
const data = nebenkostenJahre[jahrKey];
|
||||
|
||||
if (!data || data.mieter.length === 0) {
|
||||
return <div className="text-gray-500">Keine Mieter für dieses Jahr erfasst.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{data.mieter.map(mieter => {
|
||||
const tage = berechneTage(mieter.einzugDatum, mieter.auszugDatum);
|
||||
const kostenSumme = data.positionen.reduce((sum, pos) => {
|
||||
return sum + berechneMieterAnteil(pos, mieter);
|
||||
}, 0);
|
||||
const vorauszahlung = (mieter.vorauszahlungMonatlich || 0) * 12;
|
||||
const differenz = vorauszahlung - kostenSumme;
|
||||
|
||||
return (
|
||||
<div key={mieter.id} className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="font-semibold text-lg mb-4">
|
||||
{mieter.vorname} {mieter.nachname}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
Zeitraum: {mieter.einzugDatum} - {mieter.auszugDatum || '31.12.'+selectedJahr} ({tage} Tage)
|
||||
</p>
|
||||
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="text-left p-2">Position</th>
|
||||
<th className="text-right p-2">Anteil</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.positionen.map(pos => (
|
||||
<tr key={pos.id} className="border-b">
|
||||
<td className="p-2">{pos.position}</td>
|
||||
<td className="text-right p-2">
|
||||
{berechneMieterAnteil(pos, mieter).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-gray-100 font-semibold">
|
||||
<tr>
|
||||
<td className="p-2">Gesamtkosten</td>
|
||||
<td className="text-right p-2">{kostenSumme.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-2">Vorauszahlung</td>
|
||||
<td className="text-right p-2">{vorauszahlung.toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
<tr className={differenz >= 0 ? 'text-green-600' : 'text-red-600'}>
|
||||
<td className="p-2">{differenz >= 0 ? 'Rückzahlung' : 'Nachzahlung'}</td>
|
||||
<td className="text-right p-2">{Math.abs(differenz).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' })}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<button className="mt-4 bg-blue-600 text-white px-4 py-2 rounded flex items-center gap-2">
|
||||
<FileDown size={16} />
|
||||
PDF für {mieter.nachname}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user