commit 189e7a23296c6993e175d0ec0363c8856aa1174e Author: Matthias Hinrichs Date: Tue Aug 26 03:17:49 2025 +0200 first commit diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..36f1488 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,103 @@ +WhereIsMyMoney - Personal Finance Management + +## Project Overview +A Go web application for personal finance management using modern frameworks. Features include comprehensive transaction management, multi-edit/delete capabilities, advanced filtering, user authentication, bank account & depot management, and a responsive UI with interactive dashboards. + +## Tech Stack +- **Backend**: Go 1.23 with Gin web framework +- **Templates**: a-h/templ for type-safe HTML templates +- **Database**: GORM with SQLite +- **Configuration**: Viper for config management +- **Sessions**: Gorilla Sessions for user sessions (Safari-compatible) +- **Authentication**: bcrypt for password hashing +- **Frontend**: Tailwind CSS for styling with modular template structure, Chart.js for data visualization +- **JavaScript**: Vanilla JS for dynamic interactions, AJAX operations, and form handling + +## Key Features +- **Transaction Management**: Full CRUD operations with pagination, advanced filtering, multi-edit/delete functionality +- **Recurring Transactions**: Automated generation of recurring transactions with flexible intervals +- **User Management**: Registration, login, logout with secure session handling and settings management +- **Bank Account Management**: Create and manage multiple bank accounts (checking, savings, credit) +- **Depot Management**: Create and manage investment depots with different brokers +- **Advanced Filtering**: Multi-criteria filtering (date range, description, category, account, type, amount) +- **Batch Operations**: Multi-select transactions for bulk editing and deletion +- **Interactive Dashboard**: Charts and graphs for financial overview using Chart.js +- **Responsive Design**: Mobile-friendly navigation with dropdown menus +- **Modular Templates**: Separate components for reusability and maintainability + +## Development +- Run server: `go run main.go` or use VS Code task "Run Server" +- Generate templates: `templ generate` +- Build: `go build -o app .` +- Server runs on http://localhost:8080 + +## API Routes +### Authentication +- **Auth**: `/login`, `/register`, `/logout` + +### Main Pages +- **Dashboard**: `/` (protected) - Financial overview with interactive charts +- **Transactions**: `/transactions` (protected) - Transaction list with filtering, pagination, and multi-operations +- **Accounts**: `/accounts` (protected) - Bank account & depot management +- **Recurring**: `/recurring` (protected) - Recurring transaction management +- **Settings**: `/settings` (protected) - User settings and password management + +### Transaction API +- **GET** `/transactions` - Transaction list with pagination and filtering +- **POST** `/transactions` - Create new transaction +- **PUT** `/transactions/:id` - Update transaction +- **DELETE** `/transactions/:id` - Delete transaction +- **PUT** `/transactions/multi-update` - Batch update multiple transactions +- **DELETE** `/transactions/multi-delete` - Batch delete multiple transactions + +### Recurring Transactions API +- **GET** `/recurring` - List recurring transactions +- **POST** `/recurring` - Create recurring transaction +- **PUT** `/recurring/:id` - Update recurring transaction +- **DELETE** `/recurring/:id` - Delete recurring transaction + +### Account Management API +- **GET** `/accounts` - View accounts overview +- **POST** `/accounts/bank` - Create bank account +- **POST** `/accounts/depot` - Create depot + +### User Management API +- **PUT** `/settings/profile` - Update user profile (name only) +- **PUT** `/settings/password` - Change user password + +## Database Models +- **User**: Authentication and user data with relationships to accounts/depots/transactions +- **Transaction**: Complete transaction data (amount, description, date, category, account, type) +- **RecurringTransaction**: Templates for automated recurring transactions with flexible intervals +- **BankAccount**: Bank account details (name, bank, IBAN, balance, account type) +- **Depot**: Investment depot details (name, broker, depot number, total value) +- **Category**: Transaction categories with icons for visual organization + +## Project Structure +``` +whereismymoney/ +├── main.go # Application entry point with routes +├── config.yaml # Configuration file +├── whereismymoney.db # SQLite database +├── static/ +│ ├── css/ +│ │ └── style.css # Custom CSS styles +│ └── js/ +│ └── dashboard-charts.js # Chart.js configuration +├── internal/ +│ ├── config/ # Configuration management +│ ├── database/ # Database connection and migrations +│ ├── handlers/ # HTTP handlers (auth, transactions, accounts, etc.) +│ ├── models/ # GORM models (User, Transaction, BankAccount, etc.) +│ └── views/ # Templ templates +│ ├── layout.templ # Base layout +│ ├── navigation.templ # Reusable navigation component +│ ├── dashboard.templ # Main dashboard with charts +│ ├── transactions.templ # Transaction list with filtering and multi-ops +│ ├── accounts.templ # Bank accounts & depots management +│ ├── recurring.templ # Recurring transactions management +│ ├── settings.templ # User settings and password management +│ ├── login.templ # Login page +│ └── register.templ # Registration page +└── README.md # Documentation +``` diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..761ba8e --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,13 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Run Server", + "type": "shell", + "command": "go run main.go", + "isBackground": true, + "problemMatcher": [], + "group": "build" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..524d6e5 --- /dev/null +++ b/README.md @@ -0,0 +1,177 @@ +# WhereIsMyMoney + +Eine moderne Webanwendung zur persönlichen Finanzverwaltung mit umfassenden Funktionen für Transaktionsmanagement und Finanzübersicht. + +## Features + +### Transaktionsmanagement +- **Vollständige CRUD-Operationen**: Erstellen, Bearbeiten, Löschen von Transaktionen +- **Multi-Edit-Funktionalität**: Mehrere Transaktionen gleichzeitig bearbeiten +- **Multi-Delete-Funktionalität**: Mehrere Transaktionen gleichzeitig löschen +- **Erweiterte Filter**: Nach Datum, Beschreibung, Kategorie, Konto, Typ und Betrag filtern +- **Paginierung**: Übersichtliche Navigation durch große Transaktionslisten +- **Regelmäßige Transaktionen**: Automatische Generierung wiederkehrender Transaktionen + +### Konten & Kategorien +- **Bankkonten-Management**: Verwaltung mehrerer Bankkonten mit verschiedenen Typen +- **Depot-Management**: Verwaltung von Investment-Depots verschiedener Broker +- **Kategorien-System**: Flexible Kategorisierung mit Icons für bessere Übersicht +- **Übersicht über Einnahmen und Ausgaben**: Detaillierte Finanzanalyse + +### Benutzerfreundlichkeit +- **Responsive Design**: Optimiert für Desktop und Mobile +- **Interaktive Dashboards**: Grafische Darstellung der Finanzdaten mit Chart.js +- **Benutzereinstellungen**: Passwort-Management und Profilverwaltung +- **Session-Management**: Sichere Benutzeranmeldung mit Safari-Kompatibilität + +### Technische Features +- **Type-safe Templates**: a-h/templ für sichere HTML-Generierung +- **Moderne Architektur**: GORM für Datenbankoperationen +- **RESTful API**: JSON-basierte Endpunkte für AJAX-Operationen +- **Filter-Persistierung**: URL-Parameter bleiben bei Navigation erhalten + +## Technologien + +- **Backend**: Go 1.23 mit Gin Framework +- **Templates**: a-h/templ für type-safe HTML Templates +- **Database**: GORM mit SQLite +- **Configuration**: Viper für Konfigurationsmanagement +- **Sessions**: Gorilla Sessions +- **Frontend**: Tailwind CSS + +## Installation + +1. Repository klonen oder herunterladen +2. In das Projektverzeichnis wechseln: + ```bash + cd whereismymoney + ``` +3. Dependencies installieren: + ```bash + go mod tidy + ``` +4. Templates generieren: + ```bash + templ generate + ``` +5. Server starten: + ```bash + go run main.go + ``` +## API Routes + +### Authentifizierung +- **Auth**: `/login`, `/register`, `/logout` + +### Hauptseiten +- **Dashboard**: `/` (geschützt) - Finanzübersicht mit Grafiken +- **Transaktionen**: `/transactions` (geschützt) - Transaktionsliste mit Filtern +- **Konten**: `/accounts` (geschützt) - Bank- und Depot-Verwaltung +- **Einstellungen**: `/settings` (geschützt) - Benutzereinstellungen + +### Transaktions-API +- **GET** `/transactions` - Transaktionsliste mit Paginierung und Filtern +- **POST** `/transactions` - Neue Transaktion erstellen +- **PUT** `/transactions/:id` - Transaktion bearbeiten +- **DELETE** `/transactions/:id` - Transaktion löschen +- **PUT** `/transactions/multi-update` - Mehrere Transaktionen bearbeiten +- **DELETE** `/transactions/multi-delete` - Mehrere Transaktionen löschen + +### Recurring Transactions +- **GET** `/recurring` - Regelmäßige Transaktionen anzeigen +- **POST** `/recurring` - Neue regelmäßige Transaktion erstellen +- **PUT** `/recurring/:id` - Regelmäßige Transaktion bearbeiten +- **DELETE** `/recurring/:id` - Regelmäßige Transaktion löschen + +6. Browser öffnen: http://localhost:8080 + +## Entwicklung + +### Templates generieren +```bash +templ generate +``` + +### Server starten +```bash +go run main.go +``` + +### Build erstellen +```bash +go build -o app . +./app +``` + +## Konfiguration + +Die App verwendet eine `config.yaml` Datei für die Konfiguration: + +```yaml +server: + host: localhost + port: "8080" + +database: + type: sqlite + path: "./whereismymoney.db" +``` + +## Projekt-Struktur + +``` +whereismymoney/ +├── main.go # Hauptanwendung mit Routing +├── config.yaml # Konfigurationsdatei +├── whereismymoney.db # SQLite Datenbank +├── static/ +│ ├── css/ +│ │ └── style.css # Custom CSS +│ └── js/ +│ └── dashboard-charts.js # Chart.js Konfiguration +├── internal/ +│ ├── config/ +│ │ └── config.go # Konfigurationsverwaltung +│ ├── database/ +│ │ └── database.go # Datenbankverbindung und Migrationen +│ ├── handlers/ +│ │ └── handlers.go # HTTP Handler (Auth, Transaktionen, etc.) +│ ├── models/ +│ │ └── models.go # GORM Modelle (User, Transaction, etc.) +│ └── views/ # Templ Templates +│ ├── layout.templ # Basis-Layout +│ ├── navigation.templ # Navigation-Komponente +│ ├── dashboard.templ # Dashboard mit Grafiken +│ ├── transactions.templ # Transaktionsliste mit Filtern +│ ├── accounts.templ # Konten-Management +│ ├── recurring.templ # Regelmäßige Transaktionen +│ ├── settings.templ # Benutzereinstellungen +│ ├── login.templ # Login-Seite +│ └── register.templ # Registrierung +└── README.md # Diese Datei +``` + +## Datenbank-Modelle + +### User +- Benutzerauthentifizierung mit bcrypt +- Beziehungen zu Konten, Depots und Transaktionen + +### Transaction +- Vollständige Transaktionsdaten (Betrag, Beschreibung, Datum, etc.) +- Verknüpfung zu Benutzern, Kategorien und Konten +- Unterstützung für Ein- und Ausgaben + +### RecurringTransaction +- Vorlage für regelmäßige Transaktionen +- Flexible Intervalle (täglich, wöchentlich, monatlich, jährlich) +- Automatische Generierung neuer Transaktionen + +### BankAccount & Depot +- Verschiedene Kontotypen (Girokonto, Sparkonto, Kreditkarte) +- Depot-Management für verschiedene Broker +- Saldo-Verfolgung + +### Category +- Kategorisierung mit Icons +- Flexible Zuordnung zu Transaktionen diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..855061e --- /dev/null +++ b/TODO.md @@ -0,0 +1,199 @@ +# WhereIsMyMoney - Feature Roadmap & TODO Liste + +## 🎯 Priorität: Hoch + +### 📊 Dashboard & Analytics +- [ ] **Erweiterte Statistiken** + - Monatliche Trends und Vergleiche + - Ausgaben nach Kategorien (Pie Chart) + - Ein-/Ausgaben Verlauf (Line Chart) + - Durchschnittliche monatliche Ausgaben + - Sparrate berechnen und anzeigen + +- [ ] **Budget-Management** + - Monatliche Budgets pro Kategorie festlegen + - Budget-Fortschritt anzeigen (Progress Bars) + - Warnungen bei Budget-Überschreitung + - Budget vs. tatsächliche Ausgaben Vergleich + +### 📱 Mobile Optimierung +- [ ] **Progressive Web App (PWA)** + - Service Worker für Offline-Funktionalität + - App Manifest für "Add to Home Screen" + - Push-Benachrichtigungen für Budget-Warnungen + - Touch-optimierte Eingabefelder + +- [ ] **Mobile UI Verbesserungen** + - Swipe-Gesten für Transaktionsaktionen + - Floating Action Button für neue Transaktionen + - Verbessertes Touch-Interface für Filter + - Optimierte Tabellen für kleine Bildschirme + +## 🎯 Priorität: Mittel + +### 🔄 Import/Export Funktionen +- [ ] **CSV Import** + - Bank-CSV Dateien importieren + - Mapping-Interface für Spalten + - Duplikat-Erkennung und -Behandlung + - Vorschau vor dem Import + +- [ ] **Export Funktionen** + - Transaktionen als CSV exportieren + - PDF-Reports generieren + - Jahresabschluss-Reports + - Kategorien-Reports + +### 🏦 Erweiterte Konten-Features +- [ ] **Kontostand-Tracking** + - Automatische Saldo-Berechnung + - Kontostand-Historie + - Überziehungskredit-Verwaltung + - Multi-Währungs-Unterstützung + +- [ ] **Depot-Erweiterungen** + - Aktien/ETF Positionen verwalten + - Performance-Tracking + - Dividenden-Tracking + - Portfolio-Diversifikation anzeigen + +### 🔍 Erweiterte Suche & Filter +- [ ] **Volltext-Suche** + - Suche in Transaktionsbeschreibungen + - Fuzzy Search für Tippfehler + - Gespeicherte Suchanfragen + - Quick-Filter Buttons + +- [ ] **Erweiterte Filter** + - Betragsbereiche (von/bis) + - Kombinierte Filter (UND/ODER) + - Filter-Presets speichern + - Erweiterte Datumsfilter (letzte 30 Tage, etc.) + +## 🎯 Priorität: Niedrig + +### 🔔 Benachrichtigungen & Erinnerungen +- [ ] **Recurring Transaction Alerts** + - E-Mail-Benachrichtigungen für fällige Transaktionen + - Push-Notifications im Browser + - Erinnerungen für unregelmäßige Ausgaben + - Budget-Warnungen + +### 🎨 UI/UX Verbesserungen +- [ ] **Themes & Personalisierung** + - Dark/Light Mode Toggle + - Anpassbare Farben + - Benutzer-definierte Kategorien-Icons + - Anpassbare Dashboard-Widgets + +- [ ] **Keyboard Shortcuts** + - Schnelle Navigation (Strg+1, Strg+2, etc.) + - Neue Transaktion anlegen (Strg+N) + - Filter öffnen (Strg+F) + - Suche fokussieren (/) + +### 🔒 Sicherheit & Backup +- [ ] **Backup & Restore** + - Automatische Datenbank-Backups + - Cloud-Backup Integration + - Import/Export der gesamten Datenbank + - Backup-Zeitplan konfigurieren + +- [ ] **Erweiterte Sicherheit** + - Two-Factor Authentication (2FA) + - Session-Timeout konfigurierbar + - Login-Versuche limitieren + - Passwort-Stärke-Meter + +### 📈 Reporting & Analysis +- [ ] **Erweiterte Reports** + - Jahresübersicht generieren + - Steuer-relevante Ausgaben filtern + - Ausgaben-Trends analysieren + - Vergleich zwischen Zeiträumen + +- [ ] **Data Visualization** + - Interaktive Charts (Zoom, Filter) + - Heatmap für Ausgaben-Muster + - Sankey-Diagramm für Geldfluss + - Forecast-Modelle für zukünftige Ausgaben + +### 🌐 Integration & API +- [ ] **Bank-Integration** + - Open Banking APIs + - Automatischer Transaction Import + - Real-time Kontostand-Updates + - Bank-spezifische Kategorisierung + +- [ ] **Externe Tools** + - YNAB Import/Export + - Mint.com Migration + - Excel/Google Sheets Sync + - Webhook-Support für externe Apps + +## 🔧 Technische Verbesserungen + +### 🏗️ Architektur +- [ ] **Performance Optimierung** + - Datenbankindexierung verbessern + - Lazy Loading für große Datensätze + - Caching für häufige Abfragen + - API Response Komprimierung + +- [ ] **Testing & Quality** + - Unit Tests für alle Handler + - Integration Tests für API Endpoints + - Frontend Testing mit Playwright + - Performance Tests für große Datenmengen + +### 🚀 Deployment & DevOps +- [ ] **Docker Containerization** + - Multi-stage Dockerfile + - Docker Compose für Development + - Health Checks implementieren + - Environment-basierte Konfiguration + +- [ ] **CI/CD Pipeline** + - GitHub Actions Setup + - Automatische Tests bei Push + - Security Scanning + - Automated Deployment + +## 📋 Kleine Verbesserungen + +### 🎯 Quick Wins +- [ ] **Benutzerfreundlichkeit** + - Lade-Spinner für AJAX-Requests + - Toast-Nachrichten für Erfolgsmeldungen + - Bessere Fehlermeldungen + - Auto-complete für Beschreibungen + +- [ ] **Validation & Error Handling** + - Client-side Formvalidierung + - Bessere Error Messages + - Input-Sanitization verbessern + - Graceful Error Recovery + +--- + +## 📝 Notizen + +### Aktuell implementierte Features ✅ +- ✅ Transaktions-CRUD mit Paginierung +- ✅ Multi-Edit und Multi-Delete +- ✅ Erweiterte Filter-Funktionen +- ✅ Recurring Transactions +- ✅ Benutzer-Management mit Settings +- ✅ Bank- und Depot-Verwaltung +- ✅ Responsive Design mit Tailwind CSS +- ✅ Dashboard mit Chart.js Integration + +### Nächste Schritte Empfehlung +1. **Dashboard Analytics erweitern** - Mehr Charts und Statistiken +2. **CSV Import implementieren** - Praktischer Nutzen für Benutzer +3. **Mobile Optimierung** - PWA Features hinzufügen +4. **Budget-Management** - Core Feature für Finanz-Apps + +--- + +*Letzte Aktualisierung: 26. August 2025* diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..24abd49 --- /dev/null +++ b/config.yaml @@ -0,0 +1,7 @@ +server: + host: localhost + port: "8080" + +database: + type: sqlite + path: "./whereismymoney.db" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2c76c82 --- /dev/null +++ b/go.mod @@ -0,0 +1,67 @@ +module whereismymoney + +go 1.23.0 + +require ( + github.com/a-h/templ v0.3.943 + github.com/gin-gonic/gin v1.10.1 + github.com/gorilla/sessions v1.4.0 + github.com/spf13/viper v1.20.1 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.30.1 +) + +require ( + github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cli/browser v1.3.0 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/fatih/color v1.16.0 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/natefinch/atomic v1.0.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/mod v0.26.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/tools v0.35.0 // indirect + google.golang.org/protobuf v1.36.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/a-h/templ/cmd/templ diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d1a2736 --- /dev/null +++ b/go.sum @@ -0,0 +1,156 @@ +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e h1:HjVbSQHy+dnlS6C3XajZ69NYAb5jbGNfHanvm1+iYlo= +github.com/a-h/parse v0.0.0-20250122154542-74294addb73e/go.mod h1:3mnrkvGpurZ4ZrTDbYU84xhwXW2TjTKShSwjRi2ihfQ= +github.com/a-h/templ v0.3.943 h1:o+mT/4yqhZ33F3ootBiHwaY4HM5EVaOJfIshvd5UNTY= +github.com/a-h/templ v0.3.943/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= +github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= +golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= +golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= +google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= +gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..18b7973 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` +} + +type ServerConfig struct { + Port string `mapstructure:"port"` + Host string `mapstructure:"host"` +} + +type DatabaseConfig struct { + Type string `mapstructure:"type"` + Path string `mapstructure:"path"` +} + +func Load() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + + // Defaults + viper.SetDefault("server.port", "8080") + viper.SetDefault("server.host", "localhost") + viper.SetDefault("database.type", "sqlite") + viper.SetDefault("database.path", "./data.db") + + // Environment variables + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + // Config file not found, use defaults + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } + } + + var config Config + if err := viper.Unmarshal(&config); err != nil { + return nil, err + } + + return &config, nil +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..a2dfa45 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,23 @@ +package database + +import ( + "whereismymoney/internal/config" + "whereismymoney/internal/models" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func Connect(cfg *config.Config) (*gorm.DB, error) { + db, err := gorm.Open(sqlite.Open(cfg.Database.Path), &gorm.Config{}) + if err != nil { + return nil, err + } + + // Auto-migrate the schema + if err := models.Migrate(db); err != nil { + return nil, err + } + + return db, nil +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go new file mode 100644 index 0000000..e9d5c53 --- /dev/null +++ b/internal/handlers/handlers.go @@ -0,0 +1,1964 @@ +package handlers + +import ( + "fmt" + "net/http" + "strconv" + "time" + "whereismymoney/internal/models" + "whereismymoney/internal/views" + + "github.com/gin-gonic/gin" + "github.com/gorilla/sessions" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +type Handler struct { + DB *gorm.DB + Store *sessions.CookieStore +} + +func NewHandler(db *gorm.DB) *Handler { + store := sessions.NewCookieStore([]byte("your-secret-key-here")) + + // Session-Optionen für Safari-Kompatibilität + store.Options = &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, // 7 Tage + HttpOnly: true, + Secure: false, // Für localhost Development + SameSite: http.SameSiteLaxMode, // Safari-kompatibel + } + + return &Handler{ + DB: db, + Store: store, + } +} + +// Dashboard zeigt das Hauptdashboard (nur für angemeldete Benutzer) +func (h *Handler) Dashboard(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Benutzer aus der Datenbank laden + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Testdaten generieren falls nötig + h.generateTestData(userID.(uint)) + + // Wiederkehrende Transaktionen generieren + h.processRecurringTransactions(userID.(uint)) + + // Dashboard-Daten sammeln + dashboardData, err := h.generateDashboardData(userID.(uint)) + if err != nil { + c.String(http.StatusInternalServerError, "Fehler beim Laden der Dashboard-Daten: %v", err) + return + } + + // Dashboard rendern + component := views.Dashboard(dashboardData) + component.Render(c.Request.Context(), c.Writer) +} + +// DashboardDataAPI liefert Dashboard-Daten als JSON für AJAX-Anfragen +func (h *Handler) DashboardDataAPI(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Testdaten generieren falls nötig + h.generateTestData(userID.(uint)) + + // Wiederkehrende Transaktionen generieren + h.processRecurringTransactions(userID.(uint)) + + // Dashboard-Daten generieren + data, err := h.generateDashboardData(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Laden der Dashboard-Daten"}) + return + } + + // Daten als JSON zurückgeben + c.JSON(http.StatusOK, data) +} + +// generateDashboardData sammelt alle Daten für das Dashboard +func (h *Handler) generateDashboardData(userID uint) (*models.DashboardData, error) { + data := &models.DashboardData{} + + // Wiederkehrende Transaktionen verarbeiten BEVOR Dashboard-Daten generiert werden + if err := h.processRecurringTransactions(userID); err != nil { + // Fehler loggen, aber nicht den gesamten Dashboard-Aufruf fehlschlagen lassen + fmt.Printf("Fehler beim Verarbeiten wiederkehrender Transaktionen: %v\n", err) + } + + // Benutzer laden + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + return nil, err + } + data.UserName = user.Name + + // Vermögensübersicht generieren + assetOverview, err := h.generateAssetOverview(userID) + if err != nil { + return nil, err + } + data.AssetOverview = *assetOverview + + // Monatliche Statistiken generieren + monthlyStats, err := h.generateMonthlyStats(userID) + if err != nil { + return nil, err + } + data.MonthlyStats = monthlyStats + + // Kategorie-Statistiken generieren + categoryStats, err := h.generateCategoryStats(userID) + if err != nil { + return nil, err + } + data.CategoryStats = categoryStats + + // Transaktionstrends generieren + transactionTrend, err := h.generateTransactionTrend(userID) + if err != nil { + return nil, err + } + data.TransactionTrend = transactionTrend + + // Aktuelle Transaktionen laden + recentTransactions, err := h.getRecentTransactions(userID) + if err != nil { + return nil, err + } + data.RecentTransactions = recentTransactions + + // Wöchentliche Statistiken generieren + weeklyStats, err := h.generateWeeklyStats(userID) + if err != nil { + return nil, err + } + data.WeeklyStats = weeklyStats + + return data, nil +} + +// generateAssetOverview erstellt eine Vermögensübersicht +func (h *Handler) generateAssetOverview(userID uint) (*models.AssetOverview, error) { + overview := &models.AssetOverview{} + + // Bank-Konten laden + var bankAccounts []models.BankAccount + if err := h.DB.Where("user_id = ?", userID).Find(&bankAccounts).Error; err != nil { + return nil, err + } + + totalBankBalance := 0.0 + bankSummaries := make([]models.BankAccountSummary, len(bankAccounts)) + for i, account := range bankAccounts { + totalBankBalance += account.Balance + bankSummaries[i] = models.BankAccountSummary{ + ID: account.ID, + Name: account.Name, + Bank: account.Bank, + AccountType: account.AccountType, + Balance: account.Balance, + } + } + + // Depots laden + var depots []models.Depot + if err := h.DB.Where("user_id = ?", userID).Find(&depots).Error; err != nil { + return nil, err + } + + totalDepotValue := 0.0 + depotSummaries := make([]models.DepotSummary, len(depots)) + for i, depot := range depots { + totalDepotValue += depot.TotalValue + depotSummaries[i] = models.DepotSummary{ + ID: depot.ID, + Name: depot.Name, + Broker: depot.Broker, + TotalValue: depot.TotalValue, + } + } + + overview.TotalBankBalance = totalBankBalance + overview.TotalDepotValue = totalDepotValue + overview.TotalAssets = totalBankBalance + totalDepotValue + overview.BankAccounts = bankSummaries + overview.Depots = depotSummaries + + return overview, nil +} + +// generateMonthlyStats erstellt monatliche Statistiken +func (h *Handler) generateMonthlyStats(userID uint) ([]models.MonthlyStats, error) { + var stats []models.MonthlyStats + + // Letzte 12 Monate + now := time.Now() + for i := 11; i >= 0; i-- { + month := now.AddDate(0, -i, 0) + startOfMonth := time.Date(month.Year(), month.Month(), 1, 0, 0, 0, 0, month.Location()) + endOfMonth := startOfMonth.AddDate(0, 1, 0).Add(-time.Nanosecond) + + var income, expenses float64 + + // Einnahmen berechnen + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date <= ?", userID, "income", startOfMonth, endOfMonth). + Select("COALESCE(SUM(amount), 0)").Scan(&income) + + // Ausgaben berechnen + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date <= ?", userID, "expense", startOfMonth, endOfMonth). + Select("COALESCE(SUM(amount), 0)").Scan(&expenses) + + netChange := income - expenses + + // Balance zu diesem Zeitpunkt (vereinfacht) + var balance float64 + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND date <= ?", userID, endOfMonth). + Select("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END), 0)").Scan(&balance) + + stats = append(stats, models.MonthlyStats{ + Month: month.Format("2006-01"), + Income: income, + Expenses: expenses, + NetChange: netChange, + Balance: balance, + }) + } + + return stats, nil +} + +// generateCategoryStats erstellt Kategorie-Statistiken +func (h *Handler) generateCategoryStats(userID uint) ([]models.CategoryStats, error) { + var stats []models.CategoryStats + + // Letzte 90 Tage für bessere Relevanz + ninetyDaysAgo := time.Now().AddDate(0, 0, -90) + + type categoryResult struct { + CategoryID *uint + CategoryName *string + TotalAmount float64 + Count int64 + } + + var results []categoryResult + + query := ` + SELECT + t.category_id, + c.name as category_name, + SUM(t.amount) as total_amount, + COUNT(*) as count + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + WHERE t.user_id = ? AND t.date >= ? AND t.type = 'expense' + GROUP BY t.category_id, c.name + ORDER BY total_amount DESC + ` + + if err := h.DB.Raw(query, userID, ninetyDaysAgo).Scan(&results).Error; err != nil { + return nil, err + } + + // Gesamtsumme für Prozentberechnung + var totalExpenses float64 + for _, result := range results { + totalExpenses += result.TotalAmount + } + + for _, result := range results { + categoryName := "Ohne Kategorie" + var categoryID uint = 0 + + if result.CategoryID != nil { + categoryID = *result.CategoryID + } + if result.CategoryName != nil { + categoryName = *result.CategoryName + } + + percentage := 0.0 + if totalExpenses > 0 { + percentage = (result.TotalAmount / totalExpenses) * 100 + } + + stats = append(stats, models.CategoryStats{ + CategoryID: categoryID, + CategoryName: categoryName, + TotalAmount: result.TotalAmount, + Count: int(result.Count), + Percentage: percentage, + }) + } + + return stats, nil +} + +// generateTransactionTrend erstellt Verlaufsdaten für Charts +func (h *Handler) generateTransactionTrend(userID uint) ([]models.TransactionTrend, error) { + var trends []models.TransactionTrend + + // Nächste 12 Monate (für Trend mit wiederkehrenden Transaktionen) + now := time.Now() + + // 12 Monate gruppiert nach Monaten + for i := 0; i < 12; i++ { + // Erster Tag des Monats + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, i, 0) + // Letzter Tag des Monats + monthEnd := monthStart.AddDate(0, 1, 0).Add(-time.Nanosecond) + + var income, expense float64 + + // Einnahmen für diesen Monat (mit DATE-Funktion) + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date < ?", userID, "income", monthStart, monthEnd.AddDate(0, 0, 1)). + Select("COALESCE(SUM(amount), 0)").Scan(&income) + + // Ausgaben für diesen Monat (mit DATE-Funktion) + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date < ?", userID, "expense", monthStart, monthEnd.AddDate(0, 0, 1)). + Select("COALESCE(SUM(amount), 0)").Scan(&expense) + + // Aktueller Kontostand berechnen + var balance float64 + if i == 0 { + // Für den ersten Monat: Aktueller Stand aus allen vergangenen Transaktionen + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND date <= ?", userID, monthEnd). + Select("COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE -amount END), 0)").Scan(&balance) + } else { + // Für zukünftige Monate: Vorheriger Balance + heutige Einnahmen - heutige Ausgaben + if len(trends) > 0 { + balance = trends[len(trends)-1].Balance + income - expense + } + } + + trends = append(trends, models.TransactionTrend{ + Date: monthStart, + Income: income, + Expense: expense, + Balance: balance, + }) + } + + return trends, nil +} + +// getRecentTransactions lädt die neuesten Transaktionen +func (h *Handler) getRecentTransactions(userID uint) ([]models.Transaction, error) { + var transactions []models.Transaction + + err := h.DB.Where("user_id = ?", userID). + Preload("Category"). + Preload("BankAccount"). + Order("date DESC, created_at DESC"). + Limit(10). + Find(&transactions).Error + + return transactions, err +} + +// generateWeeklyStats erstellt wöchentliche Statistiken +func (h *Handler) generateWeeklyStats(userID uint) ([]models.WeeklyStats, error) { + var stats []models.WeeklyStats + + // Letzte 4 Wochen + now := time.Now() + for i := 3; i >= 0; i-- { + // Wochenstart (Montag) + weekStart := now.AddDate(0, 0, -7*i) + for weekStart.Weekday() != time.Monday { + weekStart = weekStart.AddDate(0, 0, -1) + } + weekStart = time.Date(weekStart.Year(), weekStart.Month(), weekStart.Day(), 0, 0, 0, 0, weekStart.Location()) + weekEnd := weekStart.AddDate(0, 0, 6).Add(23*time.Hour + 59*time.Minute + 59*time.Second) + + var income, expenses float64 + + // Einnahmen berechnen + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date <= ?", userID, "income", weekStart, weekEnd). + Select("COALESCE(SUM(amount), 0)").Scan(&income) + + // Ausgaben berechnen + h.DB.Model(&models.Transaction{}). + Where("user_id = ? AND type = ? AND date >= ? AND date <= ?", userID, "expense", weekStart, weekEnd). + Select("COALESCE(SUM(amount), 0)").Scan(&expenses) + + netChange := income - expenses + weekLabel := fmt.Sprintf("KW %d", getWeekNumber(weekStart)) + + stats = append(stats, models.WeeklyStats{ + Week: weekLabel, + Income: income, + Expenses: expenses, + NetChange: netChange, + StartDate: weekStart, + EndDate: weekEnd, + }) + } + + return stats, nil +} + +// getWeekNumber berechnet die Kalenderwoche +func getWeekNumber(date time.Time) int { + _, week := date.ISOWeek() + return week +} + +// ShowLogin zeigt die Login-Seite +func (h *Handler) ShowLogin(c *gin.Context) { + errorMsg := "" + switch c.Query("error") { + case "invalid": + errorMsg = "Ungültige E-Mail-Adresse oder Passwort" + case "session": + errorMsg = "Fehler beim Erstellen der Sitzung. Bitte versuchen Sie es erneut." + case "required": + errorMsg = "Bitte melden Sie sich an, um diese Seite zu besuchen" + } + + component := views.LoginPage(errorMsg) + component.Render(c.Request.Context(), c.Writer) +} + +// ShowRegister zeigt die Registrierungs-Seite +func (h *Handler) ShowRegister(c *gin.Context) { + errorMsg := "" + switch c.Query("error") { + case "password_mismatch": + errorMsg = "Die Passwörter stimmen nicht überein" + case "email_exists": + errorMsg = "Ein Konto mit dieser E-Mail-Adresse existiert bereits" + case "server": + errorMsg = "Ein Server-Fehler ist aufgetreten. Bitte versuchen Sie es erneut." + case "session": + errorMsg = "Fehler beim Erstellen der Sitzung. Bitte versuchen Sie es erneut." + } + + component := views.RegisterPage(errorMsg) + component.Render(c.Request.Context(), c.Writer) +} + +// Login verarbeitet die Anmeldung +func (h *Handler) Login(c *gin.Context) { + email := c.PostForm("email") + password := c.PostForm("password") + + // Benutzer in der Datenbank finden + var user models.User + if err := h.DB.Where("email = ?", email).First(&user).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/login?error=invalid") + return + } + + // Passwort überprüfen + if !user.CheckPassword(password) { + c.Redirect(http.StatusSeeOther, "/login?error=invalid") + return + } + + // Session erstellen + session, _ := h.Store.Get(c.Request, "user-session") + session.Values["user_id"] = user.ID + session.Values["user_name"] = user.Name + + // Session explizit speichern mit Fehlerbehandlung + if err := session.Save(c.Request, c.Writer); err != nil { + c.Redirect(http.StatusSeeOther, "/login?error=session") + return + } + + c.Redirect(http.StatusSeeOther, "/") +} + +// Register verarbeitet die Registrierung +func (h *Handler) Register(c *gin.Context) { + name := c.PostForm("name") + email := c.PostForm("email") + password := c.PostForm("password") + passwordConfirm := c.PostForm("password_confirm") + + // Passwort-Bestätigung überprüfen + if password != passwordConfirm { + c.Redirect(http.StatusSeeOther, "/register?error=password_mismatch") + return + } + + // Neuen Benutzer erstellen + user := models.User{ + Name: name, + Email: email, + } + + // Passwort hashen + if err := user.HashPassword(password); err != nil { + c.Redirect(http.StatusSeeOther, "/register?error=server") + return + } + + // Benutzer in der Datenbank speichern + if err := h.DB.Create(&user).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/register?error=email_exists") + return + } + + // Session erstellen + session, _ := h.Store.Get(c.Request, "user-session") + session.Values["user_id"] = user.ID + session.Values["user_name"] = user.Name + + // Session explizit speichern mit Fehlerbehandlung + if err := session.Save(c.Request, c.Writer); err != nil { + c.Redirect(http.StatusSeeOther, "/register?error=session") + return + } + + c.Redirect(http.StatusSeeOther, "/") +} + +// Logout meldet den Benutzer ab +func (h *Handler) Logout(c *gin.Context) { + session, _ := h.Store.Get(c.Request, "user-session") + session.Values["user_id"] = nil + session.Values["user_name"] = nil + session.Options.MaxAge = -1 + session.Save(c.Request, c.Writer) + + c.Redirect(http.StatusSeeOther, "/login") +} + +// ShowAccounts zeigt die Konten- und Depot-Verwaltung +func (h *Handler) ShowAccounts(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + userName, _ := session.Values["user_name"].(string) + + // Bankkonten laden + var bankAccounts []models.BankAccount + h.DB.Where("user_id = ?", userID).Find(&bankAccounts) + + // Depots laden + var depots []models.Depot + h.DB.Where("user_id = ?", userID).Find(&depots) + + // Template rendern + views.Accounts(userName, bankAccounts, depots).Render(c.Request.Context(), c.Writer) +} + +// CreateBankAccount erstellt ein neues Bankkonto +func (h *Handler) CreateBankAccount(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Formulardaten lesen + name := c.PostForm("name") + bankName := c.PostForm("bank_name") + accountType := c.PostForm("account_type") + iban := c.PostForm("iban") + + // Balance parsen + var balance float64 + if balanceStr := c.PostForm("balance"); balanceStr != "" { + if parsed, err := parseFloat(balanceStr); err == nil { + balance = parsed + } + } + + // Validierung + if name == "" || bankName == "" || accountType == "" { + c.Redirect(http.StatusSeeOther, "/accounts?error=required_fields") + return + } + + // Bankkonto erstellen + bankAccount := models.BankAccount{ + UserID: userID.(uint), + Name: name, + Bank: bankName, + AccountType: accountType, + IBAN: iban, + Balance: balance, + } + + if err := h.DB.Create(&bankAccount).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/accounts?error=create_failed") + return + } + + c.Redirect(http.StatusSeeOther, "/accounts?success=bank_created") +} + +// CreateDepot erstellt ein neues Depot +func (h *Handler) CreateDepot(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Formulardaten lesen + name := c.PostForm("name") + brokerName := c.PostForm("broker_name") + depotNumber := c.PostForm("depot_number") + + // Total Value parsen + var totalValue float64 + if valueStr := c.PostForm("total_value"); valueStr != "" { + if parsed, err := parseFloat(valueStr); err == nil { + totalValue = parsed + } + } + + // Validierung + if name == "" || brokerName == "" { + c.Redirect(http.StatusSeeOther, "/accounts?error=required_fields") + return + } + + // Depot erstellen + depot := models.Depot{ + UserID: userID.(uint), + Name: name, + Broker: brokerName, + DepotNumber: depotNumber, + TotalValue: totalValue, + } + + if err := h.DB.Create(&depot).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/accounts?error=create_failed") + return + } + + c.Redirect(http.StatusSeeOther, "/accounts?success=depot_created") +} + +// parseFloat ist eine Hilfsfunktion zum sicheren Parsen von Float-Werten +func parseFloat(s string) (float64, error) { + if s == "" { + return 0, nil + } + return strconv.ParseFloat(s, 64) +} + +// ShowTransactions zeigt die Transaktionsübersicht +func (h *Handler) ShowTransactions(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + userName, _ := session.Values["user_name"].(string) + + // Paginierung Parameter + page := 1 + if pageStr := c.Query("page"); pageStr != "" { + if p, err := strconv.Atoi(pageStr); err == nil && p > 0 { + page = p + } + } + + pageSize := 20 + offset := (page - 1) * pageSize + + // Filter Parameter + dateFrom := c.Query("date_from") + dateTo := c.Query("date_to") + searchDescription := c.Query("search_description") + filterCategory := c.Query("filter_category") + filterAccount := c.Query("filter_account") + filterType := c.Query("filter_type") + amountMin := c.Query("amount_min") + amountMax := c.Query("amount_max") + + // Base Query für Transaktionen + query := h.DB.Model(&models.Transaction{}).Where("user_id = ?", userID) + + // Filter anwenden + if dateFrom != "" { + if parsedDate, err := time.Parse("2006-01-02", dateFrom); err == nil { + query = query.Where("date >= ?", parsedDate) + } + } + if dateTo != "" { + if parsedDate, err := time.Parse("2006-01-02", dateTo); err == nil { + query = query.Where("date <= ?", parsedDate) + } + } + if searchDescription != "" { + query = query.Where("description LIKE ?", "%"+searchDescription+"%") + } + if filterCategory != "" { + if filterCategory == "none" { + query = query.Where("category_id IS NULL") + } else if categoryID, err := strconv.Atoi(filterCategory); err == nil { + query = query.Where("category_id = ?", categoryID) + } + } + if filterAccount != "" { + if accountID, err := strconv.Atoi(filterAccount); err == nil { + query = query.Where("bank_account_id = ?", accountID) + } + } + if filterType != "" { + query = query.Where("type = ?", filterType) + } + if amountMin != "" { + if minAmount, err := strconv.ParseFloat(amountMin, 64); err == nil { + query = query.Where("amount >= ?", minAmount) + } + } + if amountMax != "" { + if maxAmount, err := strconv.ParseFloat(amountMax, 64); err == nil { + query = query.Where("amount <= ?", maxAmount) + } + } + + // Gesamtanzahl der gefilterten Transaktionen für Paginierung + var totalCount int64 + query.Count(&totalCount) + + totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize)) + + // Transaktionen laden (neueste zuerst, mit Paginierung und Filtern) + var transactions []models.Transaction + query.Preload("Category"). + Preload("BankAccount"). + Preload("RecurrenceRule"). + Order("date DESC"). + Limit(pageSize). + Offset(offset). + Find(&transactions) + + // Bankkonten für Dropdown laden + var bankAccounts []models.BankAccount + h.DB.Where("user_id = ?", userID).Find(&bankAccounts) + + // Kategorien laden + var categories []models.Category + h.DB.Where("user_id = ?", userID).Find(&categories) + + // Regelmäßige Transaktionen laden + var recurrenceRules []models.RecurrenceRule + h.DB.Where("user_id = ? AND is_active = ?", userID, true). + Preload("Category"). + Preload("BankAccount"). + Find(&recurrenceRules) + + // Template rendern + views.Transactions(userName, transactions, bankAccounts, categories, recurrenceRules, page, totalPages, totalCount).Render(c.Request.Context(), c.Writer) +} + +// CreateTransaction erstellt eine neue Transaktion +func (h *Handler) CreateTransaction(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Formulardaten lesen + description := c.PostForm("description") + amountStr := c.PostForm("amount") + transactionType := c.PostForm("type") // "income" or "expense" + dateStr := c.PostForm("date") + categoryIDStr := c.PostForm("category_id") + bankAccountIDStr := c.PostForm("bank_account_id") + + // Validierung + if description == "" || amountStr == "" || transactionType == "" || dateStr == "" { + c.Redirect(http.StatusSeeOther, "/transactions?error=required_fields") + return + } + + // Amount parsen + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=invalid_amount") + return + } + + // Datum parsen + date, err := time.Parse("2006-01-02", dateStr) + if err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=invalid_date") + return + } + + // Optional: Category ID parsen + var categoryID *uint + if categoryIDStr != "" && categoryIDStr != "0" { + if parsed, err := strconv.ParseUint(categoryIDStr, 10, 32); err == nil { + id := uint(parsed) + categoryID = &id + } + } + + // Optional: Bank Account ID parsen + var bankAccountID *uint + if bankAccountIDStr != "" && bankAccountIDStr != "0" { + if parsed, err := strconv.ParseUint(bankAccountIDStr, 10, 32); err == nil { + id := uint(parsed) + bankAccountID = &id + } + } + + // Transaktion erstellen + transaction := models.Transaction{ + UserID: userID.(uint), + Amount: amount, + Description: description, + Type: transactionType, + Date: date, + CategoryID: categoryID, + BankAccountID: bankAccountID, + IsRecurring: false, + } + + if err := h.DB.Create(&transaction).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=create_failed") + return + } + + c.Redirect(http.StatusSeeOther, "/transactions?success=transaction_created") +} + +// CreateRecurringTransaction erstellt eine neue regelmäßige Transaktion +func (h *Handler) CreateRecurringTransaction(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Formulardaten lesen + description := c.PostForm("description") + amountStr := c.PostForm("amount") + transactionType := c.PostForm("type") + startDateStr := c.PostForm("start_date") + endDateStr := c.PostForm("end_date") + interval := c.PostForm("interval") + intervalCountStr := c.PostForm("interval_count") + categoryIDStr := c.PostForm("category_id") + bankAccountIDStr := c.PostForm("bank_account_id") + dayOfMonthStr := c.PostForm("day_of_month") + dayOfWeekStr := c.PostForm("day_of_week") + + // Validierung + if description == "" || amountStr == "" || transactionType == "" || startDateStr == "" || interval == "" { + c.Redirect(http.StatusSeeOther, "/transactions?error=required_fields") + return + } + + // Amount parsen + amount, err := strconv.ParseFloat(amountStr, 64) + if err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=invalid_amount") + return + } + + // Start-Datum parsen + startDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=invalid_date") + return + } + + // End-Datum parsen (optional) + var endDate *time.Time + if endDateStr != "" { + if parsed, err := time.Parse("2006-01-02", endDateStr); err == nil { + endDate = &parsed + } + } + + // Interval Count parsen + intervalCount := 1 + if intervalCountStr != "" { + if parsed, err := strconv.Atoi(intervalCountStr); err == nil && parsed > 0 { + intervalCount = parsed + } + } + + // Optional: Category ID parsen + var categoryID *uint + if categoryIDStr != "" && categoryIDStr != "0" { + if parsed, err := strconv.ParseUint(categoryIDStr, 10, 32); err == nil { + id := uint(parsed) + categoryID = &id + } + } + + // Optional: Bank Account ID parsen + var bankAccountID *uint + if bankAccountIDStr != "" && bankAccountIDStr != "0" { + if parsed, err := strconv.ParseUint(bankAccountIDStr, 10, 32); err == nil { + id := uint(parsed) + bankAccountID = &id + } + } + + // Optional: Day of Month parsen + var dayOfMonth *int + if dayOfMonthStr != "" { + if parsed, err := strconv.Atoi(dayOfMonthStr); err == nil && parsed >= 1 && parsed <= 31 { + dayOfMonth = &parsed + } + } + + // Optional: Day of Week parsen + var dayOfWeek *int + if dayOfWeekStr != "" { + if parsed, err := strconv.Atoi(dayOfWeekStr); err == nil && parsed >= 0 && parsed <= 6 { + dayOfWeek = &parsed + } + } + + // RecurrenceRule erstellen + recurrenceRule := models.RecurrenceRule{ + UserID: userID.(uint), + Amount: amount, + Description: description, + Type: transactionType, + CategoryID: categoryID, + BankAccountID: bankAccountID, + Interval: interval, + IntervalCount: intervalCount, + StartDate: startDate, + EndDate: endDate, + DayOfMonth: dayOfMonth, + DayOfWeek: dayOfWeek, + IsActive: true, + } + + if err := h.DB.Create(&recurrenceRule).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/transactions?error=create_failed") + return + } + + c.Redirect(http.StatusSeeOther, "/transactions?success=recurring_created") +} + +// DeleteTransaction löscht eine Transaktion +func (h *Handler) DeleteTransaction(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Transaction ID aus URL Parameter + transactionIDStr := c.Param("id") + transactionID, err := strconv.ParseUint(transactionIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Transaktions-ID"}) + return + } + + // Transaktion löschen (nur wenn sie dem Benutzer gehört) + result := h.DB.Where("id = ? AND user_id = ?", transactionID, userID).Delete(&models.Transaction{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Löschen"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaktion nicht gefunden"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// GetTransactionData gibt die Daten einer Transaktion für das Edit-Modal zurück +func (h *Handler) GetTransactionData(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Transaction ID aus URL Parameter + transactionIDStr := c.Param("id") + transactionID, err := strconv.ParseUint(transactionIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Transaktions-ID"}) + return + } + + // Transaktion laden + var transaction models.Transaction + result := h.DB.Where("id = ? AND user_id = ?", transactionID, userID).First(&transaction) + if result.Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaktion nicht gefunden"}) + return + } + + // Daten für JavaScript formatieren + transactionData := gin.H{ + "id": transaction.ID, + "description": transaction.Description, + "amount": transaction.Amount, + "type": transaction.Type, + "date": transaction.Date.Format("2006-01-02"), + "category_id": transaction.CategoryID, + "bank_account_id": transaction.BankAccountID, + } + + c.JSON(http.StatusOK, transactionData) +} + +// UpdateTransaction aktualisiert eine existierende Transaktion +func (h *Handler) UpdateTransaction(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Transaction ID aus URL Parameter + transactionIDStr := c.Param("id") + transactionID, err := strconv.ParseUint(transactionIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Transaktions-ID"}) + return + } + + // JSON Body parsen + var reqData struct { + Description string `json:"description"` + Amount string `json:"amount"` + Type string `json:"type"` + Date string `json:"date"` + CategoryID string `json:"category_id"` + BankAccountID string `json:"bank_account_id"` + } + + if err := c.ShouldBindJSON(&reqData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Daten"}) + return + } + + // Daten validieren und konvertieren + amount, err := strconv.ParseFloat(reqData.Amount, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültiger Betrag"}) + return + } + + date, err := time.Parse("2006-01-02", reqData.Date) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültiges Datum"}) + return + } + + bankAccountID, err := strconv.ParseUint(reqData.BankAccountID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Konto-ID"}) + return + } + + // Optional: Category ID parsen + var categoryID *uint + if reqData.CategoryID != "" && reqData.CategoryID != "0" { + if parsed, err := strconv.ParseUint(reqData.CategoryID, 10, 32); err == nil { + id := uint(parsed) + categoryID = &id + } + } + + // Transaktion laden und aktualisieren + var transaction models.Transaction + result := h.DB.Where("id = ? AND user_id = ?", transactionID, userID).First(&transaction) + if result.Error != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Transaktion nicht gefunden"}) + return + } + + // Transaktion aktualisieren + transaction.Description = reqData.Description + transaction.Amount = amount + transaction.Type = reqData.Type + transaction.Date = date + transaction.CategoryID = categoryID + bankID := uint(bankAccountID) + transaction.BankAccountID = &bankID + + if err := h.DB.Save(&transaction).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Speichern"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// MultiUpdateTransactions aktualisiert mehrere Transaktionen gleichzeitig +func (h *Handler) MultiUpdateTransactions(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // JSON Request parsen + var reqData struct { + TransactionIDs []string `json:"transaction_ids"` + Description *string `json:"description,omitempty"` + Amount *string `json:"amount,omitempty"` + Type *string `json:"type,omitempty"` + Date *string `json:"date,omitempty"` + CategoryID *string `json:"category_id,omitempty"` + BankAccountID *string `json:"bank_account_id,omitempty"` + } + + if err := c.ShouldBindJSON(&reqData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Anfrage"}) + return + } + + if len(reqData.TransactionIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Keine Transaktionen ausgewählt"}) + return + } + + // Updates vorbereiten + updates := make(map[string]interface{}) + + if reqData.Description != nil && *reqData.Description != "" { + updates["description"] = *reqData.Description + } + + if reqData.Amount != nil && *reqData.Amount != "" { + amount, err := strconv.ParseFloat(*reqData.Amount, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültiger Betrag"}) + return + } + updates["amount"] = amount + } + + if reqData.Type != nil && *reqData.Type != "" { + if *reqData.Type != "income" && *reqData.Type != "expense" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültiger Transaktionstyp"}) + return + } + updates["type"] = *reqData.Type + } + + if reqData.Date != nil && *reqData.Date != "" { + date, err := time.Parse("2006-01-02", *reqData.Date) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültiges Datum"}) + return + } + updates["date"] = date + } + + if reqData.CategoryID != nil { + if *reqData.CategoryID == "null" || *reqData.CategoryID == "" { + updates["category_id"] = nil + } else { + categoryID, err := strconv.ParseUint(*reqData.CategoryID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Kategorie-ID"}) + return + } + updates["category_id"] = uint(categoryID) + } + } + + if reqData.BankAccountID != nil { + if *reqData.BankAccountID == "null" || *reqData.BankAccountID == "" { + updates["bank_account_id"] = nil + } else { + bankAccountID, err := strconv.ParseUint(*reqData.BankAccountID, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Konto-ID"}) + return + } + bankID := uint(bankAccountID) + updates["bank_account_id"] = &bankID + } + } + + if len(updates) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Keine Änderungen angegeben"}) + return + } + + // Transaktionen aktualisieren + result := h.DB.Model(&models.Transaction{}). + Where("id IN ? AND user_id = ?", reqData.TransactionIDs, userID). + Updates(updates) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Aktualisieren der Transaktionen"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "updated": result.RowsAffected, + }) +} + +// MultiDeleteTransactions löscht mehrere Transaktionen gleichzeitig +func (h *Handler) MultiDeleteTransactions(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // JSON Request parsen + var reqData struct { + TransactionIDs []string `json:"transaction_ids"` + } + + if err := c.ShouldBindJSON(&reqData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Anfrage"}) + return + } + + if len(reqData.TransactionIDs) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "Keine Transaktionen ausgewählt"}) + return + } + + // Transaktionen löschen (nur die des angemeldeten Users) + result := h.DB.Where("id IN ? AND user_id = ?", reqData.TransactionIDs, userID).Delete(&models.Transaction{}) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Löschen der Transaktionen"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "deleted": result.RowsAffected, + }) +} + +// ToggleRecurrenceRule aktiviert/deaktiviert eine regelmäßige Transaktion +func (h *Handler) ToggleRecurrenceRule(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // RecurrenceRule ID aus URL Parameter + ruleIDStr := c.Param("id") + ruleID, err := strconv.ParseUint(ruleIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Regel-ID"}) + return + } + + // Regel finden und Status umschalten + var rule models.RecurrenceRule + if err := h.DB.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Regel nicht gefunden"}) + return + } + + rule.IsActive = !rule.IsActive + if err := h.DB.Save(&rule).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Speichern"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true, "is_active": rule.IsActive}) +} + +// generateTestData erstellt Testdaten für ein leeres Dashboard +func (h *Handler) generateTestData(userID uint) error { + // Prüfen, ob bereits Transaktionen vorhanden sind + var count int64 + h.DB.Model(&models.Transaction{}).Where("user_id = ?", userID).Count(&count) + if count > 0 { + return nil // Bereits Daten vorhanden + } + + // Prüfen, ob bereits wiederkehrende Regeln vorhanden sind + var ruleCount int64 + h.DB.Model(&models.RecurrenceRule{}).Where("user_id = ?", userID).Count(&ruleCount) + if ruleCount > 0 { + return nil // Bereits Regeln vorhanden + } + + // Beispielkategorien erstellen + categories := []models.Category{ + {UserID: userID, Name: "Gehalt", Description: "Monatliches Gehalt", Color: "#22c55e", Icon: "💰"}, + {UserID: userID, Name: "Lebensmittel", Description: "Einkäufe und Groceries", Color: "#f59e0b", Icon: "🛒"}, + {UserID: userID, Name: "Transport", Description: "ÖPNV, Benzin, etc.", Color: "#3b82f6", Icon: "🚗"}, + {UserID: userID, Name: "Unterhaltung", Description: "Kino, Restaurant, etc.", Color: "#8b5cf6", Icon: "🎬"}, + {UserID: userID, Name: "Miete", Description: "Wohnungsmiete", Color: "#ef4444", Icon: "🏠"}, + } + + for _, category := range categories { + if err := h.DB.Create(&category).Error; err != nil { + return err + } + } + + // Testdaten für die letzten 3 Monate generieren + now := time.Now() + transactions := []models.Transaction{} + + for i := 0; i < 90; i++ { // 90 Tage + date := now.AddDate(0, 0, -i) + + // Gehalt am ersten des Monats + if date.Day() == 1 { + transactions = append(transactions, models.Transaction{ + UserID: userID, + Amount: 3500.0, + Description: "Gehalt", + Type: "income", + Date: date, + CategoryID: &categories[0].ID, + }) + } + + // Miete am 1. des Monats + if date.Day() == 1 { + transactions = append(transactions, models.Transaction{ + UserID: userID, + Amount: 1200.0, + Description: "Wohnungsmiete", + Type: "expense", + Date: date, + CategoryID: &categories[4].ID, + }) + } + + // Zufällige tägliche Ausgaben + if i%3 == 0 { // Jeden 3. Tag + // Lebensmittel + transactions = append(transactions, models.Transaction{ + UserID: userID, + Amount: float64(15 + (i%50)), + Description: "Einkauf Supermarkt", + Type: "expense", + Date: date, + CategoryID: &categories[1].ID, + }) + } + + if i%7 == 0 { // Wöchentlich + // Transport + transactions = append(transactions, models.Transaction{ + UserID: userID, + Amount: float64(25 + (i%30)), + Description: "ÖPNV Ticket", + Type: "expense", + Date: date, + CategoryID: &categories[2].ID, + }) + } + + if i%10 == 0 { // Alle 10 Tage + // Unterhaltung + transactions = append(transactions, models.Transaction{ + UserID: userID, + Amount: float64(40 + (i%60)), + Description: "Restaurant / Kino", + Type: "expense", + Date: date, + CategoryID: &categories[3].ID, + }) + } + } + + // Alle Transaktionen in die Datenbank speichern + for _, transaction := range transactions { + if err := h.DB.Create(&transaction).Error; err != nil { + return err + } + } + + // Wiederkehrende Transaktionen erstellen + rules := []models.RecurrenceRule{ + { + UserID: userID, + Amount: 3500.0, + Description: "Monatliches Gehalt", + CategoryID: &categories[0].ID, + Type: "income", + Interval: "monthly", + IntervalCount: 1, + StartDate: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()), + DayOfMonth: intPtr(1), + IsActive: true, + }, + { + UserID: userID, + Amount: 1200.0, + Description: "Wohnungsmiete", + CategoryID: &categories[4].ID, + Type: "expense", + Interval: "monthly", + IntervalCount: 1, + StartDate: time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()), + DayOfMonth: intPtr(1), + IsActive: true, + }, + { + UserID: userID, + Amount: 80.0, + Description: "Wöchentlicher Einkauf", + CategoryID: &categories[1].ID, + Type: "expense", + Interval: "weekly", + IntervalCount: 1, + StartDate: time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), + DayOfWeek: intPtr(6), // Samstag + IsActive: true, + }, + } + + // Wiederkehrende Regeln speichern + for _, rule := range rules { + if err := h.DB.Create(&rule).Error; err != nil { + return err + } + } + + return nil +} + +// intPtr gibt einen Pointer auf einen int zurück +func intPtr(i int) *int { + return &i +} + +// TestRecurringTransactions ist eine Test-Route für wiederkehrende Transaktionen +func (h *Handler) TestRecurringTransactions(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Wiederkehrende Transaktionen verarbeiten + err := h.processRecurringTransactions(userID.(uint)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Aktuelle Transaktionen abfragen + var transactions []models.Transaction + h.DB.Where("user_id = ? AND is_recurring = ?", userID, true). + Preload("Category"). + Order("date DESC"). + Limit(20). + Find(&transactions) + + c.JSON(http.StatusOK, gin.H{ + "message": "Wiederkehrende Transaktionen verarbeitet", + "recurring_transactions": transactions, + }) +} + +// UpdateRecurrenceRule aktualisiert eine wiederkehrende Transaktion +func (h *Handler) UpdateRecurrenceRule(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // RecurrenceRule ID aus URL Parameter + ruleIDStr := c.Param("id") + ruleID, err := strconv.ParseUint(ruleIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Regel-ID"}) + return + } + + // Existierende Regel laden + var rule models.RecurrenceRule + if err := h.DB.Where("id = ? AND user_id = ?", ruleID, userID).First(&rule).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Regel nicht gefunden"}) + return + } + + // Request Body parsen + var updateData struct { + Amount *float64 `json:"amount"` + Description *string `json:"description"` + CategoryID *uint `json:"category_id"` + Type *string `json:"type"` + Interval *string `json:"interval"` + IntervalCount *int `json:"interval_count"` + DayOfWeek *int `json:"day_of_week"` + DayOfMonth *int `json:"day_of_month"` + IsActive *bool `json:"is_active"` + StartDate *string `json:"start_date"` + EndDate *string `json:"end_date"` + } + + if err := c.ShouldBindJSON(&updateData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Daten"}) + return + } + + // Felder aktualisieren (nur wenn sie gesetzt sind) + if updateData.Amount != nil { + rule.Amount = *updateData.Amount + } + if updateData.Description != nil { + rule.Description = *updateData.Description + } + if updateData.CategoryID != nil { + rule.CategoryID = updateData.CategoryID + } + if updateData.Type != nil { + rule.Type = *updateData.Type + } + if updateData.Interval != nil { + rule.Interval = *updateData.Interval + } + if updateData.IntervalCount != nil { + rule.IntervalCount = *updateData.IntervalCount + } + if updateData.DayOfWeek != nil { + rule.DayOfWeek = updateData.DayOfWeek + } + if updateData.DayOfMonth != nil { + rule.DayOfMonth = updateData.DayOfMonth + } + if updateData.IsActive != nil { + rule.IsActive = *updateData.IsActive + } + if updateData.StartDate != nil { + if startDate, err := time.Parse("2006-01-02", *updateData.StartDate); err == nil { + rule.StartDate = startDate + } + } + if updateData.EndDate != nil { + if *updateData.EndDate == "" { + rule.EndDate = nil + } else { + if endDate, err := time.Parse("2006-01-02", *updateData.EndDate); err == nil { + rule.EndDate = &endDate + } + } + } + + // Regel speichern + if err := h.DB.Save(&rule).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Speichern"}) + return + } + + // Aktualisierte Regel mit Kategorie laden + h.DB.Preload("Category").First(&rule, rule.ID) + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "rule": rule, + }) +} + +// GetRecurrenceRules lädt alle wiederkehrenden Transaktionen eines Benutzers +func (h *Handler) GetRecurrenceRules(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Alle Regeln des Benutzers laden + var rules []models.RecurrenceRule + if err := h.DB.Where("user_id = ?", userID). + Preload("Category"). + Order("created_at DESC"). + Find(&rules).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Laden der Regeln"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "rules": rules, + }) +} + +// DeleteRecurrenceRule löscht eine wiederkehrende Transaktion +func (h *Handler) DeleteRecurrenceRule(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // RecurrenceRule ID aus URL Parameter + ruleIDStr := c.Param("id") + ruleID, err := strconv.ParseUint(ruleIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Regel-ID"}) + return + } + + // Regel löschen (nur wenn sie dem Benutzer gehört) + result := h.DB.Where("id = ? AND user_id = ?", ruleID, userID).Delete(&models.RecurrenceRule{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Löschen"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Regel nicht gefunden"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// processRecurringTransactions generiert alle fälligen wiederkehrenden Transaktionen +func (h *Handler) processRecurringTransactions(userID uint) error { + // Alle aktiven Wiederholungsregeln für den Benutzer laden + var rules []models.RecurrenceRule + if err := h.DB.Where("user_id = ? AND is_active = ?", userID, true).Find(&rules).Error; err != nil { + return err + } + + // Für jede Regel prüfen, ob neue Transaktionen generiert werden müssen + for _, rule := range rules { + // Bis zu 10 Transaktionen pro Regel generieren (um Endlosschleifen zu vermeiden) + for i := 0; i < 10; i++ { + transaction, err := rule.GenerateTransaction(h.DB) + if err != nil { + // Fehler loggen, aber weitermachen + fmt.Printf("Fehler beim Generieren einer wiederkehrenden Transaktion (Regel ID %d): %v\n", rule.ID, err) + break + } + + if transaction == nil { + // Keine weitere Transaktion zu generieren + break + } + + fmt.Printf("Wiederkehrende Transaktion generiert: %s (€%.2f) für %s\n", + transaction.Description, transaction.Amount, transaction.Date.Format("02.01.2006")) + } + } + + return nil +} + +// ShowRecurringTransactions zeigt die Verwaltungsseite für wiederkehrende Transaktionen +func (h *Handler) ShowRecurringTransactions(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login?error=required") + return + } + + // Benutzer aus der Datenbank laden + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/login?error=user_not_found") + return + } + + // Alle Regeln des Benutzers laden + var rules []models.RecurrenceRule + if err := h.DB.Where("user_id = ?", userID). + Preload("Category"). + Order("created_at DESC"). + Find(&rules).Error; err != nil { + c.String(http.StatusInternalServerError, "Fehler beim Laden der wiederkehrenden Transaktionen") + return + } + + // Template rendern + component := views.RecurringTransactions(user.Name, rules) + component.Render(c.Request.Context(), c.Writer) +} + +// ShowSettings zeigt die Einstellungsseite an +func (h *Handler) ShowSettings(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login") + return + } + + // Benutzer laden + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.String(http.StatusInternalServerError, "Fehler beim Laden der Benutzerdaten") + return + } + + // Kategorien laden + var categories []models.Category + h.DB.Where("user_id = ?", userID).Find(&categories) + + // Bankkonten laden + var bankAccounts []models.BankAccount + h.DB.Where("user_id = ?", userID).Find(&bankAccounts) + + // Template rendern + views.Settings(user.Name, user, categories, bankAccounts).Render(c.Request.Context(), c.Writer) +} + +// UpdateUserSettings aktualisiert die Benutzereinstellungen +func (h *Handler) UpdateUserSettings(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // JSON Body parsen + var reqData struct { + Username string `json:"username"` + } + + if err := c.ShouldBindJSON(&reqData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Daten"}) + return + } + + // Benutzer aktualisieren (nur Name, E-Mail bleibt unverändert) + result := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("name", reqData.Username) + + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Speichern"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// UpdatePassword ändert das Benutzerpasswort +func (h *Handler) UpdatePassword(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // JSON Body parsen + var reqData struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + + if err := c.ShouldBindJSON(&reqData); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Daten"}) + return + } + + // Benutzer laden + var user models.User + if err := h.DB.First(&user, userID).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Laden der Benutzerdaten"}) + return + } + + // Aktuelles Passwort überprüfen + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(reqData.CurrentPassword)); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Aktuelles Passwort ist falsch"}) + return + } + + // Neues Passwort hashen + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(reqData.NewPassword), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Hashen des Passworts"}) + return + } + + // Passwort aktualisieren + result := h.DB.Model(&user).Update("password_hash", string(hashedPassword)) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Speichern des Passworts"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// CreateCategory erstellt eine neue Kategorie +func (h *Handler) CreateCategory(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.Redirect(http.StatusSeeOther, "/login") + return + } + + // Form-Daten lesen + name := c.PostForm("name") + icon := c.PostForm("icon") + color := c.PostForm("color") + + if name == "" || icon == "" { + c.Redirect(http.StatusSeeOther, "/settings?error=invalid_data") + return + } + + // Kategorie erstellen + category := models.Category{ + Name: name, + Icon: icon, + Color: color, + UserID: userID.(uint), + } + + if err := h.DB.Create(&category).Error; err != nil { + c.Redirect(http.StatusSeeOther, "/settings?error=create_failed") + return + } + + c.Redirect(http.StatusSeeOther, "/settings") +} + +// DeleteCategory löscht eine Kategorie +func (h *Handler) DeleteCategory(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Category ID aus URL Parameter + categoryIDStr := c.Param("id") + categoryID, err := strconv.ParseUint(categoryIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Kategorie-ID"}) + return + } + + // Kategorie löschen (nur wenn sie dem Benutzer gehört) + result := h.DB.Where("id = ? AND user_id = ?", categoryID, userID).Delete(&models.Category{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Löschen"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Kategorie nicht gefunden"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} + +// DeleteBankAccount löscht ein Bankkonto +func (h *Handler) DeleteBankAccount(c *gin.Context) { + // Session überprüfen + session, _ := h.Store.Get(c.Request, "user-session") + userID, ok := session.Values["user_id"] + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Nicht angemeldet"}) + return + } + + // Account ID aus URL Parameter + accountIDStr := c.Param("id") + accountID, err := strconv.ParseUint(accountIDStr, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Ungültige Konto-ID"}) + return + } + + // Konto löschen (nur wenn es dem Benutzer gehört) + result := h.DB.Where("id = ? AND user_id = ?", accountID, userID).Delete(&models.BankAccount{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Fehler beim Löschen"}) + return + } + + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Konto nicht gefunden"}) + return + } + + c.JSON(http.StatusOK, gin.H{"success": true}) +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..306c2ac --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,336 @@ +package models + +import ( + "time" + + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" +) + +// User repräsentiert einen Benutzer des Systems +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `json:"name" gorm:"not null"` + Email string `json:"email" gorm:"uniqueIndex;not null"` + PasswordHash string `json:"-" gorm:"not null"` // JSON-Tag "-" versteckt das Feld in API-Responses + BankAccounts []BankAccount `gorm:"foreignKey:UserID" json:"bank_accounts,omitempty"` + Depots []Depot `gorm:"foreignKey:UserID" json:"depots,omitempty"` + Transactions []Transaction `gorm:"foreignKey:UserID" json:"transactions,omitempty"` + Categories []Category `gorm:"foreignKey:UserID" json:"categories,omitempty"` + RecurrenceRules []RecurrenceRule `gorm:"foreignKey:UserID" json:"recurrence_rules,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// BankAccount repräsentiert ein Bankkonto eines Benutzers +type BankAccount struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id" gorm:"not null"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Name string `json:"name" gorm:"not null"` + Bank string `json:"bank" gorm:"not null"` + IBAN string `json:"iban"` + Balance float64 `json:"balance" gorm:"default:0"` + AccountType string `json:"account_type" gorm:"not null"` // "checking", "savings", "credit" + Transactions []Transaction `gorm:"foreignKey:BankAccountID" json:"transactions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Depot repräsentiert ein Wertpapierdepot eines Benutzers +type Depot struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id" gorm:"not null"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Name string `json:"name" gorm:"not null"` + Broker string `json:"broker" gorm:"not null"` + DepotNumber string `json:"depot_number"` + TotalValue float64 `json:"total_value" gorm:"default:0"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Category repräsentiert eine Transaktionskategorie +type Category struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id" gorm:"not null"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + Name string `json:"name" gorm:"not null"` + Description string `json:"description"` + Color string `json:"color" gorm:"default:'#6B7280'"` // Default-Farbe + Icon string `json:"icon" gorm:"default:'💰'"` // Default-Icon + Transactions []Transaction `gorm:"foreignKey:CategoryID" json:"transactions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Transaction repräsentiert eine Finanztransaktion +type Transaction struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id" gorm:"not null"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + BankAccountID *uint `json:"bank_account_id"` // Optional: kann NULL sein für Depot-Transaktionen + BankAccount *BankAccount `gorm:"foreignKey:BankAccountID" json:"bank_account,omitempty"` + CategoryID *uint `json:"category_id"` // Optional + Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Amount float64 `json:"amount" gorm:"not null"` + Description string `json:"description" gorm:"not null"` + Type string `json:"type" gorm:"not null"` // "income" oder "expense" + Date time.Time `json:"date" gorm:"not null"` + IsRecurring bool `json:"is_recurring" gorm:"default:false"` + RecurrenceRuleID *uint `json:"recurrence_rule_id"` // Optional: Link zur Wiederholungsregel + RecurrenceRule *RecurrenceRule `gorm:"foreignKey:RecurrenceRuleID" json:"recurrence_rule,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RecurrenceRule definiert die Regeln für wiederkehrende Transaktionen +type RecurrenceRule struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `json:"user_id" gorm:"not null"` + User User `gorm:"foreignKey:UserID" json:"user,omitempty"` + + // Template-Informationen für neue Transaktionen + Amount float64 `json:"amount" gorm:"not null"` + Description string `json:"description" gorm:"not null"` + CategoryID *uint `json:"category_id"` + Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"` + Type string `json:"type" gorm:"not null"` // "income" or "expense" + BankAccountID *uint `json:"bank_account_id"` + BankAccount *BankAccount `gorm:"foreignKey:BankAccountID" json:"bank_account,omitempty"` + + // Wiederholungsregeln + Interval string `json:"interval" gorm:"not null"` // "daily", "weekly", "monthly", "yearly" + IntervalCount int `json:"interval_count" gorm:"default:1"` // z.B. alle 2 Wochen = weekly + 2 + StartDate time.Time `json:"start_date" gorm:"not null"` + EndDate *time.Time `json:"end_date"` // Optional: NULL bedeutet unbegrenzt + DayOfWeek *int `json:"day_of_week"` // 0=Sonntag, 1=Montag, etc. (für wöchentlich) + DayOfMonth *int `json:"day_of_month"` // 1-31 (für monatlich) + IsActive bool `json:"is_active" gorm:"default:true"` + LastGenerated *time.Time `json:"last_generated"` // Letztes generiertes Datum + + // Beziehungen zu generierten Transaktionen + Transactions []Transaction `gorm:"foreignKey:RecurrenceRuleID" json:"transactions,omitempty"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Dashboard-spezifische Strukturen für Auswertungen + +// AssetOverview stellt eine Vermögensübersicht dar +type AssetOverview struct { + TotalBankBalance float64 `json:"total_bank_balance"` + TotalDepotValue float64 `json:"total_depot_value"` + TotalAssets float64 `json:"total_assets"` + BankAccounts []BankAccountSummary `json:"bank_accounts"` + Depots []DepotSummary `json:"depots"` +} + +// BankAccountSummary für Dashboard-Übersicht +type BankAccountSummary struct { + ID uint `json:"id"` + Name string `json:"name"` + Bank string `json:"bank"` + AccountType string `json:"account_type"` + Balance float64 `json:"balance"` +} + +// DepotSummary für Dashboard-Übersicht +type DepotSummary struct { + ID uint `json:"id"` + Name string `json:"name"` + Broker string `json:"broker"` + TotalValue float64 `json:"total_value"` +} + +// MonthlyStats für monatliche Statistiken +type MonthlyStats struct { + Month string `json:"month"` + Income float64 `json:"income"` + Expenses float64 `json:"expenses"` + NetChange float64 `json:"net_change"` + Balance float64 `json:"balance"` +} + +// WeeklyStats für wöchentliche Statistiken +type WeeklyStats struct { + Week string `json:"week"` + Income float64 `json:"income"` + Expenses float64 `json:"expenses"` + NetChange float64 `json:"net_change"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` +} + +// CategoryStats für Kategorie-Auswertungen +type CategoryStats struct { + CategoryID uint `json:"category_id"` + CategoryName string `json:"category_name"` + TotalAmount float64 `json:"total_amount"` + Count int `json:"count"` + Percentage float64 `json:"percentage"` +} + +// TransactionTrend für Verlaufsdaten +type TransactionTrend struct { + Date time.Time `json:"date"` + Income float64 `json:"income"` + Expense float64 `json:"expense"` + Balance float64 `json:"balance"` +} + +// DashboardData kombiniert alle Dashboard-Daten +type DashboardData struct { + UserName string `json:"user_name"` + AssetOverview AssetOverview `json:"asset_overview"` + MonthlyStats []MonthlyStats `json:"monthly_stats"` + WeeklyStats []WeeklyStats `json:"weekly_stats"` + CategoryStats []CategoryStats `json:"category_stats"` + TransactionTrend []TransactionTrend `json:"transaction_trend"` + RecentTransactions []Transaction `json:"recent_transactions"` +} + +// HashPassword erstellt einen bcrypt Hash des Passworts +func (u *User) HashPassword(password string) error { + hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return err + } + u.PasswordHash = string(hashedBytes) + return nil +} + +// CheckPassword vergleicht das eingegebene Passwort mit dem Hash +func (u *User) CheckPassword(password string) bool { + err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) + return err == nil +} + +// IsRecurringTransaction prüft, ob eine Transaktion wiederkehrend ist +func (t *Transaction) IsRecurringTransaction() bool { + return t.IsRecurring && t.RecurrenceRule != nil +} + +// GetNextOccurrence berechnet das nächste Auftreten einer wiederkehrenden Transaktion +func (r *RecurrenceRule) GetNextOccurrence(from time.Time) *time.Time { + if !r.IsActive { + return nil + } + + var next time.Time + + switch r.Interval { + case "daily": + next = from.AddDate(0, 0, r.IntervalCount) + case "weekly": + next = from.AddDate(0, 0, 7*r.IntervalCount) + // Wenn ein bestimmter Wochentag gewünscht ist + if r.DayOfWeek != nil { + daysUntilTargetDay := (*r.DayOfWeek - int(from.Weekday()) + 7) % 7 + if daysUntilTargetDay == 0 && from.Equal(r.StartDate) { + daysUntilTargetDay = 7 * r.IntervalCount + } + next = from.AddDate(0, 0, daysUntilTargetDay) + } + case "monthly": + next = from.AddDate(0, r.IntervalCount, 0) + // Wenn ein bestimmter Tag im Monat gewünscht ist + if r.DayOfMonth != nil && *r.DayOfMonth >= 1 && *r.DayOfMonth <= 31 { + next = time.Date(next.Year(), next.Month(), *r.DayOfMonth, next.Hour(), next.Minute(), next.Second(), next.Nanosecond(), next.Location()) + } + case "yearly": + next = from.AddDate(r.IntervalCount, 0, 0) + default: + return nil + } + + // Prüfen, ob das nächste Datum nach dem Enddatum liegt + if r.EndDate != nil && next.After(*r.EndDate) { + return nil + } + + return &next +} + +// CanGenerate prüft, ob eine neue Instanz der wiederkehrenden Transaktion generiert werden kann +func (r *RecurrenceRule) CanGenerate() bool { + if !r.IsActive { + return false + } + + now := time.Now() + + // Prüfen, ob wir noch vor dem Enddatum sind + if r.EndDate != nil && now.After(*r.EndDate) { + return false + } + + // Prüfen, ob es Zeit für die nächste Generierung ist + lastDate := r.StartDate + if r.LastGenerated != nil { + lastDate = *r.LastGenerated + } + + nextOccurrence := r.GetNextOccurrence(lastDate) + if nextOccurrence == nil { + return false + } + + // Geändert: Generiere Transaktionen für die nächsten 12 Monate (für Dashboard-Trend) + future12Months := now.AddDate(1, 0, 0) + if nextOccurrence.After(future12Months) { + return false + } + + return true +} + +// GenerateTransaction erstellt eine neue Transaktion basierend auf der Wiederholungsregel +func (r *RecurrenceRule) GenerateTransaction(db *gorm.DB) (*Transaction, error) { + if !r.CanGenerate() { + return nil, nil // Keine Transaktion zu generieren + } + + // Nächstes Datum berechnen + lastDate := r.StartDate + if r.LastGenerated != nil { + lastDate = *r.LastGenerated + } + + nextDate := r.GetNextOccurrence(lastDate) + if nextDate == nil { + return nil, nil + } + + // Neue Transaktion erstellen + transaction := &Transaction{ + UserID: r.UserID, + BankAccountID: r.BankAccountID, + Amount: r.Amount, + Description: r.Description, + CategoryID: r.CategoryID, + Type: r.Type, + Date: *nextDate, + IsRecurring: true, + RecurrenceRuleID: &r.ID, + } + + // Transaktion in der Datenbank speichern + if err := db.Create(transaction).Error; err != nil { + return nil, err + } + + // LastGenerated aktualisieren + r.LastGenerated = nextDate + if err := db.Save(r).Error; err != nil { + return nil, err + } + + return transaction, nil +} + +// Migrate führt die Datenbank-Migration durch +func Migrate(db *gorm.DB) error { + return db.AutoMigrate(&User{}, &Category{}, &Transaction{}, &BankAccount{}, &Depot{}, &RecurrenceRule{}) +} diff --git a/internal/views/accounts.templ b/internal/views/accounts.templ new file mode 100644 index 0000000..2c2fd12 --- /dev/null +++ b/internal/views/accounts.templ @@ -0,0 +1,265 @@ +package views + +import ( + "whereismymoney/internal/models" + "fmt" +) + +templ Accounts(userName string, bankAccounts []models.BankAccount, depots []models.Depot) { + @Layout("Konten & Depots - WhereIsMyMoney") { + @Navigation(userName) + + +
+
+
+ +
+

Konten & Depots

+

Verwalte deine Bankkonten und Wertpapierdepots

+
+ + +
+ + +
+ +
+ +
+

Bankkonten

+ if len(bankAccounts) == 0 { +
+ + + +

Keine Bankkonten

+

Füge dein erstes Bankkonto hinzu, um zu beginnen.

+
+ } else { +
+ for _, account := range bankAccounts { +
+
+
+

{ account.Name }

+

{ account.Bank }

+

{ account.AccountType }

+ if account.IBAN != "" { +

{ account.IBAN }

+ } +
+
+

+ { fmt.Sprintf("%.2f", account.Balance) } € +

+ + Aktiv + +
+
+
+ + +
+
+ } +
+ } +
+ + +
+

Depots

+ if len(depots) == 0 { +
+ + + +

Keine Depots

+

Füge dein erstes Depot hinzu, um zu beginnen.

+
+ } else { +
+ for _, depot := range depots { +
+
+
+

{ depot.Name }

+

{ depot.Broker }

+ if depot.DepotNumber != "" { +

Depot: { depot.DepotNumber }

+ } +
+
+

+ { fmt.Sprintf("%.2f", depot.TotalValue) } € +

+ + Aktiv + +
+
+
+ + +
+
+ } +
+ } +
+
+
+
+
+ + + + + + + + + } +} diff --git a/internal/views/accounts_templ.go b/internal/views/accounts_templ.go new file mode 100644 index 0000000..46ed8d1 --- /dev/null +++ b/internal/views/accounts_templ.go @@ -0,0 +1,254 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "whereismymoney/internal/models" +) + +func Accounts(userName string, bankAccounts []models.BankAccount, depots []models.Depot) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = Navigation(userName).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Konten & Depots

Verwalte deine Bankkonten und Wertpapierdepots

Bankkonten

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(bankAccounts) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Keine Bankkonten

Füge dein erstes Bankkonto hinzu, um zu beginnen.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, account := range bankAccounts { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(account.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 60, Col: 73} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(account.Bank) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 61, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(account.AccountType) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 62, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if account.IBAN != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(account.IBAN) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 64, Col: 66} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", account.Balance)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 69, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €

Aktiv
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Depots

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(depots) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Keine Depots

Füge dein erstes Depot hinzu, um zu beginnen.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, depot := range depots { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(depot.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 107, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(depot.Broker) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 108, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if depot.DepotNumber != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Depot: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(depot.DepotNumber) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 110, Col: 78} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", depot.TotalValue)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/accounts.templ`, Line: 115, Col: 53} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €

Aktiv
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Konten & Depots - WhereIsMyMoney").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/dashboard.templ b/internal/views/dashboard.templ new file mode 100644 index 0000000..7a90dce --- /dev/null +++ b/internal/views/dashboard.templ @@ -0,0 +1,138 @@ +package views + +import ( + "fmt" + "whereismymoney/internal/models" +) + +templ Dashboard(data *models.DashboardData) { + @Layout("WhereIsMyMoney") { + @Navigation(data.UserName) + + +
+
+
+ +
+

Dashboard

+

Willkommen zurück, { data.UserName }!

+
+ + +
+
+

Depots

+

€{ fmt.Sprintf("%.2f", data.AssetOverview.TotalDepotValue) }

+

{ fmt.Sprintf("%d Depots", len(data.AssetOverview.Depots)) }

+
+ +
+

Gesamtvermögen

+

€{ fmt.Sprintf("%.2f", data.AssetOverview.TotalAssets) }

+

Bank + Depot

+
+
+ + +
+ +
+

Monatliche Entwicklung

+
+ +
+
+ + +
+

Einnahmen vs Ausgaben

+
+ +
+
+
+ + +
+

12-Monats Vorausschau

+
+ +
+
+ + +
+ + if len(data.AssetOverview.Depots) > 0 { +
+
+

Depots

+
+
+ for _, depot := range data.AssetOverview.Depots { +
+
+
+

{ depot.Name }

+

{ depot.Broker }

+
+

€{ fmt.Sprintf("%.2f", depot.TotalValue) }

+
+
+ } +
+
+ } +
+ + + if len(data.RecentTransactions) > 0 { +
+
+

Aktuelle Transaktionen

+
+
+ for _, transaction := range data.RecentTransactions { +
+
+
+

{ transaction.Description }

+
+ { transaction.Date.Format("02.01.2006") } + if transaction.Category != nil { + + { transaction.Category.Name } + } + if transaction.BankAccount != nil { + + { transaction.BankAccount.Name } + } +
+
+
+ if transaction.Type == "income" { +

+€{ fmt.Sprintf("%.2f", transaction.Amount) }

+ } else { +

-€{ fmt.Sprintf("%.2f", transaction.Amount) }

+ } +

{ transaction.Type }

+
+
+
+ } +
+ +
+ } + + + + +
+
+
+ } +} diff --git a/internal/views/dashboard_templ.go b/internal/views/dashboard_templ.go new file mode 100644 index 0000000..32d6a20 --- /dev/null +++ b/internal/views/dashboard_templ.go @@ -0,0 +1,320 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "whereismymoney/internal/models" +) + +func Dashboard(data *models.DashboardData) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = Navigation(data.UserName).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Dashboard

Willkommen zurück, ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.UserName) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 19, Col: 71} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("!

Depots

€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", data.AssetOverview.TotalDepotValue)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 26, Col: 111} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d Depots", len(data.AssetOverview.Depots))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 27, Col: 98} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Gesamtvermögen

€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", data.AssetOverview.TotalAssets)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 32, Col: 109} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Bank + Depot

Monatliche Entwicklung

Einnahmen vs Ausgaben

12-Monats Vorausschau

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.AssetOverview.Depots) > 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Depots

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, depot := range data.AssetOverview.Depots { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(depot.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 77, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(depot.Broker) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 78, Col: 60} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", depot.TotalValue)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 80, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(data.RecentTransactions) > 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Aktuelle Transaktionen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, transaction := range data.RecentTransactions { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 100, Col: 82} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Date.Format("02.01.2006")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 102, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.Category != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 105, Col: 47} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + if transaction.BankAccount != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.BankAccount.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 109, Col: 50} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.Type == "income" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

+€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", transaction.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 115, Col: 106} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

-€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", transaction.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 117, Col: 104} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Type) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/dashboard.templ`, Line: 119, Col: 63} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("WhereIsMyMoney").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/layout.templ b/internal/views/layout.templ new file mode 100644 index 0000000..6738230 --- /dev/null +++ b/internal/views/layout.templ @@ -0,0 +1,16 @@ +package views + +templ Layout(title string) { + + + + + + { title } + + + + { children... } + + +} diff --git a/internal/views/layout_templ.go b/internal/views/layout_templ.go new file mode 100644 index 0000000..2ac33f6 --- /dev/null +++ b/internal/views/layout_templ.go @@ -0,0 +1,61 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Layout(title string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var2 string + templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/layout.templ`, Line: 9, Col: 16} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/login.templ b/internal/views/login.templ new file mode 100644 index 0000000..399554d --- /dev/null +++ b/internal/views/login.templ @@ -0,0 +1,66 @@ +package views + +templ LoginPage(errorMsg string) { + @Layout("Anmelden") { +
+
+
+

+ Bei WhereIsMyMoney anmelden +

+

+ Oder + + neues Konto erstellen + +

+
+ + if errorMsg != "" { +
+
+
+ + + +
+
+

+ { errorMsg } +

+
+
+
+ } + +
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+ +
+
+
+
+ } +} diff --git a/internal/views/login_templ.go b/internal/views/login_templ.go new file mode 100644 index 0000000..0e8a93b --- /dev/null +++ b/internal/views/login_templ.go @@ -0,0 +1,81 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func LoginPage(errorMsg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Bei WhereIsMyMoney anmelden

Oder neues Konto erstellen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errorMsg != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/login.templ`, Line: 29, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Anmelden").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/navigation.templ b/internal/views/navigation.templ new file mode 100644 index 0000000..8c8a7f8 --- /dev/null +++ b/internal/views/navigation.templ @@ -0,0 +1,129 @@ +package views + +templ Navigation(userName string) { + + + + +} diff --git a/internal/views/navigation_templ.go b/internal/views/navigation_templ.go new file mode 100644 index 0000000..557a946 --- /dev/null +++ b/internal/views/navigation_templ.go @@ -0,0 +1,79 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func Navigation(userName string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/recurring.templ b/internal/views/recurring.templ new file mode 100644 index 0000000..a1b4058 --- /dev/null +++ b/internal/views/recurring.templ @@ -0,0 +1,324 @@ +package views + +import ( + "fmt" + "whereismymoney/internal/models" +) + +templ RecurringTransactions(userName string, rules []models.RecurrenceRule) { + @Layout("Wiederkehrende Transaktionen - WhereIsMyMoney") { + @Navigation(userName) + + +
+
+
+ +
+

Wiederkehrende Transaktionen

+

Verwalten Sie Ihre regelmäßigen Ein- und Ausgaben

+
+ + +
+
+

Ihre wiederkehrenden Transaktionen

+
+ + if len(rules) == 0 { +
+

Noch keine wiederkehrenden Transaktionen vorhanden.

+
+ } else { +
+ for _, rule := range rules { +
+
+
+
+

{ rule.Description }

+ if rule.IsActive { + + Aktiv + + } else { + + Inaktiv + + } +
+ +
+
+ Betrag: + if rule.Type == "income" { + +€{ fmt.Sprintf("%.2f", rule.Amount) } + } else { + -€{ fmt.Sprintf("%.2f", rule.Amount) } + } +
+ +
+ Intervall: + + switch rule.Interval { + case "daily": + if rule.IntervalCount == 1 { + Täglich + } else { + Alle { fmt.Sprintf("%d", rule.IntervalCount) } Tage + } + case "weekly": + if rule.IntervalCount == 1 { + Wöchentlich + } else { + Alle { fmt.Sprintf("%d", rule.IntervalCount) } Wochen + } + case "monthly": + if rule.IntervalCount == 1 { + Monatlich + } else { + Alle { fmt.Sprintf("%d", rule.IntervalCount) } Monate + } + case "yearly": + if rule.IntervalCount == 1 { + Jährlich + } else { + Alle { fmt.Sprintf("%d", rule.IntervalCount) } Jahre + } + default: + { rule.Interval } + } + +
+ + if rule.Category != nil { +
+ Kategorie: + { rule.Category.Name } +
+ } + +
+ Start: + { rule.StartDate.Format("02.01.2006") } +
+ + if rule.EndDate != nil { +
+ Ende: + { rule.EndDate.Format("02.01.2006") } +
+ } +
+
+ +
+ + + + + +
+
+
+ } +
+ } +
+ + + + + + +
+
+
+ } +} diff --git a/internal/views/recurring_templ.go b/internal/views/recurring_templ.go new file mode 100644 index 0000000..5ec74ea --- /dev/null +++ b/internal/views/recurring_templ.go @@ -0,0 +1,504 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "whereismymoney/internal/models" +) + +func RecurringTransactions(userName string, rules []models.RecurrenceRule) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = Navigation(userName).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Wiederkehrende Transaktionen

Verwalten Sie Ihre regelmäßigen Ein- und Ausgaben

Ihre wiederkehrenden Transaktionen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(rules) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Noch keine wiederkehrenden Transaktionen vorhanden.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, rule := range rules { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var4 string + templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 39, Col: 77} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.IsActive { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Aktiv") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Inaktiv") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Betrag: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.Type == "income" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("+€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", rule.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 55, Col: 101} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("-€") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", rule.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 57, Col: 99} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Intervall: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + switch rule.Interval { + case "daily": + if rule.IntervalCount == 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Täglich") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Alle ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var7 string + templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", rule.IntervalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 69, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" Tage") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + case "weekly": + if rule.IntervalCount == 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Wöchentlich") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Alle ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", rule.IntervalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 75, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" Wochen") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + case "monthly": + if rule.IntervalCount == 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Monatlich") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Alle ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", rule.IntervalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 81, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" Monate") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + case "yearly": + if rule.IntervalCount == 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Jährlich") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Alle ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", rule.IntervalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 87, Col: 62} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" Jahre") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + default: + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Interval) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 90, Col: 32} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.Category != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Kategorie: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 98, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Start: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(rule.StartDate.Format("02.01.2006")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 104, Col: 70} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.EndDate != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Ende: ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(rule.EndDate.Format("02.01.2006")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/recurring.templ`, Line: 110, Col: 69} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Wiederkehrende Transaktion bearbeiten

Leer lassen für unbegrenzte Laufzeit

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Wiederkehrende Transaktionen - WhereIsMyMoney").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/register.templ b/internal/views/register.templ new file mode 100644 index 0000000..269a24e --- /dev/null +++ b/internal/views/register.templ @@ -0,0 +1,65 @@ +package views + +templ RegisterPage(errorMsg string) { + @Layout("Registrieren") { +
+
+
+

+ Neues Konto erstellen +

+

+ Oder + + mit bestehendem Konto anmelden + +

+
+ + if errorMsg != "" { +
+
+
+ + + +
+
+

+ { errorMsg } +

+
+
+
+ } + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+ } +} diff --git a/internal/views/register_templ.go b/internal/views/register_templ.go new file mode 100644 index 0000000..a321069 --- /dev/null +++ b/internal/views/register_templ.go @@ -0,0 +1,81 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func RegisterPage(errorMsg string) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Neues Konto erstellen

Oder mit bestehendem Konto anmelden

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if errorMsg != "" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var3 string + templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/register.templ`, Line: 29, Col: 19} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Registrieren").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/settings.templ b/internal/views/settings.templ new file mode 100644 index 0000000..0562158 --- /dev/null +++ b/internal/views/settings.templ @@ -0,0 +1,312 @@ +package views + +import ( + "whereismymoney/internal/models" + "fmt" +) + +templ Settings(userName string, user models.User, categories []models.Category, bankAccounts []models.BankAccount) { + @Layout("Einstellungen - WhereIsMyMoney") { +
+ @Navigation(userName) + +
+
+
+

Einstellungen

+

Verwalte deine Kontoinformationen und App-Einstellungen

+
+ +
+ +
+
+
+

Benutzereinstellungen

+
+
+
+
+ + +
+
+ + +

Die E-Mail-Adresse dient als eindeutige Benutzer-ID und kann nicht geändert werden.

+
+
+ +
+
+
+
+ + +
+
+

Passwort ändern

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+ + +
+ +
+
+

Kategorien

+
+
+
+ for _, category := range categories { +
+ { category.Icon } { category.Name } + +
+ } +
+ +
+
+ + +
+
+

Bankkonten

+
+
+
+ for _, account := range bankAccounts { +
+
+
{ account.Name }
+
{ account.Bank }
+
+ +
+ } +
+ + Konten verwalten + +
+
+ + +
+
+

App-Informationen

+
+
+
Version: 1.0.0
+
Erstellt mit Go & Templ
+
© 2025 WhereIsMyMoney
+
+
+
+
+
+
+
+ + + + + + } +} diff --git a/internal/views/settings_templ.go b/internal/views/settings_templ.go new file mode 100644 index 0000000..105089d --- /dev/null +++ b/internal/views/settings_templ.go @@ -0,0 +1,195 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "whereismymoney/internal/models" +) + +func Settings(userName string, user models.User, categories []models.Category, bankAccounts []models.BankAccount) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = Navigation(userName).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Einstellungen

Verwalte deine Kontoinformationen und App-Einstellungen

Benutzereinstellungen

Die E-Mail-Adresse dient als eindeutige Benutzer-ID und kann nicht geändert werden.

Passwort ändern

Kategorien

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, category := range categories { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var5 string + templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(category.Icon) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/settings.templ`, Line: 87, Col: 49} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var6 string + templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/settings.templ`, Line: 87, Col: 67} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Bankkonten

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + for _, account := range bankAccounts { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var8 string + templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(account.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/settings.templ`, Line: 110, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var9 string + templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(account.Bank) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/settings.templ`, Line: 111, Col: 54} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Konten verwalten

App-Informationen

Version: 1.0.0
Erstellt mit Go & Templ
© 2025 WhereIsMyMoney
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Einstellungen - WhereIsMyMoney").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/views/transactions.templ b/internal/views/transactions.templ new file mode 100644 index 0000000..c9af05f --- /dev/null +++ b/internal/views/transactions.templ @@ -0,0 +1,1228 @@ +package views + +import ( + "whereismymoney/internal/models" + "fmt" + "time" +) + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +templ Transactions(userName string, transactions []models.Transaction, bankAccounts []models.BankAccount, categories []models.Category, recurrenceRules []models.RecurrenceRule, currentPage int, totalPages int, totalCount int64) { + @Layout("Transaktionen - WhereIsMyMoney") { + @Navigation(userName) + + +
+
+
+ +
+

Transaktionen

+

Verwalte deine Einnahmen und Ausgaben

+
+ + +
+ + +
+ + +
+
+

Filter & Suche

+
+ +
+ + +
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+
+ + +
+ + +
+ Alle Transaktionen werden angezeigt +
+
+
+
+ + +
+
+ +
+
+ + +
+
+
+

Letzte Transaktionen

+ +
+
+ + + + + + + + + + + + + + if len(transactions) == 0 { + + + + } + for _, transaction := range transactions { + + + + + + + + + + } + +
+ + DatumBeschreibungKategorieKontoBetragAktionen
+ Noch keine Transaktionen vorhanden +
+ + + { transaction.Date.Format("02.01.2006") } + + { transaction.Description } + if transaction.IsRecurring { + + 🔄 Regelmäßig + + } + + if transaction.Category != nil { + + { transaction.Category.Icon } { transaction.Category.Name } + + } else { + Ohne Kategorie + } + + if transaction.BankAccount != nil { + { transaction.BankAccount.Name } + } else { + Ohne Konto + } + + if transaction.Type == "income" { + +{ fmt.Sprintf("%.2f", transaction.Amount) } € + } else { + -{ fmt.Sprintf("%.2f", transaction.Amount) } € + } + + + +
+
+ + if totalPages > 1 { +
+
+
+ Zeige { fmt.Sprintf("%d", (currentPage-1)*20+1) } bis { fmt.Sprintf("%d", min(currentPage*20, int(totalCount))) } von { fmt.Sprintf("%d", totalCount) } Transaktionen +
+
+ + if currentPage > 1 { + + Vorherige + + } else { + + Vorherige + + } + + +
+ if currentPage > 3 { + + 1 + + if currentPage > 4 { + ... + } + } + + for i := max(1, currentPage-2); i <= min(totalPages, currentPage+2); i++ { + if i == currentPage { + + { fmt.Sprintf("%d", i) } + + } else { + + { fmt.Sprintf("%d", i) } + + } + } + + if currentPage < totalPages-2 { + if currentPage < totalPages-3 { + ... + } + + { fmt.Sprintf("%d", totalPages) } + + } +
+ + + if currentPage < totalPages { + + Nächste + + } else { + + Nächste + + } +
+
+
+ } +
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + } +} + +func getIntervalText(interval string, count int) string { + var base string + switch interval { + case "daily": + base = "Tag" + if count > 1 { + base = "Tage" + } + case "weekly": + base = "Woche" + if count > 1 { + base = "Wochen" + } + case "monthly": + base = "Monat" + if count > 1 { + base = "Monate" + } + case "yearly": + base = "Jahr" + if count > 1 { + base = "Jahre" + } + default: + return interval + } + + if count == 1 { + return fmt.Sprintf("Jeden %s", base) + } + return fmt.Sprintf("Alle %d %s", count, base) +} + +func getNextExecutionDate(rule models.RecurrenceRule) string { + if !rule.IsActive { + return "Pausiert" + } + + lastDate := rule.StartDate + if rule.LastGenerated != nil { + lastDate = *rule.LastGenerated + } + + nextDate := rule.GetNextOccurrence(lastDate) + if nextDate == nil { + return "Beendet" + } + + return nextDate.Format("02.01.2006") +} diff --git a/internal/views/transactions_templ.go b/internal/views/transactions_templ.go new file mode 100644 index 0000000..a7c970b --- /dev/null +++ b/internal/views/transactions_templ.go @@ -0,0 +1,1221 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.2.778 +package views + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import ( + "fmt" + "time" + "whereismymoney/internal/models" +) + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func Transactions(userName string, transactions []models.Transaction, bankAccounts []models.BankAccount, categories []models.Category, recurrenceRules []models.RecurrenceRule, currentPage int, totalPages int, totalCount int64) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = Navigation(userName).Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Transaktionen

Verwalte deine Einnahmen und Ausgaben

Filter & Suche

Alle Transaktionen werden angezeigt

Letzte Transaktionen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(transactions) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, transaction := range transactions { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
DatumBeschreibungKategorieKontoBetragAktionen
Noch keine Transaktionen vorhanden
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var10 string + templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Date.Format("02.01.2006")) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 185, Col: 52} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var11 string + templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 188, Col: 38} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.IsRecurring { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("🔄 Regelmäßig") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.Category != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var12 string + templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Category.Icon) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 198, Col: 42} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var13 string + templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.Category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 198, Col: 72} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Ohne Kategorie") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.BankAccount != nil { + var templ_7745c5c3_Var14 string + templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(transaction.BankAccount.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 206, Col: 44} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Ohne Konto") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if transaction.Type == "income" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("+") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var15 string + templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", transaction.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 213, Col: 97} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("-") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var16 string + templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", transaction.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 215, Col: 95} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if totalPages > 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
Zeige ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var19 string + templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", (currentPage-1)*20+1)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 236, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" bis ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var20 string + templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", min(currentPage*20, int(totalCount)))) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 236, Col: 122} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" von ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var21 string + templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 236, Col: 160} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" Transaktionen
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentPage > 1 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Vorherige") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Vorherige") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentPage > 3 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("1 ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentPage > 4 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("... ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + for i := max(1, currentPage-2); i <= min(totalPages, currentPage+2); i++ { + if i == currentPage { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var24 string + templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 264, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var26 string + templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", i)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 268, Col: 37} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + } + if currentPage < totalPages-2 { + if currentPage < totalPages-3 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("...") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var28 string + templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 278, Col: 45} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if currentPage < totalPages { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Nächste") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Nächste") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Regelmäßige Transaktionen

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if len(recurrenceRules) == 0 { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + for _, rule := range recurrenceRules { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
BeschreibungIntervallNächste AusführungBetragStatusAktionen
Noch keine regelmäßigen Transaktionen vorhanden
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var30 string + templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Description) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 329, Col: 31} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.Category != nil { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var31 string + templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Category.Icon) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 332, Col: 35} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" ") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var32 string + templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(rule.Category.Name) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 332, Col: 58} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var33 string + templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinStringErrs(getIntervalText(rule.Interval, rule.IntervalCount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 337, Col: 65} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var34 string + templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(getNextExecutionDate(rule)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 340, Col: 41} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.Type == "income" { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("+") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var35 string + templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", rule.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 344, Col: 90} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("-") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var36 string + templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", rule.Amount)) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/views/transactions.templ`, Line: 346, Col: 88} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(" €") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if rule.IsActive { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Aktiv") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("Pausiert") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString("

Mehrere Transaktionen bearbeiten

Nur die Felder ausfüllen, die für alle ausgewählten Transaktionen geändert werden sollen. Leere Felder bleiben unverändert.

Leer lassen um unverändert zu lassen

Leer lassen um unverändert zu lassen

Leer lassen um unverändert zu lassen

Transaktionen löschen

Sind Sie sicher?

Sie sind dabei, 0 Transaktionen zu löschen. Diese Aktion kann nicht rückgängig gemacht werden.

Warnung: Alle ausgewählten Transaktionen werden permanent gelöscht. Stellen Sie sicher, dass Sie die richtigen Transaktionen ausgewählt haben.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) + templ_7745c5c3_Err = Layout("Transaktionen - WhereIsMyMoney").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return templ_7745c5c3_Err + }) +} + +func getIntervalText(interval string, count int) string { + var base string + switch interval { + case "daily": + base = "Tag" + if count > 1 { + base = "Tage" + } + case "weekly": + base = "Woche" + if count > 1 { + base = "Wochen" + } + case "monthly": + base = "Monat" + if count > 1 { + base = "Monate" + } + case "yearly": + base = "Jahr" + if count > 1 { + base = "Jahre" + } + default: + return interval + } + + if count == 1 { + return fmt.Sprintf("Jeden %s", base) + } + return fmt.Sprintf("Alle %d %s", count, base) +} + +func getNextExecutionDate(rule models.RecurrenceRule) string { + if !rule.IsActive { + return "Pausiert" + } + + lastDate := rule.StartDate + if rule.LastGenerated != nil { + lastDate = *rule.LastGenerated + } + + nextDate := rule.GetNextOccurrence(lastDate) + if nextDate == nil { + return "Beendet" + } + + return nextDate.Format("02.01.2006") +} + +var _ = templruntime.GeneratedTemplate diff --git a/main.go b/main.go new file mode 100644 index 0000000..d6ba223 --- /dev/null +++ b/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "log" + "whereismymoney/internal/config" + "whereismymoney/internal/database" + "whereismymoney/internal/handlers" + + "github.com/gin-gonic/gin" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatal("Fehler beim Laden der Konfiguration:", err) + } + + // Connect to database + db, err := database.Connect(cfg) + if err != nil { + log.Fatal("Fehler beim Verbinden zur Datenbank:", err) + } + + // Initialize handlers + h := handlers.NewHandler(db) + + // Setup Gin router + r := gin.Default() + + // Statische Dateien servieren + r.Static("/static", "./static") + + // Auth Routes + r.GET("/login", h.ShowLogin) + r.POST("/login", h.Login) + r.GET("/register", h.ShowRegister) + r.POST("/register", h.Register) + r.POST("/logout", h.Logout) + + // Protected Routes + r.GET("/", h.Dashboard) + r.GET("/api/dashboard-data", h.DashboardDataAPI) + r.GET("/accounts", h.ShowAccounts) + r.POST("/accounts/bank", h.CreateBankAccount) + r.POST("/accounts/depot", h.CreateDepot) + + // Test Route für wiederkehrende Transaktionen + r.GET("/test/recurring", h.TestRecurringTransactions) + + // Transaction Routes + r.GET("/transactions", h.ShowTransactions) + r.POST("/transactions", h.CreateTransaction) + r.POST("/transactions/recurring", h.CreateRecurringTransaction) + r.DELETE("/transactions/:id", h.DeleteTransaction) + r.GET("/transactions/:id/data", h.GetTransactionData) + r.PUT("/transactions/:id", h.UpdateTransaction) + r.PUT("/transactions/multi-update", h.MultiUpdateTransactions) + r.DELETE("/transactions/multi-delete", h.MultiDeleteTransactions) + r.POST("/transactions/recurring/:id/toggle", h.ToggleRecurrenceRule) + + // Recurring Transactions Management + r.GET("/recurring", h.ShowRecurringTransactions) + + // Recurring Transaction Management Routes + r.GET("/api/recurring-rules", h.GetRecurrenceRules) + r.PUT("/api/recurring-rules/:id", h.UpdateRecurrenceRule) + r.DELETE("/api/recurring-rules/:id", h.DeleteRecurrenceRule) + + // Settings Routes + r.GET("/settings", h.ShowSettings) + r.PUT("/settings/user", h.UpdateUserSettings) + r.PUT("/settings/password", h.UpdatePassword) + r.POST("/settings/categories", h.CreateCategory) + r.DELETE("/settings/categories/:id", h.DeleteCategory) + r.DELETE("/settings/accounts/:id", h.DeleteBankAccount) + + addr := cfg.Server.Host + ":" + cfg.Server.Port + log.Printf("Server startet auf %s...", addr) + log.Printf("Öffne http://%s in deinem Browser", addr) + + if err := r.Run(":" + cfg.Server.Port); err != nil { + log.Fatal("Server konnte nicht gestartet werden:", err) + } +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..e69de29 diff --git a/static/js/dashboard-charts.js b/static/js/dashboard-charts.js new file mode 100644 index 0000000..8392cd8 --- /dev/null +++ b/static/js/dashboard-charts.js @@ -0,0 +1,316 @@ +// Dashboard Charts JavaScript - Mit echten Daten aus der API +document.addEventListener('DOMContentLoaded', function() { + console.log('Dashboard charts initializing...'); + + // Prüfe ob Chart.js geladen ist + if (typeof Chart === 'undefined') { + console.error('Chart.js not loaded!'); + return; + } + + console.log('Chart.js loaded, version:', Chart.version); + + // Dashboard-Daten über API laden + fetch('/api/dashboard-data') + .then(response => { + console.log('API Response status:', response.status); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) + .then(data => { + console.log('Dashboard data loaded:', data); + initializeCharts(data); + }) + .catch(error => { + console.error('Error loading dashboard data:', error); + // Fallback mit Demo-Daten + console.log('Using fallback demo data...'); + initializeChartsWithDemoData(); + }); +}); + +function initializeChartsWithDemoData() { + console.log('Initializing charts with demo data...'); + + const demoData = { + monthly_stats: [ + { month: '2024-01', income: 3000, expenses: 2200, net_change: 800, balance: 5000 }, + { month: '2024-02', income: 3200, expenses: 2400, net_change: 800, balance: 5800 }, + { month: '2024-03', income: 2800, expenses: 2100, net_change: 700, balance: 6500 } + ], + category_stats: [ + { category_name: 'Lebensmittel', total_amount: 600, percentage: 27.3 }, + { category_name: 'Transport', total_amount: 250, percentage: 11.4 }, + { category_name: 'Unterhaltung', total_amount: 180, percentage: 8.2 }, + { category_name: 'Sonstiges', total_amount: 170, percentage: 7.7 } + ], + transaction_trend: [ + { date: '2024-03-01T00:00:00Z', income: 100, expense: 50, balance: 5000 }, + { date: '2024-03-02T00:00:00Z', income: 0, expense: 80, balance: 4920 }, + { date: '2024-03-03T00:00:00Z', income: 200, expense: 120, balance: 5000 }, + { date: '2024-03-04T00:00:00Z', income: 50, expense: 90, balance: 4960 }, + { date: '2024-03-05T00:00:00Z', income: 0, expense: 70, balance: 4890 } + ], + asset_overview: { + total_bank_balance: 15000, + total_depot_value: 25000 + } + }; + + initializeCharts(demoData); +} + +function initializeCharts(data) { + console.log('Initializing charts with data:', data); + + // Chart 1: Monatliche Statistiken + createMonthlyChart(data); + + // Chart 2: Einnahmen vs Ausgaben + createIncomeExpenseChart(data); + + // Chart 3: Trend-Chart + createTrendChart(data); + + console.log('All charts initialized'); +} + +function createMonthlyChart(data) { + const ctx = document.getElementById('monthlyChart'); + if (!ctx) { + console.warn('Monthly chart canvas not found'); + return; + } + + try { + // Echte Datenstruktur aus der API verwenden + const monthlyStats = data.monthly_stats || data.monthlyStats || []; + console.log('Monthly stats data:', monthlyStats); + + new Chart(ctx.getContext('2d'), { + type: 'bar', + data: { + labels: monthlyStats.map(d => { + // Month aus "2024-01" Format zu "Januar 2024" konvertieren + const [year, month] = d.month.split('-'); + const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + return `${monthNames[parseInt(month) - 1]} ${year}`; + }), + datasets: [{ + label: 'Einnahmen', + data: monthlyStats.map(d => parseFloat(d.income || 0)), + backgroundColor: 'rgba(34, 197, 94, 0.7)', + borderColor: 'rgba(34, 197, 94, 1)', + borderWidth: 1 + }, { + label: 'Ausgaben', + data: monthlyStats.map(d => parseFloat(d.expenses || 0)), + backgroundColor: 'rgba(239, 68, 68, 0.7)', + borderColor: 'rgba(239, 68, 68, 1)', + borderWidth: 1 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + y: { + beginAtZero: true, + ticks: { + callback: function(value) { + return '€' + value.toLocaleString(); + } + } + } + }, + plugins: { + title: { + display: true, + text: 'Monatliche Ein- und Ausgaben' + } + } + } + }); + console.log('Monthly chart created'); + } catch (error) { + console.error('Error creating monthly chart:', error); + } +} + +function createIncomeExpenseChart(data) { + const ctx = document.getElementById('incomeExpenseChart'); + if (!ctx) { + console.warn('Income/Expense chart canvas not found'); + return; + } + + try { + // Echte Datenstruktur verwenden + const monthlyStats = data.monthly_stats || data.monthlyStats || []; + console.log('Income/Expense stats data:', monthlyStats); + + const totalIncome = monthlyStats.reduce((sum, d) => sum + parseFloat(d.income || 0), 0); + const totalExpenses = monthlyStats.reduce((sum, d) => sum + parseFloat(d.expenses || 0), 0); + + new Chart(ctx.getContext('2d'), { + type: 'pie', + data: { + labels: ['Einnahmen', 'Ausgaben'], + datasets: [{ + data: [totalIncome, totalExpenses], + backgroundColor: ['#22c55e', '#ef4444'], + borderWidth: 2, + borderColor: '#ffffff' + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + title: { + display: true, + text: 'Einnahmen vs. Ausgaben' + }, + legend: { + position: 'bottom' + }, + tooltip: { + callbacks: { + label: function(context) { + return context.label + ': €' + context.parsed.toLocaleString(); + } + } + } + } + } + }); + console.log('Income/Expense chart created'); + } catch (error) { + console.error('Error creating income/expense chart:', error); + } +} + +function createTrendChart(data) { + const ctx = document.getElementById('trendChart'); + if (!ctx) { + console.warn('Trend chart canvas not found'); + return; + } + + try { + // Echte Datenstruktur verwenden + const trendData = data.transaction_trend || data.transactionTrend || []; + console.log('Trend data:', trendData); + + new Chart(ctx.getContext('2d'), { + type: 'bar', // Basis-Typ für Balken + data: { + labels: trendData.map(d => { + // Datum als Monat/Jahr formatieren für 12-Monats-Ansicht + const date = new Date(d.date); + return date.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' }); + }), + datasets: [{ + label: 'Einnahmen', + type: 'bar', // Explizit als Bar definieren + data: trendData.map(d => parseFloat(d.income || 0)), + backgroundColor: 'rgba(34, 197, 94, 0.7)', + borderColor: 'rgba(34, 197, 94, 1)', + borderWidth: 1, + yAxisID: 'y' + }, { + label: 'Ausgaben', + type: 'bar', // Explizit als Bar definieren + data: trendData.map(d => parseFloat(d.expense || 0)), + backgroundColor: 'rgba(239, 68, 68, 0.7)', + borderColor: 'rgba(239, 68, 68, 1)', + borderWidth: 1, + yAxisID: 'y' + }, { + label: 'Saldo', + type: 'line', // Explizit als Linie definieren + data: trendData.map(d => parseFloat(d.balance || 0)), + backgroundColor: 'rgba(59, 130, 246, 0.1)', + borderColor: 'rgba(59, 130, 246, 1)', + borderWidth: 3, + fill: false, + tension: 0.1, + yAxisID: 'y1', + pointRadius: 3, + pointHoverRadius: 5 + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false, + }, + scales: { + x: { + display: true, + title: { + display: true, + text: 'Datum' + } + }, + y: { + type: 'linear', + display: true, + position: 'left', + title: { + display: true, + text: 'Einnahmen/Ausgaben (€)' + }, + beginAtZero: true, + ticks: { + callback: function(value) { + return '€' + value.toLocaleString(); + } + } + }, + y1: { + type: 'linear', + display: true, + position: 'right', + title: { + display: true, + text: 'Saldo (€)' + }, + grid: { + drawOnChartArea: false, + }, + ticks: { + callback: function(value) { + return '€' + value.toLocaleString(); + } + } + } + }, + plugins: { + title: { + display: true, + text: '90-Tage Trend' + }, + legend: { + position: 'top' + }, + tooltip: { + callbacks: { + label: function(context) { + return context.dataset.label + ': €' + context.parsed.y.toLocaleString(); + } + } + } + } + } + }); + console.log('Trend chart created'); + } catch (error) { + console.error('Error creating trend chart:', error); + } +} diff --git a/whereismymoney b/whereismymoney new file mode 100755 index 0000000..dd086e5 Binary files /dev/null and b/whereismymoney differ diff --git a/whereismymoney.db b/whereismymoney.db new file mode 100644 index 0000000..57085d4 Binary files /dev/null and b/whereismymoney.db differ