Compare commits

...

34 Commits

Author SHA1 Message Date
sebseb7
adfcd90dcf Enhance transaction filtering by time range in API
- Implemented dynamic SQL WHERE clause to filter transactions based on various time range formats: quarter, year, and month.
- Removed redundant post-processing logic for filtering transactions, as the SQL query now handles this directly.
- Updated summary calculations to reflect the new transaction filtering approach, ensuring accurate reporting of totals and JTL matches.
2025-08-23 04:17:17 +02:00
sebseb7
bb610e0480 Add duplicate transaction check in CSV import process
- Implemented a check for existing transactions in the database to prevent duplicates during CSV imports.
- Added SQL query to count existing transactions based on key fields before insertion.
- Enhanced error handling to log and skip duplicate transactions, improving data integrity during the import process.
2025-08-22 23:46:20 +02:00
sebseb7
44d6cf6352 Update CSV import queries to include 'pending' status for datevlink field
- Modified SQL update queries in the csvImport.js file to allow for 'pending' status in addition to NULL or empty values for the datevlink field in both tUmsatzBeleg and tPdfObjekt tables. This change enhances the handling of datevlink updates during the import process.
2025-08-22 23:23:58 +02:00
sebseb7
74529d8b19 Enhanced error handling and logging for the DATEV export process. 2025-08-21 11:41:17 +02:00
sebseb7
bd7c6dddbf Enhance CSV import functionality with improved error messaging and logging
- Updated error message in CSVImportPanel to include a period for better readability.
- Added console logs in the CSV import API route to track the import process and precheck status.
- Removed redundant validation for 'Beguenstigter/Zahlungspflichtiger' to streamline error handling during CSV import.
2025-08-21 04:46:30 +02:00
sebseb7
8e8d93e4a6 Implement Google OAuth flow and enhance login functionality
- Updated the Google Sign-In integration to utilize the new OAuth callback mechanism.
- Added a redirect flow for Google authentication, improving user experience.
- Enhanced error handling and user feedback during the login process.
- Removed hardcoded Google client ID in favor of environment variable usage.
- Introduced a new component for handling OAuth callbacks and updated the App component to manage authentication states accordingly.
- Improved API route for processing OAuth callbacks, including token exchange and user verification.
2025-08-15 19:48:45 +02:00
sebseb7
fee9f02faa Enhance Accounting Items Management with JTL Kontierung Integration
- Added a new API route to fetch JTL Kontierung data based on transaction ID.
- Implemented loading of JTL Kontierung data in the AccountingItemsManager component.
- Updated UI to display JTL Kontierung data for debugging purposes.
- Enhanced user feedback during processing tasks in the App component with tooltips and progress indicators.
2025-08-08 11:32:57 +02:00
sebseb7
bcd7eea1b4 Update API target and port configuration; upgrade OpenAI model version
- Changed API proxy target from localhost:5000 to localhost:5500 in both webpack configurations.
- Updated server port from 5000 to 5500 in src/index.js for consistency.
- Upgraded OpenAI model from "gpt-4o-mini" to "gpt-5-mini" in document processing routes, enhancing processing capabilities.
2025-08-08 10:31:33 +02:00
sebseb7
281754de22 Add OpenAI API integration and document processing features
- Added OpenAI API key configuration to .env.example.
- Integrated OpenAI for document processing, including markdown conversion and data extraction.
- Implemented new API routes for fetching document processing status and handling various processing tasks.
- Enhanced the App component to manage document status and processing states with user feedback via Snackbar.
- Updated CSVImportPanel and TableManagement components to support navigation to specific tabs based on processing results.
- Introduced transaction handling in the database configuration for improved error management during document processing.
2025-08-06 11:11:23 +02:00
sebseb7
d60da0a7aa Refactor CSVImportDialog to CSVImportPanel and enhance UI components
- Renamed CSVImportDialog component to CSVImportPanel for clarity.
- Replaced Dialog with Paper component for improved layout.
- Removed unused code and comments to streamline the component.
- Updated import result messages for better user feedback.
- Enhanced button styles and layout for a more user-friendly interface.
- Added new API route for importing DATEV Beleglinks to the database, including validation and error handling.
2025-08-05 10:17:54 +02:00
sebseb7
46c9e9b97d Add Accounting Items Management and SQL Integration
- Introduced AccountingItemsManager component for managing accounting entries within transactions.
- Implemented API routes for creating, retrieving, updating, and deleting accounting items.
- Added SQL queries to handle accounting items linked to transactions, supporting both numeric and string transaction IDs.
- Enhanced CSV import functionality to include new accounting item handling.
- Created mssql.md documentation for SQL command usage related to accounting items.
2025-08-05 09:25:32 +02:00
sebseb7
096d4d0530 Refactor BankingKreditorSelector and DocumentRenderer components to enhance transaction assignment handling. Remove unused 'assigned_by' field from API requests and update onSave callbacks to pass assigned Kreditor details. Improve SQL queries in bankingTransactions.js to handle both numeric and string transaction IDs, and streamline transaction insertion logic. Update transaction retrieval in transactions.js to include additional assigned Kreditor information. 2025-08-02 10:07:36 +02:00
sebseb7
20cd0b34bc Implement new Kreditor creation functionality in BankingKreditorSelector component. Add state management for new Kreditor details, validation, and error handling. Enhance transaction handling to support banking transactions without IBAN. Update UI to allow users to create new Kreditors directly from the selector, including ID generation and validation feedback. 2025-08-02 09:14:49 +02:00
sebseb7
da435d2e66 u 2025-08-02 08:26:08 +02:00
sebseb7
5c416c77f0 Refactor data.js to remove old CSV parsing logic and integrate a new router structure. Consolidate transaction retrieval and enhance error handling for various endpoints. Update the DATEV export functionality to utilize the new database queries and improve overall code organization. 2025-08-01 15:45:01 +02:00
sebseb7
3886e64ef6 Enhance transaction retrieval in data.js by adding additional fields from the UmsatzBeleg table and implementing a separate query for links data. Update transaction mapping to include new document data and filter links based on transaction IDs. 2025-08-01 15:15:01 +02:00
sebseb7
3f2cad2426 i 2025-08-01 14:53:14 +02:00
sebseb7
89d481bbbf Implement CSV parsing and import functionality in data.js, replacing old database-based logic. Add endpoints for retrieving available months and importing transactions, with enhanced error handling and validation for CSV data. Update transaction processing to include date and amount parsing. 2025-08-01 14:07:07 +02:00
sebseb7
6218fc3c12 Refactor CSV import API call in CSVImportDialog to use a structured request body. Increase JSON payload limit in index.js to support larger CSV imports. 2025-08-01 14:03:03 +02:00
sebseb7
cbc826a4e1 Implement CSV import functionality in TableManagement component and update API routes to utilize database for CSV data handling. Remove old CSV parsing logic and enhance month retrieval from the database. Add UI elements for CSV import in the TableManagement view. 2025-08-01 13:32:59 +02:00
sebseb7
fbfd918d81 Remove data.csv file and update README to reflect new features including CSV import and banking account management. Enhance TransactionsTable and KreditorTable components with banking account handling, including UI updates and validation logic. Update SQL schema to support banking accounts and adjust API routes for improved data handling. Implement new document rendering logic for banking transactions and enhance recipient rendering with banking account status. Add new views and indexes for better transaction management. 2025-08-01 13:26:26 +02:00
sebseb7
6cde543938 Enhance webpack configuration for improved performance and development experience. Add filesystem caching and watch options. Update KreditorSelector to handle prefilled data and improve state management. Refactor TransactionsTable to manage focus during dialog interactions. Update admin tables to manage focus restoration and improve dialog handling. Implement IBAN filtering in IbanSelectionFilter and enhance document rendering with Kreditor information. Update SQL schema to allow multiple IBANs for the same Kreditor and adjust API routes for better data handling. 2025-08-01 12:03:15 +02:00
sebseb7
481f4db389 Add form validation to KontoTable and KreditorTable components. Disable save button when form is invalid. Clean up console logs in TextHeaderWithFilter and dataUtils. 2025-08-01 10:44:17 +02:00
sebseb7
976c802b11 Enhance App component with view management by adding Tabs for Dashboard and Table views. Update DataViewer to receive current view and handle export data accordingly. Refactor BUTable to improve form validation and handle vst values more robustly. Update admin routes to ensure vst is correctly processed. 2025-08-01 10:39:41 +02:00
sebseb7
5470bebfc4 Implement navigation and view management in DataViewer, adding Dashboard and TableManagement components. Update export data handling based on current view. Create new components for managing Kreditor, Konto, and BU tables with CRUD functionality. Refactor admin routes to remove admin access checks and streamline data handling for various entities. 2025-08-01 10:22:43 +02:00
sebseb7
092fa0f8bd u 2025-08-01 10:03:29 +02:00
sebseb7
fa6690135a u 2025-08-01 10:03:12 +02:00
sebseb7
1ec1e1e5f6 u 2025-08-01 09:26:47 +02:00
sebseb7
be7a928ce2 Add SortHeader component for sorting functionality in TransactionsTable. Update TransactionsTable to prevent row selection when clicking on IBAN text. Minor adjustment in gridConfig for layout consistency. 2025-07-20 08:37:18 +02:00
sebseb7
58f5bb4b4f Refactor dashboard route to use mock data instead of database queries, simplifying the code and improving performance. Remove unnecessary database operations and error handling for user data retrieval. 2025-07-20 07:47:25 +02:00
sebseb7
429fd70497 Implement DATEV export functionality in DataViewer and enhance TransactionsTable with selection features and improved row styling. Update environment variables and add devServer configuration in webpack for better development experience. 2025-07-20 07:47:18 +02:00
sebseb7
2a43b7106d Add header component for text filters 2025-07-20 05:12:04 +02:00
sebseb7
b9af7694a0 Remove debug login page and refactor TransactionsTable to utilize new utility functions and cell renderers for improved code organization and maintainability. 2025-07-20 05:06:28 +02:00
sebseb7
992adc7bcf Enhance TransactionsTable with custom cell renderers for description and recipient fields, and adjust row height for improved layout. 2025-07-20 04:58:29 +02:00
67 changed files with 9668 additions and 2728 deletions

View File

@@ -1,4 +1,4 @@
---
alwaysApply: true
---
sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W
sqlcmd -C -S tcp:192.168.56.1,1497 -U sa -P 'sa_tekno23' -d eazybusiness -W

View File

@@ -0,0 +1,7 @@
---
alwaysApply: true
---
pm2 restart 10 -> restart backend (configured as "npm run dev:backend")
pm2 restart 11 -> restart backend (configured as "npm run dev:frontend")
(both should rarely neer restart because in dev mode HMR for frontend, and nodemon for backend should already do that)

10
.env
View File

@@ -1,10 +0,0 @@
GOOGLE_CLIENT_ID=928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=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
REACT_APP_GOOGLE_CLIENT_ID=928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com
AUTHORIZED_EMAILS=sebgreenbus@gmail.com,growsdd@gmail.com
JWT_SECRET=7vK2gQp9zX1wR4eT6sB8uN0cLmY5aV3j
DB_SERVER=192.168.56.1
DB_DATABASE=eazybusiness
DB_USERNAME=app
DB_PASSWORD=readonly
DB_PORT=1497

View File

@@ -8,6 +8,9 @@ REACT_APP_GOOGLE_CLIENT_ID=your_google_client_id_here
# JWT Secret
JWT_SECRET=your_jwt_secret_here
# OpenAI API Configuration
OPENAI_API_KEY=your_openai_api_key_here
# Authorized Email Addresses (comma-separated)
AUTHORIZED_EMAILS=admin@example.com,user1@example.com,user2@example.com

31
.eslintrc.js Normal file
View File

@@ -0,0 +1,31 @@
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: [
'eslint:recommended',
],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
ecmaFeatures: {
jsx: true,
},
},
rules: {
// Prevent browser alert dialogs
'no-alert': 'error',
'no-confirm': 'error',
'no-prompt': 'error',
// Additional helpful rules
'no-console': 'warn',
'no-debugger': 'error',
},
globals: {
// Allow React globals
React: 'readonly',
},
};

1
.gitignore vendored
View File

@@ -1 +1,2 @@
node_modules
.env

4
.kilocode/rules/mssql.md Normal file
View File

@@ -0,0 +1,4 @@
# mssql.md
sqlcmd -C -S tcp:192.168.56.1,1497 -U app -P 'readonly' -d eazybusiness -W

478
README.md
View File

@@ -1,18 +1,20 @@
# FibDash
A modern React Material-UI dashboard application with Google SSO authentication and MSSQL database integration.
A modern React Material-UI dashboard for financial reconciliation with Google SSO authentication, CSV import/analysis, DATEV export, and optional MSSQL integration with JTL tables.
## Features
- 🚀 **React 18** with class components
- 🎨 **Material-UI (MUI)** for beautiful, modern UI
- 🔐 **Google SSO Authentication**
- 🗄️ **MSSQL Database** integration
- **Webpack Dev Server** with Hot Module Reload
- 🔄 **Express API** with hot reload via nodemon
- 🛡️ **JWT Authentication** for API security
- 📱 **Responsive Design**
- 🎯 **Production Ready** build configuration
- 🚀 React 18 (class components)
- 🎨 Material-UI (MUI) UI with responsive layout
- 🔐 Google SSO (Google Identity Services) + JWT API auth
- 🗄️ MSSQL integration (optional; app runs without DB)
- 📥 CSV import of bank transactions (German MT940-like CSV)
- 🔍 Reconciliation view: CSV vs JTL (if DB available)
- 📤 DATEV export for selected month/quarter/year
- 🧩 Admin data management (Kreditor, Konto, BU)
- ⚡ Webpack dev server (HMR) + nodemon hot-reload
- 🛡️ Email allowlist authorization
- 🧰 Production single-process build (Express serves React)
## Architecture
@@ -27,337 +29,249 @@ fibdash/
│ └── public/
│ └── index.html # HTML template
├── src/ # Backend Express API
│ ├── config/ # Database configuration
│ ├── middleware/ # Authentication middleware
│ ├── routes/ # API routes
│ ├── database/ # Database schema
│ ├── config/ # Database configuration (MSSQL)
│ ├── middleware/ # Auth + email allowlist middleware
│ ├── routes/ # API routes (auth, data, admin, dashboard)
│ ├── database/ # SQL schema and CSV import schema
│ └── index.js # Express server entry point
├── webpack.config.js # Webpack configuration
├── nginx.*.conf # Nginx reverse-proxy configs (dev/prod/simple)
├── webpack*.config.js # Webpack configs
├── docker-compose.dev.yml # Optional dev docker-compose for proxying
├── data.csv # Sample CSV for local analysis
└── package.json # Dependencies and scripts
```
## Functional Overview
Authentication and Authorization
- Login: Frontend uses Google Identity Services. The backend validates the ID token and issues a JWT.
- Email allowlist: Only emails in AUTHORIZED_EMAILS or matching DB rule are allowed.
- ENV allowlist: fast path check.
- DB check: optional, queries JTL tables to verify access by attributes 219/220.
- JWT middleware guards all /api routes.
CSV Analysis and Reconciliation
- Upload or place CSV at project root (data.csv) for quick testing.
- CSV parsing: German semicolon-separated format with headers like Buchungstag, Betrag, Verwendungszweck, IBAN, etc.
- Reconciliation:
- If MSSQL available: fetch JTL transactions (tZahlungsabgleichUmsatz), related PDFs (tUmsatzBeleg, tPdfObjekt), and links; match by date+amount.
- Kreditor lookup: optional mapping via fibdash.Kreditor using IBAN; also supports banking accounts via is_banking.
- Summary totals: income, expenses, net, match counts.
DATEV Export
- Endpoint returns CSV in DATEV format for the chosen period (month, quarter, year).
- Headers and column mapping created server-side; amounts normalized; encoding served as text/csv.
Admin Management
- Kreditor: CRUD for name, kreditorId, IBAN (optional if is_banking=true).
- Konto: CRUD account numbers/names used for accounting.
- BU (Buchungsschlüssel): CRUD including optional VSt.
- System info endpoint.
Health
- /api/health returns OK and timestamp.
## API Surface (key endpoints)
Auth
- POST /api/auth/google — Google token exchange → JWT
- GET /api/auth/verify — Validate JWT, returns user
- POST /api/auth/logout — Stateless logout success
Dashboard
- GET /api/dashboard — Mock dashboard stats
- GET /api/dashboard/user — Returns current JWT user
Data and Reconciliation
- GET /api/data/months — Available months inferred from data.csv
- GET /api/data/transactions/:timeRange — Combined CSV + JTL view with summary
- timeRange supports YYYY-MM, YYYY, YYYY-Q1..Q4
- GET /api/data/datev/:timeRange — Download DATEV CSV
- GET /api/data/pdf/umsatzbeleg/:kUmsatzBeleg — Stream PDF from tUmsatzBeleg
- GET /api/data/pdf/pdfobject/:kPdfObjekt — Stream PDF from tPdfObjekt
Kreditor, Konto, BU
- GET /api/data/kreditors
- GET /api/data/kreditors/:id
- POST /api/data/kreditors
- PUT /api/data/kreditors/:id
- DELETE /api/data/kreditors/:id
- GET /api/data/assignable-kreditors — only non-banking
- BankingAccountTransactions assignments: POST/PUT/DELETE, and GET /api/data/banking-transactions/:transactionId
- Admin counterparts exist under /api/admin for Kreditor/Konto/BU CRUD.
CSV Import (to DB)
- POST /api/data/import-csv-transactions — Validates rows and inserts into fibdash.CSVTransactions
- GET /api/data/csv-transactions — Paginated list with kreditor joins and assignment info
- GET /api/data/csv-import-batches — Import batch summaries
## Frontend UX Summary
App shell
- Top AppBar with title, tabs (Dashboard, Stammdaten), current user, DATEV export button when applicable, and logout.
Login
- Button triggers Google prompt; robust error messaging for SSO, service unavailability, authorization.
Dashboard view
- Month selector, summary header, and a transactions table with reconciliation indicators (CSV-only, JTL-only, matched), PDF links when available.
Stammdaten view
- Management UI for Kreditors, Konten, and Buchungsschlüssel (class-based components under client/src/components/admin).
CSV Import
- Modal dialog with drag-and-drop or file picker, header detection, basic validation, progress, and results summary; uses /api/data/import-csv-transactions.
## Prerequisites
- Node.js (v16 or higher)
- MSSQL Server instance
- Google Cloud Platform account for OAuth setup
- Node.js (v16+)
- Optionally: MSSQL Server for JTL and fibdash schema
- Google Cloud project and OAuth 2.0 Client ID
## Setup Instructions
### 1. Clone and Install Dependencies
## Setup
1) Clone and install
```bash
git clone <your-repo-url>
cd fibdash
npm install
```
### 2. Environment Configuration
Copy the example environment file and configure your settings:
2) Environment
```bash
cp .env.example .env
# then edit .env
```
Edit `.env` with your actual configuration:
Required variables
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- REACT_APP_GOOGLE_CLIENT_ID (must match GOOGLE_CLIENT_ID)
- JWT_SECRET
- Optional authorization: AUTHORIZED_EMAILS=admin@company.com,user@company.com
- MSSQL: DB_SERVER, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PORT=1433
- Server: PORT=5000, NODE_ENV=development
```env
# Google OAuth Configuration
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
3) Google OAuth
- Create OAuth 2.0 Web Client in Google Cloud Console.
- Authorized origins: http://localhost:5001 (dev), your domain(s).
- Authorized redirects: matching roots (e.g., http://localhost:5001/).
- See detailed guide: [docs/GOOGLE_OAUTH_SETUP.md](docs/GOOGLE_OAUTH_SETUP.md)
# Frontend Environment Variables (REACT_APP_ prefix required)
REACT_APP_GOOGLE_CLIENT_ID=your_google_client_id_here
# JWT Secret (generate a secure random string)
JWT_SECRET=your_jwt_secret_here
# MSSQL Database Configuration
DB_SERVER=your_mssql_server_here
DB_DATABASE=your_database_name_here
DB_USERNAME=your_db_username_here
DB_PASSWORD=your_db_password_here
DB_PORT=1433
# Server Configuration
PORT=5000
NODE_ENV=development
```
### 3. Google OAuth Setup
1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
2. Create a new project or select an existing one
3. Enable the Google+ API
4. Create OAuth 2.0 credentials:
- Application type: Web application
- Authorized JavaScript origins: `http://localhost:3000`
- Authorized redirect URIs: `http://localhost:3000`
5. Copy the Client ID and Client Secret to your `.env` file
### 4. Database Setup
1. Create a new MSSQL database
2. Run the schema creation script:
4) Database (optional but required for JTL features)
- Create DB and run schemas:
- Core schema: see src/database/schema.sql
- CSV import table schemas if needed
- The app will run without DB; JTL features and admin CRUD will error if DB not configured.
5) Development
```bash
# Connect to your MSSQL server and run:
sqlcmd -S your_server -d your_database -i src/database/schema.sql
```
Or manually execute the SQL commands in `src/database/schema.sql`
### 5. Start Development Servers
Run both frontend and backend development servers:
```bash
npm run dev
```
This will start:
- Frontend dev server on `http://localhost:5001` (with hot reload)
- Backend API server on `http://localhost:5000` (with nodemon auto-restart)
Or run them separately:
```bash
# Frontend only
npm run dev # runs frontend at 5001 and backend at 5000
# or separately:
npm run dev:frontend
# Backend only
npm run dev:backend
```
### 6. Optional: Nginx Setup for Development
For a more production-like development environment, you can set up nginx as a reverse proxy:
#### Automatic Setup (Linux/macOS)
6) Optional Nginx for dev
- Automatic:
```bash
npm run setup:nginx
```
#### Manual Setup
1. Install nginx on your system
2. Copy the nginx configuration:
- Manual:
```bash
sudo cp nginx.simple.conf /etc/nginx/sites-available/fibdash-dev
sudo ln -s /etc/nginx/sites-available/fibdash-dev /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
- Hosts entry (optional): 127.0.0.1 fibdash.local
3. Add to your hosts file (optional):
With nginx:
- App: http://localhost/ or http://fibdash.local/
- API: http://localhost/api/
- Direct FE: http://localhost:5001/
- Direct BE: http://localhost:5000/
## Production
Single-process model
- Express serves built React app and handles APIs and auth.
Build and run
```bash
echo "127.0.0.1 fibdash.local" | sudo tee -a /etc/hosts
npm start # build frontend and start backend
# or:
npm run build
npm run start:prod
```
With nginx setup, you can access:
- **Main app**: `http://localhost/` or `http://fibdash.local/`
- **API**: `http://localhost/api/` or `http://fibdash.local/api/`
- **Direct Frontend**: `http://localhost:5001/`
- **Direct Backend**: `http://localhost:5000/`
### Production Nginx Setup
For production deployment, use the production nginx configuration:
Nginx production reverse proxy
```bash
# Copy production nginx config
sudo cp nginx.prod.conf /etc/nginx/sites-available/fibdash-prod
sudo ln -s /etc/nginx/sites-available/fibdash-prod /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
```
The production config includes:
- **Security headers** (CSP, XSS protection, etc.)
- **Static asset caching** (1 year for JS/CSS/images)
- **Gzip compression** for better performance
- **SSL/HTTPS support** (commented out, ready to enable)
Prod features:
- Static asset caching, gzip, security headers, optional TLS.
## Email Authorization
## Environment variables
FibDash includes built-in email authorization to restrict access to specific users.
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- REACT_APP_GOOGLE_CLIENT_ID
- JWT_SECRET
- AUTHORIZED_EMAILS (comma-separated; if unset/empty, no users can access)
- DB_SERVER, DB_DATABASE, DB_USERNAME, DB_PASSWORD, DB_PORT
- PORT, NODE_ENV
### Setup Authorization
## Data model (MSSQL)
1. **Add authorized emails to your `.env` file:**
```env
AUTHORIZED_EMAILS=admin@yourcompany.com,user1@yourcompany.com,user2@yourcompany.com
```
Core tables referenced
- eazybusiness.dbo.tZahlungsabgleichUmsatz (+ tUmsatzKontierung)
- tUmsatzBeleg (PDF storage), tPdfObjekt (PDF objects), tZahlungsabgleichUmsatzLink (links)
- fibdash.Kreditor (id, iban, name, kreditorId, is_banking)
- fibdash.Konto (id, konto, name)
- fibdash.BU (id, bu, name, vst)
- fibdash.BankingAccountTransactions (assignment records)
- fibdash.CSVTransactions (imported CSV rows)
2. **First email is admin**: The first email in the list automatically gets admin privileges
See SQL in src/database/schema.sql and src/database/csv_transactions_schema.sql for exact DDL.
3. **No authorization**: If `AUTHORIZED_EMAILS` is not set or empty, **NO USERS** can access the app
## Developer notes
### Admin Features
- Backend auto-detects DB availability. If DB env is missing, it logs a warning and continues. Endpoints that require DB will respond with 500/404 as applicable.
- data.csv at repo root is used by /api/data/months and /api/data/transactions/* for local CSV-based analysis.
- Matching logic between CSV and JTL uses exact amount and same-day date to mark matches.
- DATEV export uses well-formed header, consistent number formatting, and limited text field lengths.
Admins (first email in the authorized list) can:
- View all authorized emails: `GET /api/admin/authorized-emails`
- Add new authorized email: `POST /api/admin/authorized-emails`
- Remove authorized email: `DELETE /api/admin/authorized-emails/:email`
- View system info: `GET /api/admin/system-info`
## Scripts
**Note**: Admin changes via API are temporary. For permanent changes, update the `.env` file.
### Authorization Flow
1. User signs in with Google
2. Backend checks if email is in `AUTHORIZED_EMAILS` list
3. If authorized → login succeeds
4. If not authorized → "Access denied" error
5. All API endpoints check authorization via middleware
## Development
### Frontend Development
- Frontend code is in the `client/` directory
- Uses Webpack dev server with hot module reload
- All components are class components (no function components)
- Material-UI for styling and components
- Authentication handled via Google SSO
### Backend Development
- Backend code is in the `src/` directory
- Express.js API server with CORS enabled
- JWT authentication middleware
- MSSQL database integration
- Auto-restart on file changes via nodemon
### API Endpoints
- `POST /api/auth/google` - Google OAuth login
- `GET /api/auth/verify` - Verify JWT token
- `POST /api/auth/logout` - Logout user
- `GET /api/dashboard` - Get dashboard data
- `GET /api/dashboard/user` - Get user-specific data
- `GET /api/health` - Health check
### Available Scripts
| Script | Description |
|--------|-------------|
| `npm run dev` | Start both frontend and backend in development mode |
| `npm run dev:frontend` | Start only the frontend webpack dev server |
| `npm run dev:backend` | Start only the backend API server with nodemon |
| `npm run build` | Create production build of frontend |
| `npm run build:prod` | Build and start production server |
| `npm start` | Build frontend and start production server |
| `npm run start:prod` | Start production server (assumes build exists) |
| `npm run setup:nginx` | Automatically setup nginx for development |
| `npm run nginx:test` | Test nginx configuration |
| `npm run nginx:reload` | Reload nginx configuration |
| `npm run nginx:start` | Start nginx service |
| `npm run nginx:stop` | Stop nginx service |
| `npm run nginx:status` | Check nginx service status |
## Production Deployment
### Single-Process Production Setup
In production, the Express backend builds the React frontend and serves it as static files. No separate frontend server is needed.
#### Build and Start Production Server
```bash
# Build frontend and start server in one command
npm start
# Or build and start separately
npm run build
npm run start:prod
```
#### Production Architecture
```
Production Setup:
┌─────────────────────────────────────┐
│ Single Express Server (Port 5000) │
├─────────────────────────────────────┤
│ • Serves static React files │
│ • Handles API routes (/api/*) │
│ • Manages authentication │
│ • Connects to MSSQL database │
└─────────────────────────────────────┘
```
#### Production Features
- **Single Process**: One Node.js server handles everything
- **Static File Serving**: Built React app served with caching headers
- **Optimized Build**: Minified JS/CSS with content hashing
- **Code Splitting**: Vendor libraries separated for better caching
- **Production Security**: CSP headers and security optimizations
## Project Structure Details
### Frontend (`client/`)
- **Components**: All React components using class-based architecture
- **Services**: API communication classes
- **Material-UI**: Modern UI components with custom theming
- **Hot Reload**: Webpack dev server with HMR enabled
### Backend (`src/`)
- **Express Server**: RESTful API with middleware
- **Authentication**: Google OAuth + JWT tokens
- **Database**: MSSQL with connection pooling
- **Hot Reload**: Nodemon for automatic server restart
## Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `GOOGLE_CLIENT_ID` | Google OAuth Client ID (backend) | Yes |
| `GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret (backend) | Yes |
| `REACT_APP_GOOGLE_CLIENT_ID` | Google OAuth Client ID (frontend) | Yes |
| `JWT_SECRET` | Secret for JWT token signing | Yes |
| `AUTHORIZED_EMAILS` | Comma-separated list of authorized email addresses | No* |
| `DB_SERVER` | MSSQL server address | Yes |
| `DB_DATABASE` | Database name | Yes |
| `DB_USERNAME` | Database username | Yes |
| `DB_PASSWORD` | Database password | Yes |
| `DB_PORT` | Database port (default: 1433) | No |
| `PORT` | API server port (default: 5000) | No |
| `NODE_ENV` | Environment mode | No |
*If `AUTHORIZED_EMAILS` is not set or empty, **NO USERS** can access the application. Only email addresses listed in `AUTHORIZED_EMAILS` can log in.
- npm run dev — run FE+BE
- npm run dev:frontend — FE only (HMR)
- npm run dev:backend — BE only (nodemon)
- npm run build — FE production build
- npm run build:prod — build and start production server
- npm start — build FE and start BE
- npm run start:prod — start BE with existing build
- npm run setup:nginx — dev nginx setup
- npm run nginx:test|reload|start|stop|status — helpers
## Troubleshooting
### Database Connection Issues
Database
- Ensure MSSQL reachable; set DB_* env; firewall allows 1433; check logs.
- Without DB, JTL and admin features wont function; CSV-only features still work.
1. Verify your MSSQL server is running and accessible
2. Check firewall settings allow connections on port 1433
3. Ensure your database credentials are correct
4. Check the server logs for detailed error messages
Google OAuth
- Ensure both GOOGLE_CLIENT_ID and REACT_APP_GOOGLE_CLIENT_ID set and equal.
- Add dev/prod origins and redirects in Google Cloud Console.
- Use HTTPS in production and ensure CSP allows Google domains.
- See docs/GOOGLE_OAUTH_SETUP.md.
### Google OAuth Issues
CORS/Headers
- Dev CORS is open. Tighten in prod behind nginx.
1. Verify your Google Client ID is correctly set
2. Check that your domain is authorized in Google Cloud Console
3. Ensure the OAuth consent screen is configured
4. Make sure you're testing on the correct domain/port
**For detailed Google OAuth troubleshooting, see: [docs/GOOGLE_OAUTH_SETUP.md](docs/GOOGLE_OAUTH_SETUP.md)**
Common fixes for CORS/GSI errors:
- Add your domain to Google Cloud Console authorized origins
- Ensure both `GOOGLE_CLIENT_ID` and `REACT_APP_GOOGLE_CLIENT_ID` are set
- Use the "Alternative Google Sign-In" button as fallback
- Consider enabling HTTPS for better OAuth compatibility
### Hot Reload Not Working
1. Check that both dev servers are running
2. Verify webpack proxy configuration
3. Clear browser cache and restart dev servers
Hot reload
- Make sure both dev servers run; clear cache; check proxy configs.
## License
ISC
ISC

View File

@@ -34,6 +34,34 @@
min-width: 200px;
}
/* Alternating row colors for better readability */
.ag-row-odd {
background-color: #f8f9fa !important;
}
.ag-row-even {
background-color: #ffffff !important;
}
/* Maintain alternating colors on hover */
.ag-row-odd:hover {
background-color: #e9ecef !important;
}
.ag-row-even:hover {
background-color: #f1f3f4 !important;
}
/* Ensure JTL-only rows (red rows) override alternating colors */
.ag-row[style*="background-color: rgb(255, 235, 238)"] {
background-color: #ffebee !important;
}
/* Selected rows */
.ag-row.selected-row {
background-color: #e3f2fd !important;
color: #1976d2 !important;
}
</style>
<script>
@@ -42,9 +70,7 @@
setTimeout(() => {
const betragHeader = document.querySelector('.ag-header-cell[col-id="numericAmount"]');
if (betragHeader) {
console.log('Found Betrag header:', betragHeader);
console.log('Header classes:', betragHeader.className);
console.log('Header HTML:', betragHeader.innerHTML);
} else {
console.log('Could not find Betrag header with col-id="numericAmount"');
// Try to find it by text content

View File

@@ -1,12 +1,20 @@
import React, { Component } from 'react';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { Container, AppBar, Toolbar, Typography, Button, Box } from '@mui/material';
import { Container, AppBar, Toolbar, Typography, Button, Box, Tabs, Tab, Badge, Chip, Divider, Snackbar, Alert, LinearProgress, Tooltip, CircularProgress } from '@mui/material';
import LoginIcon from '@mui/icons-material/Login';
import DashboardIcon from '@mui/icons-material/Dashboard';
import DownloadIcon from '@mui/icons-material/Download';
import TableChart from '@mui/icons-material/TableChart';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import DocumentScannerIcon from '@mui/icons-material/DocumentScanner';
import ExtractIcon from '@mui/icons-material/TextSnippet';
import EmailIcon from '@mui/icons-material/Email';
import UploadIcon from '@mui/icons-material/Upload';
import AuthService from './services/AuthService';
import DataViewer from './components/DataViewer';
import Login from './components/Login';
import OAuthCallback from './components/OAuthCallback';
const theme = createTheme({
palette: {
@@ -27,6 +35,20 @@ class App extends Component {
isAuthenticated: false,
user: null,
loading: true,
exportData: null, // { selectedMonth, canExport, onExport }
currentView: 'dashboard', // 'dashboard' or 'tables'
documentStatus: null,
processingStatus: {
markdown: false,
extraction: false,
datevSync: false,
datevUpload: false
},
snackbar: {
open: false,
message: '',
severity: 'info' // 'success', 'error', 'warning', 'info'
}
};
this.authService = new AuthService();
}
@@ -35,6 +57,15 @@ class App extends Component {
this.checkAuthStatus();
}
componentDidUpdate(prevState) {
// Clear targetTab after navigation is complete
if (this.state.targetTab && prevState.currentView !== this.state.currentView) {
setTimeout(() => {
this.setState({ targetTab: null });
}, 100); // Small delay to ensure navigation completes
}
}
checkAuthStatus = async () => {
try {
const token = localStorage.getItem('token');
@@ -42,6 +73,7 @@ class App extends Component {
const user = await this.authService.verifyToken(token);
if (user) {
this.setState({ isAuthenticated: true, user, loading: false });
this.fetchDocumentStatus();
return;
}
}
@@ -58,6 +90,7 @@ class App extends Component {
if (result.success) {
localStorage.setItem('token', result.token);
this.setState({ isAuthenticated: true, user: result.user });
this.fetchDocumentStatus();
}
} catch (error) {
console.error('Login failed:', error);
@@ -68,11 +101,146 @@ class App extends Component {
handleLogout = () => {
localStorage.removeItem('token');
this.setState({ isAuthenticated: false, user: null });
this.setState({ isAuthenticated: false, user: null, exportData: null });
};
updateExportData = (exportData) => {
this.setState({ exportData });
};
handleViewChange = (event, newValue) => {
this.setState({ currentView: newValue });
};
showSnackbar = (message, severity = 'info') => {
this.setState({
snackbar: {
open: true,
message,
severity
}
});
};
handleSnackbarClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}
this.setState({
snackbar: {
...this.state.snackbar,
open: false
}
});
};
fetchDocumentStatus = async () => {
try {
const token = localStorage.getItem('token');
if (!token) {
console.log('No token found for document status');
return;
}
console.log('Fetching document status...');
const response = await fetch('/api/data/document-status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const status = await response.json();
console.log('Document status received:', status);
this.setState({ documentStatus: status });
} else {
console.error('Failed to fetch document status:', response.status, await response.text());
}
} catch (error) {
console.error('Error fetching document status:', error);
}
};
handleProcessing = async (processType) => {
if (this.state.processingStatus[processType]) {
return; // Already processing
}
// Handle datev upload navigation
if (processType === 'datev-upload') {
this.setState({
currentView: 'tables',
targetTab: {
level1: 3, // CSV Import tab
level2: 'DATEV_LINKS' // DATEV Beleglinks tab
}
});
return;
}
// Check if there are documents to process
const statusKey = processType === 'datev-sync' ? 'needDatevSync' :
processType === 'extraction' ? 'needExtraction' : 'needMarkdown';
if (!this.state.documentStatus || this.state.documentStatus[statusKey] === 0) {
this.showSnackbar(`No documents need ${processType} processing at this time.`, 'info');
return;
}
this.setState(prevState => ({
processingStatus: {
...prevState.processingStatus,
[processType]: true
}
}));
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await fetch(`/api/data/process-${processType}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
console.log(`${processType} processing result:`, result);
this.showSnackbar(`${processType} processing completed successfully!`, 'success');
// Refresh document status after successful processing
await this.fetchDocumentStatus();
} else {
const error = await response.json();
console.error(`Failed to process ${processType}:`, error);
this.showSnackbar(`Failed to process ${processType}: ${error.error || response.status}`, 'error');
}
} catch (error) {
console.error(`Error processing ${processType}:`, error);
this.showSnackbar(`Error processing ${processType}: ${error.message}`, 'error');
} finally {
this.setState(prevState => ({
processingStatus: {
...prevState.processingStatus,
[processType]: false
}
}));
}
};
isOAuthCallback = () => {
return window.location.pathname === '/auth/callback';
};
render() {
const { isAuthenticated, user, loading } = this.state;
const { isAuthenticated, user, loading, currentView, documentStatus, processingStatus, snackbar } = this.state;
// Debug logging
console.log('App render - documentStatus:', documentStatus);
console.log('App render - isAuthenticated:', isAuthenticated);
if (loading) {
return (
@@ -93,7 +261,7 @@ class App extends Component {
<AppBar position="static">
<Toolbar>
<DashboardIcon sx={{ mr: { xs: 1, sm: 2 } }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
<Typography variant="h6" component="div" sx={{ mr: 3 }}>
FibDash
</Typography>
{isAuthenticated && user && (
@@ -101,12 +269,166 @@ class App extends Component {
<Typography
variant="body2"
sx={{
mr: { xs: 1, sm: 2 },
mr: { xs: 2, sm: 3 },
display: { xs: 'none', sm: 'block' }
}}
>
Willkommen, {user.name}
</Typography>
<Tabs
value={currentView}
onChange={this.handleViewChange}
sx={{
mr: 'auto',
'& .MuiTab-root': {
color: 'rgba(255, 255, 255, 0.7)',
minHeight: 48,
'&.Mui-selected': {
color: 'white'
}
},
'& .MuiTabs-indicator': {
backgroundColor: 'white'
}
}}
>
<Tab
icon={<DashboardIcon />}
label="Dashboard"
value="dashboard"
sx={{ minHeight: 48 }}
/>
<Tab
icon={<TableChart />}
label="Stammdaten"
value="tables"
sx={{ minHeight: 48 }}
/>
</Tabs>
<Divider orientation="vertical" flexItem sx={{ mx: 2, backgroundColor: 'rgba(255, 255, 255, 0.3)' }} />
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Tooltip title={processingStatus.markdown ? 'Running markdown conversion… this can take a while' : 'Process markdown conversion'} arrow>
<span>
<Button
color="inherit"
size="small"
onClick={() => this.handleProcessing('markdown')}
disabled={processingStatus.markdown || !documentStatus}
sx={{
minWidth: 'auto',
px: 1,
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' }
}}
>
<Badge
badgeContent={documentStatus?.needMarkdown || 0}
color={documentStatus?.needMarkdown > 0 ? "error" : "default"}
max={999999}
sx={{ mr: 0.5 }}
>
<DocumentScannerIcon fontSize="small" />
</Badge>
{processingStatus.markdown && (
<CircularProgress size={14} color="inherit" />
)}
</Button>
</span>
</Tooltip>
<Tooltip title={processingStatus.extraction ? 'Running data extraction… this can take a while' : 'Process data extraction'} arrow>
<span>
<Button
color="inherit"
size="small"
onClick={() => this.handleProcessing('extraction')}
disabled={processingStatus.extraction || !documentStatus}
sx={{
minWidth: 'auto',
px: 1,
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' }
}}
>
<Badge
badgeContent={documentStatus?.needExtraction || 0}
color={documentStatus?.needExtraction > 0 ? "warning" : "default"}
max={999999}
sx={{ mr: 0.5 }}
>
<ExtractIcon fontSize="small" />
</Badge>
{processingStatus.extraction && (
<CircularProgress size={14} color="inherit" />
)}
</Button>
</span>
</Tooltip>
<Tooltip title={processingStatus.datevSync ? 'Running DATEV sync… this can take a while' : 'Process Datev sync'} arrow>
<span>
<Button
color="inherit"
size="small"
onClick={() => this.handleProcessing('datev-sync')}
disabled={processingStatus.datevSync || !documentStatus}
sx={{
minWidth: 'auto',
px: 1,
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' }
}}
>
<Badge
badgeContent={documentStatus?.needDatevSync || 0}
color={documentStatus?.needDatevSync > 0 ? "info" : "default"}
max={999999}
sx={{ mr: 0.5 }}
>
<EmailIcon fontSize="small" />
</Badge>
{processingStatus.datevSync && (
<CircularProgress size={14} color="inherit" />
)}
</Button>
</span>
</Tooltip>
<Button
color="inherit"
size="small"
onClick={() => this.handleProcessing('datev-upload')}
disabled={processingStatus.datevUpload || !documentStatus}
sx={{
minWidth: 'auto',
px: 1,
'&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.1)' }
}}
title="Process Datev CSV upload"
>
<Badge
badgeContent={documentStatus?.needDatevUpload || 0}
color={documentStatus?.needDatevUpload > 0 ? "secondary" : "default"}
max={999999}
sx={{ mr: 0.5 }}
>
<UploadIcon fontSize="small" />
</Badge>
{processingStatus.datevUpload && <PlayArrowIcon fontSize="small" />}
</Button>
</Box>
{this.state.exportData && (
<Button
color="inherit"
onClick={this.state.exportData.onExport}
disabled={!this.state.exportData.canExport}
size="small"
sx={{
mr: { xs: 1, sm: 2 },
minWidth: { xs: 'auto', sm: 'auto' },
px: { xs: 1, sm: 2 }
}}
>
<DownloadIcon sx={{ mr: { xs: 0, sm: 1 } }} />
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
DATEV Export
</Box>
</Button>
)}
<Button
color="inherit"
onClick={this.handleLogout}
@@ -124,17 +446,44 @@ class App extends Component {
</>
)}
</Toolbar>
{(processingStatus.markdown || processingStatus.extraction || processingStatus.datevSync) && (
<LinearProgress color="secondary" />
)}
</AppBar>
<Box sx={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
<Container maxWidth="xl" sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
{isAuthenticated ? (
<DataViewer user={user} />
<Container maxWidth={false} sx={{ mt: 4, flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column', width: '100%' }}>
{this.isOAuthCallback() ? (
<OAuthCallback />
) : isAuthenticated ? (
<DataViewer
user={user}
onUpdateExportData={this.updateExportData}
currentView={currentView}
onViewChange={this.handleViewChange}
targetTab={this.state.targetTab}
/>
) : (
<Login onLogin={this.handleLogin} />
)}
</Container>
</Box>
<Snackbar
open={snackbar.open}
autoHideDuration={6000}
onClose={this.handleSnackbarClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
>
<Alert
onClose={this.handleSnackbarClose}
severity={snackbar.severity}
variant="filled"
sx={{ width: '100%' }}
>
{snackbar.message}
</Alert>
</Snackbar>
</ThemeProvider>
);
}

View File

@@ -0,0 +1,550 @@
import React, { Component } from 'react';
import {
Box,
Typography,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
Chip
} from '@mui/material';
import {
Add as AddIcon,
Delete as DeleteIcon,
Edit as EditIcon,
Save as SaveIcon,
Cancel as CancelIcon
} from '@mui/icons-material';
import AuthService from '../services/AuthService';
class AccountingItemsManager extends Component {
constructor(props) {
super(props);
this.state = {
accountingItems: [],
kontos: [],
bus: [],
loading: true,
editingItem: null,
showCreateDialog: false,
showCreateKontoDialog: false,
jtlKontierung: null,
newItem: {
umsatz_brutto: '',
soll_haben_kz: 'S',
konto: '',
bu: '',
rechnungsnummer: '',
buchungstext: ''
},
newKonto: {
konto: '',
name: ''
},
error: null,
saving: false
};
this.authService = new AuthService();
}
componentDidMount() {
this.loadData();
this.loadJtlKontierung();
}
loadData = async () => {
try {
// Load accounting items for this transaction
await this.loadAccountingItems();
// Load Konto and BU options
await Promise.all([
this.loadKontos(),
this.loadBUs()
]);
this.setState({ loading: false });
} catch (error) {
console.error('Error loading data:', error);
this.setState({
error: 'Fehler beim Laden der Daten',
loading: false
});
}
};
loadJtlKontierung = async () => {
try {
const { transaction } = this.props;
if (!transaction || !transaction.jtlId) {
this.setState({ jtlKontierung: undefined });
return;
}
const response = await this.authService.apiCall(`/data/jtl-kontierung/${transaction.jtlId}`);
if (!response) return;
if (response.ok) {
const data = await response.json();
this.setState({ jtlKontierung: data });
} else {
const err = await response.json();
console.error('Failed to load JTL Kontierung:', err);
this.setState({ jtlKontierung: undefined });
}
} catch (e) {
console.error('Error loading JTL Kontierung:', e);
this.setState({ jtlKontierung: undefined });
}
}
loadAccountingItems = async () => {
const { transaction } = this.props;
if (!transaction?.id) return;
try {
const response = await this.authService.apiCall(`/data/accounting-items/${transaction.id}`);
if (response && response.ok) {
const items = await response.json();
this.setState({ accountingItems: items });
}
} catch (error) {
console.error('Error loading accounting items:', error);
}
};
loadKontos = async () => {
try {
const response = await this.authService.apiCall('/data/kontos');
if (response && response.ok) {
const kontos = await response.json();
this.setState({ kontos });
}
} catch (error) {
console.error('Error loading kontos:', error);
}
};
loadBUs = async () => {
try {
const response = await this.authService.apiCall('/data/bus');
if (response && response.ok) {
const bus = await response.json();
this.setState({ bus });
}
} catch (error) {
console.error('Error loading BUs:', error);
}
};
handleCreateItem = () => {
const { transaction } = this.props;
this.setState({
showCreateDialog: true,
newItem: {
umsatz_brutto: Math.abs(transaction.numericAmount || 0).toString(),
soll_haben_kz: (transaction.numericAmount || 0) >= 0 ? 'H' : 'S',
konto: '',
bu: '',
rechnungsnummer: '',
buchungstext: transaction.description || ''
}
});
};
handleSaveItem = async () => {
const { transaction } = this.props;
const { newItem } = this.state;
if (!newItem.umsatz_brutto || !newItem.konto) {
this.setState({ error: 'Betrag und Konto sind erforderlich' });
return;
}
this.setState({ saving: true, error: null });
try {
const itemData = {
...newItem,
transaction_id: transaction.isFromCSV ? null : transaction.id,
csv_transaction_id: transaction.isFromCSV ? transaction.id : null,
buchungsdatum: transaction.parsed_date || new Date().toISOString().split('T')[0]
};
const response = await this.authService.apiCall('/data/accounting-items', {
method: 'POST',
body: JSON.stringify(itemData)
});
if (response && response.ok) {
await this.loadAccountingItems();
this.setState({
showCreateDialog: false,
saving: false,
newItem: {
umsatz_brutto: '',
soll_haben_kz: 'S',
konto: '',
bu: '',
rechnungsnummer: '',
buchungstext: ''
}
});
} else {
const errorData = await response.json();
this.setState({
error: errorData.error || 'Fehler beim Speichern',
saving: false
});
}
} catch (error) {
console.error('Error saving accounting item:', error);
this.setState({
error: 'Fehler beim Speichern',
saving: false
});
}
};
handleCreateKonto = async () => {
const { newKonto } = this.state;
if (!newKonto.konto || !newKonto.name) {
this.setState({ error: 'Konto-Nummer und Name sind erforderlich' });
return;
}
this.setState({ saving: true, error: null });
try {
const response = await this.authService.apiCall('/data/kontos', {
method: 'POST',
body: JSON.stringify(newKonto)
});
if (response && response.ok) {
await this.loadKontos();
this.setState({
showCreateKontoDialog: false,
saving: false,
newKonto: { konto: '', name: '' }
});
} else {
const errorData = await response.json();
this.setState({
error: errorData.error || 'Fehler beim Erstellen des Kontos',
saving: false
});
}
} catch (error) {
console.error('Error creating konto:', error);
this.setState({
error: 'Fehler beim Erstellen des Kontos',
saving: false
});
}
};
handleDeleteItem = async (itemId) => {
if (!window.confirm('Buchungsposten wirklich löschen?')) return;
try {
const response = await this.authService.apiCall(`/data/accounting-items/${itemId}`, {
method: 'DELETE'
});
if (response && response.ok) {
await this.loadAccountingItems();
}
} catch (error) {
console.error('Error deleting accounting item:', error);
this.setState({ error: 'Fehler beim Löschen' });
}
};
calculateTotal = () => {
return this.state.accountingItems.reduce((sum, item) => {
const amount = parseFloat(item.umsatz_brutto) || 0;
return sum + (item.soll_haben_kz === 'S' ? amount : -amount);
}, 0);
};
render() {
const { transaction } = this.props;
const {
accountingItems,
kontos,
bus,
loading,
showCreateDialog,
showCreateKontoDialog,
newItem,
newKonto,
error,
saving
} = this.state;
if (loading) {
return <Typography>Lade Buchungsdaten...</Typography>;
}
const transactionAmount = transaction.numericAmount || 0;
const currentTotal = this.calculateTotal();
const isBalanced = Math.abs(currentTotal - Math.abs(transactionAmount)) < 0.01;
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">
Buchungsposten
</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={this.handleCreateItem}
size="small"
>
Hinzufügen
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Box sx={{ mb: 2, p: 2, bgcolor: isBalanced ? '#e8f5e8' : '#fff3e0', borderRadius: 1 }}>
<Typography variant="body2">
<strong>Transaktionsbetrag:</strong> {Math.abs(transactionAmount).toFixed(2)}
</Typography>
<Typography variant="body2">
<strong>Summe Buchungsposten:</strong> {Math.abs(currentTotal).toFixed(2)}
</Typography>
<Chip
label={isBalanced ? "✅ Ausgeglichen" : "⚠️ Nicht ausgeglichen"}
color={isBalanced ? "success" : "warning"}
size="small"
sx={{ mt: 1 }}
/>
</Box>
{transaction?.jtlId && (
<Box sx={{ mb: 2, p: 2, border: '1px dashed #999', borderRadius: 1 }}>
<Typography variant="subtitle2">Debug: tUmsatzKontierung.data</Typography>
<Typography variant="caption" component="div" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{this.state.jtlKontierung === undefined
? 'undefined'
: this.state.jtlKontierung === null
? 'null'
: typeof this.state.jtlKontierung === 'object'
? JSON.stringify(this.state.jtlKontierung, null, 2)
: String(this.state.jtlKontierung)}
</Typography>
</Box>
)}
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Betrag</TableCell>
<TableCell>S/H</TableCell>
<TableCell>Konto</TableCell>
<TableCell>BU</TableCell>
<TableCell>Buchungstext</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{accountingItems.map((item) => (
<TableRow key={item.id}>
<TableCell>{parseFloat(item.umsatz_brutto).toFixed(2)} </TableCell>
<TableCell>
<Chip
label={item.soll_haben_kz}
color={item.soll_haben_kz === 'S' ? 'primary' : 'secondary'}
size="small"
/>
</TableCell>
<TableCell>
{item.konto} - {item.konto_name}
</TableCell>
<TableCell>
{item.bu ? `${item.bu} - ${item.bu_name}` : '-'}
</TableCell>
<TableCell>{item.buchungstext || '-'}</TableCell>
<TableCell>
<IconButton
size="small"
onClick={() => this.handleDeleteItem(item.id)}
color="error"
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
{accountingItems.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">
<Typography color="textSecondary">
Keine Buchungsposten vorhanden
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
{/* Create Item Dialog */}
<Dialog open={showCreateDialog} onClose={() => this.setState({ showCreateDialog: false })} maxWidth="sm" fullWidth>
<DialogTitle>Neuen Buchungsposten erstellen</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Betrag"
type="number"
value={newItem.umsatz_brutto}
onChange={(e) => this.setState({
newItem: { ...newItem, umsatz_brutto: e.target.value }
})}
required
fullWidth
/>
<FormControl fullWidth>
<InputLabel>Soll/Haben</InputLabel>
<Select
value={newItem.soll_haben_kz}
onChange={(e) => this.setState({
newItem: { ...newItem, soll_haben_kz: e.target.value }
})}
>
<MenuItem value="S">Soll (S)</MenuItem>
<MenuItem value="H">Haben (H)</MenuItem>
</Select>
</FormControl>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<FormControl fullWidth>
<InputLabel>Konto</InputLabel>
<Select
value={newItem.konto}
onChange={(e) => this.setState({
newItem: { ...newItem, konto: e.target.value }
})}
>
{kontos.map((konto) => (
<MenuItem key={konto.id} value={konto.konto}>
{konto.konto} - {konto.name}
</MenuItem>
))}
</Select>
</FormControl>
<Button
variant="outlined"
onClick={() => this.setState({ showCreateKontoDialog: true })}
sx={{ minWidth: 'auto', px: 1 }}
>
<AddIcon />
</Button>
</Box>
<FormControl fullWidth>
<InputLabel>BU (Steuercode)</InputLabel>
<Select
value={newItem.bu}
onChange={(e) => this.setState({
newItem: { ...newItem, bu: e.target.value }
})}
>
<MenuItem value="">Kein BU</MenuItem>
{bus.map((bu) => (
<MenuItem key={bu.id} value={bu.bu}>
{bu.bu} - {bu.name} ({bu.vst}%)
</MenuItem>
))}
</Select>
</FormControl>
<TextField
label="Buchungstext"
value={newItem.buchungstext}
onChange={(e) => this.setState({
newItem: { ...newItem, buchungstext: e.target.value }
})}
fullWidth
multiline
rows={2}
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showCreateDialog: false })}>
Abbrechen
</Button>
<Button onClick={this.handleSaveItem} variant="contained" disabled={saving}>
{saving ? 'Speichern...' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* Create Konto Dialog */}
<Dialog open={showCreateKontoDialog} onClose={() => this.setState({ showCreateKontoDialog: false })} maxWidth="xs" fullWidth>
<DialogTitle>Neues Konto erstellen</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Konto-Nummer"
value={newKonto.konto}
onChange={(e) => this.setState({
newKonto: { ...newKonto, konto: e.target.value }
})}
required
fullWidth
/>
<TextField
label="Konto-Name"
value={newKonto.name}
onChange={(e) => this.setState({
newKonto: { ...newKonto, name: e.target.value }
})}
required
fullWidth
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => this.setState({ showCreateKontoDialog: false })}>
Abbrechen
</Button>
<Button onClick={this.handleCreateKonto} variant="contained" disabled={saving}>
{saving ? 'Erstellen...' : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}
export default AccountingItemsManager;

View File

@@ -0,0 +1,429 @@
import React, { Component } from 'react';
import {
Box,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Button,
Alert,
CircularProgress,
Typography,
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import AuthService from '../services/AuthService';
import KreditorService from '../services/KreditorService';
class BankingKreditorSelector extends Component {
constructor(props) {
super(props);
this.state = {
assignableKreditors: [],
selectedKreditorId: '',
loading: false,
error: null,
saving: false,
showCreateKreditor: false,
newKreditor: {
name: '',
kreditorId: ''
},
creating: false,
validationErrors: []
};
this.authService = new AuthService();
this.kreditorService = new KreditorService();
}
componentDidMount() {
this.loadAssignableKreditors();
this.loadExistingAssignment();
// Pre-fill new kreditor data with description (actual company name) instead of Beguenstigter (banking service name)
const prefilledName = this.props.transaction?.description || '';
if (prefilledName) {
this.setState({
newKreditor: {
...this.state.newKreditor,
name: prefilledName
}
});
}
}
componentDidUpdate(prevProps) {
// Reload data when transaction changes (only for database transactions)
const currentTransactionId = this.props.transaction?.id;
const prevTransactionId = prevProps.transaction?.id;
console.log('componentDidUpdate - current:', currentTransactionId, 'prev:', prevTransactionId);
if (currentTransactionId !== prevTransactionId) {
console.log('Transaction changed, reloading assignment');
this.loadExistingAssignment();
}
}
loadAssignableKreditors = async () => {
try {
this.setState({ loading: true, error: null });
const response = await this.authService.apiCall('/data/assignable-kreditors');
if (response && response.ok) {
const kreditors = await response.json();
this.setState({ assignableKreditors: kreditors, loading: false });
} else {
this.setState({
error: 'Fehler beim Laden der verfügbaren Kreditoren',
loading: false
});
}
} catch (error) {
console.error('Error loading assignable kreditors:', error);
this.setState({
error: 'Fehler beim Laden der verfügbaren Kreditoren',
loading: false
});
}
};
loadExistingAssignment = async () => {
// Only load assignments for regular database transactions, not CSV transactions
const transactionId = this.props.transaction?.id;
console.log('loadExistingAssignment called with:', {
transactionId,
csv_id: this.props.transaction?.csv_id,
fullTransaction: this.props.transaction
});
if (!transactionId) {
console.log('Skipping loadExistingAssignment - no transaction ID');
return;
}
try {
const response = await this.authService.apiCall(
`/data/banking-transactions/${transactionId}`
);
if (response && response.ok) {
const assignments = await response.json();
if (assignments.length > 0) {
const assignment = assignments[0];
this.setState({
selectedKreditorId: assignment.assigned_kreditor_id || '',
});
}
}
} catch (error) {
console.error('Error loading existing assignment:', error);
// Don't show error for missing assignments - it's normal
}
};
handleKreditorChange = (event) => {
const value = event.target.value;
if (value === 'create_new') {
this.setState({ showCreateKreditor: true, selectedKreditorId: '' });
} else {
this.setState({ selectedKreditorId: value, showCreateKreditor: false });
}
};
handleNewKreditorChange = (field, value) => {
this.setState({
newKreditor: {
...this.state.newKreditor,
[field]: value
},
validationErrors: []
});
};
generateKreditorId = () => {
const randomDigits = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
const kreditorId = `70${randomDigits}`;
this.setState({
newKreditor: {
...this.state.newKreditor,
kreditorId
}
});
};
handleCreateKreditor = async () => {
const { newKreditor } = this.state;
// Create regular kreditor data (no IBAN because transaction was processed through banking account)
const kreditorDataToValidate = {
...newKreditor,
iban: null,
is_banking: false, // This is a regular kreditor (actual company) that will be assigned to banking transactions
is_manual_assignment: true // This is a manual assignment for a banking transaction, so no IBAN is required
};
// Validate the data
const validationErrors = this.kreditorService.validateKreditorData(kreditorDataToValidate);
if (validationErrors.length > 0) {
this.setState({ validationErrors });
return;
}
this.setState({ creating: true, error: null });
try {
const createdKreditor = await this.kreditorService.createKreditor(kreditorDataToValidate);
// Add the new kreditor to the list and select it
this.setState({
assignableKreditors: [...this.state.assignableKreditors, createdKreditor],
selectedKreditorId: createdKreditor.id,
showCreateKreditor: false,
creating: false,
newKreditor: { name: '', kreditorId: '' },
validationErrors: []
});
} catch (error) {
console.error('Error creating kreditor:', error);
this.setState({
error: error.message,
creating: false
});
}
};
handleSave = async () => {
const { transaction, user, onSave } = this.props;
const { selectedKreditorId } = this.state;
if (!selectedKreditorId) {
this.setState({ error: 'Bitte wählen Sie einen Kreditor aus' });
return;
}
this.setState({ saving: true, error: null });
try {
let response;
if (transaction.id) {
// Check for existing assignment first
const checkResponse = await this.authService.apiCall(
`/data/banking-transactions/${transaction.id}`
);
if (checkResponse && checkResponse.ok) {
const existingAssignments = await checkResponse.json();
if (existingAssignments.length > 0) {
// Update existing assignment
response = await this.authService.apiCall(
`/data/banking-transactions/${existingAssignments[0].id}`,
{
method: 'PUT',
body: JSON.stringify({
assigned_kreditor_id: parseInt(selectedKreditorId),
})
}
);
} else {
// Create new assignment
response = await this.authService.apiCall(
'/data/banking-transactions',
{
method: 'POST',
body: JSON.stringify({
transaction_id: transaction.isFromCSV ? null : transaction.id,
csv_transaction_id: transaction.isFromCSV ? transaction.id : null,
banking_iban: transaction['Kontonummer/IBAN'] || transaction.kontonummer_iban,
assigned_kreditor_id: parseInt(selectedKreditorId),
})
}
);
}
}
} else {
this.setState({
error: 'Transaktion hat keine gültige ID',
saving: false
});
return;
}
if (response && response.ok) {
this.setState({ saving: false });
if (onSave) {
// Find the assigned kreditor from the list to pass to callback
const assignedKreditor = this.state.assignableKreditors.find(
k => k.id === parseInt(selectedKreditorId)
);
onSave(assignedKreditor);
}
} else {
const errorData = await response.json();
this.setState({
error: errorData.error || 'Fehler beim Speichern der Zuordnung',
saving: false
});
}
} catch (error) {
console.error('Error saving kreditor assignment:', error);
this.setState({
error: 'Fehler beim Speichern der Zuordnung',
saving: false
});
}
};
render() {
const {
assignableKreditors,
selectedKreditorId,
loading,
error,
saving,
showCreateKreditor,
newKreditor,
creating,
validationErrors
} = this.state;
if (loading) {
return (
<Box display="flex" justifyContent="center" py={2}>
<CircularProgress size={20} />
<Typography variant="caption" sx={{ ml: 1 }}>
Lade Kreditoren...
</Typography>
</Box>
);
}
return (
<Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<FormControl fullWidth sx={{ mb: 2 }} size="small">
<InputLabel id="kreditor-select-label">
Kreditor auswählen *
</InputLabel>
<Select
labelId="kreditor-select-label"
value={selectedKreditorId}
onChange={this.handleKreditorChange}
label="Kreditor auswählen *"
>
{assignableKreditors.map((kreditor) => (
<MenuItem key={kreditor.id} value={kreditor.id}>
{kreditor.name} ({kreditor.kreditorId})
</MenuItem>
))}
<MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
<AddIcon sx={{ mr: 1 }} />
Neuen Kreditor erstellen
</MenuItem>
</Select>
</FormControl>
{showCreateKreditor && (
<Box sx={{ mt: 2, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
<Typography variant="body2" sx={{ mb: 2, fontWeight: 'bold' }}>
Neuen Kreditor erstellen:
</Typography>
{validationErrors.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }}>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</Alert>
)}
<TextField
label="Name *"
value={newKreditor.name}
onChange={(e) => this.handleNewKreditorChange('name', e.target.value)}
fullWidth
size="small"
sx={{ mb: 2 }}
placeholder="Name des Kreditors"
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end', mb: 2 }}>
<TextField
label="Kreditor-ID *"
value={newKreditor.kreditorId}
onChange={(e) => this.handleNewKreditorChange('kreditorId', e.target.value)}
size="small"
placeholder="70001"
sx={{ flexGrow: 1 }}
/>
<Button
onClick={this.generateKreditorId}
variant="outlined"
size="small"
>
Generieren
</Button>
</Box>
<Typography variant="caption" color="textSecondary" sx={{ display: 'block', mb: 2 }}>
Die Kreditor-ID muss mit "70" beginnen, gefolgt von mindestens 3 Ziffern.
Keine IBAN erforderlich, da diese Transaktion über ein Banking-Konto abgewickelt wurde.
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
onClick={this.handleCreateKreditor}
variant="contained"
size="small"
disabled={creating}
startIcon={creating ? <CircularProgress size={16} /> : null}
>
{creating ? 'Erstellen...' : 'Kreditor erstellen'}
</Button>
<Button
onClick={() => this.setState({ showCreateKreditor: false, validationErrors: [] })}
variant="outlined"
size="small"
disabled={creating}
>
Abbrechen
</Button>
</Box>
</Box>
)}
<Button
onClick={this.handleSave}
variant="contained"
disabled={!selectedKreditorId || saving}
size="small"
sx={{
bgcolor: '#ff5722',
'&:hover': { bgcolor: '#e64a19' }
}}
>
{saving ? (
<>
<CircularProgress size={16} sx={{ mr: 1 }} />
Speichern...
</>
) : (
'Kreditor zuordnen'
)}
</Button>
</Box>
);
}
}
export default BankingKreditorSelector;

View File

@@ -0,0 +1,502 @@
import React, { Component } from 'react';
import {
Button,
Typography,
Box,
Alert,
CircularProgress,
LinearProgress,
Chip,
Tabs,
Tab,
Divider,
Paper,
} from '@mui/material';
import {
CloudUpload as UploadIcon,
CheckCircle as SuccessIcon,
Error as ErrorIcon,
Link as LinkIcon,
AccountBalance as AccountIcon,
InfoOutlined as InfoIcon,
} from '@mui/icons-material';
import AuthService from '../services/AuthService';
const IMPORT_TYPES = {
BANKING: 'BANKING',
DATEV_LINKS: 'DATEV_LINKS',
};
class CSVImportPanel extends Component {
constructor(props) {
super(props);
this.state = {
// common
activeTab: IMPORT_TYPES.BANKING,
importing: false,
imported: false,
importResult: null,
error: null,
// drag/drop visual
dragOver: false,
// banking state
file: null,
csvData: null,
headers: null,
// datev links state
datevFile: null,
datevCsvData: null,
datevHeaders: null,
};
this.authService = new AuthService();
this.fileInputRef = React.createRef();
this.datevFileInputRef = React.createRef();
}
componentDidMount() {
// Check if we should navigate to a specific tab
if (this.props.targetTab) {
this.setState({ activeTab: this.props.targetTab });
}
}
componentDidUpdate(prevProps) {
// Handle targetTab changes
if (this.props.targetTab !== prevProps.targetTab && this.props.targetTab) {
this.setState({ activeTab: this.props.targetTab });
}
}
// Tab switch resets type-specific state but keeps success state as-is
handleTabChange = (_e, value) => {
this.setState({
activeTab: value,
// clear type-specific selections and errors
file: null,
csvData: null,
headers: null,
datevFile: null,
datevCsvData: null,
datevHeaders: null,
error: null,
dragOver: false,
// keep importing false when switching
importing: false,
// keep imported/result to show success for last action regardless of tab
// Alternatively, uncomment next two lines to reset success on tab change:
// imported: false,
// importResult: null,
});
};
// Generic CSV parser (semicolon with quotes)
parseCSV = (text) => {
const lines = text.split('\n').filter(line => line.trim());
if (lines.length < 2) {
throw new Error('CSV-Datei muss mindestens eine Kopfzeile und eine Datenzeile enthalten');
}
const parseCSVLine = (line) => {
const result = [];
let current = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ';' && !inQuotes) {
result.push(current.trim());
current = '';
} else {
current += char;
}
}
result.push(current.trim());
return result;
};
const headers = parseCSVLine(lines[0]);
const dataRows = lines.slice(1).map(line => {
const values = parseCSVLine(line);
const row = {};
headers.forEach((header, index) => {
row[header] = values[index] || '';
});
return row;
});
return { headers, dataRows };
};
// Banking file handlers
handleFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
this.processFile(file, IMPORT_TYPES.BANKING);
}
};
// DATEV file handlers
handleDatevFileSelect = (event) => {
const file = event.target.files[0];
if (file) {
this.processFile(file, IMPORT_TYPES.DATEV_LINKS);
}
};
handleDrop = (event) => {
event.preventDefault();
this.setState({ dragOver: false });
const file = event.dataTransfer.files[0];
if (file) {
// route to active tab
this.processFile(file, this.state.activeTab);
}
};
handleDragOver = (event) => {
event.preventDefault();
this.setState({ dragOver: true });
};
handleDragLeave = () => {
this.setState({ dragOver: false });
};
processFile = (file, type) => {
if (!file.name.toLowerCase().endsWith('.csv')) {
this.setState({ error: 'Bitte wählen Sie eine CSV-Datei aus' });
return;
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const text = e.target.result;
const { headers, dataRows } = this.parseCSV(text);
if (type === IMPORT_TYPES.BANKING) {
this.setState({
file,
csvData: dataRows,
headers,
error: null,
});
} else {
this.setState({
datevFile: file,
datevCsvData: dataRows,
datevHeaders: headers,
error: null,
});
}
} catch (err) {
console.error('Error parsing CSV:', err);
this.setState({ error: err.message || 'Fehler beim Lesen der CSV-Datei' });
}
};
reader.readAsText(file, 'UTF-8');
};
handleImport = async () => {
const {
activeTab,
file, csvData, headers,
datevFile, datevCsvData, datevHeaders,
} = this.state;
const isBanking = activeTab === IMPORT_TYPES.BANKING;
const hasData = isBanking ? (csvData && csvData.length > 0) : (datevCsvData && datevCsvData.length > 0);
if (!hasData) {
this.setState({ error: 'Keine Daten zum Importieren gefunden' });
return;
}
this.setState({ importing: true, error: null });
try {
let endpoint = '';
let payload = {};
if (isBanking) {
endpoint = '/data/import-csv-transactions';
payload = {
transactions: csvData,
headers: headers,
filename: file.name,
batchId: `import_${Date.now()}_${file.name}`,
};
} else {
// Placeholder endpoint for DATEV Beleglinks (adjust when backend is available)
endpoint = '/data/import-datev-beleglinks';
payload = {
beleglinks: datevCsvData,
headers: datevHeaders,
filename: datevFile.name,
batchId: `datev_${Date.now()}_${datevFile.name}`,
};
}
const response = await this.authService.apiCall(endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
if (response && response.ok) {
const result = await response.json();
this.setState({
importing: false,
imported: true,
importResult: result,
});
if (this.props.onImportSuccess) {
this.props.onImportSuccess(result);
}
} else {
let errorText = 'Import fehlgeschlagen';
try {
const errorData = await response.json();
errorText = errorData.error || errorText;
} catch (_) {}
this.setState({
importing: false,
error: errorText,
});
}
} catch (error) {
console.error('Import error:', error);
this.setState({
importing: false,
error: 'Netzwerkfehler beim Import',
});
}
};
handleClose = () => {
this.setState({
// common
importing: false,
imported: false,
importResult: null,
error: null,
dragOver: false,
// banking
file: null,
csvData: null,
headers: null,
// datev
datevFile: null,
datevCsvData: null,
datevHeaders: null,
});
};
renderUploadPanel = ({ isBanking }) => {
const {
dragOver,
file, csvData, headers,
datevFile, datevCsvData, datevHeaders,
} = this.state;
const currentFile = isBanking ? file : datevFile;
const currentHeaders = isBanking ? headers : datevHeaders;
const currentData = isBanking ? csvData : datevCsvData;
const onClickPick = () => {
if (isBanking) {
this.fileInputRef.current?.click();
} else {
this.datevFileInputRef.current?.click();
}
};
return (
<>
<Box
sx={{
border: '2px dashed',
borderColor: dragOver ? 'primary.main' : 'grey.300',
borderRadius: 2,
p: 4,
textAlign: 'center',
bgcolor: dragOver ? 'action.hover' : 'background.paper',
cursor: 'pointer',
mb: 2,
}}
onDrop={this.handleDrop}
onDragOver={this.handleDragOver}
onDragLeave={this.handleDragLeave}
onClick={onClickPick}
>
<input
type="file"
accept=".csv"
onChange={isBanking ? this.handleFileSelect : this.handleDatevFileSelect}
ref={isBanking ? this.fileInputRef : this.datevFileInputRef}
style={{ display: 'none' }}
/>
{isBanking ? (
<AccountIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
) : (
<LinkIcon sx={{ fontSize: 48, color: 'grey.400', mb: 2 }} />
)}
<Typography variant="h6" gutterBottom>
{isBanking ? 'Bankkontoumsätze CSV hier ablegen oder klicken zum Auswählen' : 'DATEV Beleglinks CSV hier ablegen oder klicken zum Auswählen'}
</Typography>
<Typography variant="body2" color="textSecondary">
Unterstützte Formate: .csv (Semikolon-getrennt)
</Typography>
</Box>
{currentFile && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Ausgewählte Datei:
</Typography>
<Chip label={currentFile.name} color="primary" />
</Box>
)}
{currentHeaders && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Erkannte Spalten ({currentHeaders.length}):
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{currentHeaders.slice(0, 10).map((header, index) => (
<Chip key={index} label={header} size="small" variant="outlined" />
))}
{currentHeaders.length > 10 && (
<Chip label={`+${currentHeaders.length - 10} weitere`} size="small" />
)}
</Box>
</Box>
)}
{currentData && (
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
{isBanking ? 'Gefundene Transaktionen' : 'Gefundene Beleglinks'}: {currentData.length}
</Typography>
<Typography variant="body2" color="textSecondary">
Die Daten werden validiert und in die Datenbank importiert.
</Typography>
</Box>
)}
</>
);
};
render() {
const {
activeTab,
importing,
imported,
importResult,
error,
csvData,
datevCsvData,
} = this.state;
const isBanking = activeTab === IMPORT_TYPES.BANKING;
const hasData = isBanking ? csvData : datevCsvData;
return (
<Paper sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
CSV Import
</Typography>
<Tabs
value={activeTab}
onChange={this.handleTabChange}
variant="fullWidth"
sx={{ borderBottom: 1, borderColor: 'divider', mb: 3 }}
>
<Tab value={IMPORT_TYPES.BANKING} iconPosition="start" icon={<AccountIcon />} label="Banking Umsätze" />
<Tab value={IMPORT_TYPES.DATEV_LINKS} iconPosition="start" icon={<LinkIcon />} label="DATEV Beleglinks" />
</Tabs>
<Box>
{!imported ? (
<>
{this.renderUploadPanel({ isBanking })}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{importing && (
<Box sx={{ mb: 2 }}>
<LinearProgress />
<Typography variant="body2" sx={{ mt: 1, textAlign: 'center' }}>
{isBanking ? 'Importiere Transaktionen...' : 'Importiere DATEV Beleglinks...'}
</Typography>
</Box>
)}
</>
) : (
<Box sx={{ textAlign: 'center', py: 2 }}>
<SuccessIcon sx={{ fontSize: 64, color: 'success.main', mb: 2 }} />
<Typography variant="h6" gutterBottom>
Import erfolgreich abgeschlossen!
</Typography>
{importResult && (
<Box sx={{ mt: 2 }}>
<Typography variant="body1" gutterBottom>
<strong>Hinzugefügt:</strong> {importResult.imported} {isBanking ? 'Transaktionen' : 'Datevlinks'}
</Typography>
{importResult.skipped > 0 && (
<Typography variant="body1" color="info.main">
<strong>Übersprungen:</strong> {importResult.skipped} Zeilen (bereits vorhanden, unbekanntes Format, etc.)
</Typography>
)}
{importResult.errors > 0 && (
<Typography variant="body1" color="warning.main">
<strong>Fehler:</strong> {importResult.errors} Zeilen konnten nicht verarbeitet werden.
</Typography>
)}
{importResult.message && (
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
{importResult.message}
</Typography>
)}
<Typography variant="body2" color="textSecondary" sx={{ mt: 1 }}>
Batch-ID: {importResult.batchId}
</Typography>
</Box>
)}
</Box>
)}
{!imported && hasData && (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button
onClick={this.handleImport}
variant="contained"
size="large"
disabled={importing || !hasData}
startIcon={importing ? <CircularProgress size={16} /> : <UploadIcon />}
>
{importing ? 'Importiere...' : 'Importieren'}
</Button>
</Box>
)}
{imported && (
<Box sx={{ mt: 3, textAlign: 'center' }}>
<Button onClick={this.handleClose} variant="outlined" size="large">
Neuer Import
</Button>
</Box>
)}
</Box>
</Paper>
);
}
}
export default CSVImportPanel;

View File

@@ -7,6 +7,8 @@ import {
import AuthService from '../services/AuthService';
import SummaryHeader from './SummaryHeader';
import TransactionsTable from './TransactionsTable';
import Dashboard from './Dashboard';
import TableManagement from './TableManagement';
class DataViewer extends Component {
constructor(props) {
@@ -18,12 +20,20 @@ class DataViewer extends Component {
summary: null,
loading: true,
error: null,
};
this.authService = new AuthService();
}
componentDidMount() {
this.loadMonths();
this.updateExportData();
}
componentDidUpdate(prevProps, prevState) {
if (prevState.loading !== this.state.loading || prevState.selectedMonth !== this.state.selectedMonth) {
this.updateExportData();
}
}
loadMonths = async () => {
@@ -70,6 +80,7 @@ class DataViewer extends Component {
const monthYear = event.target.value;
this.setState({ selectedMonth: monthYear });
this.loadTransactions(monthYear);
this.updateExportData(monthYear);
};
@@ -98,8 +109,21 @@ class DataViewer extends Component {
}
};
updateExportData = (selectedMonth = this.state.selectedMonth) => {
if (this.props.onUpdateExportData) {
this.props.onUpdateExportData({
selectedMonth,
canExport: !!selectedMonth && !this.state.loading && this.props.currentView === 'dashboard',
onExport: this.downloadDatev
});
}
};
render() {
const { months, selectedMonth, transactions, summary, loading, error } = this.state;
const { user, currentView } = this.props;
if (loading && !transactions.length) {
return (
@@ -119,24 +143,31 @@ class DataViewer extends Component {
return (
<Box sx={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ flexShrink: 0 }}>
<SummaryHeader
months={months}
selectedMonth={selectedMonth}
summary={summary}
loading={loading}
onMonthChange={this.handleMonthChange}
onDownloadDatev={this.downloadDatev}
/>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>
<TransactionsTable
transactions={transactions}
selectedMonth={selectedMonth}
loading={loading}
/>
</Box>
{currentView === 'dashboard' ? (
<>
<Box sx={{ flexShrink: 0 }}>
<SummaryHeader
months={months}
selectedMonth={selectedMonth}
summary={summary}
loading={loading}
onMonthChange={this.handleMonthChange}
/>
</Box>
<Box sx={{ flex: 1, minHeight: 0 }}>
<TransactionsTable
transactions={transactions}
selectedMonth={selectedMonth}
loading={loading}
/>
</Box>
</>
) : (
<Box sx={{ flex: 1, minHeight: 0, overflow: 'auto', p: 2 }}>
<TableManagement user={user} targetTab={this.props.targetTab} />
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,345 @@
import React, { Component } from 'react';
import {
Select,
MenuItem,
FormControl,
InputLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
Box,
Typography,
Alert,
CircularProgress
} from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import KreditorService from '../services/KreditorService';
class KreditorSelector extends Component {
constructor(props) {
super(props);
this.state = {
kreditors: [],
selectedKreditorId: props.selectedKreditorId || '',
loading: false,
createDialogOpen: false,
newKreditor: {
iban: '',
name: '',
kreditorId: ''
},
validationErrors: [],
error: null,
creating: false
};
this.kreditorService = new KreditorService();
}
componentDidMount() {
this.loadKreditors();
// If prefilled data is provided, set it in the newKreditor state
const updates = {};
if (this.props.prefilledIban) {
updates.iban = this.props.prefilledIban;
}
if (this.props.prefilledName) {
updates.name = this.props.prefilledName;
}
if (Object.keys(updates).length > 0) {
this.setState({
newKreditor: {
...this.state.newKreditor,
...updates
}
});
}
}
componentDidUpdate(prevProps) {
if (prevProps.selectedKreditorId !== this.props.selectedKreditorId) {
this.setState({ selectedKreditorId: this.props.selectedKreditorId || '' });
}
// If prefilled props change, update the newKreditor state
const updates = {};
if (prevProps.prefilledIban !== this.props.prefilledIban && this.props.prefilledIban) {
updates.iban = this.props.prefilledIban;
}
if (prevProps.prefilledName !== this.props.prefilledName && this.props.prefilledName) {
updates.name = this.props.prefilledName;
}
if (Object.keys(updates).length > 0) {
this.setState({
newKreditor: {
...this.state.newKreditor,
...updates
}
});
}
}
loadKreditors = async () => {
this.setState({ loading: true, error: null });
try {
const kreditors = await this.kreditorService.getAllKreditors();
this.setState({ kreditors, loading: false });
} catch (error) {
console.error('Error loading kreditors:', error);
this.setState({
error: error.message,
loading: false
});
}
};
handleKreditorChange = (event) => {
const selectedKreditorId = event.target.value;
if (selectedKreditorId === 'create_new') {
this.setState({ createDialogOpen: true });
return;
}
this.setState({ selectedKreditorId });
if (this.props.onKreditorChange) {
const selectedKreditor = this.state.kreditors.find(k => k.id === selectedKreditorId);
this.props.onKreditorChange(selectedKreditor);
}
};
handleCreateDialogClose = () => {
this.setState({
createDialogOpen: false,
newKreditor: {
iban: this.props.prefilledIban || '',
name: this.props.prefilledName || '',
kreditorId: ''
},
validationErrors: [],
error: null
});
};
handleNewKreditorChange = (field, value) => {
this.setState({
newKreditor: {
...this.state.newKreditor,
[field]: value
},
validationErrors: [] // Clear validation errors when user types
});
};
generateKreditorId = () => {
// Generate a kreditorId starting with 70 followed by random digits
const randomDigits = Math.floor(Math.random() * 10000).toString().padStart(4, '0');
const kreditorId = `70${randomDigits}`;
this.setState({
newKreditor: {
...this.state.newKreditor,
kreditorId
}
});
};
handleCreateKreditor = async () => {
const { newKreditor } = this.state;
// For banking kreditors (when allowEmptyIban is true), mark as banking if IBAN is empty
const kreditorDataToValidate = {
...newKreditor,
is_banking: this.props.allowEmptyIban && (!newKreditor.iban || newKreditor.iban.trim() === ''),
iban: newKreditor.iban || null // Convert empty string to null
};
// Validate the data
const validationErrors = this.kreditorService.validateKreditorData(kreditorDataToValidate);
if (validationErrors.length > 0) {
this.setState({ validationErrors });
return;
}
this.setState({ creating: true, error: null });
try {
const createdKreditor = await this.kreditorService.createKreditor(kreditorDataToValidate);
// Add the new kreditor to the list and select it
const updatedKreditors = [...this.state.kreditors, createdKreditor];
this.setState({
kreditors: updatedKreditors,
selectedKreditorId: createdKreditor.id,
creating: false
});
// Notify parent component
if (this.props.onKreditorChange) {
this.props.onKreditorChange(createdKreditor);
}
this.handleCreateDialogClose();
} catch (error) {
console.error('Error creating kreditor:', error);
this.setState({
error: error.message,
creating: false
});
}
};
render() {
const {
kreditors,
selectedKreditorId,
loading,
createDialogOpen,
newKreditor,
validationErrors,
error,
creating
} = this.state;
const { label = "Kreditor", disabled = false, fullWidth = true } = this.props;
return (
<>
<FormControl fullWidth={fullWidth} disabled={disabled || loading}>
<InputLabel>{label}</InputLabel>
<Select
value={selectedKreditorId}
onChange={this.handleKreditorChange}
label={label}
>
<MenuItem value="">
<em>Keinen Kreditor auswählen</em>
</MenuItem>
{kreditors.map((kreditor) => (
<MenuItem key={kreditor.id} value={kreditor.id}>
{kreditor.name} ({kreditor.kreditorId}) - {kreditor.iban}
</MenuItem>
))}
{(this.props.allowCreate !== false) && (
<MenuItem value="create_new" sx={{ color: 'primary.main', fontWeight: 'bold' }}>
<AddIcon sx={{ mr: 1 }} />
Neuen Kreditor erstellen
</MenuItem>
)}
</Select>
</FormControl>
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
{/* Create Kreditor Dialog */}
<Dialog
open={createDialogOpen}
onClose={this.handleCreateDialogClose}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{this.props.allowEmptyIban ? 'Neuen Banking-Kreditor erstellen' : 'Neuen Kreditor erstellen'}
</DialogTitle>
<DialogContent>
<Box sx={{ pt: 1 }}>
{this.props.allowEmptyIban && (
<Alert severity="info" sx={{ mb: 2 }}>
Sie erstellen einen Kreditor für eine Banking-Transaktion. Die IBAN kann leer bleiben, da diese Transaktion über ein Banking-Konto (z.B. PayPal) abgewickelt wurde.
</Alert>
)}
{validationErrors.length > 0 && (
<Alert severity="error" sx={{ mb: 2 }}>
<ul style={{ margin: 0, paddingLeft: '20px' }}>
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Name"
value={newKreditor.name}
onChange={(e) => this.handleNewKreditorChange('name', e.target.value)}
fullWidth
margin="normal"
required
/>
<TextField
label="IBAN"
value={newKreditor.iban}
onChange={(e) => this.handleNewKreditorChange('iban', e.target.value.toUpperCase())}
fullWidth
margin="normal"
required={!this.props.allowEmptyIban}
placeholder={this.props.allowEmptyIban ? "Leer lassen für Banking-Kreditor" : "DE89 3704 0044 0532 0130 00"}
helperText={this.props.allowEmptyIban ? "Für Banking-Transaktionen kann die IBAN leer bleiben" : ""}
/>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'flex-end' }}>
<TextField
label="Kreditor-ID"
value={newKreditor.kreditorId}
onChange={(e) => this.handleNewKreditorChange('kreditorId', e.target.value)}
margin="normal"
required
placeholder="70001"
sx={{ flexGrow: 1 }}
/>
<Button
onClick={this.generateKreditorId}
variant="outlined"
sx={{ mb: 1 }}
>
Generieren
</Button>
</Box>
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
Die Kreditor-ID muss mit "70" beginnen, gefolgt von mindestens 3 Ziffern.
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button
onClick={this.handleCreateDialogClose}
disabled={creating}
>
Abbrechen
</Button>
<Button
onClick={this.handleCreateKreditor}
variant="contained"
disabled={creating}
startIcon={creating ? <CircularProgress size={16} /> : null}
>
{creating ? 'Erstellen...' : 'Erstellen'}
</Button>
</DialogActions>
</Dialog>
</>
);
}
}
export default KreditorSelector;

View File

@@ -9,6 +9,10 @@ class Login extends Component {
error: null,
loading: false,
};
// Flags to track FedCM attempts and success
this.fedcmAttempted = false;
this.fedcmSucceeded = false;
}
componentDidMount() {
@@ -34,11 +38,17 @@ class Login extends Component {
initializeGoogleSignIn = () => {
if (window.google && window.google.accounts) {
try {
// Note: Removed debug logging to avoid deprecated method warnings
console.log('REACT_APP_GOOGLE_CLIENT_ID', process.env.REACT_APP_GOOGLE_CLIENT_ID);
console.log('Current origin for Google auth:', window.location.origin);
console.log('User agent:', navigator.userAgent);
window.google.accounts.id.initialize({
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID || 'your_google_client_id_here',
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID,
callback: this.handleGoogleResponse,
auto_select: false,
cancel_on_tap_outside: true,
cancel_on_tap_outside: false,
});
console.log('✅ Google Sign-In initialized');
} catch (error) {
@@ -48,6 +58,9 @@ class Login extends Component {
};
handleGoogleResponse = (response) => {
// Mark FedCM as successful if we get here
this.fedcmSucceeded = true;
this.setState({ loading: true, error: null });
this.props.onLogin(response)
.catch((error) => {
@@ -70,6 +83,11 @@ class Login extends Component {
errorMessage = '🚫 Zugriff verweigert: Ihre E-Mail-Adresse ist nicht autorisiert. Versuchen Sie, sich mit einem anderen Google-Konto anzumelden.';
} else if (error.message.includes('No authorized users configured')) {
errorMessage = '🔒 Kein Zugriff: Derzeit sind keine Benutzer autorisiert. Wenden Sie sich an den Administrator.';
} else if (error.message.includes('Not signed in with the identity provider') ||
error.message.includes('NetworkError') ||
error.message.includes('FedCM')) {
// FedCM failed, offer redirect option
errorMessage = '🔄 Schnelle Anmeldung nicht verfügbar. Versuchen Sie die Standard-Anmeldung.';
} else {
// Show the actual error message from the server
errorMessage = `❌ Anmeldefehler: ${error.message}`;
@@ -92,29 +110,105 @@ class Login extends Component {
return;
}
// Clear any previous error
this.setState({ error: null, loading: false });
// Clear any previous error and start loading
this.setState({ error: null, loading: true });
// Try FedCM first (seamless for users already signed in to Google)
console.log('🎯 Trying FedCM first for optimal UX...');
this.tryFedCMFirst();
};
tryFedCMFirst = () => {
if (window.google && window.google.accounts && window.google.accounts.id) {
try {
window.google.accounts.id.prompt();
} catch (error) {
console.error('Google prompt error:', error);
this.setState({
error: 'Google-Anmeldung konnte nicht geladen werden. Die Seite wird aktualisiert, um es erneut zu versuchen.',
loading: true
console.log('✅ Trying FedCM for seamless sign-in...');
// Listen for the specific FedCM errors that indicate no Google session
const originalConsoleError = console.error;
let errorIntercepted = false;
console.error = (...args) => {
const errorMessage = args.join(' ');
if (!errorIntercepted && (
errorMessage.includes('Not signed in with the identity provider') ||
errorMessage.includes('FedCM get() rejects with NetworkError') ||
errorMessage.includes('Error retrieving a token')
)) {
errorIntercepted = true;
console.error = originalConsoleError; // Restore immediately
console.log('🔄 FedCM failed (user not signed in to Google), using redirect...');
this.redirectToGoogleOAuth();
return;
}
originalConsoleError.apply(console, args);
};
// Try FedCM
window.google.accounts.id.prompt((notification) => {
console.log('🔍 FedCM notification:', notification);
console.error = originalConsoleError; // Restore console.error
// If we get here without error, FedCM is working
});
setTimeout(() => window.location.reload(), 2000);
} catch (error) {
console.error('FedCM initialization error, falling back to redirect:', error);
this.redirectToGoogleOAuth();
}
} else {
this.setState({
error: 'Google-Anmeldung nicht geladen. Die Seite wird aktualisiert, um es erneut zu versuchen.',
loading: true
});
setTimeout(() => window.location.reload(), 2000);
// Google Identity Services not loaded, go straight to redirect
console.log('📋 GSI not loaded, using redirect flow...');
this.redirectToGoogleOAuth();
}
};
redirectToGoogleOAuth = () => {
try {
// Generate a random state parameter for security
const state = this.generateRandomString(32);
sessionStorage.setItem('oauth_state', state);
// Build the Google OAuth2 authorization URL
const params = new URLSearchParams({
client_id: process.env.REACT_APP_GOOGLE_CLIENT_ID,
redirect_uri: window.location.origin + '/auth/callback',
response_type: 'code',
scope: 'openid email profile',
state: state,
access_type: 'online',
prompt: 'select_account'
});
const authUrl = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
console.log('🔗 Redirecting to Google OAuth:', authUrl);
// Redirect to Google OAuth
window.location.href = authUrl;
} catch (error) {
console.error('Redirect OAuth error:', error);
this.setState({
error: 'Google-Anmeldung konnte nicht gestartet werden.',
loading: false
});
}
};
generateRandomString = (length) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
};
handleUseRedirect = () => {
console.log('🔄 User chose redirect flow');
this.setState({ error: null, loading: true });
this.redirectToGoogleOAuth();
};
@@ -157,6 +251,20 @@ class Login extends Component {
{loading ? 'Anmeldung läuft...' : 'Mit Google anmelden'}
</Button>
{error && error.includes('Standard-Anmeldung') && (
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<GoogleIcon />}
onClick={this.handleUseRedirect}
disabled={loading}
sx={{ py: 1.5, mt: 2 }}
>
Standard Google-Anmeldung verwenden
</Button>
)}
<Typography variant="caption" display="block" textAlign="center" sx={{ mt: 2 }}>

View File

@@ -0,0 +1,45 @@
import React, { Component } from 'react';
import {
Box,
Tabs,
Tab,
Paper,
} from '@mui/material';
import {
Dashboard as DashboardIcon,
TableChart as TableIcon,
} from '@mui/icons-material';
class Navigation extends Component {
render() {
const { currentView, onViewChange } = this.props;
return (
<Paper elevation={1} sx={{ mb: 3 }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={currentView}
onChange={onViewChange}
variant="fullWidth"
sx={{ minHeight: 48 }}
>
<Tab
icon={<DashboardIcon />}
label="Dashboard"
value="dashboard"
sx={{ minHeight: 48 }}
/>
<Tab
icon={<TableIcon />}
label="Stammdaten"
value="tables"
sx={{ minHeight: 48 }}
/>
</Tabs>
</Box>
</Paper>
);
}
}
export default Navigation;

View File

@@ -0,0 +1,133 @@
import React, { Component } from 'react';
import { Box, CircularProgress, Typography, Alert } from '@mui/material';
class OAuthCallback extends Component {
constructor(props) {
super(props);
this.state = {
loading: true,
error: null,
};
}
componentDidMount() {
this.handleOAuthCallback();
}
handleOAuthCallback = async () => {
try {
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const state = urlParams.get('state');
const error = urlParams.get('error');
// Check for OAuth errors
if (error) {
throw new Error(`OAuth error: ${error}`);
}
// Verify state parameter for security
const storedState = sessionStorage.getItem('oauth_state');
if (!state || state !== storedState) {
throw new Error('Invalid state parameter - possible CSRF attack');
}
// Clear stored state
sessionStorage.removeItem('oauth_state');
if (!code) {
throw new Error('No authorization code received');
}
console.log('🔑 Authorization code received, exchanging for tokens...');
// Exchange authorization code for tokens via our backend
const response = await fetch('/api/auth/google/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: code,
redirect_uri: window.location.origin + '/auth/callback'
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.token) {
console.log('✅ OAuth callback successful');
// Store the JWT token
localStorage.setItem('token', data.token);
// Redirect to main app
window.location.href = '/';
} else {
throw new Error(data.message || 'Authentication failed');
}
} catch (error) {
console.error('OAuth callback error:', error);
this.setState({
loading: false,
error: error.message || 'Authentication failed'
});
}
};
render() {
const { loading, error } = this.state;
if (error) {
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="60vh"
flexDirection="column"
>
<Alert severity="error" sx={{ mb: 2, maxWidth: 400 }}>
<Typography variant="h6" gutterBottom>
Anmeldung fehlgeschlagen
</Typography>
<Typography variant="body2">
{error}
</Typography>
</Alert>
<Typography variant="body2" color="textSecondary">
<a href="/" style={{ color: 'inherit' }}>
Zurück zur Anmeldung
</a>
</Typography>
</Box>
);
}
return (
<Box
display="flex"
justifyContent="center"
alignItems="center"
minHeight="60vh"
flexDirection="column"
>
<CircularProgress size={60} sx={{ mb: 2 }} />
<Typography variant="h6" gutterBottom>
Anmeldung wird verarbeitet...
</Typography>
<Typography variant="body2" color="textSecondary">
Sie werden automatisch weitergeleitet.
</Typography>
</Box>
);
}
}
export default OAuthCallback;

View File

@@ -95,8 +95,7 @@ class SummaryHeader extends Component {
selectedMonth,
summary,
loading,
onMonthChange,
onDownloadDatev
onMonthChange
} = this.props;
if (!summary) return null;
@@ -249,23 +248,19 @@ class SummaryHeader extends Component {
<Grid item xs={6} sm={4} md={1}>
<Box textAlign="center">
<Typography variant="caption" color="textSecondary">JTL </Typography>
<Typography variant="h6" sx={{ fontWeight: 'bold', color: '#388e3c', fontSize: { xs: '0.9rem', sm: '1.25rem' } }}>
{summary.jtlMatches || 0}
<Typography
variant="h6"
sx={{
fontWeight: 'bold',
color: summary.jtlMatches === undefined ? '#856404' : '#388e3c',
fontSize: { xs: '0.9rem', sm: '1.25rem' }
}}
>
{summary.jtlMatches === undefined ? '?' : summary.jtlMatches}
</Typography>
</Box>
</Grid>
<Grid item xs={12} sm={4} md={2}>
<Button
variant="contained"
startIcon={<DownloadIcon />}
onClick={onDownloadDatev}
disabled={!selectedMonth || loading}
size="small"
sx={{ height: 'fit-content' }}
>
DATEV Export
</Button>
</Grid>
</Grid>
</Paper>
);

View File

@@ -0,0 +1,121 @@
import React, { Component } from 'react';
import {
Box,
Tabs,
Tab,
Paper,
Typography,
} from '@mui/material';
import {
AccountBalance as KreditorIcon,
AccountBalanceWallet as KontoIcon,
Receipt as BUIcon,
CloudUpload as ImportIcon,
} from '@mui/icons-material';
import KreditorTable from './admin/KreditorTable';
import KontoTable from './admin/KontoTable';
import BUTable from './admin/BUTable';
import CSVImportPanel from './CSVImportDialog';
class TableManagement extends Component {
constructor(props) {
super(props);
this.state = {
activeTab: 0,
csvImportOpen: false,
};
}
componentDidMount() {
// Check if we should navigate to a specific tab
if (this.props.targetTab?.level1 !== undefined) {
this.setState({ activeTab: this.props.targetTab.level1 });
}
}
componentDidUpdate(prevProps) {
// Handle targetTab changes
if (this.props.targetTab?.level1 !== prevProps.targetTab?.level1 &&
this.props.targetTab?.level1 !== undefined) {
this.setState({ activeTab: this.props.targetTab.level1 });
}
}
handleTabChange = (event, newValue) => {
this.setState({ activeTab: newValue });
};
handleOpenCSVImport = () => {
this.setState({ csvImportOpen: true });
};
handleCloseCSVImport = () => {
this.setState({ csvImportOpen: false });
};
render() {
const { activeTab } = this.state;
const { user } = this.props;
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Stammdaten verwalten
</Typography>
<Paper elevation={2}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={activeTab}
onChange={this.handleTabChange}
variant="fullWidth"
>
<Tab
icon={<KreditorIcon />}
label="Kreditoren"
sx={{ minHeight: 64 }}
/>
<Tab
icon={<KontoIcon />}
label="Konten"
sx={{ minHeight: 64 }}
/>
<Tab
icon={<BUIcon />}
label="Buchungsschlüssel"
sx={{ minHeight: 64 }}
/>
<Tab
icon={<ImportIcon />}
label="CSV Import"
sx={{ minHeight: 64 }}
/>
</Tabs>
</Box>
<Box sx={{ p: 3 }}>
{activeTab === 0 && <KreditorTable user={user} />}
{activeTab === 1 && <KontoTable user={user} />}
{activeTab === 2 && <BUTable user={user} />}
{activeTab === 3 && (
<Box>
<Typography variant="h6" gutterBottom>
CSV Transaktionen importieren
</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Hier können Sie CSV-Dateien von Ihrer Bank importieren. Die Daten werden in die Datenbank gespeichert und können dann Banking-Konten zugeordnet werden.
</Typography>
<CSVImportPanel
user={user}
targetTab={this.props.targetTab?.level2}
/>
</Box>
)}
</Box>
</Paper>
</Box>
);
}
}
export default TableManagement;

View File

@@ -9,260 +9,105 @@ import {
Paper,
Typography,
CircularProgress,
IconButton,
Tooltip,
} from '@mui/material';
import CheckboxFilter from './filters/CheckboxFilter';
import TextHeaderWithFilter from './headers/TextHeaderWithFilter';
import { Clear as ClearIcon } from '@mui/icons-material';
import { getColumnDefs, defaultColDef, gridOptions } from './config/gridConfig';
import { processTransactionData, getRowStyle, getRowClass, getSelectedDisplayName } from './utils/dataUtils';
class TransactionsTable extends Component {
constructor(props) {
super(props);
this.state = {
columnDefs: [
{
headerName: 'Datum',
field: 'Buchungstag',
width: 100,
valueFormatter: (params) => this.formatDate(params.value),
pinned: 'left',
sortable: true,
filter: 'agDateColumnFilter',
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Beschreibung',
field: 'description',
width: 350,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'description'
},
{
headerName: 'Empfänger/Zahler',
field: 'Beguenstigter/Zahlungspflichtiger',
width: 200,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Beguenstigter/Zahlungspflichtiger'
},
{
headerName: 'Betrag',
field: 'numericAmount',
width: 120,
cellRenderer: this.AmountRenderer,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: false,
type: 'rightAligned',
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Typ',
field: 'typeText',
width: 70,
cellRenderer: this.TypeRenderer,
sortable: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'income',
label: 'Einnahme',
color: 'success',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#388e3c'
},
condition: (fieldValue) => fieldValue === 'Einnahme'
},
{
value: 'expense',
label: 'Ausgabe',
color: 'error',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#d32f2f'
},
condition: (fieldValue) => fieldValue === 'Ausgabe'
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'JTL',
field: 'hasJTL',
width: 70,
cellRenderer: this.JtlRenderer,
sortable: true,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'present',
label: 'Vorhanden',
color: 'success',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#388e3c',
border: 'none',
fontSize: '8px',
color: 'white',
fontWeight: 'bold',
content: '✓'
},
condition: (fieldValue) => fieldValue === true
},
{
value: 'missing',
label: 'Fehlend',
color: 'error',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#f5f5f5',
border: '1px solid #ccc'
},
condition: (fieldValue) => fieldValue === false
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
}
],
defaultColDef: {
resizable: true,
sortable: true,
filter: true,
floatingFilter: false,
suppressHeaderMenuButton: false
},
gridOptions: {
animateRows: true,
rowSelection: {
mode: 'singleRow',
enableClickSelection: true
},
rowBuffer: 10,
// Enable virtualization (default behavior)
suppressRowVirtualisation: false,
suppressColumnVirtualisation: false,
// Performance optimizations
suppressChangeDetection: false,
// Row height
rowHeight: 35,
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100
}
columnDefs: getColumnDefs(),
defaultColDef: defaultColDef,
gridOptions: gridOptions,
totalRows: 0,
displayedRows: 0,
selectedRows: new Set() // Track selected row IDs
};
// Ref for header checkbox to update it directly
this.headerCheckboxRef = null;
}
formatAmount = (amount) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
}
return dateString;
};
getMonthName = (monthYear) => {
if (!monthYear) return '';
const [year, month] = monthYear.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
};
// Custom cell renderers as React components
AmountRenderer = (params) => {
const amount = params.value;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<span style={{ color: color, fontWeight: '600' }}>
{this.formatAmount(amount)}
</span>
);
};
TypeRenderer = (params) => {
const amount = params.data.numericAmount;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color }}></div>
</div>
);
};
JtlRenderer = (params) => {
const hasJTL = params.value;
const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
const border = hasJTL ? 'none' : '1px solid #ccc';
componentDidMount() {
// Add window resize listener for auto-resizing columns
this.handleResize = () => {
if (this.gridApi) {
// Small delay to ensure DOM has updated
setTimeout(() => {
this.gridApi.sizeColumnsToFit();
}, 100);
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: backgroundColor,
border: border,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
color: 'white',
fontWeight: 'bold'
}}>
{hasJTL && '✓'}
</div>
</div>
);
};
window.addEventListener('resize', this.handleResize);
// Add dialog open listener to blur grid focus
this.handleDialogOpen = () => {
if (this.gridApi) {
// Clear any focused cells to prevent aria-hidden conflicts
this.gridApi.clearFocusedCell();
// Also blur any focused elements within the grid
const gridElement = document.querySelector('.ag-root-wrapper');
if (gridElement) {
const focusedElement = gridElement.querySelector(':focus');
if (focusedElement) {
focusedElement.blur();
}
}
}
};
// Listen for dialog open events (Material-UI dialogs)
this.handleFocusIn = (event) => {
// If focus moves to a dialog, blur the grid
if (event.target.closest('[role="dialog"]')) {
this.handleDialogOpen();
}
};
document.addEventListener('focusin', this.handleFocusIn);
}
// Row styling based on JTL status
getRowStyle = (params) => {
if (params.data.isJTLOnly) {
return {
backgroundColor: '#ffebee',
borderLeft: '4px solid #f44336'
};
componentWillUnmount() {
// Clean up event listeners
if (this.handleResize) {
window.removeEventListener('resize', this.handleResize);
}
return null;
};
// Process data for AG Grid
processTransactionData = (transactions) => {
return transactions.map(transaction => ({
...transaction,
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
isIncome: transaction.numericAmount >= 0,
typeText: transaction.numericAmount >= 0 ? 'Einnahme' : 'Ausgabe'
}));
};
// Clean up dialog focus listener
if (this.handleFocusIn) {
document.removeEventListener('focusin', this.handleFocusIn);
}
// Check if grid API is still valid before removing listeners
if (this.gridApi && !this.gridApi.isDestroyed()) {
this.gridApi.removeEventListener('modelUpdated', this.onModelUpdated);
this.gridApi.removeEventListener('filterChanged', this.onFilterChanged);
}
}
componentDidUpdate(prevProps) {
// Update data without recreating grid when transactions change
if (prevProps.transactions !== this.props.transactions && this.gridApi) {
const processedTransactions = this.props.transactions ? this.processTransactionData(this.props.transactions) : [];
const processedTransactions = this.props.transactions ? processTransactionData(this.props.transactions) : [];
// Use setGridOption to update data while preserving grid state
this.gridApi.setGridOption('rowData', processedTransactions);
// Update total rows count and displayed rows
this.setState({
totalRows: processedTransactions.length,
displayedRows: processedTransactions.length
});
}
}
@@ -280,12 +125,26 @@ class TransactionsTable extends Component {
this.gridApi = params.api;
this.gridColumnApi = params.columnApi;
// Store reference to this component for header access
params.api.fibdashComponent = this;
params.api.setHeaderCheckboxRef = (ref) => {
this.headerCheckboxRef = ref;
};
// Set initial data if available
if (this.props.transactions) {
const processedTransactions = this.processTransactionData(this.props.transactions);
const processedTransactions = processTransactionData(this.props.transactions);
this.gridApi.setGridOption('rowData', processedTransactions);
this.setState({
totalRows: processedTransactions.length,
displayedRows: processedTransactions.length
});
}
// Add event listeners for row count updates
params.api.addEventListener('modelUpdated', this.onModelUpdated);
params.api.addEventListener('filterChanged', this.onFilterChanged);
// Auto-size columns to fit content
params.api.sizeColumnsToFit();
@@ -293,34 +152,151 @@ class TransactionsTable extends Component {
this.gridInitialized = true;
};
getSelectedDisplayName = (selectedValue) => {
if (!selectedValue) return '';
if (selectedValue.includes('-Q')) {
const [year, quarterPart] = selectedValue.split('-Q');
return `Q${quarterPart} ${year}`;
} else if (selectedValue.length === 4) {
return `Jahr ${selectedValue}`;
} else {
return this.getMonthName(selectedValue);
onModelUpdated = () => {
if (this.gridApi) {
const displayedRows = this.gridApi.getDisplayedRowCount();
this.setState({ displayedRows });
}
};
onFilterChanged = () => {
if (this.gridApi) {
const displayedRows = this.gridApi.getDisplayedRowCount();
this.setState({ displayedRows });
}
};
hasActiveFilters = () => {
if (!this.gridApi) return false;
const filterModel = this.gridApi.getFilterModel();
return filterModel && Object.keys(filterModel).length > 0;
};
clearAllFilters = () => {
if (this.gridApi) {
this.gridApi.setFilterModel(null);
}
};
onCellClicked = (event) => {
// Skip selection for specific cells
const field = event.colDef.field;
// Don't select on selection column or document column (last cell)
if (field === 'selection' || field === 'documents') {
return;
}
// For IBAN column, check if we clicked on the actual text (which should trigger filter)
if (field === 'Kontonummer/IBAN') {
const clickTarget = event.event?.target;
// If clicked on a span element (the IBAN text), don't select row
if (clickTarget && clickTarget.tagName === 'SPAN' && clickTarget.style.textDecoration === 'underline') {
return;
}
}
// Toggle row selection
const rowData = event.data;
if (!rowData) return;
const rowId = rowData.id || event.rowIndex;
const isCurrentlySelected = this.state.selectedRows.has(rowId);
// Toggle selection
this.onSelectionChange(rowId, rowData, !isCurrentlySelected);
};
// Custom selection methods
onSelectionChange = (rowId, rowData, isSelected) => {
const selectedRows = new Set(this.state.selectedRows);
if (isSelected) {
selectedRows.add(rowId);
} else {
selectedRows.delete(rowId);
}
this.setState({ selectedRows }, () => {
// Update only grid context, avoid column definition changes that cause resizing
if (this.gridApi) {
this.gridApi.setGridOption('context', {
selectedRows: selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh only the selection column cells
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to show selection background
this.gridApi.redrawRows();
// Update header checkbox directly via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
}
}
});
console.log('Selected rows:', Array.from(selectedRows));
};
onSelectAll = (selectAll) => {
const selectedRows = new Set();
if (selectAll && this.gridApi) {
// Select all displayed rows
this.gridApi.forEachNodeAfterFilter((node) => {
if (node.data) {
const rowId = node.data.id || node.rowIndex;
selectedRows.add(rowId);
}
});
}
// If selectAll is false, selectedRows stays empty (clears all)
this.setState({ selectedRows }, () => {
// Update only grid context, avoid column definition changes that cause resizing
if (this.gridApi) {
this.gridApi.setGridOption('context', {
selectedRows: selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh only the selection column cells
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to show selection background
this.gridApi.redrawRows();
// Update header checkbox directly via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(selectedRows, this.state.displayedRows);
}
}
});
console.log('Selected rows:', Array.from(selectedRows));
};
render() {
const { selectedMonth, loading } = this.props;
const { totalRows, displayedRows, selectedRows } = this.state;
return (
<Paper elevation={2} sx={{
m: 0,
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
<Box sx={{ p: 1, flexShrink: 0 }}>
<Typography variant="h6" component="h2" gutterBottom sx={{ m: 0 }}>
Transaktionen für {this.getSelectedDisplayName(selectedMonth)}
</Typography>
</Box>
<Box sx={{ flex: 1, width: '100%', minHeight: 0, position: 'relative' }}>
{loading && (
@@ -343,16 +319,28 @@ class TransactionsTable extends Component {
)}
<div style={{ height: '100%', width: '100%' }}>
<AgGridReact
key="transactions-grid" // Stable key prevents recreation
key="transactions-grid"
columnDefs={this.state.columnDefs}
// Remove rowData prop - data is set via API in componentDidUpdate
defaultColDef={this.state.defaultColDef}
gridOptions={this.state.gridOptions}
onGridReady={this.onGridReady}
getRowStyle={this.getRowStyle}
getRowStyle={(params) => getRowStyle(params, this.state.selectedRows)}
getRowClass={(params) => getRowClass(params, this.state.selectedRows)}
suppressRowTransform={true}
// Use new theming system
theme={themeQuartz}
// Pass selection state through context
context={{
selectedRows: this.state.selectedRows,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
}}
// Add row click selection
onCellClicked={this.onCellClicked}
// Virtualization settings for performance
rowBuffer={10}
suppressRowVirtualisation={false}
@@ -362,10 +350,139 @@ class TransactionsTable extends Component {
animateRows={true}
// Maintain state across data updates
maintainColumnOrder={true}
suppressColumnStateEvents={false}
/>
</div>
</Box>
{/* Status Bar */}
<Box sx={{
flexShrink: 0,
px: 2,
py: 1,
borderTop: '1px solid #e0e0e0',
backgroundColor: '#f8f9fa',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center' }}>
<Typography variant="body2" color="textSecondary">
{displayedRows === totalRows
? `${totalRows} Zeilen angezeigt`
: `${displayedRows} von ${totalRows} Zeilen angezeigt`
}
</Typography>
{selectedRows.size > 0 && (
<Tooltip title="Auswahl löschen">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
px: 1,
py: 0.5,
borderRadius: 1,
'&:hover': {
backgroundColor: 'primary.light',
'& .selection-text': {
color: 'white'
},
'& .clear-selection-icon': {
color: 'white'
}
}
}}
onClick={() => {
const emptySelection = new Set();
this.setState({ selectedRows: emptySelection }, () => {
if (this.gridApi) {
// Update grid context
this.gridApi.setGridOption('context', {
selectedRows: emptySelection,
onSelectionChange: this.onSelectionChange,
onSelectAll: this.onSelectAll,
totalRows: this.state.totalRows,
displayedRows: this.state.displayedRows
});
// Refresh cell checkboxes
this.gridApi.refreshCells({ columns: ['selection'], force: true });
// Refresh row styles to clear selection backgrounds
this.gridApi.redrawRows();
// Update header checkbox via ref
if (this.headerCheckboxRef) {
this.headerCheckboxRef.updateState(emptySelection, this.state.displayedRows);
}
}
});
}}
>
<Typography
variant="body2"
color="primary"
sx={{ fontWeight: 'medium' }}
className="selection-text"
>
{selectedRows.size} ausgewählt
</Typography>
<ClearIcon
sx={{
fontSize: 16,
color: 'primary.main'
}}
className="clear-selection-icon"
/>
</Box>
</Tooltip>
)}
</Box>
{this.hasActiveFilters() && (
<Tooltip title="Alle Filter löschen">
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
cursor: 'pointer',
px: 1,
py: 0.5,
borderRadius: 1,
'&:hover': {
backgroundColor: 'primary.light',
'& .filter-text': {
color: 'white'
},
'& .clear-icon': {
color: 'white'
}
}
}}
onClick={this.clearAllFilters}
>
<Typography
variant="body2"
color="primary"
sx={{ fontWeight: 'medium' }}
className="filter-text"
>
Filter aktiv
</Typography>
<ClearIcon
sx={{
fontSize: 16,
color: 'primary.main'
}}
className="clear-icon"
/>
</Box>
</Tooltip>
)}
</Box>
</Paper>
);
}

View File

@@ -0,0 +1,385 @@
import React, { Component } from 'react';
import {
Box,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Typography,
Alert,
CircularProgress,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import AuthService from '../../services/AuthService';
class BUTable extends Component {
constructor(props) {
super(props);
this.state = {
buchungsschluessel: [],
loading: true,
error: null,
dialogOpen: false,
editingBU: null,
confirmDialogOpen: false,
itemToDelete: null,
formData: {
bu: '',
name: '',
vst: '',
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
this.loadBuchungsschluessel();
}
loadBuchungsschluessel = async () => {
try {
const response = await this.authService.apiCall('/admin/buchungsschluessel');
if (response && response.ok) {
const data = await response.json();
this.setState({ buchungsschluessel: data.buchungsschluessel, loading: false });
} else {
this.setState({ error: 'Fehler beim Laden der Buchungsschlüssel', loading: false });
}
} catch (error) {
console.error('Error loading buchungsschluessel:', error);
this.setState({ error: 'Fehler beim Laden der Buchungsschlüssel', loading: false });
}
};
handleOpenDialog = (bu = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingBU: bu,
formData: bu ? {
bu: bu.bu,
name: bu.name,
vst: bu.vst !== null && bu.vst !== undefined ? bu.vst.toString() : '',
} : {
bu: '',
name: '',
vst: '',
},
});
};
handleCloseDialog = () => {
this.setState({
dialogOpen: false,
editingBU: null,
formData: {
bu: '',
name: '',
vst: '',
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
this.setState({
formData: {
...this.state.formData,
[field]: event.target.value,
},
});
};
isFormValid = () => {
const { formData } = this.state;
return formData.bu.trim() !== '' &&
formData.name.trim() !== '' &&
formData.vst !== '';
};
handleSave = async () => {
const { editingBU, formData } = this.state;
// Convert vst to number or null
const payload = {
...formData,
vst: formData.vst !== '' ? parseFloat(formData.vst) : null,
};
try {
const url = editingBU
? `/admin/buchungsschluessel/${editingBU.id}`
: '/admin/buchungsschluessel';
const method = editingBU ? 'PUT' : 'POST';
const response = await this.authService.apiCall(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (response && response.ok) {
this.handleCloseDialog();
this.loadBuchungsschluessel();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Speichern' });
}
} catch (error) {
console.error('Error saving BU:', error);
this.setState({ error: 'Fehler beim Speichern des Buchungsschlüssels' });
}
};
handleDeleteClick = (bu) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: bu,
});
};
handleDeleteConfirm = async () => {
const { itemToDelete } = this.state;
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/buchungsschluessel/${itemToDelete.id}`, {
method: 'DELETE',
});
if (response && response.ok) {
this.loadBuchungsschluessel();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Löschen' });
}
} catch (error) {
console.error('Error deleting BU:', error);
this.setState({ error: 'Fehler beim Löschen des Buchungsschlüssels' });
}
};
handleDeleteCancel = () => {
this.setState({
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
const { buchungsschluessel, loading, error, dialogOpen, editingBU, formData } = this.state;
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">Buchungsschlüssel</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.handleOpenDialog()}
>
Neuer Buchungsschlüssel
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>BU</TableCell>
<TableCell>Name</TableCell>
<TableCell align="right">VST %</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{buchungsschluessel.map((bu) => (
<TableRow key={bu.id}>
<TableCell>{bu.bu}</TableCell>
<TableCell>{bu.name}</TableCell>
<TableCell align="right">
{bu.vst !== null && bu.vst !== undefined ? `${bu.vst}%` : '-'}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => this.handleOpenDialog(bu)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => this.handleDeleteClick(bu)}
color="error"
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="bu-dialog-title"
aria-describedby="bu-dialog-content"
>
<DialogTitle id="bu-dialog-title">
{editingBU ? 'Buchungsschlüssel bearbeiten' : 'Neuer Buchungsschlüssel'}
</DialogTitle>
<DialogContent id="bu-dialog-content">
<TextField
autoFocus
margin="dense"
label="BU"
fullWidth
variant="outlined"
value={formData.bu}
onChange={this.handleInputChange('bu')}
sx={{ mb: 2 }}
helperText="z.B. 9, 8, 506, 511"
/>
<TextField
margin="dense"
label="Name"
fullWidth
variant="outlined"
value={formData.name}
onChange={this.handleInputChange('name')}
sx={{ mb: 2 }}
helperText="z.B. 19% VST, 7% VST, Dienstleistung aus EU"
/>
<TextField
margin="dense"
label="Vorsteuer %"
fullWidth
variant="outlined"
type="number"
value={formData.vst}
onChange={this.handleInputChange('vst')}
helperText="z.B. 19.00 für 19% (optional)"
inputProps={{
step: 0.01,
min: 0,
max: 100,
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog
open={this.state.confirmDialogOpen}
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-content"
>
<DialogTitle id="confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Buchungsschlüssel "${this.state.itemToDelete.bu} - ${this.state.itemToDelete.name}" wirklich löschen?`
}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={this.handleDeleteCancel}>
Abbrechen
</Button>
<Button onClick={this.handleDeleteConfirm} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}
export default BUTable;

View File

@@ -0,0 +1,354 @@
import React, { Component } from 'react';
import {
Box,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Typography,
Alert,
CircularProgress,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import AuthService from '../../services/AuthService';
class KontoTable extends Component {
constructor(props) {
super(props);
this.state = {
konten: [],
loading: true,
error: null,
dialogOpen: false,
editingKonto: null,
confirmDialogOpen: false,
itemToDelete: null,
formData: {
konto: '',
name: '',
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
this.loadKonten();
}
loadKonten = async () => {
try {
const response = await this.authService.apiCall('/admin/konten');
if (response && response.ok) {
const data = await response.json();
this.setState({ konten: data.konten, loading: false });
} else {
this.setState({ error: 'Fehler beim Laden der Konten', loading: false });
}
} catch (error) {
console.error('Error loading konten:', error);
this.setState({ error: 'Fehler beim Laden der Konten', loading: false });
}
};
handleOpenDialog = (konto = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingKonto: konto,
formData: konto ? {
konto: konto.konto,
name: konto.name,
} : {
konto: '',
name: '',
},
});
};
handleCloseDialog = () => {
this.setState({
dialogOpen: false,
editingKonto: null,
formData: {
konto: '',
name: '',
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
this.setState({
formData: {
...this.state.formData,
[field]: event.target.value,
},
});
};
isFormValid = () => {
const { formData } = this.state;
return formData.konto.trim() !== '' &&
formData.name.trim() !== '';
};
handleSave = async () => {
const { editingKonto, formData } = this.state;
try {
const url = editingKonto
? `/admin/konten/${editingKonto.id}`
: '/admin/konten';
const method = editingKonto ? 'PUT' : 'POST';
const response = await this.authService.apiCall(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (response && response.ok) {
this.handleCloseDialog();
this.loadKonten();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Speichern' });
}
} catch (error) {
console.error('Error saving konto:', error);
this.setState({ error: 'Fehler beim Speichern des Kontos' });
}
};
handleDeleteClick = (konto) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: konto,
});
};
handleDeleteConfirm = async () => {
const { itemToDelete } = this.state;
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/konten/${konto.id}`, {
method: 'DELETE',
});
if (response && response.ok) {
this.loadKonten();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Löschen' });
}
} catch (error) {
console.error('Error deleting konto:', error);
this.setState({ error: 'Fehler beim Löschen des Kontos' });
}
};
handleDeleteCancel = () => {
this.setState({
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
const { konten, loading, error, dialogOpen, editingKonto, formData } = this.state;
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">Konten</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.handleOpenDialog()}
>
Neues Konto
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Konto</TableCell>
<TableCell>Name</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{konten.map((konto) => (
<TableRow key={konto.id}>
<TableCell>{konto.konto}</TableCell>
<TableCell>{konto.name}</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => this.handleOpenDialog(konto)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => this.handleDeleteClick(konto)}
color="error"
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="konto-dialog-title"
aria-describedby="konto-dialog-content"
>
<DialogTitle id="konto-dialog-title">
{editingKonto ? 'Konto bearbeiten' : 'Neues Konto'}
</DialogTitle>
<DialogContent id="konto-dialog-content">
<TextField
autoFocus
margin="dense"
label="Konto"
fullWidth
variant="outlined"
value={formData.konto}
onChange={this.handleInputChange('konto')}
sx={{ mb: 2 }}
helperText="z.B. 5400"
/>
<TextField
margin="dense"
label="Name"
fullWidth
variant="outlined"
value={formData.name}
onChange={this.handleInputChange('name')}
helperText="z.B. Wareneingang 19%"
/>
</DialogContent>
<DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog
open={this.state.confirmDialogOpen}
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="konto-confirm-dialog-title"
aria-describedby="konto-confirm-dialog-content"
>
<DialogTitle id="konto-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="konto-confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Konto "${this.state.itemToDelete.konto} - ${this.state.itemToDelete.name}" wirklich löschen?`
}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={this.handleDeleteCancel}>
Abbrechen
</Button>
<Button onClick={this.handleDeleteConfirm} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}
export default KontoTable;

View File

@@ -0,0 +1,411 @@
import React, { Component } from 'react';
import {
Box,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Typography,
Alert,
CircularProgress,
Checkbox,
FormControlLabel,
} from '@mui/material';
import {
Add as AddIcon,
Edit as EditIcon,
Delete as DeleteIcon,
} from '@mui/icons-material';
import AuthService from '../../services/AuthService';
class KreditorTable extends Component {
constructor(props) {
super(props);
this.state = {
kreditoren: [],
loading: true,
error: null,
dialogOpen: false,
editingKreditor: null,
confirmDialogOpen: false,
itemToDelete: null,
formData: {
iban: '',
name: '',
kreditorId: '',
is_banking: false,
},
};
this.authService = new AuthService();
// Focus management refs
this.triggerRef = React.createRef();
this.dialogRef = React.createRef();
this.confirmDialogRef = React.createRef();
}
componentDidMount() {
this.loadKreditoren();
}
loadKreditoren = async () => {
try {
const response = await this.authService.apiCall('/admin/kreditoren');
if (response && response.ok) {
const data = await response.json();
this.setState({ kreditoren: data.kreditoren, loading: false });
} else {
this.setState({ error: 'Fehler beim Laden der Kreditoren', loading: false });
}
} catch (error) {
console.error('Error loading kreditoren:', error);
this.setState({ error: 'Fehler beim Laden der Kreditoren', loading: false });
}
};
handleOpenDialog = (kreditor = null) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
dialogOpen: true,
editingKreditor: kreditor,
formData: kreditor ? {
iban: kreditor.iban || '',
name: kreditor.name,
kreditorId: kreditor.kreditorId,
is_banking: Boolean(kreditor.is_banking),
} : {
iban: '',
name: '',
kreditorId: '',
is_banking: false,
},
});
};
handleCloseDialog = () => {
this.setState({
dialogOpen: false,
editingKreditor: null,
formData: {
iban: '',
name: '',
kreditorId: '',
is_banking: false,
},
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
handleInputChange = (field) => (event) => {
this.setState({
formData: {
...this.state.formData,
[field]: event.target.value,
},
});
};
handleCheckboxChange = (field) => (event) => {
this.setState({
formData: {
...this.state.formData,
[field]: event.target.checked,
},
});
};
isFormValid = () => {
const { formData } = this.state;
// Name and kreditorId are always required
const basicFieldsValid = formData.name.trim() !== '' && formData.kreditorId.trim() !== '';
// IBAN is optional for banking accounts, required for regular kreditors
const ibanValid = formData.is_banking || formData.iban.trim() !== '';
return basicFieldsValid && ibanValid;
};
handleSave = async () => {
const { editingKreditor, formData } = this.state;
try {
const url = editingKreditor
? `/admin/kreditoren/${editingKreditor.id}`
: '/admin/kreditoren';
const method = editingKreditor ? 'PUT' : 'POST';
const response = await this.authService.apiCall(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
if (response && response.ok) {
this.handleCloseDialog();
this.loadKreditoren();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Speichern' });
}
} catch (error) {
console.error('Error saving kreditor:', error);
this.setState({ error: 'Fehler beim Speichern des Kreditors' });
}
};
handleDeleteClick = (kreditor) => {
// Store reference to the trigger element for focus restoration
this.triggerRef.current = document.activeElement;
this.setState({
confirmDialogOpen: true,
itemToDelete: kreditor,
});
};
handleDeleteConfirm = async () => {
const { itemToDelete } = this.state;
if (!itemToDelete) return;
this.setState({ confirmDialogOpen: false, itemToDelete: null });
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
try {
const response = await this.authService.apiCall(`/admin/kreditoren/${itemToDelete.id}`, {
method: 'DELETE',
});
if (response && response.ok) {
this.loadKreditoren();
} else {
const errorData = await response.json();
this.setState({ error: errorData.error || 'Fehler beim Löschen' });
}
} catch (error) {
console.error('Error deleting kreditor:', error);
this.setState({ error: 'Fehler beim Löschen des Kreditors' });
}
};
handleDeleteCancel = () => {
this.setState({
confirmDialogOpen: false,
itemToDelete: null,
});
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (this.triggerRef.current && this.triggerRef.current.focus) {
this.triggerRef.current.focus();
}
}, 100);
};
render() {
const { kreditoren, loading, error, dialogOpen, editingKreditor, formData } = this.state;
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
return (
<Box>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6">Kreditoren</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => this.handleOpenDialog()}
>
Neuer Kreditor
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Kreditor ID</TableCell>
<TableCell>Name</TableCell>
<TableCell>IBAN</TableCell>
<TableCell>Typ</TableCell>
<TableCell align="right">Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{kreditoren.map((kreditor) => (
<TableRow key={kreditor.id}>
<TableCell>{kreditor.kreditorId}</TableCell>
<TableCell>{kreditor.name}</TableCell>
<TableCell style={{
color: kreditor.is_banking ? '#ff5722' : 'inherit',
fontWeight: kreditor.is_banking ? 'bold' : 'normal'
}}>
{kreditor.iban || 'Keine IBAN'}
</TableCell>
<TableCell>
{kreditor.is_banking ?
<span style={{ color: '#ff5722', fontWeight: 'bold' }}>Banking</span> :
'Kreditor'
}
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => this.handleOpenDialog(kreditor)}
>
<EditIcon />
</IconButton>
<IconButton
size="small"
onClick={() => this.handleDeleteClick(kreditor)}
color="error"
>
<DeleteIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
<Dialog
open={dialogOpen}
onClose={this.handleCloseDialog}
maxWidth="sm"
fullWidth
ref={this.dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="kreditor-dialog-title"
aria-describedby="kreditor-dialog-content"
>
<DialogTitle id="kreditor-dialog-title">
{editingKreditor ? 'Kreditor bearbeiten' : 'Neuer Kreditor'}
</DialogTitle>
<DialogContent id="kreditor-dialog-content">
<TextField
autoFocus
margin="dense"
label="Kreditor ID"
fullWidth
variant="outlined"
value={formData.kreditorId}
onChange={this.handleInputChange('kreditorId')}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="Name"
fullWidth
variant="outlined"
value={formData.name}
onChange={this.handleInputChange('name')}
sx={{ mb: 2 }}
/>
<TextField
margin="dense"
label="IBAN"
fullWidth
variant="outlined"
value={formData.iban}
onChange={this.handleInputChange('iban')}
helperText={formData.is_banking ? "IBAN ist optional für Banking-Konten" : ""}
sx={{ mb: 2 }}
/>
<FormControlLabel
control={
<Checkbox
checked={formData.is_banking}
onChange={this.handleCheckboxChange('is_banking')}
color="primary"
/>
}
label="Banking-Konto (z.B. PayPal) - benötigt manuelle Kreditor-Zuordnung"
/>
</DialogContent>
<DialogActions>
<Button onClick={this.handleCloseDialog}>Abbrechen</Button>
<Button
onClick={this.handleSave}
variant="contained"
disabled={!this.isFormValid()}
>
Speichern
</Button>
</DialogActions>
</Dialog>
{/* Confirmation Dialog */}
<Dialog
open={this.state.confirmDialogOpen}
onClose={this.handleDeleteCancel}
maxWidth="sm"
fullWidth
ref={this.confirmDialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="kreditor-confirm-dialog-title"
aria-describedby="kreditor-confirm-dialog-content"
>
<DialogTitle id="kreditor-confirm-dialog-title">Löschen bestätigen</DialogTitle>
<DialogContent id="kreditor-confirm-dialog-content">
<Typography>
{this.state.itemToDelete &&
`Kreditor "${this.state.itemToDelete.name}" wirklich löschen?`
}
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={this.handleDeleteCancel}>
Abbrechen
</Button>
<Button onClick={this.handleDeleteConfirm} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
}
export default KreditorTable;

View File

@@ -0,0 +1,21 @@
import React from 'react';
const AmountRenderer = (params) => {
const formatAmount = (amount) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
};
const amount = params.value;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<span style={{ color: color, fontWeight: '600' }}>
{formatAmount(amount)}
</span>
);
};
export default AmountRenderer;

View File

@@ -0,0 +1,11 @@
import React from 'react';
const DescriptionRenderer = (params) => {
return (
<span style={{ fontSize: '0.7rem', lineHeight: '1.2' }}>
{params.value}
</span>
);
};
export default DescriptionRenderer;

View File

@@ -0,0 +1,715 @@
import React, { useState } from 'react';
import {
Box,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
List,
ListItem,
ListItemText,
ListItemIcon,
Typography,
Divider,
Tabs,
Tab,
Alert,
Chip,
Paper
} from '@mui/material';
import {
PictureAsPdf as PdfIcon,
Link as LinkIcon,
Description as DocumentIcon,
OpenInNew as OpenIcon,
ContentCopy as CopyIcon
} from '@mui/icons-material';
import { AgGridReact } from 'ag-grid-react';
import KreditorSelector from '../KreditorSelector';
import BankingKreditorSelector from '../BankingKreditorSelector';
import AccountingItemsManager from '../AccountingItemsManager';
const DocumentRenderer = (params) => {
// Check for pdfs and links regardless of transaction source
const pdfs = params.data.pdfs || [];
const links = params.data.links || [];
const [dialogOpen, setDialogOpen] = useState(false);
const [tabValue, setTabValue] = useState(0);
const [error, setError] = useState(null);
// Focus management refs
const triggerRef = React.useRef(null);
const dialogRef = React.useRef(null);
// Always show something clickable, even if no documents
const hasDocuments = pdfs.length > 0 || links.length > 0;
// Combine PDFs and links since links are also PDFs
const allDocuments = [
...pdfs.map(pdf => ({ ...pdf, type: 'pdf' })),
...links.map(link => ({ ...link, type: 'link' }))
];
const totalCount = allDocuments.length;
const handleClick = () => {
// Store reference to the trigger element for focus restoration
triggerRef.current = document.activeElement;
setDialogOpen(true);
};
const handleClose = () => {
setDialogOpen(false);
setTabValue(0); // Reset to first tab when closing
setError(null); // Clear any errors when closing
// Restore focus to the trigger element after dialog closes
setTimeout(() => {
if (triggerRef.current && triggerRef.current.focus) {
triggerRef.current.focus();
}
}, 100);
};
const handleTabChange = (event, newValue) => {
setTabValue(newValue);
};
const handleCopyDatevLink = (datevlink) => {
if (datevlink) {
navigator.clipboard.writeText(datevlink);
}
};
const handleOpenPdf = async (document) => {
let endpoint = '';
if (document.type === 'pdf' && document.kUmsatzBeleg) {
// PDF from tUmsatzBeleg table
endpoint = `/data/pdf/umsatzbeleg/${document.kUmsatzBeleg}`;
} else if (document.type === 'link' && document.kPdfObjekt) {
// PDF from tPdfObjekt table (linked documents)
endpoint = `/data/pdf/pdfobject/${document.kPdfObjekt}`;
} else {
console.error('Unable to determine PDF URL for document:', document);
return;
}
try {
// Create an authenticated API call
const token = localStorage.getItem('token');
const response = await fetch(`/api${endpoint}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
// Get the PDF blob
const blob = await response.blob();
// Create a blob URL and open it in a new tab
const blobUrl = URL.createObjectURL(blob);
const newWindow = window.open(blobUrl, '_blank');
// Clean up the blob URL after a delay to allow the browser to load it
setTimeout(() => {
URL.revokeObjectURL(blobUrl);
}, 1000);
// If window.open was blocked, try alternative approach
if (!newWindow) {
// Create a temporary download link
const a = document.createElement('a');
a.href = blobUrl;
a.target = '_blank';
a.download = `document_${document.kUmsatzBeleg || document.kPdfObjekt}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
}
} catch (error) {
console.error('Error opening PDF:', error);
setError('Fehler beim Öffnen des PDFs. Bitte versuchen Sie es erneut.');
}
};
// Extract line items from document extraction data
const extractLineItems = () => {
const lineItems = [];
allDocuments.forEach((doc, docIndex) => {
if (doc.extraction) {
try {
const extractionData = JSON.parse(doc.extraction);
if (extractionData.net_amounts_and_tax) {
extractionData.net_amounts_and_tax.forEach((item, itemIndex) => {
lineItems.push({
id: `${docIndex}-${itemIndex}`,
document: doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument',
documentIndex: docIndex + 1,
netAmount: item.net_amount || 0,
taxRate: item.tax_rate || 0,
taxAmount: item.tax_amount || 0,
grossAmount: (item.net_amount || 0) + (item.tax_amount || 0),
currency: extractionData.currency || 'EUR',
invoiceNumber: extractionData.invoice_number || '',
date: extractionData.date || '',
sender: extractionData.sender || '',
kreditorId: extractionData.kreditor_id || null,
kreditorName: extractionData.kreditor_name || '',
kreditorCode: extractionData.kreditor_code || ''
});
});
}
} catch (error) {
console.error('Error parsing extraction data:', error);
}
}
});
return lineItems;
};
const lineItems = extractLineItems();
// AG Grid column definitions for line items
const columnDefs = [
{
headerName: 'Dok',
field: 'documentIndex',
width: 50,
cellStyle: { textAlign: 'center' }
},
{
headerName: 'Rechnungsnr.',
field: 'invoiceNumber',
width: 120
},
{
headerName: 'Datum',
field: 'date',
width: 100
},
{
headerName: 'Absender',
field: 'sender',
width: 150,
tooltipField: 'sender'
},
{
headerName: 'Kreditor',
field: 'kreditor',
width: 200,
cellRenderer: (params) => {
return (
<Box sx={{ height: '100%', display: 'flex', alignItems: 'center' }}>
<KreditorSelector
selectedKreditorId={params.data.kreditorId}
onKreditorChange={(kreditor) => {
// Update the line item with the selected kreditor
if (params.data && params.api) {
params.data.kreditorId = kreditor ? kreditor.id : null;
params.data.kreditorName = kreditor ? kreditor.name : '';
params.data.kreditorCode = kreditor ? kreditor.kreditorId : '';
params.api.refreshCells({ rowNodes: [params.node] });
}
}}
label=""
fullWidth={false}
/>
</Box>
);
},
editable: false,
sortable: false,
filter: false
},
{
headerName: 'Netto',
field: 'netAmount',
width: 80,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
},
{
headerName: 'MwSt %',
field: 'taxRate',
width: 70,
type: 'rightAligned',
valueFormatter: (params) => `${params.value}%`
},
{
headerName: 'MwSt',
field: 'taxAmount',
width: 80,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
},
{
headerName: 'Brutto',
field: 'grossAmount',
width: 90,
type: 'rightAligned',
valueFormatter: (params) => {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: params.data.currency || 'EUR'
}).format(params.value);
}
}
];
const defaultColDef = {
sortable: true,
filter: false,
resizable: true,
suppressSizeToFit: false
};
return (
<>
<Box
sx={{
display: 'flex',
gap: 0.5,
alignItems: 'center',
height: '100%',
width: '100%',
cursor: 'pointer',
'&:hover': {
backgroundColor: 'rgba(0, 0, 0, 0.04)'
}
}}
onClick={handleClick}
>
{hasDocuments ? (
<>
<PdfIcon sx={{ fontSize: 14, color: '#d32f2f' }} />
{totalCount > 1 && (
<Box sx={{
fontSize: '8px',
color: '#d32f2f',
fontWeight: 'bold',
position: 'relative',
backgroundColor: 'white',
borderRadius: '50%',
width: 12,
height: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '1px solid #d32f2f',
marginLeft: '-6px'
}}>
{totalCount}
</Box>
)}
</>
) : (
<Box sx={{
fontSize: '8px',
color: '#999',
fontWeight: 'normal',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
</Box>
)}
</Box>
<Dialog
open={dialogOpen}
onClose={handleClose}
maxWidth="lg"
fullWidth
ref={dialogRef}
disableAutoFocus={false}
disableEnforceFocus={false}
disableRestoreFocus={true}
aria-labelledby="document-dialog-title"
aria-describedby="document-dialog-content"
>
<DialogTitle id="document-dialog-title">
{hasDocuments ? `Dokumente (${totalCount})` : 'Dokumentinformationen'}
{!params.data['Kontonummer/IBAN'] && (
<Typography variant="caption" sx={{ display: 'block', color: '#ff5722', fontWeight: 'normal' }}>
Banking-Transaktion Kreditor-Zuordnung erforderlich
</Typography>
)}
{params.data.description && (
<Typography variant="body2" sx={{
display: 'block',
color: 'text.secondary',
fontWeight: 'normal',
mt: 1,
p: 1,
bgcolor: '#f5f5f5',
borderRadius: 1,
border: '1px solid #e0e0e0'
}}>
<strong>Beschreibung:</strong> {params.data.description}
</Typography>
)}
</DialogTitle>
<DialogContent sx={{ p: 0 }} id="document-dialog-content">
{error && (
<Alert severity="error" sx={{ m: 2 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tabValue} onChange={handleTabChange}>
<Tab label="Dokumente" />
<Tab label={`Buchungen (${lineItems.length})`} />
<Tab
label="Kreditor"
sx={{
color: !params.data['Kontonummer/IBAN']
? 'text.secondary'
: (params.data.hasKreditor && params.data.kreditor && !params.data.kreditor.is_banking) || params.data.assignedKreditor
? 'success.main'
: 'warning.main',
'&.Mui-selected': {
color: !params.data['Kontonummer/IBAN']
? 'text.secondary'
: (params.data.hasKreditor && params.data.kreditor && !params.data.kreditor.is_banking) || params.data.assignedKreditor
? 'success.main'
: 'warning.main',
}
}}
/>
</Tabs>
</Box>
{tabValue === 0 && (
<Box sx={{ p: 2 }}>
{hasDocuments ? (
<List>
{allDocuments.map((doc, index) => (
<React.Fragment key={index}>
<ListItem>
<ListItemIcon>
<PdfIcon sx={{ color: '#d32f2f' }} />
</ListItemIcon>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle2">
{doc.type === 'pdf' ? 'PDF Dokument' : 'Verknüpftes Dokument'} #{index + 1}
</Typography>
{doc.datevlink && (
<Typography variant="body2" color="textSecondary">
DATEV Link: {doc.datevlink}
</Typography>
)}
{doc.note && (
<Typography variant="body2" color="textSecondary">
Notiz: {doc.note}
</Typography>
)}
<Box sx={{ mt: 1, display: 'flex', gap: 1 }}>
<Button
size="small"
startIcon={<OpenIcon />}
onClick={() => handleOpenPdf(doc)}
variant="outlined"
>
PDF öffnen
</Button>
{doc.datevlink && (
<Button
size="small"
startIcon={<CopyIcon />}
onClick={() => handleCopyDatevLink(doc.datevlink)}
variant="outlined"
>
DATEV Link kopieren
</Button>
)}
</Box>
</Box>
</ListItem>
{index < allDocuments.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<PdfIcon sx={{ fontSize: 48, color: '#ccc', mb: 2 }} />
<Typography variant="h6" color="textSecondary" gutterBottom>
Keine Dokumente verfügbar
</Typography>
<Typography variant="body2" color="textSecondary">
Für diese Transaktion sind keine PDF-Dokumente oder Verknüpfungen vorhanden.
</Typography>
</Box>
)}
</Box>
)}
{tabValue === 1 && (
<Box sx={{ p: 2 }}>
{/* Accounting Items Manager */}
<AccountingItemsManager transaction={params.data} />
{/* Document Line Items (if any) */}
{lineItems.length > 0 && (
<Box sx={{ mt: 3 }}>
<Typography variant="h6" gutterBottom>
Erkannte Positionen aus Dokumenten
</Typography>
<div style={{ height: '300px', width: '100%' }}>
<AgGridReact
columnDefs={columnDefs}
rowData={lineItems}
defaultColDef={defaultColDef}
suppressRowTransform={true}
rowHeight={50}
headerHeight={35}
domLayout="normal"
/>
</div>
</Box>
)}
</Box>
)}
{tabValue === 2 && (
<Box sx={{ p: 2 }}>
<Typography variant="h6" gutterBottom>
Kreditor Information
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
IBAN
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
{params.data['Kontonummer/IBAN'] || 'Keine IBAN verfügbar'}
</Typography>
{/* Show different content based on IBAN availability and Kreditor status */}
{!params.data['Kontonummer/IBAN'] ? (
<Box>
<Chip
label="Banking-Transaktion"
color="warning"
size="small"
sx={{ mb: 2 }}
/>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Diese Transaktion wurde über ein Banking-Konto (z.B. PayPal) abgewickelt und benötigt eine Kreditor-Zuordnung.
</Typography>
{/* Show current assignment or assignment form for banking transactions */}
{params.data.assignedKreditor ? (
<Box sx={{ p: 2, bgcolor: '#e8f5e8', borderRadius: 1, mb: 2 }}>
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
Zugeordnet zu: {params.data.assignedKreditor.name}
</Typography>
<Typography variant="caption" sx={{ color: '#666' }}>
Kreditor ID: {params.data.assignedKreditor.kreditorId}
</Typography>
</Box>
) : (
<Box>
<Typography variant="body2" sx={{ mb: 2, color: '#ff5722', fontWeight: 'bold' }}>
Keine Zuordnung - Bitte Kreditor zuweisen
</Typography>
<Typography variant="caption" sx={{ color: '#666', mb: 2, display: 'block' }}>
Wählen Sie einen bestehenden Kreditor aus oder erstellen Sie einen neuen Kreditor:
</Typography>
<BankingKreditorSelector
transaction={params.data}
user={params.context?.user}
onSave={async (assignedKreditor) => {
// Update the transaction data with the new assignment
if (assignedKreditor) {
params.data.assignedKreditor = assignedKreditor;
}
// Refresh the entire row to update colors and content
if (params.api) {
params.api.refreshCells({
rowNodes: [params.node],
force: true
});
}
// Force dialog re-render by updating state
this.forceUpdate();
}}
/>
</Box>
)}
</Box>
) : params.data.hasKreditor ? (
<Box>
{!params.data.kreditor?.is_banking && (
<Chip
label="Kreditor gefunden"
color="success"
size="small"
sx={{ mb: 2 }}
/>
)}
{params.data.kreditor?.is_banking && (
<Chip
label="Banking-Konto erkannt"
color="warning"
size="small"
sx={{ mb: 2 }}
/>
)}
<Box sx={{ mt: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Kreditor Details
</Typography>
<Typography variant="body2">
<strong>Name:</strong> {params.data.kreditor.name}
</Typography>
<Typography variant="body2">
<strong>Kreditor ID:</strong> {params.data.kreditor.kreditorId}
</Typography>
<Typography variant="body2">
<strong>Typ:</strong> {params.data.kreditor.is_banking ? 'Banking-Konto' : 'Kreditor'}
</Typography>
</Box>
{/* Banking Account Assignment Section */}
{params.data.kreditor.is_banking && (
<Box sx={{ mt: 3, p: 2, bgcolor: '#fff3e0', borderRadius: 1, border: '1px solid #ff9800' }}>
<Typography variant="subtitle2" gutterBottom sx={{ color: '#ff5722', fontWeight: 'bold' }}>
🏦 Banking-Konto Zuordnung
</Typography>
<Typography variant="body2" sx={{ mb: 2, color: '#666' }}>
Dieses IBAN ist ein Banking-Konto (z.B. PayPal). Transaktionen müssen einem echten Kreditor zugeordnet werden.
</Typography>
{/* Show current assignment or assignment form */}
{params.data.assignedKreditor ? (
<Box sx={{ p: 2, bgcolor: '#e8f5e8', borderRadius: 1, mb: 2 }}>
<Typography variant="body2" sx={{ color: '#2e7d32', fontWeight: 'bold' }}>
Zugeordnet zu: {params.data.assignedKreditor.name}
</Typography>
<Typography variant="caption" sx={{ color: '#666' }}>
Kreditor ID: {params.data.assignedKreditor.kreditorId}
</Typography>
</Box>
) : (
<Box>
<Typography variant="body2" sx={{ mb: 2, color: '#ff5722', fontWeight: 'bold' }}>
Keine Zuordnung - Bitte Kreditor zuweisen
</Typography>
<Typography variant="caption" sx={{ color: '#666', mb: 2, display: 'block' }}>
Wählen Sie den echten Kreditor für diese Banking-Transaktion aus:
</Typography>
<BankingKreditorSelector
transaction={params.data}
user={params.context?.user}
onSave={async (assignedKreditor) => {
// Update the transaction data with the new assignment
if (assignedKreditor) {
params.data.assignedKreditor = assignedKreditor;
}
// Refresh the entire row to update colors and content
if (params.api) {
params.api.refreshCells({
rowNodes: [params.node],
force: true
});
}
// Force dialog re-render by updating state
this.forceUpdate();
}}
/>
</Box>
)}
</Box>
)}
</Box>
) : (
<Box>
<Chip
label="Kein Kreditor gefunden"
color="warning"
size="small"
sx={{ mb: 2 }}
/>
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
Sie können einen neuen Kreditor für diese IBAN erstellen:
</Typography>
<KreditorSelector
selectedKreditorId=""
onKreditorChange={(kreditor) => {
console.log('Kreditor selected/created:', kreditor);
if (kreditor) {
// Update the transaction data to reflect the new kreditor
params.data.kreditor = kreditor;
params.data.hasKreditor = true;
// Update all transactions with the same IBAN in the grid
if (params.api && kreditor.iban) {
const nodesToRefresh = [];
params.api.forEachNode((node) => {
if (node.data && node.data['Kontonummer/IBAN'] === kreditor.iban) {
node.data.kreditor = kreditor;
node.data.hasKreditor = true;
nodesToRefresh.push(node);
}
});
// Refresh specific cells to show updated colors and data
if (nodesToRefresh.length > 0) {
params.api.refreshCells({
rowNodes: nodesToRefresh,
columns: ['Kontonummer/IBAN'],
force: true
});
}
}
// Close and reopen dialog to show updated status
setDialogOpen(false);
setTimeout(() => setDialogOpen(true), 100);
}
}}
prefilledIban={params.data['Kontonummer/IBAN']}
prefilledName={params.data['Beguenstigter/Zahlungspflichtiger']}
allowCreate={true}
/>
</Box>
)}
</Paper>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Schließen</Button>
</DialogActions>
</Dialog>
</>
);
};
export default DocumentRenderer;

View File

@@ -0,0 +1,53 @@
import React from 'react';
const JtlRenderer = (params) => {
const hasJTL = params.value;
// Handle undefined state (database unavailable)
if (hasJTL === undefined) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
color: '#856404',
fontWeight: 'bold'
}}>
?
</div>
</div>
);
}
const backgroundColor = hasJTL ? '#388e3c' : '#f5f5f5';
const border = hasJTL ? 'none' : '1px solid #ccc';
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{
width: '12px',
height: '12px',
borderRadius: '50%',
backgroundColor: backgroundColor,
border: border,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
color: 'white',
fontWeight: 'bold'
}}>
{hasJTL && '✓'}
</div>
</div>
);
};
export default JtlRenderer;

View File

@@ -0,0 +1,87 @@
import React from 'react';
const RecipientRenderer = (params) => {
const isIbanColumn = params.colDef.field === 'Kontonummer/IBAN';
const value = params.value;
const handleClick = (event) => {
if (isIbanColumn && value && params.api) {
// Stop event propagation to prevent row selection
event.stopPropagation();
// Default behavior: Apply filter to IBAN column
const currentFilterModel = params.api.getFilterModel();
params.api.setFilterModel({
...currentFilterModel,
'Kontonummer/IBAN': {
filterType: 'iban-selection',
values: [value]
}
});
}
};
// Determine color based on Kreditor status for IBAN column
const getIbanColor = () => {
if (!isIbanColumn || !value) return 'inherit';
// Check if this transaction has Kreditor information
if (params.data && params.data.hasKreditor) {
// Check if the kreditor is a banking account
if (params.data.kreditor?.is_banking) {
// Check if banking transaction has assigned kreditor
if (params.data.assignedKreditor) {
return '#00e676'; // Bright neon green for banking account with assigned kreditor
} else {
return '#ff5722'; // Red-orange for banking account needing assignment
}
} else {
return '#2e7d32'; // Dark green for regular kreditor
}
} else if (params.data && value) {
return '#ed6c02'; // Orange for IBAN without Kreditor
}
return '#1976d2'; // Default blue for clickable IBAN
};
const getTitle = () => {
if (!isIbanColumn || !value) return undefined;
if (params.data && params.data.hasKreditor) {
if (params.data.kreditor?.is_banking) {
if (params.data.assignedKreditor) {
return `Banking-IBAN "${value}" - Zugeordnet zu: ${params.data.assignedKreditor.name} (zum Filtern klicken)`;
} else {
return `Banking-IBAN "${value}" - BENÖTIGT KREDITOR-ZUORDNUNG (zum Filtern klicken)`;
}
} else {
return `IBAN "${value}" - Kreditor: ${params.data.kreditor?.name || 'Unbekannt'} (zum Filtern klicken)`;
}
} else if (params.data && value) {
return `IBAN "${value}" - Kein Kreditor gefunden (zum Filtern klicken)`;
}
return `Nach IBAN "${value}" filtern`;
};
return (
<span
style={{
fontSize: '0.7rem',
lineHeight: '1.2',
cursor: isIbanColumn && value ? 'pointer' : 'default',
color: getIbanColor(),
textDecoration: isIbanColumn && value ? 'underline' : 'none',
fontWeight: isIbanColumn && params.data && params.data.hasKreditor ? 'bold' : 'normal'
}}
onClick={isIbanColumn && value ? handleClick : undefined}
title={getTitle()}
>
{value}
</span>
);
};
export default RecipientRenderer;

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Checkbox } from '@mui/material';
const SelectionRenderer = (params) => {
const { data } = params;
if (!data) return null;
// Create a unique row ID (using row index or a unique field)
const rowId = data.id || params.rowIndex;
// Get selection state and methods from grid context
const context = params.context || {};
const { selectedRows, onSelectionChange } = context;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
const handleChange = (event) => {
event.stopPropagation(); // Prevent row click events
if (onSelectionChange) {
onSelectionChange(rowId, data, event.target.checked);
}
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}
onClick={(e) => e.stopPropagation()} // Prevent row click events
>
<Checkbox
checked={isSelected || false}
onChange={handleChange}
size="small"
sx={{
padding: 0,
'& .MuiSvgIcon-root': {
fontSize: 18
}
}}
/>
</div>
);
};
export default SelectionRenderer;

View File

@@ -0,0 +1,14 @@
import React from 'react';
const TypeRenderer = (params) => {
const amount = params.data.numericAmount;
const color = amount >= 0 ? '#388e3c' : '#d32f2f';
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', backgroundColor: color }}></div>
</div>
);
};
export default TypeRenderer;

View File

@@ -0,0 +1,232 @@
import CheckboxFilter from '../filters/CheckboxFilter';
import IbanSelectionFilter from '../filters/IbanSelectionFilter';
import TextHeaderWithFilter from '../headers/TextHeaderWithFilter';
import SelectionHeader from '../headers/SelectionHeader';
import AmountRenderer from '../cellRenderers/AmountRenderer';
import TypeRenderer from '../cellRenderers/TypeRenderer';
import JtlRenderer from '../cellRenderers/JtlRenderer';
import DescriptionRenderer from '../cellRenderers/DescriptionRenderer';
import RecipientRenderer from '../cellRenderers/RecipientRenderer';
import DocumentRenderer from '../cellRenderers/DocumentRenderer';
import SelectionRenderer from '../cellRenderers/SelectionRenderer';
const formatDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
return `${parts[0]}.${parts[1]}.20${parts[2]}`;
}
return dateString;
};
export const getColumnDefs = () => [
{
headerName: '',
field: 'selection',
width: 50,
pinned: 'left',
sortable: false,
filter: false,
resizable: false,
suppressHeaderMenuButton: true,
cellRenderer: SelectionRenderer,
headerComponent: SelectionHeader,
headerComponentParams: {
// These will be set by the parent component
}
},
{
headerName: 'Datum',
field: 'Buchungstag',
width: 100,
valueFormatter: (params) => formatDate(params.value),
pinned: 'left',
sortable: true,
filter: 'agDateColumnFilter',
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Beschreibung',
field: 'description',
width: 350,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'description',
cellRenderer: DescriptionRenderer
},
{
headerName: 'Name',
field: 'Beguenstigter/Zahlungspflichtiger',
width: 200,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Beguenstigter/Zahlungspflichtiger',
cellRenderer: RecipientRenderer
},
{
headerName: 'IBAN',
field: 'Kontonummer/IBAN',
width: 180,
sortable: true,
filter: IbanSelectionFilter,
headerComponent: TextHeaderWithFilter,
tooltipField: 'Kontonummer/IBAN',
cellRenderer: RecipientRenderer
},
{
headerName: 'BIC',
field: 'BIC (SWIFT-Code)',
width: 120,
sortable: true,
headerComponent: TextHeaderWithFilter,
tooltipField: 'BIC',
cellRenderer: RecipientRenderer
},
{
headerName: 'Betrag',
field: 'numericAmount',
width: 120,
cellRenderer: AmountRenderer,
sortable: true,
filter: 'agNumberColumnFilter',
floatingFilter: false,
type: 'rightAligned',
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Typ',
field: 'typeText',
width: 70,
cellRenderer: TypeRenderer,
sortable: false,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'income',
label: 'Einnahme',
color: 'success',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#388e3c'
},
condition: (fieldValue) => fieldValue === 'Einnahme'
},
{
value: 'expense',
label: 'Ausgabe',
color: 'error',
dotStyle: {
width: '8px',
height: '8px',
backgroundColor: '#d32f2f'
},
condition: (fieldValue) => fieldValue === 'Ausgabe'
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'JTL',
field: 'hasJTL',
width: 70,
cellRenderer: JtlRenderer,
sortable: false,
filter: CheckboxFilter,
filterParams: {
filterOptions: [
{
value: 'present',
label: 'Vorhanden',
color: 'success',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#388e3c',
border: 'none',
fontSize: '8px',
color: 'white',
fontWeight: 'bold',
content: '✓'
},
condition: (fieldValue) => fieldValue === true
},
{
value: 'missing',
label: 'Fehlend',
color: 'error',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#f5f5f5',
border: '1px solid #ccc'
},
condition: (fieldValue) => fieldValue === false
},
{
value: 'undefined',
label: 'Unbekannt',
color: 'warning',
dotStyle: {
width: '12px',
height: '12px',
backgroundColor: '#fff3cd',
border: '1px solid #ffc107',
fontSize: '8px',
color: '#856404',
fontWeight: 'bold',
content: '?'
},
condition: (fieldValue) => fieldValue === undefined
}
]
},
floatingFilter: false,
headerComponent: TextHeaderWithFilter
},
{
headerName: 'Dokumente',
field: 'documents',
width: 90,
cellRenderer: DocumentRenderer,
sortable: false,
filter: false,
headerComponent: TextHeaderWithFilter
}
];
export const defaultColDef = {
resizable: true,
sortable: true,
filter: true,
floatingFilter: false,
suppressHeaderMenuButton: false
};
export const gridOptions = {
animateRows: true,
rowSelection: {
mode: 'multiRow',
enableClickSelection: false
},
rowBuffer: 10,
// Enable virtualization (default behavior)
suppressRowVirtualisation: false,
suppressColumnVirtualisation: false,
// Performance optimizations
suppressChangeDetection: false,
// Row height
rowHeight: 26,
headerHeight: 40,
// Pagination (optional - can be removed for infinite scrolling)
pagination: false,
paginationPageSize: 100,
// Disable cell selection and focus
cellSelection: false,
suppressCellFocus: true
};

View File

@@ -103,9 +103,13 @@ export default class CheckboxFilter {
};
destroy() {
if (this.reactRoot) {
this.reactRoot.unmount();
}
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
}
renderReactComponent() {
@@ -172,10 +176,13 @@ export default class CheckboxFilter {
);
// Recreate React root every time to avoid state corruption
if (this.reactRoot) {
this.reactRoot.unmount();
}
this.reactRoot = createRoot(this.eGui);
this.reactRoot.render(<FilterComponent />);
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
}
this.reactRoot = createRoot(this.eGui);
this.reactRoot.render(<FilterComponent />);
}, 0);
}
}

View File

@@ -0,0 +1,297 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import {
FormControl,
Select,
MenuItem,
Checkbox,
ListItemText,
Box,
Chip,
Button,
TextField,
Typography,
Divider
} from '@mui/material';
export default class IbanSelectionFilter {
constructor() {
this.state = {
selectedValues: [],
availableValues: [],
partialIban: ''
};
// Create the DOM element that AG Grid expects
this.eGui = document.createElement('div');
this.eGui.style.minWidth = '250px';
this.eGui.style.padding = '8px';
// Create a ref for the text input
this.textInputRef = React.createRef();
}
init(params) {
this.params = params;
this.updateAvailableValues();
this.renderReactComponent();
}
getGui() {
return this.eGui;
}
destroy() {
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {
this.reactRoot.unmount();
this.reactRoot = null;
}
}, 0);
}
updateAvailableValues() {
if (!this.params || !this.params.api) return;
// Get all row data
const allRowData = [];
this.params.api.forEachNode(node => {
if (node.data) {
allRowData.push(node.data);
}
});
// Create a map of IBAN to names, then get unique combinations
const ibanToNameMap = new Map();
allRowData.forEach(row => {
const iban = row['Kontonummer/IBAN'];
const name = row['Beguenstigter/Zahlungspflichtiger'];
if (iban && iban.trim() !== '' && iban !== '0000000000') {
if (!ibanToNameMap.has(iban)) {
ibanToNameMap.set(iban, name || '');
}
}
});
// Convert to array of objects with iban and name
this.availableValues = Array.from(ibanToNameMap.entries()).map(([iban, name]) => ({
iban,
name: name || 'Unbekannt'
}));
}
isFilterActive() {
return this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '';
}
doesFilterPass(params) {
const { selectedValues, partialIban } = this.state;
const value = params.data['Kontonummer/IBAN'];
// If no filters are active, show all rows
if (selectedValues.length === 0 && partialIban.trim() === '') {
return true;
}
// Check if row matches selected IBANs
const matchesSelected = selectedValues.length === 0 || selectedValues.includes(value);
// Check if row matches partial IBAN (case-insensitive)
const matchesPartial = partialIban.trim() === '' ||
(value && value.toLowerCase().includes(partialIban.toLowerCase()));
// Both conditions must be true (AND logic)
return matchesSelected && matchesPartial;
}
getModel() {
if (!this.isFilterActive()) return null;
return {
filterType: 'iban-selection',
values: this.state.selectedValues,
partialIban: this.state.partialIban
};
}
setModel(model) {
if (!model) {
this.state.selectedValues = [];
this.state.partialIban = '';
} else {
this.state.selectedValues = model.values || [];
this.state.partialIban = model.partialIban || '';
}
// Update the text field value directly if it exists
if (this.textInputRef.current) {
const inputElement = this.textInputRef.current.querySelector('input');
if (inputElement) {
inputElement.value = this.state.partialIban;
}
}
this.renderReactComponent();
}
handleSelectionChange = (selectedValues) => {
this.state.selectedValues = selectedValues;
this.renderReactComponent();
// Notify AG Grid that filter changed
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
handlePartialIbanChange = (partialIban) => {
this.state.partialIban = partialIban;
// Update the clear button visibility without full re-render
this.updateClearButtonVisibility();
// Notify AG Grid that filter changed
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
updateClearButtonVisibility = () => {
// Find the clear button container and update its visibility
const clearButtonContainer = this.eGui.querySelector('.clear-button-container');
if (clearButtonContainer) {
const shouldShow = this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '';
clearButtonContainer.style.display = shouldShow ? 'block' : 'none';
}
};
clearFilter = () => {
this.state.selectedValues = [];
this.state.partialIban = '';
// Clear the text field directly using the ref
if (this.textInputRef.current) {
const inputElement = this.textInputRef.current.querySelector('input');
if (inputElement) {
inputElement.value = '';
}
}
this.renderReactComponent();
if (this.params && this.params.filterChangedCallback) {
this.params.filterChangedCallback();
}
};
renderReactComponent() {
if (!this.reactRoot) {
this.reactRoot = createRoot(this.eGui);
}
const FilterComponent = () => (
<Box sx={{ minWidth: 250 }} className="ag-filter-custom">
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 'bold' }}>
IBAN Filter
</Typography>
<TextField
ref={this.textInputRef}
fullWidth
size="small"
label="IBAN eingeben"
placeholder="z.B. DE89, 1234..."
defaultValue={this.state.partialIban}
onChange={(event) => this.handlePartialIbanChange(event.target.value)}
sx={{ mb: 2 }}
/>
<Divider sx={{ mb: 2 }} />
<Typography variant="body2" sx={{ mb: 1, color: 'text.secondary' }}>
Oder aus der Liste auswählen:
</Typography>
<FormControl fullWidth size="small">
<Select
multiple
value={this.state.selectedValues}
onChange={(event) => this.handleSelectionChange(event.target.value)}
displayEmpty
renderValue={(selected) => {
if (selected.length === 0) {
return <em>Alle IBANs</em>;
}
return (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.slice(0, 2).map((value) => {
const ibanData = this.availableValues.find(item => item.iban === value);
return (
<Chip
key={value}
label={ibanData ? `${ibanData.name.slice(0, 15)}...` : value.slice(-4)}
size="small"
color="primary"
variant="outlined"
/>
);
})}
{selected.length > 2 && (
<Chip
label={`+${selected.length - 2} mehr`}
size="small"
color="primary"
variant="outlined"
/>
)}
</Box>
);
}}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
width: 300,
},
},
}}
>
{this.availableValues.map((item) => (
<MenuItem key={item.iban} value={item.iban}>
<Checkbox
checked={this.state.selectedValues.indexOf(item.iban) > -1}
size="small"
/>
<ListItemText
primary={item.name}
secondary={item.iban}
primaryTypographyProps={{ fontSize: '0.8rem' }}
secondaryTypographyProps={{ fontSize: '0.7rem', color: 'text.secondary' }}
/>
</MenuItem>
))}
</Select>
</FormControl>
<Box
className="clear-button-container"
sx={{ mt: 1, textAlign: 'right' }}
style={{ display: (this.state.selectedValues.length > 0 || this.state.partialIban.trim() !== '') ? 'block' : 'none' }}
>
<Button
onClick={this.clearFilter}
size="small"
variant="text"
color="primary"
sx={{ fontSize: '0.75rem' }}
>
Alle Filter löschen
</Button>
</Box>
</Box>
);
this.reactRoot.render(<FilterComponent />);
}
}

View File

@@ -1,216 +0,0 @@
import React, { Component } from 'react';
import {
Select,
MenuItem,
FormControl,
InputLabel,
Checkbox,
ListItemText,
Box,
Chip
} from '@mui/material';
class SelectionFilter extends Component {
constructor(props) {
super(props);
this.state = {
selectedValues: [],
isActive: false
};
}
// AG Grid filter interface methods
init = (params) => {
this.params = params;
this.updateAvailableValues();
};
getGui = () => {
return this.eGui;
};
destroy = () => {
// Cleanup if needed
};
componentDidMount() {
// Get unique values from the column data
this.updateAvailableValues();
}
componentDidUpdate(prevProps) {
if (prevProps.api !== this.props.api) {
this.updateAvailableValues();
}
}
updateAvailableValues = () => {
const api = (this.params && this.params.api) || this.props.api;
const colDef = (this.params && this.params.colDef) || this.props.colDef;
if (!api || !colDef) return;
// Get all row data
const allRowData = [];
api.forEachNode(node => {
if (node.data) {
allRowData.push(node.data);
}
});
// Extract unique values from the column
const values = [...new Set(allRowData.map(row => row[colDef.field]))];
this.availableValues = values.filter(val => val != null);
};
isFilterActive = () => {
return this.state.selectedValues.length > 0;
};
doesFilterPass = (params) => {
const { selectedValues } = this.state;
if (selectedValues.length === 0) return true;
const value = params.data[this.props.colDef.field];
return selectedValues.includes(value);
};
getModel = () => {
if (!this.isFilterActive()) return null;
return {
filterType: 'selection',
values: this.state.selectedValues
};
};
setModel = (model) => {
if (!model) {
this.setState({ selectedValues: [], isActive: false });
} else {
this.setState({
selectedValues: model.values || [],
isActive: true
});
}
};
notifyFilterChanged = () => {
// Use the params callback if available, otherwise try props
const callback = (this.params && this.params.filterChangedCallback) ||
this.props.filterChangedCallback ||
this.props.onFilterChanged;
if (callback && typeof callback === 'function') {
callback();
}
};
handleSelectionChange = (event) => {
const values = event.target.value;
this.setState({
selectedValues: values,
isActive: values.length > 0
}, () => {
this.notifyFilterChanged();
});
};
clearFilter = () => {
this.setState({ selectedValues: [], isActive: false }, () => {
this.notifyFilterChanged();
});
};
getDisplayValue = (value) => {
const colDef = (this.params && this.params.colDef) || this.props.colDef;
// Use custom display formatter if provided
if (colDef && colDef.filterParams && colDef.filterParams.valueFormatter) {
return colDef.filterParams.valueFormatter(value);
}
return value;
};
render() {
const { selectedValues } = this.state;
const colDef = (this.params && this.params.colDef) || this.props.colDef;
if (!this.availableValues || !colDef) return null;
const displayValues = this.availableValues.map(val => ({
value: val,
display: this.getDisplayValue(val)
}));
return (
<Box
ref={(el) => this.eGui = el}
sx={{ minWidth: 200 }}
className="ag-filter-custom"
>
<FormControl fullWidth size="small">
<InputLabel>Filter {colDef.headerName}</InputLabel>
<Select
multiple
value={selectedValues}
onChange={this.handleSelectionChange}
label={`Filter ${colDef.headerName}`}
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => (
<Chip
key={value}
label={this.getDisplayValue(value)}
size="small"
color="primary"
variant="outlined"
/>
))}
</Box>
)}
MenuProps={{
PaperProps: {
style: {
maxHeight: 300,
width: 250,
},
},
}}
>
{displayValues.map((item) => (
<MenuItem key={item.value} value={item.value}>
<Checkbox
checked={selectedValues.indexOf(item.value) > -1}
size="small"
/>
<ListItemText primary={item.display} />
</MenuItem>
))}
</Select>
</FormControl>
{selectedValues.length > 0 && (
<Box sx={{ mt: 1, textAlign: 'right' }}>
<button
onClick={this.clearFilter}
style={{
background: 'none',
border: 'none',
color: '#1976d2',
cursor: 'pointer',
fontSize: '12px',
textDecoration: 'underline'
}}
>
Clear Filter
</button>
</Box>
)}
</Box>
);
}
}
export default SelectionFilter;

View File

@@ -0,0 +1,64 @@
import React, { useState, useEffect, useImperativeHandle, forwardRef } from 'react';
import { Checkbox } from '@mui/material';
const SelectionHeader = forwardRef((params, ref) => {
const [checkboxState, setCheckboxState] = useState({ checked: false, indeterminate: false });
// Expose updateState method to parent via ref
useImperativeHandle(ref, () => ({
updateState: (selectedRows, displayedRows) => {
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
const someSelected = selectedRows.size > 0;
const indeterminate = someSelected && !allSelected;
setCheckboxState({ checked: allSelected, indeterminate });
}
}));
// Register this component with parent on mount
useEffect(() => {
if (params.api?.setHeaderCheckboxRef) {
params.api.setHeaderCheckboxRef(ref.current || { updateState: (selectedRows, displayedRows) => {
const allSelected = displayedRows > 0 && selectedRows.size >= displayedRows;
const someSelected = selectedRows.size > 0;
const indeterminate = someSelected && !allSelected;
setCheckboxState({ checked: allSelected, indeterminate });
}});
}
}, [params.api, ref]);
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%'
}}
>
<Checkbox
size="small"
checked={checkboxState.checked}
indeterminate={checkboxState.indeterminate}
onClick={(event) => {
event.stopPropagation();
const parentComponent = params.api?.fibdashComponent;
if (parentComponent && parentComponent.onSelectAll) {
const { selectedRows } = parentComponent.state;
const shouldSelectAll = selectedRows.size === 0;
parentComponent.onSelectAll(shouldSelectAll);
}
}}
sx={{
padding: 0,
'& .MuiSvgIcon-root': {
fontSize: 18
}
}}
/>
</div>
);
});
export default SelectionHeader;

View File

@@ -0,0 +1,69 @@
import React, { useState, useEffect } from 'react';
import { ArrowUpward, ArrowDownward } from '@mui/icons-material';
const SortHeader = (params) => {
const [sortState, setSortState] = useState(null);
// Update sort state when it changes
useEffect(() => {
const updateSortState = () => {
const currentSort = params.column?.getSort();
setSortState(currentSort);
};
// Initial sort state
updateSortState();
// Listen for sort changes
if (params.api) {
params.api.addEventListener('sortChanged', updateSortState);
return () => {
// Check if grid API is still valid before removing listener
if (params.api && !params.api.isDestroyed()) {
params.api.removeEventListener('sortChanged', updateSortState);
}
};
}
}, [params.api, params.column]);
const handleSort = (direction, event) => {
if (params.setSort) {
params.setSort(direction, event.shiftKey);
}
};
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
width: '100%',
gap: 0,
backgroundColor: '#f8f9fa',
borderBottom: '1px solid #dee2e6'
}}>
<ArrowUpward
sx={{
fontSize: '10px',
color: sortState === 'asc' ? '#0969da' : '#ccc',
opacity: sortState === 'asc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => handleSort('asc', e)}
/>
<ArrowDownward
sx={{
fontSize: '10px',
color: sortState === 'desc' ? '#0969da' : '#ccc',
opacity: sortState === 'desc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => handleSort('desc', e)}
/>
</div>
);
};
export default SortHeader;

View File

@@ -36,13 +36,24 @@ class HeaderComponent extends Component {
}
componentWillUnmount() {
// Clean up event listener
if (this.props.params && this.props.params.api) {
// Clean up event listener - check if grid API is still valid
if (this.props.params && this.props.params.api && !this.props.params.api.isDestroyed()) {
this.props.params.api.removeEventListener('filterChanged', this.onFilterChanged);
}
}
onFilterChanged = () => {
// Check if this column's filter was cleared and reset local state
if (this.props.params && this.props.params.api && this.props.params.column) {
const colId = this.props.params.column.colId;
const filterModel = this.props.params.api.getFilterModel();
// If this column no longer has a filter, clear the local filterValue
if (!filterModel || !filterModel[colId]) {
this.setState({ filterValue: '' });
}
}
// Force re-render to update filter icon color
this.forceUpdate();
};
@@ -134,6 +145,9 @@ class HeaderComponent extends Component {
const isTextColumn = column.colDef.field === 'description' ||
column.colDef.field === 'Beguenstigter/Zahlungspflichtiger';
const showTextFilter = isTextColumn;
// Check if sorting is disabled for this column
const isSortingDisabled = column.colDef.sortable === false;
return (
<Box sx={{
@@ -153,44 +167,46 @@ class HeaderComponent extends Component {
fontWeight: 600,
fontSize: '13px',
color: '#212529',
cursor: 'pointer',
cursor: isSortingDisabled ? 'default' : 'pointer',
minWidth: 'fit-content',
marginRight: '6px',
userSelect: 'none',
'&:hover': {
color: '#0969da'
color: isSortingDisabled ? '#212529' : '#0969da'
}
}}
onClick={(e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
onClick={isSortingDisabled ? undefined : (e) => this.onSortRequested(sortDirection === 'asc' ? 'desc' : 'asc', e)}
>
{displayName}
</Typography>
{/* Sort Icons - Always visible */}
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginRight: '6px',
gap: 0
}}>
<SortUpIcon sx={{
fontSize: '10px',
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'asc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('asc', e)}
/>
<SortDownIcon sx={{
fontSize: '10px',
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'desc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('desc', e)}
/>
</Box>
{/* Sort Icons - Only visible when sorting is enabled */}
{!isSortingDisabled && (
<Box sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginRight: '6px',
gap: 0
}}>
<SortUpIcon sx={{
fontSize: '10px',
color: sortDirection === 'asc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'asc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('asc', e)}
/>
<SortDownIcon sx={{
fontSize: '10px',
color: sortDirection === 'desc' ? '#0969da' : '#ccc',
opacity: sortDirection === 'desc' ? 1 : 0.5,
cursor: 'pointer'
}}
onClick={(e) => this.onSortRequested('desc', e)}
/>
</Box>
)}
{/* Filter Input - only for text columns */}
{showTextFilter && (
@@ -274,21 +290,19 @@ export default class TextHeaderWithFilter {
this.eGui.style.height = '100%';
this.eGui.style.display = 'flex';
this.eGui.style.flexDirection = 'column';
console.log('TextHeaderWithFilter constructor');
}
init(params) {
this.params = params;
console.log('TextHeaderWithFilter init params:', params);
// Listen for menu close events to keep state in sync
if (params.api) {
params.api.addEventListener('popupMenuVisibleChanged', (event) => {
this.popupMenuListener = (event) => {
if (!event.visible && this.headerComponent) {
this.headerComponent.setState({ menuOpen: false });
}
});
};
params.api.addEventListener('popupMenuVisibleChanged', this.popupMenuListener);
}
// Render React component into the DOM element
@@ -296,11 +310,15 @@ export default class TextHeaderWithFilter {
}
getGui() {
console.log('TextHeaderWithFilter getGui called');
return this.eGui;
}
destroy() {
// Clean up event listener if grid API is still valid
if (this.params && this.params.api && !this.params.api.isDestroyed() && this.popupMenuListener) {
this.params.api.removeEventListener('popupMenuVisibleChanged', this.popupMenuListener);
}
// Use setTimeout to avoid unmounting during render
setTimeout(() => {
if (this.reactRoot) {

View File

@@ -0,0 +1,61 @@
export const processTransactionData = (transactions) => {
return transactions.map((transaction, index) => ({
...transaction,
// Use actual database ID, no fake ID generation
description: transaction['Verwendungszweck'] || transaction['Buchungstext'],
type: transaction.numericAmount >= 0 ? 'Income' : 'Expense',
isIncome: transaction.numericAmount >= 0,
typeText: transaction.numericAmount >= 0 ? 'Einnahme' : 'Ausgabe'
}));
};
export const getRowStyle = (params, selectedRows) => {
const rowId = params.data?.id || params.rowIndex;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
if (params.data.isJTLOnly) {
return {
backgroundColor: isSelected ? '#e3f2fd' : '#ffebee',
borderLeft: '4px solid #f44336'
};
}
if (isSelected) {
console.log('Row is selected but using CSS class instead:', rowId);
return null; // Don't apply inline styles for selected rows
}
// Return null to allow CSS classes (ag-row-odd/ag-row-even) to handle alternating colors
return null;
};
export const getRowClass = (params, selectedRows) => {
const rowId = params.data?.id || params.rowIndex;
const isSelected = selectedRows && selectedRows.has && selectedRows.has(rowId);
if (isSelected) {
return 'selected-row';
}
return '';
};
export const getMonthName = (monthYear) => {
if (!monthYear) return '';
const [year, month] = monthYear.split('-');
const date = new Date(year, month - 1);
return date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
};
export const getSelectedDisplayName = (selectedValue) => {
if (!selectedValue) return '';
if (selectedValue.includes('-Q')) {
const [year, quarterPart] = selectedValue.split('-Q');
return `Q${quarterPart} ${year}`;
} else if (selectedValue.length === 4) {
return `Jahr ${selectedValue}`;
} else {
return getMonthName(selectedValue);
}
};

View File

@@ -0,0 +1,192 @@
class KreditorService {
constructor() {
// API is mounted under /api (see src/index.js). Keep consistent with AuthService.
this.baseURL = '/api';
}
async getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
};
}
async handleResponse(response) {
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.log('Server error response:', errorData);
// Handle different types of errors with clearer messages
if (response.status === 502 || response.status === 503) {
throw new Error('FibDash Service nicht verfügbar - Bitte versuchen Sie es später erneut');
} else if (response.status === 500) {
throw new Error('FibDash Server Fehler - Bitte kontaktieren Sie den Administrator');
} else if (response.status === 403) {
const message = errorData.message || 'Zugriff verweigert';
throw new Error(message);
} else if (response.status === 401) {
throw new Error('Authentifizierung fehlgeschlagen - Bitte melden Sie sich erneut an');
} else if (response.status === 404) {
throw new Error('Kreditor nicht gefunden');
} else if (response.status === 409) {
const message = errorData.error || 'Kreditor bereits vorhanden';
throw new Error(message);
} else if (response.status === 400) {
const message = errorData.error || 'Ungültige Daten';
throw new Error(message);
} else {
const errorMessage = errorData.error || errorData.message || `HTTP ${response.status}: Unbekannter Fehler`;
throw new Error(errorMessage);
}
}
return await response.json();
}
async getAllKreditors() {
try {
const url = `${this.baseURL}/data/kreditors`;
const response = await fetch(url, {
method: 'GET',
headers: await this.getAuthHeaders(),
});
return await this.handleResponse(response);
} catch (error) {
console.error('Error fetching kreditors:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error;
}
}
// Convenience: find kreditor by kreditorId (string compare)
async findKreditorByCode(kreditorId) {
const all = await this.getAllKreditors();
return (all || []).find(k => String(k.kreditorId).trim() === String(kreditorId).trim());
}
async getKreditorById(id) {
try {
const url = `${this.baseURL}/data/kreditors/${id}`;
const response = await fetch(url, {
method: 'GET',
headers: await this.getAuthHeaders(),
});
return await this.handleResponse(response);
} catch (error) {
console.error('Error fetching kreditor:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error;
}
}
async createKreditor(kreditorData) {
try {
const url = `${this.baseURL}/data/kreditors`;
console.debug('[KreditorService] POST', url, kreditorData);
const response = await fetch(url, {
method: 'POST',
headers: await this.getAuthHeaders(),
body: JSON.stringify(kreditorData),
});
return await this.handleResponse(response);
} catch (error) {
// Map unique constraint errors to a clearer duplicate message when possible
const msg = (error && error.message) || '';
if (/unique|bereits vorhanden|already exists|kreditor/i.test(msg)) {
throw new Error('Kreditor bereits vorhanden');
}
console.error('Error creating kreditor:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error;
}
}
async updateKreditor(id, kreditorData) {
try {
const url = `${this.baseURL}/data/kreditors/${id}`;
const response = await fetch(url, {
method: 'PUT',
headers: await this.getAuthHeaders(),
body: JSON.stringify(kreditorData),
});
return await this.handleResponse(response);
} catch (error) {
console.error('Error updating kreditor:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error;
}
}
async deleteKreditor(id) {
try {
const url = `${this.baseURL}/data/kreditors/${id}`;
const response = await fetch(url, {
method: 'DELETE',
headers: await this.getAuthHeaders(),
});
return await this.handleResponse(response);
} catch (error) {
console.error('Error deleting kreditor:', error);
if (error instanceof TypeError && error.message.includes('fetch')) {
throw new Error('FibDash Service nicht erreichbar - Prüfen Sie Ihre Internetverbindung oder versuchen Sie es später erneut');
}
throw error;
}
}
// Utility method to validate kreditor data
validateKreditorData(kreditorData) {
const errors = [];
// IBAN is only required for non-banking accounts that are not manual assignments
const isBanking = kreditorData.is_banking || false;
const hasIban = kreditorData.iban && kreditorData.iban.trim() !== '';
const isManualAssignment = kreditorData.is_manual_assignment || false;
if (!isBanking && !hasIban && !isManualAssignment) {
errors.push('IBAN ist erforderlich (außer für Banking-Konten oder manuelle Zuordnungen)');
}
if (!kreditorData.name || kreditorData.name.trim() === '') {
errors.push('Name ist erforderlich');
}
if (!kreditorData.kreditorId || kreditorData.kreditorId.trim() === '') {
errors.push('Kreditor-ID ist erforderlich');
}
// Basic IBAN format validation (simplified) - only if IBAN is provided
if (hasIban && !/^[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}$/i.test(kreditorData.iban.replace(/\s/g, ''))) {
errors.push('IBAN Format ist ungültig');
}
// Validate kreditorId format (should start with 70xxx for regular kreditors)
if (kreditorData.kreditorId && !isBanking && !/^70\d{3,}$/.test(kreditorData.kreditorId)) {
errors.push('Kreditor-ID muss mit 70 beginnen gefolgt von mindestens 3 Ziffern (außer für Banking-Konten)');
}
// For banking accounts, warn about special handling
if (isBanking && hasIban) {
// This is just informational, not an error
console.info('Banking-Konto erkannt: Transaktionen benötigen manuelle Kreditor-Zuordnung');
}
return errors;
}
}
export default KreditorService;

1050
data.csv

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>FibDash Login Debug</title>
<script src="https://accounts.google.com/gsi/client" async defer></script>
</head>
<body>
<h1>FibDash Login Debug</h1>
<div id="status">Initializing...</div>
<div id="g_id_onload"
data-client_id="928121624463-jbgfdlgem22scs1k9c87ucg4ffvaik6o.apps.googleusercontent.com"
data-callback="handleCredentialResponse">
</div>
<div class="g_id_signin" data-type="standard"></div>
<script>
function handleCredentialResponse(response) {
console.log('Google credential response:', response);
document.getElementById('status').innerHTML = 'Got Google token, sending to server...';
// Send to our backend
fetch('/api/auth/google', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: response.credential
})
})
.then(response => {
console.log('Server response status:', response.status);
return response.json();
})
.then(data => {
console.log('Server response data:', data);
if (data.success) {
document.getElementById('status').innerHTML = '✅ Login successful! User: ' + data.user.email;
} else {
document.getElementById('status').innerHTML = '❌ Login failed: ' + (data.message || data.error);
}
})
.catch(error => {
console.error('Login error:', error);
document.getElementById('status').innerHTML = '❌ Network error: ' + error.message;
});
}
window.onload = function() {
document.getElementById('status').innerHTML = 'Ready - Click "Sign in with Google"';
};
</script>
</body>
</html>

View File

@@ -1,56 +0,0 @@
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.dev.conf:/etc/nginx/conf.d/default.conf
- ./logs/nginx:/var/log/nginx
depends_on:
- frontend
- backend
restart: unless-stopped
networks:
- fibdash-network
frontend:
build:
context: .
dockerfile: Dockerfile.dev.frontend
ports:
- "5001:5001"
volumes:
- ./client:/app/client
- /app/node_modules
environment:
- NODE_ENV=development
- CHOKIDAR_USEPOLLING=true
networks:
- fibdash-network
command: npm run dev:frontend
backend:
build:
context: .
dockerfile: Dockerfile.dev.backend
ports:
- "5000:5000"
volumes:
- ./src:/app/src
- /app/node_modules
environment:
- NODE_ENV=development
env_file:
- .env
networks:
- fibdash-network
command: npm run dev:backend
networks:
fibdash-network:
driver: bridge
volumes:
node_modules:

34
package-lock.json generated
View File

@@ -21,6 +21,8 @@
"google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"mssql": "^9.1.0",
"nodemailer": "^7.0.5",
"openai": "^5.12.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
@@ -7142,6 +7144,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz",
"integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
@@ -7365,6 +7376,27 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/openai": {
"version": "5.12.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-5.12.0.tgz",
"integrity": "sha512-vUdt02xiWgOHiYUmW0Hj1Qu9OKAiVQu5Bd547ktVCiMKC1BkB5L3ImeEnCyq3WpRKR6ZTaPgekzqdozwdPs7Lg==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -9827,7 +9859,7 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"

View File

@@ -6,7 +6,7 @@
"scripts": {
"dev": "concurrently \"npm run dev:frontend\" \"npm run dev:backend\"",
"dev:frontend": "webpack serve --mode development --config webpack.config.js",
"dev:backend": "nodemon src/index.js",
"dev:backend": "nodemon --exitcrash src/index.js",
"build": "webpack --config webpack.prod.config.js",
"build:prod": "npm run build && npm run start:prod",
"start": "npm run build && node src/index.js",
@@ -32,6 +32,8 @@
"google-auth-library": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"mssql": "^9.1.0",
"nodemailer": "^7.0.5",
"openai": "^5.12.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},

View File

@@ -73,10 +73,42 @@ const executeQuery = async (query, params = {}) => {
}
};
const executeTransaction = async (callback) => {
if (!process.env.DB_SERVER) {
throw new Error('Database not configured');
}
let pool;
let transaction;
try {
pool = await getPool();
transaction = new sql.Transaction(pool);
await transaction.begin();
const result = await callback(transaction);
await transaction.commit();
return result;
} catch (error) {
if (transaction) {
try {
await transaction.rollback();
} catch (rollbackError) {
console.error('Transaction rollback failed:', rollbackError);
}
}
console.error('Transaction error:', error);
throw error;
}
};
module.exports = {
config,
getPool,
testConnection,
executeQuery,
executeTransaction,
sql,
};

View File

@@ -0,0 +1,124 @@
-- CSV Transactions Import Schema
-- This script creates a table to store imported CSV transaction data
-- Create CSVTransactions table to store imported CSV data
CREATE TABLE fibdash.CSVTransactions (
id INT IDENTITY(1,1) PRIMARY KEY,
-- Original CSV columns (German names as they appear in CSV)
buchungstag NVARCHAR(50), -- "Buchungstag"
wertstellung NVARCHAR(50), -- "Wertstellung"
umsatzart NVARCHAR(100), -- "Umsatzart"
betrag DECIMAL(15,2), -- "Betrag" (numeric value)
betrag_original NVARCHAR(50), -- Original string from CSV
waehrung NVARCHAR(10), -- "Waehrung"
beguenstigter_zahlungspflichtiger NVARCHAR(500), -- "Beguenstigter/Zahlungspflichtiger"
kontonummer_iban NVARCHAR(50), -- "Kontonummer/IBAN"
bic NVARCHAR(20), -- "BIC"
verwendungszweck NVARCHAR(1000), -- "Verwendungszweck"
-- Processed/computed fields
parsed_date DATE, -- Parsed buchungstag
numeric_amount DECIMAL(15,2), -- Processed amount
-- Import metadata
import_date DATETIME2 NOT NULL DEFAULT GETDATE(),
import_batch_id NVARCHAR(100), -- To group imports from same file
source_filename NVARCHAR(255), -- Original CSV filename
source_row_number INT, -- Row number in original CSV
-- Processing status
is_processed BIT NOT NULL DEFAULT 0, -- Whether this transaction has been processed
processing_notes NVARCHAR(500), -- Any processing notes or errors
-- Create indexes for performance
INDEX IX_CSVTransactions_IBAN (kontonummer_iban),
INDEX IX_CSVTransactions_Date (parsed_date),
INDEX IX_CSVTransactions_Amount (numeric_amount),
INDEX IX_CSVTransactions_ImportBatch (import_batch_id),
INDEX IX_CSVTransactions_Processed (is_processed)
);
-- Update BankingAccountTransactions to reference CSVTransactions
-- Add a new column to support both AccountingItems and CSVTransactions
ALTER TABLE fibdash.BankingAccountTransactions
ADD csv_transaction_id INT NULL;
-- Add foreign key constraint
ALTER TABLE fibdash.BankingAccountTransactions
ADD CONSTRAINT FK_BankingAccountTransactions_CSVTransactions
FOREIGN KEY (csv_transaction_id) REFERENCES fibdash.CSVTransactions(id);
-- Create index for the new column
CREATE INDEX IX_BankingAccountTransactions_CSVTransactionId
ON fibdash.BankingAccountTransactions(csv_transaction_id);
-- Update the view to include CSV transactions
DROP VIEW IF EXISTS fibdash.vw_TransactionsWithKreditors;
GO
CREATE VIEW fibdash.vw_TransactionsWithKreditors AS
-- AccountingItems transactions
SELECT
'AccountingItems' as source_table,
ai.id as transaction_id,
NULL as csv_transaction_id,
ai.umsatz_brutto as amount,
ai.buchungsdatum as transaction_date,
NULL as kontonummer_iban, -- AccountingItems uses gegenkonto
ai.buchungstext as description,
k.name as kreditor_name,
k.kreditorId as kreditor_id,
k.is_banking as kreditor_is_banking,
bat.assigned_kreditor_id,
ak.name as assigned_kreditor_name,
ak.kreditorId as assigned_kreditor_id_code,
bat.assigned_date,
bat.notes as assignment_notes,
CASE
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NOT NULL THEN 'banking_assigned'
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NULL THEN 'banking_unassigned'
WHEN k.is_banking = 0 THEN 'regular_kreditor'
ELSE 'no_kreditor'
END as transaction_type
FROM fibdash.AccountingItems ai
LEFT JOIN fibdash.Kreditor k ON ai.gegenkonto = k.kreditorId
LEFT JOIN fibdash.BankingAccountTransactions bat ON ai.id = bat.transaction_id
LEFT JOIN fibdash.Kreditor ak ON bat.assigned_kreditor_id = ak.id
UNION ALL
-- CSV transactions
SELECT
'CSVTransactions' as source_table,
NULL as transaction_id,
csv.id as csv_transaction_id,
csv.numeric_amount as amount,
csv.parsed_date as transaction_date,
csv.kontonummer_iban,
csv.verwendungszweck as description,
k.name as kreditor_name,
k.kreditorId as kreditor_id,
k.is_banking as kreditor_is_banking,
bat.assigned_kreditor_id,
ak.name as assigned_kreditor_name,
ak.kreditorId as assigned_kreditor_id_code,
bat.assigned_date,
bat.notes as assignment_notes,
CASE
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NOT NULL THEN 'banking_assigned'
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NULL THEN 'banking_unassigned'
WHEN k.is_banking = 0 THEN 'regular_kreditor'
ELSE 'no_kreditor'
END as transaction_type
FROM fibdash.CSVTransactions csv
LEFT JOIN fibdash.Kreditor k ON csv.kontonummer_iban = k.iban
LEFT JOIN fibdash.BankingAccountTransactions bat ON csv.id = bat.csv_transaction_id
LEFT JOIN fibdash.Kreditor ak ON bat.assigned_kreditor_id = ak.id;
GO
PRINT 'CSV Transactions schema created successfully!';
PRINT 'Created CSVTransactions table';
PRINT 'Updated BankingAccountTransactions table';
PRINT 'Updated vw_TransactionsWithKreditors view';

View File

@@ -1,36 +1,177 @@
-- FibDash Database Schema
-- Run these commands in your MSSQL database
-- Create Users table
CREATE TABLE Users (
-- Create fibdash schema
IF NOT EXISTS (SELECT * FROM sys.schemas WHERE name = 'fibdash')
BEGIN
EXEC('CREATE SCHEMA fibdash')
END
GO
-- Create Kreditor table
-- Multiple IBANs can have the same kreditor name and kreditorId
-- IBAN can be NULL for Kreditors that don't have an IBAN (for banking account assignments)
-- is_banking flag indicates if this IBAN represents a banking account (like PayPal) rather than a direct creditor
CREATE TABLE fibdash.Kreditor (
id INT IDENTITY(1,1) PRIMARY KEY,
google_id NVARCHAR(255) UNIQUE NOT NULL,
email NVARCHAR(255) UNIQUE NOT NULL,
iban NVARCHAR(34) NULL, -- Nullable to allow Kreditors without IBAN
name NVARCHAR(255) NOT NULL,
picture NVARCHAR(500),
created_at DATETIME2 DEFAULT GETDATE(),
last_login DATETIME2,
is_active BIT DEFAULT 1
kreditorId NVARCHAR(50) NOT NULL,
is_banking BIT NOT NULL DEFAULT 0 -- 1 = banking account, 0 = regular creditor
);
-- Create UserPreferences table
CREATE TABLE UserPreferences (
-- Create unique index on IBAN to prevent duplicate IBANs (allows NULL values)
CREATE UNIQUE INDEX UQ_Kreditor_IBAN_NotNull
ON fibdash.Kreditor(iban)
WHERE iban IS NOT NULL;
-- Create AccountingItems table
-- Based on CSV structure: umsatz brutto, soll/haben kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext, beleglink
CREATE TABLE fibdash.AccountingItems (
id INT IDENTITY(1,1) PRIMARY KEY,
user_id INT NOT NULL,
theme NVARCHAR(50) DEFAULT 'light',
language NVARCHAR(10) DEFAULT 'en',
notifications_enabled BIT DEFAULT 1,
created_at DATETIME2 DEFAULT GETDATE(),
updated_at DATETIME2 DEFAULT GETDATE(),
FOREIGN KEY (user_id) REFERENCES Users(id) ON DELETE CASCADE
umsatz_brutto DECIMAL(15,2) NOT NULL, -- gross turnover amount
soll_haben_kz CHAR(1) NOT NULL CHECK (soll_haben_kz IN ('S', 'H')), -- S = eingang (debit), H = ausgang (credit)
konto NVARCHAR(10) NOT NULL, -- account (e.g. 5400 = wareneingang 19%)
gegenkonto NVARCHAR(50) NOT NULL, -- counter account references Kreditor(kreditorId)
bu NVARCHAR(10), -- tax code (9 = 19%vst, 8 = 7%vst, 506 = dienstleistung aus EU, 511 = dienstleistung ausserhalb EU)
buchungsdatum DATE NOT NULL, -- booking date
rechnungsnummer NVARCHAR(100), -- invoice number (belegfeld 1)
buchungstext NVARCHAR(500), -- booking text (supplier/customer name + purpose)
beleglink NVARCHAR(500) -- document link
);
-- Create Konto table
CREATE TABLE fibdash.Konto (
id INT IDENTITY(1,1) PRIMARY KEY,
konto NVARCHAR(10) NOT NULL,
name NVARCHAR(255) NOT NULL
);
-- Ensure Konto.konto is unique to support FK references
ALTER TABLE fibdash.Konto
ADD CONSTRAINT UQ_Konto_konto UNIQUE (konto);
-- Create BU table
CREATE TABLE fibdash.BU (
id INT IDENTITY(1,1) PRIMARY KEY,
bu NVARCHAR(10) NOT NULL,
name NVARCHAR(255) NOT NULL,
vst DECIMAL(5,2) -- Vorsteuer percentage (e.g., 19.00 for 19%)
);
-- Ensure BU.bu is unique to support FK references
ALTER TABLE fibdash.BU
ADD CONSTRAINT UQ_BU_bu UNIQUE (bu);
/*
CSV
umsatz brutto ,
soll / haben kz ( S = eingang, H = ausgang),
,,,
konto (XXXX , z.b. 5400 = wareneingang 19%),
gegenkonto (70XXX),
bu (9 = 19%vst , 8 = 7%vst, 506 = dienstleistung aus EU, 511 = dienstleistung ausserhalb EU),
buchungsdatum, (MDD)
rechnungsnummer (belegfeld 1),
,,
buchungstext (lierferantenname / kundenname , + verwendungszweck)
,,,,,
beleglink
--
nicht abziehbare vorstreuer buchen auf 5600
*/
-- Create indexes for better performance
CREATE INDEX IX_Users_Email ON Users(email);
CREATE INDEX IX_Users_GoogleId ON Users(google_id);
CREATE INDEX IX_UserPreferences_UserId ON UserPreferences(user_id);
CREATE INDEX IX_Kreditor_IBAN ON fibdash.Kreditor(iban);
CREATE INDEX IX_Kreditor_KreditorId ON fibdash.Kreditor(kreditorId);
CREATE INDEX IX_Kreditor_IsBanking ON fibdash.Kreditor(is_banking);
CREATE INDEX IX_AccountingItems_Buchungsdatum ON fibdash.AccountingItems(buchungsdatum);
CREATE INDEX IX_AccountingItems_Konto ON fibdash.AccountingItems(konto);
CREATE INDEX IX_AccountingItems_Rechnungsnummer ON fibdash.AccountingItems(rechnungsnummer);
CREATE INDEX IX_AccountingItems_SollHabenKz ON fibdash.AccountingItems(soll_haben_kz);
CREATE INDEX IX_BankingAccountTransactions_TransactionId ON fibdash.BankingAccountTransactions(transaction_id);
CREATE INDEX IX_BankingAccountTransactions_BankingIban ON fibdash.BankingAccountTransactions(banking_iban);
CREATE INDEX IX_BankingAccountTransactions_AssignedKreditorId ON fibdash.BankingAccountTransactions(assigned_kreditor_id);
-- Add FK from AccountingItems.bu -> BU(bu)
ALTER TABLE fibdash.AccountingItems
ADD CONSTRAINT FK_AccountingItems_BU_BU
FOREIGN KEY (bu) REFERENCES fibdash.BU(bu);
-- Add FK from AccountingItems.gegenkonto -> Kreditor(kreditorId)
ALTER TABLE fibdash.AccountingItems
ADD CONSTRAINT FK_AccountingItems_Gegenkonto_Kreditor
FOREIGN KEY (gegenkonto) REFERENCES fibdash.Kreditor(kreditorId);
-- Add FK from AccountingItems.konto -> Konto(konto)
ALTER TABLE fibdash.AccountingItems
ADD CONSTRAINT FK_AccountingItems_Konto_Konto
FOREIGN KEY (konto) REFERENCES fibdash.Konto(konto);
-- Create BankingAccountTransactions table to map banking account transactions to Kreditors
-- This table handles cases where an IBAN is a banking account (like PayPal) and needs
-- to be mapped to the actual creditor for accounting purposes
CREATE TABLE fibdash.BankingAccountTransactions (
id INT IDENTITY(1,1) PRIMARY KEY,
transaction_id INT NOT NULL, -- References AccountingItems.id
banking_iban NVARCHAR(34) NOT NULL, -- The banking account IBAN (e.g., PayPal)
assigned_kreditor_id INT NOT NULL, -- References Kreditor.id for the actual creditor
assigned_date DATETIME2 NOT NULL DEFAULT GETDATE(),
assigned_by NVARCHAR(100), -- User who made the assignment
notes NVARCHAR(500), -- Optional notes about the assignment
-- Foreign key constraints
CONSTRAINT FK_BankingAccountTransactions_AccountingItems
FOREIGN KEY (transaction_id) REFERENCES fibdash.AccountingItems(id),
CONSTRAINT FK_BankingAccountTransactions_Kreditor
FOREIGN KEY (assigned_kreditor_id) REFERENCES fibdash.Kreditor(id)
);
-- Add vst column to existing BU table (for databases created before this update)
-- IF NOT EXISTS (SELECT * FROM sys.columns WHERE object_id = OBJECT_ID('fibdash.BU') AND name = 'vst')
-- BEGIN
-- ALTER TABLE fibdash.BU ADD vst DECIMAL(5,2);
-- END
-- Insert sample data (optional)
-- Note: This will only work after you have real Google user data
-- INSERT INTO Users (google_id, email, name, picture)
-- VALUES ('sample_google_id', 'user@example.com', 'Lorem Ipsum User', 'https://example.com/picture.jpg');
-- INSERT INTO fibdash.Kreditor (iban, name, kreditorId)
-- VALUES ('DE89370400440532013000', 'Sample Kreditor', '70001');
-- INSERT INTO fibdash.Konto (konto, name) VALUES
-- ('5400', 'Wareneingang 19%'),
-- ('5600', 'Nicht abziehbare Vorsteuer');
-- INSERT INTO fibdash.BU (bu, name, vst) VALUES
-- ('9', '19% VST', 19.00),
-- ('8', '7% VST', 7.00),
-- ('506', 'Dienstleistung aus EU', NULL),
-- ('511', 'Dienstleistung außerhalb EU', NULL);
-- Create view to easily query transactions with their assigned Kreditors
-- This view combines regular transactions with banking account assignments
CREATE VIEW fibdash.vw_TransactionsWithKreditors AS
SELECT
ai.*,
k.name as kreditor_name,
k.kreditorId as kreditor_id,
k.is_banking as kreditor_is_banking,
bat.assigned_kreditor_id,
ak.name as assigned_kreditor_name,
ak.kreditorId as assigned_kreditor_id_code,
bat.assigned_date,
bat.notes as assignment_notes,
CASE
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NOT NULL THEN 'banking_assigned'
WHEN k.is_banking = 1 AND bat.assigned_kreditor_id IS NULL THEN 'banking_unassigned'
WHEN k.is_banking = 0 THEN 'regular_kreditor'
ELSE 'no_kreditor'
END as transaction_type
FROM fibdash.AccountingItems ai
LEFT JOIN fibdash.Kreditor k ON ai.gegenkonto = k.kreditorId
LEFT JOIN fibdash.BankingAccountTransactions bat ON ai.id = bat.transaction_id
LEFT JOIN fibdash.Kreditor ak ON bat.assigned_kreditor_id = ak.id;

View File

@@ -10,11 +10,11 @@ const dataRoutes = require('./routes/data');
const dbConfig = require('./config/database');
const app = express();
const PORT = process.env.PORT || 5000;
const PORT = process.env.PORT || 5500;
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.json({ limit: '10mb' })); // Increased limit for CSV imports
// Routes
app.use('/api/auth', authRoutes);
@@ -27,12 +27,7 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
// Debug login page (development only)
if (process.env.NODE_ENV !== 'production') {
app.get('/debug-login', (req, res) => {
res.sendFile(path.join(__dirname, '../debug-login.html'));
});
}
// Serve static files in production
if (process.env.NODE_ENV === 'production') {

View File

@@ -1,16 +1,5 @@
const checkAuthorizedEmail = (req, res, next) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
// If no authorized emails are configured, deny all users
if (!authorizedEmails || authorizedEmails.trim() === '') {
return res.status(403).json({
error: 'Access denied',
message: 'No authorized users configured. Contact administrator.'
});
}
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
const userEmail = req.user?.email?.toLowerCase();
const checkAuthorizedEmail = async (req, res, next) => {
const userEmail = req.user?.email;
if (!userEmail) {
return res.status(401).json({
@@ -19,27 +8,75 @@ const checkAuthorizedEmail = (req, res, next) => {
});
}
if (!emailList.includes(userEmail)) {
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is not authorized to access this application'
try {
const authorized = await isEmailAuthorized(userEmail);
if (!authorized) {
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is not authorized to access this application'
});
}
next();
} catch (error) {
console.error('Authorization check failed:', error);
return res.status(500).json({
error: 'Authorization check failed',
message: 'Unable to verify authorization. Please try again.'
});
}
next();
};
const isEmailAuthorized = (email) => {
const isEmailAuthorized = async (email) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
const userEmail = email.toLowerCase();
// If no authorized emails are configured, deny all users
if (!authorizedEmails || authorizedEmails.trim() === '') {
return false;
console.log(`🔍 Checking authorization for email: ${userEmail}`);
// First check environment variable
if (authorizedEmails && authorizedEmails.trim() !== '') {
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
if (emailList.includes(userEmail)) {
console.log(`✅ Email authorized via AUTHORIZED_EMAILS environment variable`);
return true;
}
console.log(`❌ Email not found in AUTHORIZED_EMAILS environment variable`);
} else {
console.log(`⚠️ No AUTHORIZED_EMAILS configured, checking database...`);
}
const emailList = authorizedEmails.split(',').map(e => e.trim().toLowerCase());
const userEmail = email.toLowerCase();
return emailList.includes(userEmail);
// Then check database
console.log(`🗄️ Checking database authorization for: ${userEmail}`);
try {
const { executeQuery } = require('../config/database');
const query = `
SELECT TOP 1 1 as authorized
FROM dbo.tAdresse
WHERE cMail = @email
AND kKunde IN (
SELECT [kKunde]
FROM [Kunde].[tKundeEigenesFeld]
WHERE kAttribut = 219 OR kAttribut = 220 AND nWertInt = 1
)
`;
const result = await executeQuery(query, { email: userEmail });
const isAuthorized = result.recordset && result.recordset.length > 0;
if (isAuthorized) {
console.log(`✅ Email authorized via database (tKundeEigenesFeld with kAttribut 219/220)`);
return true;
} else {
console.log(`❌ Email not authorized via database`);
return false;
}
} catch (error) {
console.error(`💥 Database authorization check failed for ${userEmail}:`, error.message);
return false;
}
};
module.exports = {

View File

@@ -1,110 +1,273 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
const { executeQuery, sql } = require('../config/database');
const fs = require('fs');
const path = require('path');
const router = express.Router();
// Check if user is admin (first email in the list or specific admin email)
const checkAdminAccess = (req, res, next) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
if (!authorizedEmails || authorizedEmails.trim() === '') {
return res.status(403).json({ error: 'No authorized emails configured' });
}
const emailList = authorizedEmails.split(',').map(email => email.trim().toLowerCase());
const userEmail = req.user?.email?.toLowerCase();
// First email in the list is considered admin, or check for specific admin emails
const adminEmails = [emailList[0]]; // First email is admin
if (!adminEmails.includes(userEmail)) {
return res.status(403).json({
error: 'Admin access required',
message: 'Only administrators can access this resource'
});
}
next();
};
// Removed admin access check - all authenticated users can access these routes
// Get current authorized emails (admin only)
router.get('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
const authorizedEmails = process.env.AUTHORIZED_EMAILS;
if (!authorizedEmails) {
return res.json({ emails: [] });
}
const emailList = authorizedEmails.split(',').map(email => email.trim());
res.json({ emails: emailList });
});
// Add authorized email (admin only)
router.post('/authorized-emails', authenticateToken, checkAdminAccess, (req, res) => {
const { email } = req.body;
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email address required' });
}
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
const newEmail = email.trim().toLowerCase();
if (emailList.map(e => e.toLowerCase()).includes(newEmail)) {
return res.status(400).json({ error: 'Email already authorized' });
}
emailList.push(email.trim());
// Note: This only updates the runtime environment variable
// For persistent changes, you'd need to update the .env file
process.env.AUTHORIZED_EMAILS = emailList.join(',');
res.json({
message: 'Email added successfully',
emails: emailList,
note: 'Changes are temporary. Update .env file for permanent changes.'
});
});
// Remove authorized email (admin only)
router.delete('/authorized-emails/:email', authenticateToken, checkAdminAccess, (req, res) => {
const emailToRemove = req.params.email.toLowerCase();
const authorizedEmails = process.env.AUTHORIZED_EMAILS || '';
const emailList = authorizedEmails.split(',').map(e => e.trim()).filter(e => e);
const filteredEmails = emailList.filter(email => email.toLowerCase() !== emailToRemove);
if (filteredEmails.length === emailList.length) {
return res.status(404).json({ error: 'Email not found in authorized list' });
}
// Don't allow removing the last admin email
if (filteredEmails.length === 0) {
return res.status(400).json({ error: 'Cannot remove all authorized emails' });
}
// Note: This only updates the runtime environment variable
process.env.AUTHORIZED_EMAILS = filteredEmails.join(',');
res.json({
message: 'Email removed successfully',
emails: filteredEmails,
note: 'Changes are temporary. Update .env file for permanent changes.'
});
});
// Get system info (admin only)
router.get('/system-info', authenticateToken, checkAdminAccess, (req, res) => {
// Get system info
router.get('/system-info', authenticateToken, (req, res) => {
res.json({
authorizedEmailsConfigured: !!process.env.AUTHORIZED_EMAILS,
totalAuthorizedEmails: process.env.AUTHORIZED_EMAILS ? process.env.AUTHORIZED_EMAILS.split(',').length : 0,
currentUser: req.user.email,
isAdmin: true,
environment: process.env.NODE_ENV || 'development'
});
});
module.exports = router;
// ==================== KREDITOR ROUTES ====================
// Get all kreditoren
router.get('/kreditoren', authenticateToken, async (req, res) => {
try {
const result = await executeQuery('SELECT id, iban, name, kreditorId, is_banking FROM fibdash.Kreditor ORDER BY name, iban');
res.json({ kreditoren: result.recordset });
} catch (error) {
console.error('Error fetching kreditoren:', error);
res.status(500).json({ error: 'Fehler beim Laden der Kreditoren' });
}
});
// Create new kreditor
router.post('/kreditoren', authenticateToken, async (req, res) => {
const { iban, name, kreditorId, is_banking } = req.body;
// IBAN is optional for banking accounts or manual kreditor assignments
const isBanking = is_banking || false;
if (!name || !kreditorId) {
return res.status(400).json({ error: 'Name und Kreditor ID sind erforderlich' });
}
// IBAN validation - required for non-banking accounts
if (!isBanking && (!iban || iban.trim() === '')) {
return res.status(400).json({ error: 'IBAN ist erforderlich (außer für Banking-Konten)' });
}
try {
await executeQuery(
'INSERT INTO fibdash.Kreditor (iban, name, kreditorId, is_banking) VALUES (@iban, @name, @kreditorId, @is_banking)',
{ iban: iban || null, name, kreditorId, is_banking: isBanking }
);
res.json({ message: 'Kreditor erfolgreich erstellt' });
} catch (error) {
console.error('Error creating kreditor:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'IBAN oder Kreditor ID bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Erstellen des Kreditors' });
}
}
});
// Update kreditor
router.put('/kreditoren/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { iban, name, kreditorId, is_banking } = req.body;
// IBAN is optional for banking accounts or manual kreditor assignments
const isBanking = is_banking || false;
if (!name || !kreditorId) {
return res.status(400).json({ error: 'Name und Kreditor ID sind erforderlich' });
}
// IBAN validation - required for non-banking accounts
if (!isBanking && (!iban || iban.trim() === '')) {
return res.status(400).json({ error: 'IBAN ist erforderlich (außer für Banking-Konten)' });
}
try {
await executeQuery(
'UPDATE fibdash.Kreditor SET iban = @iban, name = @name, kreditorId = @kreditorId, is_banking = @is_banking WHERE id = @id',
{ iban: iban || null, name, kreditorId, is_banking: isBanking, id }
);
res.json({ message: 'Kreditor erfolgreich aktualisiert' });
} catch (error) {
console.error('Error updating kreditor:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'IBAN oder Kreditor ID bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Aktualisieren des Kreditors' });
}
}
});
// Delete kreditor
router.delete('/kreditoren/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await executeQuery('DELETE FROM fibdash.Kreditor WHERE id = @id', { id });
res.json({ message: 'Kreditor erfolgreich gelöscht' });
} catch (error) {
console.error('Error deleting kreditor:', error);
if (error.number === 547) { // Foreign key constraint violation
res.status(400).json({ error: 'Kreditor kann nicht gelöscht werden, da er in Buchungen verwendet wird' });
} else {
res.status(500).json({ error: 'Fehler beim Löschen des Kreditors' });
}
}
});
// ==================== KONTO ROUTES ====================
// Get all konten
router.get('/konten', authenticateToken, async (req, res) => {
try {
const result = await executeQuery('SELECT * FROM fibdash.Konto ORDER BY konto');
res.json({ konten: result.recordset });
} catch (error) {
console.error('Error fetching konten:', error);
res.status(500).json({ error: 'Fehler beim Laden der Konten' });
}
});
// Create new konto
router.post('/konten', authenticateToken, async (req, res) => {
const { konto, name } = req.body;
if (!konto || !name) {
return res.status(400).json({ error: 'Konto und Name sind erforderlich' });
}
try {
await executeQuery(
'INSERT INTO fibdash.Konto (konto, name) VALUES (@konto, @name)',
{ konto, name }
);
res.json({ message: 'Konto erfolgreich erstellt' });
} catch (error) {
console.error('Error creating konto:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'Konto bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Erstellen des Kontos' });
}
}
});
// Update konto
router.put('/konten/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { konto, name } = req.body;
if (!konto || !name) {
return res.status(400).json({ error: 'Konto und Name sind erforderlich' });
}
try {
await executeQuery(
'UPDATE fibdash.Konto SET konto = @konto, name = @name WHERE id = @id',
{ konto, name, id }
);
res.json({ message: 'Konto erfolgreich aktualisiert' });
} catch (error) {
console.error('Error updating konto:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'Konto bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Aktualisieren des Kontos' });
}
}
});
// Delete konto
router.delete('/konten/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await executeQuery('DELETE FROM fibdash.Konto WHERE id = @id', { id });
res.json({ message: 'Konto erfolgreich gelöscht' });
} catch (error) {
console.error('Error deleting konto:', error);
if (error.number === 547) { // Foreign key constraint violation
res.status(400).json({ error: 'Konto kann nicht gelöscht werden, da es in Buchungen verwendet wird' });
} else {
res.status(500).json({ error: 'Fehler beim Löschen des Kontos' });
}
}
});
// ==================== BU (BUCHUNGSSCHLÜSSEL) ROUTES ====================
// Get all buchungsschluessel
router.get('/buchungsschluessel', authenticateToken, async (req, res) => {
try {
const result = await executeQuery('SELECT * FROM fibdash.BU ORDER BY bu');
res.json({ buchungsschluessel: result.recordset });
} catch (error) {
console.error('Error fetching buchungsschluessel:', error);
res.status(500).json({ error: 'Fehler beim Laden der Buchungsschlüssel' });
}
});
// Create new buchungsschluessel
router.post('/buchungsschluessel', authenticateToken, async (req, res) => {
const { bu, name, vst } = req.body;
if (!bu || !name) {
return res.status(400).json({ error: 'BU und Name sind erforderlich' });
}
try {
await executeQuery(
'INSERT INTO fibdash.BU (bu, name, vst) VALUES (@bu, @name, @vst)',
{ bu, name, vst: vst !== undefined && vst !== '' ? vst : null }
);
res.json({ message: 'Buchungsschlüssel erfolgreich erstellt' });
} catch (error) {
console.error('Error creating BU:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'Buchungsschlüssel bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Erstellen des Buchungsschlüssels' });
}
}
});
// Update buchungsschluessel
router.put('/buchungsschluessel/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
const { bu, name, vst } = req.body;
if (!bu || !name) {
return res.status(400).json({ error: 'BU und Name sind erforderlich' });
}
try {
await executeQuery(
'UPDATE fibdash.BU SET bu = @bu, name = @name, vst = @vst WHERE id = @id',
{ bu, name, vst: vst !== undefined && vst !== '' ? vst : null, id }
);
res.json({ message: 'Buchungsschlüssel erfolgreich aktualisiert' });
} catch (error) {
console.error('Error updating BU:', error);
if (error.number === 2627) { // Unique constraint violation
res.status(400).json({ error: 'Buchungsschlüssel bereits vorhanden' });
} else {
res.status(500).json({ error: 'Fehler beim Aktualisieren des Buchungsschlüssels' });
}
}
});
// Delete buchungsschluessel
router.delete('/buchungsschluessel/:id', authenticateToken, async (req, res) => {
const { id } = req.params;
try {
await executeQuery('DELETE FROM fibdash.BU WHERE id = @id', { id });
res.json({ message: 'Buchungsschlüssel erfolgreich gelöscht' });
} catch (error) {
console.error('Error deleting BU:', error);
if (error.number === 547) { // Foreign key constraint violation
res.status(400).json({ error: 'Buchungsschlüssel kann nicht gelöscht werden, da er in Buchungen verwendet wird' });
} else {
res.status(500).json({ error: 'Fehler beim Löschen des Buchungsschlüssels' });
}
}
});
module.exports = router;

View File

@@ -35,7 +35,7 @@ router.post('/google', async (req, res) => {
console.log(`👤 Google token verified for: ${email}`);
// Check if email is authorized
const authorized = isEmailAuthorized(email);
const authorized = await isEmailAuthorized(email);
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
if (!authorized) {
@@ -46,50 +46,15 @@ router.post('/google', async (req, res) => {
});
}
// Check if user exists in database (optional - auth works without DB)
let user;
try {
// Only try database operations if DB is configured
if (process.env.DB_SERVER) {
const userResult = await executeQuery(
'SELECT * FROM Users WHERE email = @email',
{ email }
);
if (userResult.recordset.length > 0) {
// User exists, update last login
user = userResult.recordset[0];
await executeQuery(
'UPDATE Users SET last_login = GETDATE(), picture = @picture WHERE id = @id',
{ picture, id: user.id }
);
} else {
// Create new user
const insertResult = await executeQuery(
`INSERT INTO Users (google_id, email, name, picture, created_at, last_login)
OUTPUT INSERTED.*
VALUES (@googleId, @email, @name, @picture, GETDATE(), GETDATE())`,
{ googleId, email, name, picture }
);
user = insertResult.recordset[0];
}
console.log('✅ Database operations completed successfully');
} else {
console.log('⚠️ No database configured, using fallback user object');
throw new Error('No database configured');
}
} catch (dbError) {
console.error('Database error during authentication:', dbError.message);
// Fallback: create user object without database
user = {
id: googleId,
email,
name,
picture,
google_id: googleId,
};
console.log('✅ Using fallback user object (no database)');
}
// Create user object from Google data (no database storage needed)
const user = {
id: googleId,
email,
name,
picture,
google_id: googleId,
};
console.log('✅ User object created from Google authentication');
// Generate JWT token
const jwtToken = generateToken(user);
@@ -111,6 +76,98 @@ router.post('/google', async (req, res) => {
}
});
// Google OAuth callback (redirect flow)
router.post('/google/callback', async (req, res) => {
try {
const { code, redirect_uri } = req.body;
console.log('🔄 Processing OAuth callback with authorization code');
if (!code) {
console.log('❌ No authorization code provided');
return res.status(400).json({ error: 'Authorization code is required' });
}
// Exchange authorization code for tokens
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code: code,
grant_type: 'authorization_code',
redirect_uri: redirect_uri,
}),
});
if (!tokenResponse.ok) {
const errorData = await tokenResponse.text();
console.log('❌ Token exchange failed:', errorData);
return res.status(400).json({ error: 'Failed to exchange authorization code' });
}
const tokens = await tokenResponse.json();
console.log('🎯 Received tokens from Google');
// Use the ID token to get user info
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload();
const googleId = payload['sub'];
const email = payload['email'];
const name = payload['name'];
const picture = payload['picture'];
console.log(`👤 OAuth callback verified for: ${email}`);
// Check if email is authorized
const authorized = await isEmailAuthorized(email);
console.log(`🔒 Email authorization check for ${email}: ${authorized ? 'ALLOWED' : 'DENIED'}`);
if (!authorized) {
console.log(`❌ Access denied for ${email}`);
return res.status(403).json({
error: 'Access denied',
message: 'Your email address is not authorized to access this application'
});
}
// Create user object
const user = {
id: googleId,
email,
name,
picture,
google_id: googleId,
};
console.log('✅ User object created from OAuth callback');
// Generate JWT token
const jwtToken = generateToken(user);
res.json({
success: true,
token: jwtToken,
user: {
id: user.id,
email: user.email,
name: user.name,
picture: user.picture,
},
});
} catch (error) {
console.error('OAuth callback error:', error);
res.status(401).json({ error: 'OAuth authentication failed' });
}
});
// Verify JWT token
router.get('/verify', authenticateToken, async (req, res) => {
try {

View File

@@ -1,7 +1,5 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { executeQuery } = require('../config/database');
const { checkAuthorizedEmail } = require('../middleware/emailAuth');
const router = express.Router();
@@ -18,45 +16,15 @@ router.get('/', authenticateToken, async (req, res) => {
recentActivity: []
};
try {
// Only try database operations if configured
if (process.env.DB_SERVER) {
// Try to fetch real data from database
const userCountResult = await executeQuery('SELECT COUNT(*) as count FROM Users');
const userCount = userCountResult.recordset[0]?.count || 0;
// Update stats with real data
dashboardData.stats[0].value = userCount.toString();
// Fetch recent activity
const activityResult = await executeQuery(`
SELECT TOP 10
CONCAT('User ', name, ' logged in') as description,
FORMAT(last_login, 'yyyy-MM-dd HH:mm') as timestamp
FROM Users
WHERE last_login IS NOT NULL
ORDER BY last_login DESC
`);
dashboardData.recentActivity = activityResult.recordset || [];
console.log('✅ Dashboard data loaded from database');
} else {
console.log('⚠️ No database configured, using mock dashboard data');
// Update with mock data
dashboardData.stats[0].value = '1';
dashboardData.stats[1].value = '$0';
dashboardData.stats[2].value = '0';
dashboardData.stats[3].value = '0%';
dashboardData.recentActivity = [
{ description: 'System started without database', timestamp: new Date().toISOString().slice(0, 16) }
];
}
} catch (dbError) {
console.error('Database query error in dashboard:', dbError.message);
// Keep fallback data
console.log('✅ Using fallback dashboard data');
}
// Use mock data since we don't store user information in database
dashboardData.stats[0].value = 'N/A';
dashboardData.stats[1].value = '$0';
dashboardData.stats[2].value = '0';
dashboardData.stats[3].value = '0%';
dashboardData.recentActivity = [
{ description: 'System operational', timestamp: new Date().toISOString().slice(0, 16) }
];
console.log('✅ Dashboard loaded with mock data (no user storage)');
res.json(dashboardData);
@@ -69,29 +37,12 @@ router.get('/', authenticateToken, async (req, res) => {
// Get user-specific data
router.get('/user', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
let userData = {
const userData = {
profile: req.user,
preferences: {},
activity: []
};
try {
// Fetch user preferences from database
const prefsResult = await executeQuery(
'SELECT * FROM UserPreferences WHERE user_id = @userId',
{ userId }
);
if (prefsResult.recordset.length > 0) {
userData.preferences = prefsResult.recordset[0];
}
} catch (dbError) {
console.error('Database query error for user data:', dbError);
}
res.json(userData);
} catch (error) {

View File

@@ -1,443 +1,10 @@
const express = require('express');
const fs = require('fs');
const path = require('path');
const { authenticateToken } = require('../middleware/auth');
// Explicitly require the index file to avoid resolving a non-router object.
const dataRouter = require('./data/index.js');
const router = express.Router();
// Parse CSV data
const parseCSV = () => {
try {
const csvPath = path.join(__dirname, '../../data.csv');
const csvData = fs.readFileSync(csvPath, 'utf8');
const lines = csvData.split('\n');
const headers = lines[0].split(';').map(h => h.replace(/"/g, ''));
const transactions = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) continue;
// Parse CSV line (handle semicolon-separated values with quotes)
const values = [];
let current = '';
let inQuotes = false;
for (let j = 0; j < line.length; j++) {
const char = line[j];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ';' && !inQuotes) {
values.push(current);
current = '';
} else {
current += char;
}
}
values.push(current); // Add last value
if (values.length >= headers.length) {
const transaction = {};
headers.forEach((header, index) => {
transaction[header] = values[index] || '';
});
// Parse date and amount
if (transaction['Buchungstag']) {
const dateParts = transaction['Buchungstag'].split('.');
if (dateParts.length === 3) {
// Convert DD.MM.YY to proper date
const day = dateParts[0];
const month = dateParts[1];
const year = '20' + dateParts[2]; // Assuming 20xx
transaction.parsedDate = new Date(year, month - 1, day);
transaction.monthYear = `${year}-${month.padStart(2, '0')}`;
}
}
// Parse amount
if (transaction['Betrag']) {
const amount = transaction['Betrag'].replace(',', '.').replace(/[^-0-9.]/g, '');
transaction.numericAmount = parseFloat(amount) || 0;
}
transactions.push(transaction);
}
}
return transactions;
} catch (error) {
console.error('Error parsing CSV:', error);
return [];
}
};
router.use('/', dataRouter);
// Get available months
router.get('/months', authenticateToken, (req, res) => {
try {
const transactions = parseCSV();
const months = [...new Set(transactions
.filter(t => t.monthYear)
.map(t => t.monthYear)
)].sort().reverse(); // Newest first
res.json({ months });
} catch (error) {
console.error('Error getting months:', error);
res.status(500).json({ error: 'Failed to load months' });
}
});
// Get database transactions for JTL comparison
const getJTLTransactions = async () => {
try {
const { executeQuery } = require('../config/database');
const query = `
SELECT
cKonto, cKontozusatz, cName, dBuchungsdatum,
tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz,
cVerwendungszweck, fBetrag, tUmsatzKontierung.data
FROM [eazybusiness].[dbo].[tZahlungsabgleichUmsatz]
LEFT JOIN tUmsatzKontierung ON (tUmsatzKontierung.kZahlungsabgleichUmsatz = tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz)
ORDER BY dBuchungsdatum desc, tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz desc
`;
const result = await executeQuery(query);
return result.recordset || [];
} catch (error) {
console.error('Error fetching JTL transactions:', error);
return [];
}
};
// Get transactions for a specific time period (month, quarter, or year)
router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
try {
const { timeRange } = req.params;
const transactions = parseCSV();
let filteredTransactions = [];
let periodDescription = '';
if (timeRange.includes('-Q')) {
// Quarter format: YYYY-Q1, YYYY-Q2, etc.
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear, tMonth] = t.monthYear.split('-');
const monthNum = parseInt(tMonth);
return tYear === year && monthNum >= startMonth && monthNum <= endMonth;
});
periodDescription = `Q${quarter} ${year}`;
} else if (timeRange.length === 4) {
// Year format: YYYY
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear] = t.monthYear.split('-');
return tYear === timeRange;
});
periodDescription = `Jahr ${timeRange}`;
} else {
// Month format: YYYY-MM
filteredTransactions = transactions.filter(t => t.monthYear === timeRange);
const [year, month] = timeRange.split('-');
const date = new Date(year, month - 1);
periodDescription = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}
const monthTransactions = filteredTransactions
.sort((a, b) => b.parsedDate - a.parsedDate); // Newest first
// Get JTL transactions for comparison
let jtlTransactions = [];
try {
jtlTransactions = await getJTLTransactions();
} catch (error) {
console.log('JTL database not available, continuing without JTL data');
}
// Filter JTL transactions for the selected time period
let jtlMonthTransactions = [];
if (timeRange.includes('-Q')) {
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
const jtlMonth = jtlDate.getMonth() + 1; // 0-based to 1-based
return jtlDate.getFullYear() === parseInt(year) &&
jtlMonth >= startMonth && jtlMonth <= endMonth;
});
} else if (timeRange.length === 4) {
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
return jtlDate.getFullYear() === parseInt(timeRange);
});
} else {
const [year, month] = timeRange.split('-');
jtlMonthTransactions = jtlTransactions.filter(jtl => {
const jtlDate = new Date(jtl.dBuchungsdatum);
return jtlDate.getFullYear() === parseInt(year) &&
jtlDate.getMonth() === parseInt(month) - 1;
});
}
// Add JTL status to each CSV transaction
const transactionsWithJTL = monthTransactions.map(transaction => {
// Try to match by amount and date (approximate matching)
const amount = transaction.numericAmount;
const transactionDate = transaction.parsedDate;
const jtlMatch = jtlMonthTransactions.find(jtl => {
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
const jtlDate = new Date(jtl.dBuchungsdatum);
// Match by amount (exact) and date (same day)
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
const dateMatch = transactionDate && jtlDate &&
transactionDate.getFullYear() === jtlDate.getFullYear() &&
transactionDate.getMonth() === jtlDate.getMonth() &&
transactionDate.getDate() === jtlDate.getDate();
return amountMatch && dateMatch;
});
return {
...transaction,
hasJTL: !!jtlMatch,
jtlId: jtlMatch ? jtlMatch.kZahlungsabgleichUmsatz : null,
isFromCSV: true
};
});
// Find JTL transactions that don't have CSV matches (red rows)
const unmatchedJTLTransactions = jtlMonthTransactions
.filter(jtl => {
const jtlAmount = parseFloat(jtl.fBetrag) || 0;
const jtlDate = new Date(jtl.dBuchungsdatum);
// Check if this JTL transaction has a CSV match
const hasCSVMatch = monthTransactions.some(transaction => {
const amount = transaction.numericAmount;
const transactionDate = transaction.parsedDate;
const amountMatch = Math.abs(amount - jtlAmount) < 0.01;
const dateMatch = transactionDate && jtlDate &&
transactionDate.getFullYear() === jtlDate.getFullYear() &&
transactionDate.getMonth() === jtlDate.getMonth() &&
transactionDate.getDate() === jtlDate.getDate();
return amountMatch && dateMatch;
});
return !hasCSVMatch;
})
.map(jtl => ({
// Convert JTL format to CSV-like format for display
'Buchungstag': new Date(jtl.dBuchungsdatum).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit'
}),
'Verwendungszweck': jtl.cVerwendungszweck || '',
'Buchungstext': 'JTL Transaction',
'Beguenstigter/Zahlungspflichtiger': jtl.cName || '',
'Betrag': jtl.fBetrag ? jtl.fBetrag.toString().replace('.', ',') : '0,00',
numericAmount: parseFloat(jtl.fBetrag) || 0,
parsedDate: new Date(jtl.dBuchungsdatum),
monthYear: timeRange,
hasJTL: true,
jtlId: jtl.kZahlungsabgleichUmsatz,
isFromCSV: false,
isJTLOnly: true
}));
// Combine CSV and JTL-only transactions
const allTransactions = [...transactionsWithJTL, ...unmatchedJTLTransactions]
.sort((a, b) => b.parsedDate - a.parsedDate);
// Calculate summary
const summary = {
totalTransactions: allTransactions.length,
totalIncome: allTransactions
.filter(t => t.numericAmount > 0)
.reduce((sum, t) => sum + t.numericAmount, 0),
totalExpenses: allTransactions
.filter(t => t.numericAmount < 0)
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
netAmount: allTransactions.reduce((sum, t) => sum + t.numericAmount, 0),
jtlMatches: allTransactions.filter(t => t.hasJTL && t.isFromCSV).length,
jtlMissing: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length,
jtlOnly: allTransactions.filter(t => t.isJTLOnly).length,
csvOnly: allTransactions.filter(t => !t.hasJTL && t.isFromCSV).length
};
res.json({
transactions: allTransactions,
summary,
timeRange,
periodDescription
});
} catch (error) {
console.error('Error getting transactions:', error);
res.status(500).json({ error: 'Failed to load transactions' });
}
});
// DATEV export functionality
const buildDatevHeader = (periodStart, periodEnd) => {
const ts = new Date().toISOString().replace(/[-T:\.Z]/g, '').slice(0, 17); // yyyymmddHHMMSSfff
const meta = {
consultant: 1001,
client: 10001,
fyStart: periodStart.slice(0, 4) + '0101', // fiscal year start
accLength: 4,
description: 'Bank Statement Export',
currency: 'EUR'
};
return [
'"EXTF"', 700, 21, '"Buchungsstapel"', 12, ts,
'', '', '', '', // 710 spare
meta.consultant, meta.client, // 11, 12
meta.fyStart, meta.accLength, // 13, 14
periodStart, periodEnd, // 15, 16
'"' + meta.description + '"',
'AM', 1, 0, 1, meta.currency
].join(';');
};
const DATEV_COLS = [
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz',
'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto',
'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum',
'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank',
'Sachverhalt', 'Zinssperre', 'Beleglink'
].join(';');
const formatDatevAmount = (amount) => {
return Math.abs(amount).toFixed(2).replace('.', ',');
};
const formatDatevDate = (dateString) => {
if (!dateString) return '';
const parts = dateString.split('.');
if (parts.length === 3) {
const day = parts[0].padStart(2, '0');
const month = parts[1].padStart(2, '0');
return day + month;
}
return '';
};
const quote = (str, maxLen = 60) => {
if (!str) return '""';
return '"' + str.slice(0, maxLen).replace(/"/g, '""') + '"';
};
// DATEV export endpoint
router.get('/datev/:timeRange', authenticateToken, async (req, res) => {
try {
const { timeRange } = req.params;
// Get transactions for the time period
const transactions = parseCSV();
let filteredTransactions = [];
let periodStart, periodEnd, filename;
if (timeRange.includes('-Q')) {
// Quarter format: YYYY-Q1, YYYY-Q2, etc.
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear, tMonth] = t.monthYear.split('-');
const monthNum = parseInt(tMonth);
return tYear === year && monthNum >= startMonth && monthNum <= endMonth;
});
periodStart = `${year}${startMonth.toString().padStart(2, '0')}01`;
periodEnd = new Date(year, endMonth, 0).toISOString().slice(0, 10).replace(/-/g, '');
filename = `DATEV_${year}_Q${quarter}.csv`;
} else if (timeRange.length === 4) {
// Year format: YYYY
filteredTransactions = transactions.filter(t => {
if (!t.monthYear) return false;
const [tYear] = t.monthYear.split('-');
return tYear === timeRange;
});
periodStart = `${timeRange}0101`;
periodEnd = `${timeRange}1231`;
filename = `DATEV_${timeRange}.csv`;
} else {
// Month format: YYYY-MM
const [year, month] = timeRange.split('-');
filteredTransactions = transactions.filter(t => t.monthYear === timeRange);
periodStart = `${year}${month.padStart(2, '0')}01`;
periodEnd = new Date(year, month, 0).toISOString().slice(0, 10).replace(/-/g, '');
filename = `DATEV_${year}_${month.padStart(2, '0')}.csv`;
}
const monthTransactions = filteredTransactions
.sort((a, b) => a.parsedDate - b.parsedDate); // Oldest first for DATEV
if (!monthTransactions.length) {
return res.status(404).json({ error: 'No transactions found for this time period' });
}
// Build DATEV format
const header = buildDatevHeader(periodStart, periodEnd);
const rows = monthTransactions.map((transaction, index) => {
const amount = Math.abs(transaction.numericAmount);
const isDebit = transaction.numericAmount < 0 ? 'S' : 'H'; // S = Soll (debit), H = Haben (credit)
return [
formatDatevAmount(amount), // #1 Umsatz
isDebit, // #2 Soll/Haben
quote('EUR', 3), // #3 WKZ Umsatz
'', '', '', // #4-6 (no FX)
'1200', // #7 Konto (Bank account)
transaction.numericAmount < 0 ? '4000' : '8400', // #8 Gegenkonto (expense/income)
'', // #9 BU-Schlüssel
formatDatevDate(transaction['Buchungstag']), // #10 Belegdatum
quote((index + 1).toString(), 36), // #11 Belegfeld 1 (sequential number)
'', '', // #12, #13
quote(transaction['Verwendungszweck'] || transaction['Buchungstext'], 60), // #14 Buchungstext
'', '', '', '', '', // #15-19 unused
'' // #20 Beleglink
].join(';');
});
const csv = [header, DATEV_COLS, ...rows].join('\r\n');
// Set headers for file download
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.setHeader('Content-Type', 'text/csv; charset=latin1');
// Convert to latin1 encoding for DATEV compatibility
const buffer = Buffer.from(csv, 'utf8');
res.send(buffer);
} catch (error) {
console.error('Error generating DATEV export:', error);
res.status(500).json({ error: 'Failed to generate DATEV export' });
}
});
module.exports = router;
module.exports = router;

View File

@@ -0,0 +1,273 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Debug: Get JTL Kontierung data for a specific JTL Umsatz (by kZahlungsabgleichUmsatz)
router.get('/jtl-kontierung/:jtlId', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { jtlId } = req.params;
const query = `
SELECT
uk.data
FROM eazybusiness.dbo.tZahlungsabgleichUmsatz z
LEFT JOIN eazybusiness.dbo.tUmsatzKontierung uk
ON uk.kZahlungsabgleichUmsatz = z.kZahlungsabgleichUmsatz
WHERE z.kZahlungsabgleichUmsatz = @jtlId
`;
const result = await executeQuery(query, { jtlId: parseInt(jtlId, 10) });
// Return undefined when no data found (do not lie with empty array/string)
if (!result.recordset || result.recordset.length === 0) {
return res.json({ data: undefined });
}
// If multiple rows exist, return all; otherwise single object
const rows = result.recordset.map(r => ({ data: r.data }));
if (rows.length === 1) {
return res.json(rows[0]);
}
return res.json(rows);
} catch (error) {
console.error('Error fetching JTL Kontierung data:', error);
res.status(500).json({ error: 'Failed to fetch JTL Kontierung data' });
}
});
// Get accounting items for a specific transaction
router.get('/accounting-items/:transactionId', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { transactionId } = req.params;
// Try both numeric and string format (similar to banking transactions)
let query, params;
const numericId = parseInt(transactionId, 10);
if (!isNaN(numericId) && numericId.toString() === transactionId) {
// It's a numeric ID - check transaction_id column
query = `
SELECT
ai.*,
k.name as konto_name,
bu.name as bu_name,
bu.vst as bu_vst
FROM fibdash.AccountingItems ai
LEFT JOIN fibdash.Konto k ON ai.konto = k.konto
LEFT JOIN fibdash.BU bu ON ai.bu = bu.bu
WHERE ai.transaction_id = @transactionId
ORDER BY ai.id
`;
params = { transactionId: numericId };
} else {
// It's a string ID - check csv_transaction_id column
query = `
SELECT
ai.*,
k.name as konto_name,
bu.name as bu_name,
bu.vst as bu_vst
FROM fibdash.AccountingItems ai
LEFT JOIN fibdash.Konto k ON ai.konto = k.konto
LEFT JOIN fibdash.BU bu ON ai.bu = bu.bu
WHERE ai.csv_transaction_id = @transactionId
ORDER BY ai.id
`;
params = { transactionId };
}
const result = await executeQuery(query, params);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching accounting items:', error);
res.status(500).json({ error: 'Failed to fetch accounting items' });
}
});
// Create accounting item for a transaction
router.post('/accounting-items', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const {
transaction_id,
csv_transaction_id,
umsatz_brutto,
soll_haben_kz,
konto,
bu,
buchungsdatum,
rechnungsnummer,
buchungstext
} = req.body;
if ((!transaction_id && !csv_transaction_id) || !umsatz_brutto || !soll_haben_kz || !konto || !buchungsdatum) {
return res.status(400).json({
error: 'Transaction ID, amount, debit/credit indicator, account, and booking date are required'
});
}
let insertQuery, queryParams;
if (csv_transaction_id) {
// For CSV transactions, use placeholder transaction_id
insertQuery = `
INSERT INTO fibdash.AccountingItems
(transaction_id, csv_transaction_id, umsatz_brutto, soll_haben_kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext)
OUTPUT INSERTED.*
VALUES (-1, @csv_transaction_id, @umsatz_brutto, @soll_haben_kz, @konto, '', @bu, @buchungsdatum, @rechnungsnummer, @buchungstext)
`;
queryParams = {
csv_transaction_id,
umsatz_brutto,
soll_haben_kz,
konto,
bu: bu || null,
buchungsdatum,
rechnungsnummer: rechnungsnummer || null,
buchungstext: buchungstext || null
};
} else {
// For regular transactions
insertQuery = `
INSERT INTO fibdash.AccountingItems
(transaction_id, csv_transaction_id, umsatz_brutto, soll_haben_kz, konto, gegenkonto, bu, buchungsdatum, rechnungsnummer, buchungstext)
OUTPUT INSERTED.*
VALUES (@transaction_id, NULL, @umsatz_brutto, @soll_haben_kz, @konto, '', @bu, @buchungsdatum, @rechnungsnummer, @buchungstext)
`;
queryParams = {
transaction_id,
umsatz_brutto,
soll_haben_kz,
konto,
bu: bu || null,
buchungsdatum,
rechnungsnummer: rechnungsnummer || null,
buchungstext: buchungstext || null
};
}
const result = await executeQuery(insertQuery, queryParams);
res.status(201).json(result.recordset[0]);
} catch (error) {
console.error('Error creating accounting item:', error);
res.status(500).json({ error: 'Failed to create accounting item' });
}
});
// Update accounting item
router.put('/accounting-items/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const { umsatz_brutto, soll_haben_kz, konto, bu, rechnungsnummer, buchungstext } = req.body;
if (!umsatz_brutto || !soll_haben_kz || !konto) {
return res.status(400).json({ error: 'Amount, debit/credit indicator, and account are required' });
}
const updateQuery = `
UPDATE fibdash.AccountingItems
SET umsatz_brutto = @umsatz_brutto,
soll_haben_kz = @soll_haben_kz,
konto = @konto,
bu = @bu,
rechnungsnummer = @rechnungsnummer,
buchungstext = @buchungstext
OUTPUT INSERTED.*
WHERE id = @id
`;
const result = await executeQuery(updateQuery, {
umsatz_brutto,
soll_haben_kz,
konto,
bu: bu || null,
rechnungsnummer: rechnungsnummer || null,
buchungstext: buchungstext || null,
id: parseInt(id, 10)
});
if (result.recordset.length === 0) {
return res.status(404).json({ error: 'Accounting item not found' });
}
res.json(result.recordset[0]);
} catch (error) {
console.error('Error updating accounting item:', error);
res.status(500).json({ error: 'Failed to update accounting item' });
}
});
// Delete accounting item
router.delete('/accounting-items/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const deleteQuery = `DELETE FROM fibdash.AccountingItems WHERE id = @id`;
await executeQuery(deleteQuery, { id: parseInt(id, 10) });
res.json({ message: 'Accounting item deleted successfully' });
} catch (error) {
console.error('Error deleting accounting item:', error);
res.status(500).json({ error: 'Failed to delete accounting item' });
}
});
// Get all Konto options
router.get('/kontos', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `SELECT * FROM fibdash.Konto ORDER BY konto`;
const result = await executeQuery(query);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching kontos:', error);
res.status(500).json({ error: 'Failed to fetch kontos' });
}
});
// Create new Konto
router.post('/kontos', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { konto, name } = req.body;
if (!konto || !name) {
return res.status(400).json({ error: 'Konto and name are required' });
}
const insertQuery = `
INSERT INTO fibdash.Konto (konto, name)
OUTPUT INSERTED.*
VALUES (@konto, @name)
`;
const result = await executeQuery(insertQuery, { konto, name });
res.status(201).json(result.recordset[0]);
} catch (error) {
console.error('Error creating konto:', error);
res.status(500).json({ error: 'Failed to create konto' });
}
});
// Get all BU options
router.get('/bus', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `SELECT * FROM fibdash.BU ORDER BY bu`;
const result = await executeQuery(query);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching BUs:', error);
res.status(500).json({ error: 'Failed to fetch BUs' });
}
});
module.exports = router;

View File

@@ -0,0 +1,198 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Get banking account transactions for a specific transaction
router.get('/banking-transactions/:transactionId', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { transactionId } = req.params;
// Try both numeric and string format
let query, params;
const numericId = parseInt(transactionId, 10);
if (!isNaN(numericId) && numericId.toString() === transactionId) {
// It's a numeric ID - check transaction_id column
query = `
SELECT
bat.*,
k.name as assigned_kreditor_name,
k.kreditorId as assigned_kreditor_id_code
FROM fibdash.BankingAccountTransactions bat
LEFT JOIN fibdash.Kreditor k ON bat.assigned_kreditor_id = k.id
WHERE bat.transaction_id = @transactionId
`;
params = { transactionId: numericId };
} else {
// It's a string ID - check csv_transaction_id column
query = `
SELECT
bat.*,
k.name as assigned_kreditor_name,
k.kreditorId as assigned_kreditor_id_code
FROM fibdash.BankingAccountTransactions bat
LEFT JOIN fibdash.Kreditor k ON bat.assigned_kreditor_id = k.id
WHERE bat.csv_transaction_id = @transactionId
`;
params = { transactionId };
}
const result = await executeQuery(query, params);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching banking account transactions:', error);
res.status(500).json({ error: 'Failed to fetch banking account transactions' });
}
});
// Create banking account transaction assignment
router.post('/banking-transactions', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { transaction_id, csv_transaction_id, banking_iban, assigned_kreditor_id, notes } = req.body;
if ((!transaction_id && !csv_transaction_id) || !banking_iban || !assigned_kreditor_id) {
return res.status(400).json({
error: 'Transaction ID (or CSV Transaction ID), banking IBAN, and assigned kreditor ID are required'
});
}
let checkQuery, checkParams;
if (csv_transaction_id) {
checkQuery = `SELECT id FROM fibdash.BankingAccountTransactions WHERE csv_transaction_id = @csv_transaction_id`;
checkParams = { csv_transaction_id };
} else {
checkQuery = `SELECT id FROM fibdash.BankingAccountTransactions WHERE transaction_id = @transaction_id`;
checkParams = { transaction_id };
}
const checkResult = await executeQuery(checkQuery, checkParams);
if (checkResult.recordset.length > 0) {
return res.status(409).json({ error: 'Banking transaction assignment already exists' });
}
let insertQuery, queryParams;
if (csv_transaction_id) {
// For CSV transactions, use a placeholder transaction_id since it's NOT NULL
insertQuery = `
INSERT INTO fibdash.BankingAccountTransactions
(transaction_id, csv_transaction_id, banking_iban, assigned_kreditor_id, notes)
OUTPUT INSERTED.*
VALUES (-1, @csv_transaction_id, @banking_iban, @assigned_kreditor_id, @notes)
`;
queryParams = {
csv_transaction_id,
banking_iban,
assigned_kreditor_id,
notes: notes || null
};
} else {
// For regular transactions
insertQuery = `
INSERT INTO fibdash.BankingAccountTransactions
(transaction_id, csv_transaction_id, banking_iban, assigned_kreditor_id, notes)
OUTPUT INSERTED.*
VALUES (@transaction_id, NULL, @banking_iban, @assigned_kreditor_id, @notes)
`;
queryParams = {
transaction_id,
banking_iban,
assigned_kreditor_id,
notes: notes || null
};
}
const result = await executeQuery(insertQuery, queryParams);
res.status(201).json(result.recordset[0]);
} catch (error) {
console.error('Error creating banking account transaction:', error);
res.status(500).json({ error: 'Failed to create banking account transaction' });
}
});
// Update banking account transaction assignment
router.put('/banking-transactions/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const { assigned_kreditor_id, notes } = req.body;
if (!assigned_kreditor_id) {
return res.status(400).json({ error: 'Assigned kreditor ID is required' });
}
const updateQuery = `
UPDATE fibdash.BankingAccountTransactions
SET assigned_kreditor_id = @assigned_kreditor_id,
notes = @notes,
assigned_date = GETDATE()
OUTPUT INSERTED.*
WHERE id = @id
`;
const result = await executeQuery(updateQuery, {
assigned_kreditor_id,
notes: notes || null,
id: parseInt(id, 10)
});
if (result.recordset.length === 0) {
return res.status(404).json({ error: 'Banking transaction assignment not found' });
}
res.json(result.recordset[0]);
} catch (error) {
console.error('Error updating banking account transaction:', error);
res.status(500).json({ error: 'Failed to update banking account transaction' });
}
});
// Delete banking account transaction assignment
router.delete('/banking-transactions/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const deleteQuery = `
DELETE FROM fibdash.BankingAccountTransactions
WHERE id = @id
`;
await executeQuery(deleteQuery, { id: parseInt(id, 10) });
res.json({ message: 'Banking transaction assignment deleted successfully' });
} catch (error) {
console.error('Error deleting banking account transaction:', error);
res.status(500).json({ error: 'Failed to delete banking account transaction' });
}
});
// Get all kreditors that can be assigned to banking transactions (non-banking kreditors)
router.get('/assignable-kreditors', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `
SELECT id, name, kreditorId
FROM fibdash.Kreditor
WHERE (is_banking = 0 OR is_banking IS NULL)
ORDER BY name
`;
const result = await executeQuery(query);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching assignable kreditors:', error);
res.status(500).json({ error: 'Failed to fetch assignable kreditors' });
}
});
module.exports = router;

View File

@@ -0,0 +1,635 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Test CSV import endpoint (no auth for testing) - ACTUALLY IMPORTS TO DATABASE
router.post('/test-csv-import', async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { transactions, filename, batchId } = req.body;
if (!transactions || !Array.isArray(transactions)) {
return res.status(400).json({ error: 'Transactions array is required' });
}
const importBatchId = batchId || 'test_import_' + Date.now();
let successCount = 0;
let errorCount = 0;
const errors = [];
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
try {
const validationErrors = [];
if (!transaction['Buchungstag'] || transaction['Buchungstag'].trim() === '') {
validationErrors.push('Buchungstag is required');
}
if (!transaction['Betrag'] || transaction['Betrag'].toString().trim() === '') {
validationErrors.push('Betrag is required');
}
if (validationErrors.length > 0) {
errors.push({
row: i + 1,
error: 'Validation failed: ' + validationErrors.join(', '),
transaction: transaction
});
errorCount++;
continue;
}
let parsedDate = null;
if (transaction['Buchungstag']) {
const dateStr = transaction['Buchungstag'].trim();
const dateParts = dateStr.split(/[.\/\-]/);
if (dateParts.length === 3) {
const day = parseInt(dateParts[0], 10);
const month = parseInt(dateParts[1], 10) - 1;
let year = parseInt(dateParts[2], 10);
if (year < 100) {
year += (year < 50) ? 2000 : 1900;
}
parsedDate = new Date(year, month, day);
if (isNaN(parsedDate.getTime())) {
parsedDate = null;
validationErrors.push('Invalid date format: ' + dateStr);
}
}
}
let numericAmount = 0;
if (transaction['Betrag']) {
const amountStr = transaction['Betrag'].toString().replace(/[^\d,.-]/g, '');
const normalizedAmount = amountStr.replace(',', '.');
numericAmount = parseFloat(normalizedAmount) || 0;
}
// Check for existing transaction to prevent duplicates
const duplicateCheckQuery = `
SELECT COUNT(*) as count FROM fibdash.CSVTransactions
WHERE buchungstag = @buchungstag
AND wertstellung = @wertstellung
AND umsatzart = @umsatzart
AND betrag = @betrag
AND beguenstigter_zahlungspflichtiger = @beguenstigter_zahlungspflichtiger
AND verwendungszweck = @verwendungszweck
`;
const duplicateCheckResult = await executeQuery(duplicateCheckQuery, {
buchungstag: transaction['Buchungstag'] || null,
wertstellung: transaction['Valutadatum'] || null,
umsatzart: transaction['Buchungstext'] || null,
betrag: numericAmount,
beguenstigter_zahlungspflichtiger: transaction['Beguenstigter/Zahlungspflichtiger'] || null,
verwendungszweck: transaction['Verwendungszweck'] || null
});
if (duplicateCheckResult.recordset[0].count > 0) {
console.log(`Skipping duplicate transaction at row ${i + 1}: ${transaction['Buchungstag']} - ${numericAmount}`);
errors.push({
row: i + 1,
error: 'Duplicate transaction (already exists in database)',
transaction: transaction
});
errorCount++;
continue;
}
const insertQuery = `
INSERT INTO fibdash.CSVTransactions
(buchungstag, wertstellung, umsatzart, betrag, betrag_original, waehrung,
beguenstigter_zahlungspflichtiger, kontonummer_iban, bic, verwendungszweck,
parsed_date, numeric_amount, import_batch_id, source_filename, source_row_number)
VALUES
(@buchungstag, @wertstellung, @umsatzart, @betrag, @betrag_original, @waehrung,
@beguenstigter_zahlungspflichtiger, @kontonummer_iban, @bic, @verwendungszweck,
@parsed_date, @numeric_amount, @import_batch_id, @source_filename, @source_row_number)
`;
await executeQuery(insertQuery, {
buchungstag: transaction['Buchungstag'] || null,
wertstellung: transaction['Valutadatum'] || null,
umsatzart: transaction['Buchungstext'] || null,
betrag: numericAmount,
betrag_original: transaction['Betrag'] || null,
waehrung: transaction['Waehrung'] || null,
beguenstigter_zahlungspflichtiger: transaction['Beguenstigter/Zahlungspflichtiger'] || null,
kontonummer_iban: transaction['Kontonummer/IBAN'] || null,
bic: transaction['BIC (SWIFT-Code)'] || null,
verwendungszweck: transaction['Verwendungszweck'] || null,
parsed_date: parsedDate,
numeric_amount: numericAmount,
import_batch_id: importBatchId,
source_filename: filename || 'test_import',
source_row_number: i + 1
});
successCount++;
} catch (error) {
console.error('Error importing transaction ' + (i + 1) + ':', error);
errors.push({
row: i + 1,
error: error.message,
transaction: transaction
});
errorCount++;
}
}
res.json({
success: true,
batchId: importBatchId,
imported: successCount,
errors: errorCount,
details: errors.length > 0 ? errors : undefined,
paypalTransaction: transactions.find(t => t['Kontonummer/IBAN'] === 'LU89751000135104200E')
});
} catch (error) {
console.error('Test import error:', error);
res.status(500).json({ error: 'Test import failed' });
}
});
// Import CSV transactions to database
router.post('/import-csv-transactions', authenticateToken, async (req, res) => {
console.log('Importing CSV transactions');
try {
const { executeQuery } = require('../../config/database');
const { transactions, filename, batchId, headers } = req.body;
if (!transactions || !Array.isArray(transactions)) {
return res.status(400).json({ error: 'Transactions array is required' });
}
const expectedHeaders = [
'Auftragskonto',
'Buchungstag',
'Valutadatum',
'Buchungstext',
'Verwendungszweck',
'Glaeubiger ID',
'Mandatsreferenz',
'Kundenreferenz (End-to-End)',
'Sammlerreferenz',
'Lastschrift Ursprungsbetrag',
'Auslagenersatz Ruecklastschrift',
'Beguenstigter/Zahlungspflichtiger',
'Kontonummer/IBAN',
'BIC (SWIFT-Code)',
'Betrag',
'Waehrung',
'Info'
];
if (headers && Array.isArray(headers)) {
const missingHeaders = expectedHeaders.filter(expected =>
!headers.some(header => header.trim() === expected)
);
if (missingHeaders.length > 0) {
return res.status(400).json({
error: 'Invalid CSV format - missing required headers',
missing: missingHeaders,
expected: expectedHeaders,
received: headers
});
}
}
if (transactions.length === 0) {
return res.status(400).json({ error: 'No transaction data found' });
}
const importBatchId = batchId || 'import_' + Date.now();
let successCount = 0;
let errorCount = 0;
const errors = [];
console.log('precheck done');
for (let i = 0; i < transactions.length; i++) {
const transaction = transactions[i];
try {
const validationErrors = [];
if (!transaction['Buchungstag'] || transaction['Buchungstag'].trim() === '') {
validationErrors.push('Buchungstag is required');
}
if (!transaction['Betrag'] || transaction['Betrag'].toString().trim() === '') {
validationErrors.push('Betrag is required');
}
if (validationErrors.length > 2) {
console.log('Skipping invalid row ' + (i + 1) + ':', validationErrors);
continue;
}
if (validationErrors.length > 0) {
errors.push({
row: i + 1,
error: 'Validation failed: ' + validationErrors.join(', '),
transaction: transaction
});
errorCount++;
continue;
}
let parsedDate = null;
if (transaction['Buchungstag']) {
const dateStr = transaction['Buchungstag'].trim();
const dateParts = dateStr.split(/[.\/\-]/);
if (dateParts.length === 3) {
const day = parseInt(dateParts[0], 10);
const month = parseInt(dateParts[1], 10) - 1;
let year = parseInt(dateParts[2], 10);
if (year < 100) {
year += (year < 50) ? 2000 : 1900;
}
parsedDate = new Date(year, month, day);
if (isNaN(parsedDate.getTime()) ||
parsedDate.getDate() !== day ||
parsedDate.getMonth() !== month ||
parsedDate.getFullYear() !== year) {
parsedDate = null;
validationErrors.push('Invalid date format: ' + dateStr);
}
} else {
validationErrors.push('Invalid date format: ' + dateStr);
}
}
let numericAmount = 0;
if (transaction['Betrag']) {
const amountStr = transaction['Betrag'].toString().replace(/[^\d,.-]/g, '');
const normalizedAmount = amountStr.replace(',', '.');
numericAmount = parseFloat(normalizedAmount) || 0;
}
// Check for existing transaction to prevent duplicates
const duplicateCheckQuery = `
SELECT COUNT(*) as count FROM fibdash.CSVTransactions
WHERE buchungstag = @buchungstag
AND wertstellung = @wertstellung
AND umsatzart = @umsatzart
AND betrag = @betrag
AND beguenstigter_zahlungspflichtiger = @beguenstigter_zahlungspflichtiger
AND verwendungszweck = @verwendungszweck
`;
const duplicateCheckResult = await executeQuery(duplicateCheckQuery, {
buchungstag: transaction['Buchungstag'] || null,
wertstellung: transaction['Valutadatum'] || null,
umsatzart: transaction['Buchungstext'] || null,
betrag: numericAmount,
beguenstigter_zahlungspflichtiger: transaction['Beguenstigter/Zahlungspflichtiger'] || null,
verwendungszweck: transaction['Verwendungszweck'] || null
});
if (duplicateCheckResult.recordset[0].count > 0) {
console.log(`Skipping duplicate transaction at row ${i + 1}: ${transaction['Buchungstag']} - ${numericAmount}`);
errors.push({
row: i + 1,
error: 'Duplicate transaction (already exists in database)',
transaction: transaction
});
errorCount++;
continue;
}
const insertQuery = `
INSERT INTO fibdash.CSVTransactions
(buchungstag, wertstellung, umsatzart, betrag, betrag_original, waehrung,
beguenstigter_zahlungspflichtiger, kontonummer_iban, bic, verwendungszweck,
parsed_date, numeric_amount, import_batch_id, source_filename, source_row_number)
VALUES
(@buchungstag, @wertstellung, @umsatzart, @betrag, @betrag_original, @waehrung,
@beguenstigter_zahlungspflichtiger, @kontonummer_iban, @bic, @verwendungszweck,
@parsed_date, @numeric_amount, @import_batch_id, @source_filename, @source_row_number)
`;
await executeQuery(insertQuery, {
buchungstag: transaction['Buchungstag'] || null,
wertstellung: transaction['Valutadatum'] || null,
umsatzart: transaction['Buchungstext'] || null,
betrag: numericAmount,
betrag_original: transaction['Betrag'] || null,
waehrung: transaction['Waehrung'] || null,
beguenstigter_zahlungspflichtiger: transaction['Beguenstigter/Zahlungspflichtiger'] || null,
kontonummer_iban: transaction['Kontonummer/IBAN'] || null,
bic: transaction['BIC (SWIFT-Code)'] || null,
verwendungszweck: transaction['Verwendungszweck'] || null,
parsed_date: parsedDate,
numeric_amount: numericAmount,
import_batch_id: importBatchId,
source_filename: filename || null,
source_row_number: i + 1
});
successCount++;
} catch (error) {
console.error('Error importing transaction ' + (i + 1) + ':', error);
errors.push({
row: i + 1,
error: error.message,
transaction: transaction
});
errorCount++;
}
}
console.log('import done',errors);
res.json({
success: true,
batchId: importBatchId,
imported: successCount,
errors: errorCount,
details: errors.length > 0 ? errors : undefined
});
} catch (error) {
console.error('Error importing CSV transactions:', error);
res.status(500).json({ error: 'Failed to import CSV transactions' });
}
});
// Get imported CSV transactions
router.get('/csv-transactions', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { batchId, limit = 100, offset = 0 } = req.query;
let query = `
SELECT
csv.*,
k.name as kreditor_name,
k.kreditorId as kreditor_id,
k.is_banking as kreditor_is_banking,
bat.assigned_kreditor_id,
ak.name as assigned_kreditor_name
FROM fibdash.CSVTransactions csv
LEFT JOIN fibdash.Kreditor k ON csv.kontonummer_iban = k.iban
LEFT JOIN fibdash.BankingAccountTransactions bat ON csv.id = bat.csv_transaction_id
LEFT JOIN fibdash.Kreditor ak ON bat.assigned_kreditor_id = ak.id
`;
const params = {};
if (batchId) {
query += ' WHERE csv.import_batch_id = @batchId';
params.batchId = batchId;
}
query += ' ORDER BY csv.parsed_date DESC, csv.id DESC';
query += ' OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY';
params.offset = parseInt(offset, 10);
params.limit = parseInt(limit, 10);
const result = await executeQuery(query, params);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching CSV transactions:', error);
res.status(500).json({ error: 'Failed to fetch CSV transactions' });
}
});
// Get CSV import batches
router.get('/csv-import-batches', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `
SELECT
import_batch_id,
source_filename,
MIN(import_date) as import_date,
COUNT(*) as transaction_count,
SUM(CASE WHEN is_processed = 1 THEN 1 ELSE 0 END) as processed_count
FROM fibdash.CSVTransactions
GROUP BY import_batch_id, source_filename
ORDER BY MIN(import_date) DESC
`;
const result = await executeQuery(query);
res.json(result.recordset);
} catch (error) {
console.error('Error fetching import batches:', error);
res.status(500).json({ error: 'Failed to fetch import batches' });
}
});
// Import DATEV Beleglinks to database
router.post('/import-datev-beleglinks', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { beleglinks, filename, batchId, headers } = req.body;
if (!beleglinks || !Array.isArray(beleglinks)) {
return res.status(400).json({ error: 'Beleglinks array is required' });
}
// Expected DATEV CSV headers from the example
const expectedHeaders = [
'Belegart', 'Geschäftspartner-Name', 'Geschäftspartner-Konto', 'Rechnungsbetrag', 'WKZ',
'Rechnungs-Nr.', 'Interne Re.-Nr.', 'Rechnungsdatum', 'BU', 'Konto', 'Konto-Bezeichnung',
'Ware/Leistung', 'Zahlungszuordnung', 'Kontoumsatzzuordnung', 'Gebucht', 'Festgeschrieben',
'Kopie', 'Eingangsdatum', 'Bezahlt', 'BezahltAm', 'Geschäftspartner-Ort', 'Skonto-Betrag 1',
'Fällig mit Skonto 1', 'Skonto 1 in %', 'Skonto-Betrag 2', 'Fällig mit Skonto 2',
'Skonto 2 in %', 'Fällig ohne Skonto', 'Steuer in %', 'USt-IdNr.', 'Kunden-Nr.',
'KOST 1', 'KOST 2', 'KOST-Menge', 'Kurs', 'Nachricht', 'Freier Text', 'IBAN', 'BIC',
'Bankkonto-Nr.', 'BLZ', 'Notiz', 'Land', 'Personalnummer', 'Nachname', 'Vorname',
'Belegkategorie', 'Bezeichnung', 'Abrechnungsmonat', 'Gültig bis', 'Prüfungsrelevant',
'Ablageort', 'Belegtyp', 'Herkunft', 'Leistungsdatum', 'Buchungstext', 'Beleg-ID',
'Zahlungsbedingung', 'Geheftet', 'Gegenkonto', 'keine Überweisung/Lastschrift erstellen',
'Aufgeteilt', 'Bereitgestellt', 'Freigegeben', 'FreigegebenAm', 'Erweiterte Belegdaten fehlen',
'Periode fehlt', 'Rechnungsdaten beim Import fehlen'
];
if (beleglinks.length === 0) {
return res.status(400).json({ error: 'No beleglink data found' });
}
const importBatchId = batchId || 'datev_import_' + Date.now();
let successCount = 0;
let errorCount = 0;
let updateCount = 0;
let insertCount = 0;
let skippedCount = 0;
const errors = [];
for (let i = 0; i < beleglinks.length; i++) {
const beleglink = beleglinks[i];
try {
// Skip empty rows or rows without Beleg-ID
const belegId = beleglink['Beleg-ID'];
if (!belegId || belegId.trim() === '') {
console.log(`Skipping row ${i + 1}: No Beleg-ID found`);
skippedCount++;
continue;
}
const validationErrors = [];
// Parse amount if available
let numericAmount = null;
if (beleglink['Rechnungsbetrag']) {
const amountStr = beleglink['Rechnungsbetrag'].toString().replace(/[^\d,.-]/g, '');
const normalizedAmount = amountStr.replace(',', '.');
numericAmount = parseFloat(normalizedAmount) || null;
}
// Parse date if available
let parsedDate = null;
if (beleglink['Rechnungsdatum']) {
const dateStr = beleglink['Rechnungsdatum'].trim();
const dateParts = dateStr.split(/[.\/\-]/);
if (dateParts.length === 3) {
const day = parseInt(dateParts[0], 10);
const month = parseInt(dateParts[1], 10) - 1;
let year = parseInt(dateParts[2], 10);
if (year < 100) {
year += (year < 50) ? 2000 : 1900;
}
parsedDate = new Date(year, month, day);
if (isNaN(parsedDate.getTime())) {
parsedDate = null;
}
}
}
// First, check if a record with this datevlink already exists
const checkExistingDatevLink = `
SELECT kUmsatzBeleg FROM eazybusiness.dbo.tUmsatzBeleg WHERE datevlink = @datevlink
`;
const existingDatevLink = await executeQuery(checkExistingDatevLink, { datevlink: belegId });
if (existingDatevLink.recordset.length > 0) {
// Record with this datevlink already exists - skip
console.log(`Datevlink already exists, skipping: ${belegId}`);
skippedCount++;
continue;
}
// Extract key from filename in 'Herkunft' column
// Examples: "Rechnung146.pdf" -> key 146 for tRechnung
// "UmsatzBeleg192.pdf" -> key 192 for tUmsatzBeleg
const herkunft = beleglink['Herkunft'];
if (!herkunft || herkunft.trim() === '') {
console.log(`Skipping row ${i + 1}: No filename in Herkunft column`);
skippedCount++;
continue;
}
// Extract the key from filename patterns
let matchFound = false;
// Pattern: UmsatzBeleg{key}.pdf -> match with tUmsatzBeleg.kUmsatzBeleg
const umsatzBelegMatch = herkunft.match(/UmsatzBeleg(\d+)\.pdf/i);
if (umsatzBelegMatch) {
const kUmsatzBeleg = parseInt(umsatzBelegMatch[1], 10);
const updateQuery = `
UPDATE eazybusiness.dbo.tUmsatzBeleg
SET datevlink = @datevlink
WHERE kUmsatzBeleg = @kUmsatzBeleg AND (datevlink IS NULL OR datevlink = '' OR datevlink = 'pending')
`;
const updateResult = await executeQuery(updateQuery, {
datevlink: belegId,
kUmsatzBeleg: kUmsatzBeleg
});
if (updateResult.rowsAffected && updateResult.rowsAffected[0] > 0) {
updateCount++;
console.log(`Added datevlink ${belegId} to tUmsatzBeleg.kUmsatzBeleg: ${kUmsatzBeleg}`);
matchFound = true;
} else {
console.log(`Skipping row ${i + 1}: UmsatzBeleg ${kUmsatzBeleg} nicht gefunden oder datevlink bereits gesetzt`);
skippedCount++;
}
}
// Pattern: Rechnung{key}.pdf -> match with tPdfObjekt.kPdfObjekt
const rechnungMatch = herkunft.match(/Rechnung(\d+)\.pdf/i);
if (!matchFound && rechnungMatch) {
const kPdfObjekt = parseInt(rechnungMatch[1], 10);
const updateQuery = `
UPDATE eazybusiness.dbo.tPdfObjekt
SET datevlink = @datevlink
WHERE kPdfObjekt = @kPdfObjekt AND (datevlink IS NULL OR datevlink = '' OR datevlink = 'pending')
`;
const updateResult = await executeQuery(updateQuery, {
datevlink: belegId,
kPdfObjekt: kPdfObjekt
});
if (updateResult.rowsAffected && updateResult.rowsAffected[0] > 0) {
updateCount++;
console.log(`Added datevlink ${belegId} to tPdfObjekt.kPdfObjekt: ${kPdfObjekt}`);
matchFound = true;
} else {
console.log(`Skipping row ${i + 1}: PdfObjekt ${kPdfObjekt} nicht gefunden oder datevlink bereits gesetzt`);
skippedCount++;
}
}
if (!matchFound) {
console.log(`Skipping row ${i + 1}: Unbekanntes Dateiformat '${herkunft}' (erwartet: UmsatzBeleg{key}.pdf oder Rechnung{key}.pdf)`);
skippedCount++;
continue;
}
successCount++;
} catch (error) {
console.error('Error processing beleglink ' + (i + 1) + ':', error);
errors.push({
row: i + 1,
error: error.message,
beleglink: beleglink
});
errorCount++;
}
}
res.json({
success: true,
batchId: importBatchId,
imported: updateCount, // Number of datevlinks actually added/updated
processed: successCount,
updated: updateCount,
inserted: insertCount,
skipped: skippedCount, // Records skipped (existing datevlinks)
errors: errorCount, // Only actual errors, not skipped records
details: errors.length > 0 ? errors : undefined,
message: `${updateCount} datevlinks hinzugefügt, ${skippedCount} bereits vorhanden, ${errorCount} Fehler`
});
} catch (error) {
console.error('Error importing DATEV beleglinks:', error);
res.status(500).json({ error: 'Failed to import DATEV beleglinks' });
}
});
module.exports = router;

296
src/routes/data/datev.js Normal file
View File

@@ -0,0 +1,296 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// DATEV helpers (ported from original file)
const buildDatevHeader = (periodStart, periodEnd) => {
const ts = new Date().toISOString().replace(/[-T:\.Z]/g, '').slice(0, 17); // yyyymmddHHMMSSfff
const meta = {
consultant: 1001,
client: 10001,
fyStart: periodStart.slice(0, 4) + '0101', // fiscal year start
accLength: 4,
description: 'Bank Statement Export',
currency: 'EUR'
};
return [
'"EXTF"', 700, 21, '"Buchungsstapel"', 12, ts,
'', '', '', '', // 710 spare
meta.consultant, meta.client, // 11, 12
meta.fyStart, meta.accLength, // 13, 14
periodStart, periodEnd, // 15, 16
'"' + meta.description + '"',
'AM', 1, 0, 1, meta.currency
].join(';');
};
const DATEV_COLS = [
'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz',
'Kurs', 'Basis-Umsatz', 'WKZ Basis-Umsatz', 'Konto',
'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum',
'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext',
'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank',
'Sachverhalt', 'Zinssperre', 'Beleglink'
].join(';');
const formatDatevAmount = (amount) => {
return Math.abs(amount).toFixed(2).replace('.', ',');
};
const formatDatevDate = (date) => {
if (!date) return '';
// Handle Date object
if (date instanceof Date) {
const day = date.getDate().toString().padStart(2, '0');
const month = (date.getMonth() + 1).toString().padStart(2, '0');
return day + month;
}
// Handle string date
const dateStr = date.toString();
const parts = dateStr.split('.');
if (parts.length === 3) {
const day = parts[0].padStart(2, '0');
const month = parts[1].padStart(2, '0');
return day + month;
}
// Try to parse as date string
const parsedDate = new Date(dateStr);
if (!isNaN(parsedDate)) {
const day = parsedDate.getDate().toString().padStart(2, '0');
const month = (parsedDate.getMonth() + 1).toString().padStart(2, '0');
return day + month;
}
return '';
};
const quote = (str, maxLen = 60) => {
if (!str) return '""';
return '"' + str.slice(0, maxLen).replace(/"/g, '""') + '"';
};
// Parse konto field which might contain multiple accounts like "5400+5300"
const parseKonto = (konto) => {
if (!konto) return '';
// Take the first account number if multiple are present
const parts = konto.split('+');
return parts[0].trim();
};
// DATEV export endpoint
router.get('/datev/:timeRange', authenticateToken, async (req, res) => {
try {
const { timeRange } = req.params;
const { executeQuery } = require('../../config/database');
// Parse the time range to get start and end dates
let startDate, endDate;
if (timeRange.includes('-Q')) {
// Quarter format: 2025-Q1
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart, 10);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
startDate = new Date(year, startMonth - 1, 1);
endDate = new Date(year, endMonth - 1, new Date(year, endMonth, 0).getDate());
} else if (timeRange.length === 4) {
// Year format: 2025
startDate = new Date(timeRange, 0, 1);
endDate = new Date(timeRange, 11, 31);
} else {
// Month format: 2025-03
const [year, month] = timeRange.split('-');
startDate = new Date(year, parseInt(month) - 1, 1);
endDate = new Date(year, parseInt(month), 0);
}
// Format dates for SQL query
const sqlStartDate = startDate.toISOString().split('T')[0];
const sqlEndDate = endDate.toISOString().split('T')[0];
// Query to get all DATEV data with proper joins
// This handles multiple documents per transaction by creating separate rows
const query = `
WITH DatevDocuments AS (
-- Get documents from tUmsatzBeleg
SELECT
uk.kZahlungsabgleichUmsatz,
zu.fBetrag as umsatz_brutto,
CASE WHEN zu.fBetrag < 0 THEN 'H' ELSE 'S' END as soll_haben_kz,
JSON_VALUE(uk.data, '$.konto1') as konto,
'' as gegenkonto, -- No creditorID in tUmsatzBeleg
-- BU determination based on amount and konto type
CASE
WHEN JSON_VALUE(uk.data, '$.konto1') IN ('3720', '3740', '2100', '1460', '1462') THEN ''
WHEN zu.fBetrag > 0 THEN ''
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '5%' THEN '9' -- 19% for purchases
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '6%' THEN '9' -- 19% for expenses
ELSE ''
END as bu,
FORMAT(zu.dBuchungsdatum, 'Mdd') as buchungsdatum_mdd,
zu.dBuchungsdatum,
'' as rechnungsnummer, -- No invoice number in tUmsatzBeleg
zu.cVerwendungszweck as buchungstext,
ub.datevlink as beleglink,
1 as priority -- tUmsatzBeleg has priority
FROM tUmsatzKontierung uk
INNER JOIN tZahlungsabgleichUmsatz zu ON uk.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
INNER JOIN tUmsatzBeleg ub ON ub.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
WHERE ub.datevlink IS NOT NULL
AND zu.dBuchungsdatum >= @startDate
AND zu.dBuchungsdatum <= @endDate
UNION ALL
-- Get documents from tPdfObjekt via tZahlungsabgleichUmsatzLink
SELECT
uk.kZahlungsabgleichUmsatz,
zu.fBetrag as umsatz_brutto,
CASE WHEN zu.fBetrag < 0 THEN 'H' ELSE 'S' END as soll_haben_kz,
JSON_VALUE(uk.data, '$.konto1') as konto,
COALESCE(JSON_VALUE(po.extraction, '$.creditorID'), '') as gegenkonto,
-- BU determination based on amount and konto type
CASE
WHEN JSON_VALUE(uk.data, '$.konto1') IN ('3720', '3740', '2100', '1460', '1462') THEN ''
WHEN zu.fBetrag > 0 THEN ''
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '5%' THEN '9' -- 19% for purchases
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '6%' THEN '9' -- 19% for expenses
ELSE ''
END as bu,
FORMAT(zu.dBuchungsdatum, 'Mdd') as buchungsdatum_mdd,
zu.dBuchungsdatum,
COALESCE(JSON_VALUE(po.extraction, '$.invoice_number'), '') as rechnungsnummer,
zu.cVerwendungszweck as buchungstext,
po.datevlink as beleglink,
2 as priority -- tPdfObjekt has lower priority
FROM tUmsatzKontierung uk
INNER JOIN tZahlungsabgleichUmsatz zu ON uk.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
INNER JOIN tZahlungsabgleichUmsatzLink zul ON zu.kZahlungsabgleichUmsatz = zul.kZahlungsabgleichUmsatz
AND zul.linktype = 'kLieferantenBestellung'
INNER JOIN tPdfObjekt po ON zul.linktarget = po.kLieferantenbestellung
WHERE po.datevlink IS NOT NULL
AND zu.dBuchungsdatum >= @startDate
AND zu.dBuchungsdatum <= @endDate
UNION ALL
-- Get transactions without documents
SELECT
uk.kZahlungsabgleichUmsatz,
zu.fBetrag as umsatz_brutto,
CASE WHEN zu.fBetrag < 0 THEN 'H' ELSE 'S' END as soll_haben_kz,
JSON_VALUE(uk.data, '$.konto1') as konto,
'' as gegenkonto,
-- BU determination based on amount and konto type
CASE
WHEN JSON_VALUE(uk.data, '$.konto1') IN ('3720', '3740', '2100', '1460', '1462') THEN ''
WHEN zu.fBetrag > 0 THEN ''
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '5%' THEN '9' -- 19% for purchases
WHEN JSON_VALUE(uk.data, '$.konto1') LIKE '6%' THEN '9' -- 19% for expenses
ELSE ''
END as bu,
FORMAT(zu.dBuchungsdatum, 'Mdd') as buchungsdatum_mdd,
zu.dBuchungsdatum,
'' as rechnungsnummer,
zu.cVerwendungszweck as buchungstext,
'' as beleglink,
3 as priority -- No documents has lowest priority
FROM tUmsatzKontierung uk
INNER JOIN tZahlungsabgleichUmsatz zu ON uk.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
WHERE zu.dBuchungsdatum >= @startDate
AND zu.dBuchungsdatum <= @endDate
AND NOT EXISTS (
SELECT 1 FROM tUmsatzBeleg ub2
WHERE ub2.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
AND ub2.datevlink IS NOT NULL
)
AND NOT EXISTS (
SELECT 1 FROM tZahlungsabgleichUmsatzLink zul2
INNER JOIN tPdfObjekt po2 ON zul2.linktarget = po2.kLieferantenbestellung
WHERE zul2.kZahlungsabgleichUmsatz = zu.kZahlungsabgleichUmsatz
AND zul2.linktype = 'kLieferantenBestellung'
AND po2.datevlink IS NOT NULL
)
)
SELECT
*,
ROW_NUMBER() OVER (PARTITION BY kZahlungsabgleichUmsatz, beleglink ORDER BY priority) as rn
FROM DatevDocuments
ORDER BY dBuchungsdatum DESC, kZahlungsabgleichUmsatz, priority
`;
const result = await executeQuery(query, {
startDate: sqlStartDate,
endDate: sqlEndDate
});
// Format data for DATEV CSV
const datevRows = [];
// Build header
const periodStart = startDate.getFullYear() +
('0' + (startDate.getMonth() + 1)).slice(-2) +
('0' + startDate.getDate()).slice(-2);
const periodEnd = endDate.getFullYear() +
('0' + (endDate.getMonth() + 1)).slice(-2) +
('0' + endDate.getDate()).slice(-2);
datevRows.push(buildDatevHeader(periodStart, periodEnd));
datevRows.push(DATEV_COLS);
// Process each transaction
result.recordset.forEach(row => {
// Skip duplicate rows (keep only the first occurrence of each transaction+beleglink combination)
if (row.rn > 1) return;
const datevRow = [
formatDatevAmount(row.umsatz_brutto), // Umsatz (ohne Soll/Haben-Kz)
row.soll_haben_kz, // Soll/Haben-Kennzeichen
'', // WKZ Umsatz
'', // Kurs
'', // Basis-Umsatz
'', // WKZ Basis-Umsatz
parseKonto(row.konto), // Konto (parsed)
row.gegenkonto || '', // Gegenkonto (ohne BU-Schlüssel)
row.bu || '', // BU-Schlüssel
row.buchungsdatum_mdd || '', // Belegdatum (MDD format)
quote(row.rechnungsnummer || ''), // Belegfeld 1 (invoice number)
'', // Belegfeld 2
'', // Skonto
quote(row.buchungstext || ''), // Buchungstext
'', // Postensperre
'', // Diverse Adressnummer
'', // Geschäftspartnerbank
'', // Sachverhalt
'', // Zinssperre
row.beleglink || '' // Beleglink
].join(';');
datevRows.push(datevRow);
});
// Generate CSV content
const csvContent = datevRows.join('\n');
// Set headers for CSV download
const filename = `EXTF_${timeRange.replace('-', '_')}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=windows-1252');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Send CSV content
res.send(csvContent);
} catch (error) {
console.error('Error generating DATEV export:', error);
res.status(500).json({ error: 'Failed to generate DATEV export' });
}
});
module.exports = router;

View File

@@ -0,0 +1,416 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const { executeQuery, executeTransaction } = require('../../config/database');
const sql = require('mssql');
const nodemailer = require('nodemailer');
const router = express.Router();
// Get document processing status
router.get('/document-status', authenticateToken, async (req, res) => {
try {
console.log('Document status endpoint called');
const queries = {
needMarkdownUmsatz: "SELECT COUNT(*) as count FROM tUmsatzBeleg WHERE markDown is null",
needMarkdownPdf: "SELECT COUNT(*) as count FROM tPdfObjekt WHERE markDown is null",
needExtractionUmsatz: "SELECT COUNT(*) as count FROM tUmsatzBeleg WHERE markDown is not null and extraction is null",
needExtractionPdf: "SELECT COUNT(*) as count FROM tPdfObjekt WHERE markDown is not null and extraction is null",
needDatevSyncUmsatz: "SELECT COUNT(*) as count FROM tUmsatzBeleg WHERE markDown is not null and datevlink is null",
needDatevSyncPdf: "SELECT COUNT(*) as count FROM tPdfObjekt WHERE markDown is not null and datevlink is null",
needDatevUploadUmsatz: "SELECT COUNT(*) as count FROM tUmsatzBeleg WHERE datevlink = 'pending'",
needDatevUploadPdf: "SELECT COUNT(*) as count FROM tPdfObjekt WHERE datevlink = 'pending'"
};
const results = {};
for (const [key, query] of Object.entries(queries)) {
const result = await executeQuery(query);
results[key] = result.recordset[0].count;
}
const status = {
needMarkdown: results.needMarkdownUmsatz + results.needMarkdownPdf,
needExtraction: results.needExtractionUmsatz + results.needExtractionPdf,
needDatevSync: results.needDatevSyncUmsatz + results.needDatevSyncPdf,
needDatevUpload: results.needDatevUploadUmsatz + results.needDatevUploadPdf,
details: {
markdown: {
umsatzBeleg: results.needMarkdownUmsatz,
pdfObjekt: results.needMarkdownPdf
},
extraction: {
umsatzBeleg: results.needExtractionUmsatz,
pdfObjekt: results.needExtractionPdf
},
datevSync: {
umsatzBeleg: results.needDatevSyncUmsatz,
pdfObjekt: results.needDatevSyncPdf
},
datevUpload: {
umsatzBeleg: results.needDatevUploadUmsatz,
pdfObjekt: results.needDatevUploadPdf
}
}
};
console.log('Document status computed:', status);
res.json(status);
} catch (error) {
console.error('Error fetching document processing status:', error);
res.status(500).json({ error: 'Failed to fetch document processing status' });
}
});
// Process markdown conversion
router.post('/process-markdown', authenticateToken, async (req, res) => {
try {
const { OpenAI } = require('openai');
// Check environment for OpenAI API key
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OpenAI API key not configured' });
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
await executeTransaction(async (transaction) => {
// Process UmsatzBeleg documents
const umsatzResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kUmsatzBeleg, content FROM tUmsatzBeleg WHERE markDown is null"
);
if (umsatzResult.recordset.length > 0) {
const { kUmsatzBeleg, content } = umsatzResult.recordset[0];
const response = await openai.responses.create({
model: "gpt-4o",
input: [
{ "role": "developer", "content": [{ "type": "input_text", "text": "Convert to Markdown" }] },
{ "role": "user", "content": [{ "type": "input_file", "filename": "invoice.pdf", "file_data": "data:application/pdf;base64," + content.toString('base64') }] }
],
text: {
"format": {
"type": "json_schema", "name": "markdown", "strict": true, "schema": { "type": "object", "properties": {
"output": { "type": "string", "description": "Input converted to Markdown" }
}, "required": ["output"], "additionalProperties": false }
}
},
tools: [],
store: false
});
const markdown = JSON.parse(response.output_text);
await new sql.Request(transaction)
.input('kUmsatzBeleg', kUmsatzBeleg)
.input('markDown', markdown.output)
.query("UPDATE tUmsatzBeleg SET markDown = @markDown WHERE kUmsatzBeleg = @kUmsatzBeleg");
}
// Process PdfObjekt documents
const pdfResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kPdfObjekt, content FROM tPdfObjekt WHERE markDown is null"
);
if (pdfResult.recordset.length > 0) {
const { kPdfObjekt, content } = pdfResult.recordset[0];
const response = await openai.responses.create({
model: "gpt-4o",
input: [
{ "role": "developer", "content": [{ "type": "input_text", "text": "Convert to Markdown" }] },
{ "role": "user", "content": [{ "type": "input_file", "filename": "invoice.pdf", "file_data": "data:application/pdf;base64," + content.toString('base64') }] }
],
text: {
"format": {
"type": "json_schema", "name": "markdown", "strict": true, "schema": { "type": "object", "properties": {
"output": { "type": "string", "description": "Input converted to Markdown" }
}, "required": ["output"], "additionalProperties": false }
}
},
tools: [],
store: false
});
const markdown = JSON.parse(response.output_text);
await new sql.Request(transaction)
.input('kPdfObjekt', kPdfObjekt)
.input('markDown', markdown.output)
.query("UPDATE tPdfObjekt SET markDown = @markDown WHERE kPdfObjekt = @kPdfObjekt");
}
});
res.json({ success: true, message: 'Markdown processing completed' });
} catch (error) {
console.error('Error processing markdown:', error);
res.status(500).json({ error: 'Failed to process markdown: ' + error.message });
}
});
// Process data extraction
router.post('/process-extraction', authenticateToken, async (req, res) => {
try {
const { OpenAI } = require('openai');
if (!process.env.OPENAI_API_KEY) {
return res.status(500).json({ error: 'OpenAI API key not configured' });
}
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
await executeTransaction(async (transaction) => {
// Get creditor IDs for extraction
const creditorResult = await new sql.Request(transaction).query(
"SELECT kreditorId FROM fibdash.Kreditor ORDER BY kreditorId"
);
const creditorIDs = creditorResult.recordset.map(r => r.kreditorId).join(', ');
// Process UmsatzBeleg documents
const umsatzResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kUmsatzBeleg, markDown FROM tUmsatzBeleg WHERE markDown is not null and extraction is null"
);
if (umsatzResult.recordset.length > 0) {
const { kUmsatzBeleg, markDown } = umsatzResult.recordset[0];
const response = await openai.responses.create({
model: "gpt-5-mini",
input: [
{ "role": "developer", "content": [{ "type": "input_text", "text": `Extract specified information from provided input and structure it in a JSON format.
The aim is to accurately identify and capture the following elements:
- Rechnungsdatum/Belegdatum (Invoice Date/Document Date),
- Rechnungsnummer/Belegnummer (Invoice Number/Document Number),
- Netto Betrag (Net Amount),
- Brutto Betrag (Gross Amount),
- and Absender (Sender).
# Steps
1. **Identify Dates**: Find and extract the invoice or document date (Rechnungsdatum/Belegdatum) from the input text.
2. **Extract Numbers**: Locate and pull out the invoice or document number (Rechnungsnummer/Belegnummer).
3. **Determine Amounts**: Identify the net amount (Netto Betrag) and the gross amount (Brutto Betrag) and the currency in the text.
4. **Source the Sender**: Extract the sender's information (Absender, Country).
5. **Structure Data**: Organize the extracted information into a JSON format following the specified schema.
# Notes
- Ensure that dates are formatted consistently.
- Be mindful of various numerical representations (e.g., with commas or periods).
- The sender's information might include company names, so recognize various formats.
- Prioritize accuracy in identifying the correct fields, as there can be similar text elements present.
Also select the CreditorID, from that List: ${creditorIDs}` }] },
{ "role": "user", "content": [{ "type": "input_text", "text": markDown }] }
],
text: {
"format": {
"type": "json_schema", "name": "invoice", "strict": true, "schema": { "type": "object", "properties": {
"date": { "type": "string", "description": "Rechungsdatum / Belegdatum in ISO 8601" },
"invoice_number": { "type": "string", "description": "Rechnungsnummer / Belegnummer / Invoicenr" },
"net_amounts_and_tax": {
"type": "array", "description": "Liste von Nettobeträgen mit jeweiligem Steuersatz und Steuerbetrag, ein Listeneintrag pro Steuersatz",
"items": { "type": "object", "properties": {
"net_amount": { "type": "number", "description": "Netto Betrag" },
"tax_rate": { "type": "number", "description": "Steuersatz in Prozent" },
"tax_amount": { "type": "number", "description": "Steuerbetrag" }
}, "required": ["net_amount", "tax_rate", "tax_amount"], "additionalProperties": false }
},
"gross_amount": { "type": "number", "description": "Brutto Betrag (muss der Summe aller net_amount + tax_amount entsprechen)" },
"currency": { "type": "string", "description": "currency code in ISO 4217" },
"country": { "type": "string", "description": "country of origin in ISO 3166" },
"sender": { "type": "string", "description": "Absender" },
"creditorID": { "type": "string", "description": "CreditorID or empty if unknown" }
}, "required": ["date", "invoice_number", "net_amounts_and_tax", "gross_amount", "currency", "country", "sender", "creditorID"], "additionalProperties": false }
}
},
tools: [],
store: false
});
const extraction = JSON.parse(response.output_text);
await new sql.Request(transaction)
.input('kUmsatzBeleg', kUmsatzBeleg)
.input('extraction', JSON.stringify(extraction))
.query("UPDATE tUmsatzBeleg SET extraction = @extraction WHERE kUmsatzBeleg = @kUmsatzBeleg");
}
// Process PdfObjekt documents
const pdfResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kPdfObjekt, markDown FROM tPdfObjekt WHERE markDown is not null and extraction is null"
);
if (pdfResult.recordset.length > 0) {
const { kPdfObjekt, markDown } = pdfResult.recordset[0];
const response = await openai.responses.create({
model: "gpt-5-mini",
input: [
{ "role": "developer", "content": [{ "type": "input_text", "text": `Extract specified information from provided input and structure it in a JSON format.
The aim is to accurately identify and capture the following elements:
- Rechnungsdatum/Belegdatum (Invoice Date/Document Date),
- Rechnungsnummer/Belegnummer (Invoice Number/Document Number),
- Netto Betrag (Net Amount),
- Brutto Betrag (Gross Amount),
- and Absender (Sender).
# Steps
1. **Identify Dates**: Find and extract the invoice or document date (Rechnungsdatum/Belegdatum) from the input text.
2. **Extract Numbers**: Locate and pull out the invoice or document number (Rechnungsnummer/Belegnummer).
3. **Determine Amounts**: Identify the net amount (Netto Betrag) and the gross amount (Brutto Betrag) and the currency in the text.
4. **Source the Sender**: Extract the sender's information (Absender, Country).
5. **Structure Data**: Organize the extracted information into a JSON format following the specified schema.
# Notes
- Ensure that dates are formatted consistently.
- Be mindful of various numerical representations (e.g., with commas or periods).
- The sender's information might include company names, so recognize various formats.
- Prioritize accuracy in identifying the correct fields, as there can be similar text elements present.
Also select the CreditorID, from that List: ${creditorIDs}` }] },
{ "role": "user", "content": [{ "type": "input_text", "text": markDown }] }
],
text: {
"format": {
"type": "json_schema", "name": "invoice", "strict": true, "schema": { "type": "object", "properties": {
"date": { "type": "string", "description": "Rechungsdatum / Belegdatum in ISO 8601" },
"invoice_number": { "type": "string", "description": "Rechnungsnummer / Belegnummer / Invoicenr" },
"net_amounts_and_tax": {
"type": "array", "description": "Liste von Nettobeträgen mit jeweiligem Steuersatz und Steuerbetrag, ein Listeneintrag pro Steuersatz",
"items": { "type": "object", "properties": {
"net_amount": { "type": "number", "description": "Netto Betrag" },
"tax_rate": { "type": "number", "description": "Steuersatz in Prozent" },
"tax_amount": { "type": "number", "description": "Steuerbetrag" }
}, "required": ["net_amount", "tax_rate", "tax_amount"], "additionalProperties": false }
},
"gross_amount": { "type": "number", "description": "Brutto Betrag (muss der Summe aller net_amount + tax_amount entsprechen)" },
"currency": { "type": "string", "description": "currency code in ISO 4217" },
"country": { "type": "string", "description": "country of origin in ISO 3166" },
"sender": { "type": "string", "description": "Absender" },
"creditorID": { "type": "string", "description": "CreditorID or empty if unknown" }
}, "required": ["date", "invoice_number", "net_amounts_and_tax", "gross_amount", "currency", "country", "sender", "creditorID"], "additionalProperties": false }
}
},
tools: [],
store: false
});
const extraction = JSON.parse(response.output_text);
await new sql.Request(transaction)
.input('kPdfObjekt', kPdfObjekt)
.input('extraction', JSON.stringify(extraction))
.query("UPDATE tPdfObjekt SET extraction = @extraction WHERE kPdfObjekt = @kPdfObjekt");
}
});
res.json({ success: true, message: 'Extraction processing completed' });
} catch (error) {
console.error('Error processing extraction:', error);
res.status(500).json({ error: 'Failed to process extraction: ' + error.message });
}
});
// Process Datev sync
router.post('/process-datev-sync', authenticateToken, async (req, res) => {
try {
const transporter = nodemailer.createTransport({
host: "smtp.gmail.com",
port: 587,
secure: false, // true for 465, false for other ports
auth: {
user: "sebgreenbus@gmail.com",
pass: "abrp idub thbi kdws", // For Gmail, you might need an app-specific password
},
});
await executeTransaction(async (transaction) => {
// Process UmsatzBeleg documents
const umsatzResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kUmsatzBeleg, content FROM tUmsatzBeleg WHERE markDown is not null and datevlink is null"
);
if (umsatzResult.recordset.length > 0) {
const { kUmsatzBeleg, content } = umsatzResult.recordset[0];
const mailOptions = {
from: '"Growheads" <sebgreenbus@gmail.com>',
to: "97bfd9eb-770f-481a-accb-e69649d36a9e@uploadmail.datev.de",
subject: `Beleg ${kUmsatzBeleg} für Datev`,
text: "", // No body text as requested
attachments: [
{
filename: `UmsatzBeleg${kUmsatzBeleg}.pdf`,
content: content,
contentType: "application/pdf",
},
],
};
try {
let info = await transporter.sendMail(mailOptions);
console.log("Message sent: %s", info.messageId);
await new sql.Request(transaction)
.input('kUmsatzBeleg', kUmsatzBeleg)
.input('datevlink', 'pending')
.query("UPDATE tUmsatzBeleg SET datevlink = @datevlink WHERE kUmsatzBeleg = @kUmsatzBeleg");
} catch (emailError) {
console.error("Error sending email:", emailError);
throw emailError;
}
}
// Process PdfObjekt documents
const pdfResult = await new sql.Request(transaction).query(
"SELECT TOP 1 kPdfObjekt, content FROM tPdfObjekt WHERE markDown is not null and datevlink is null"
);
if (pdfResult.recordset.length > 0) {
const { kPdfObjekt, content } = pdfResult.recordset[0];
const mailOptions = {
from: '"Growheads" <sebgreenbus@gmail.com>',
to: "97bfd9eb-770f-481a-accb-e69649d36a9e@uploadmail.datev.de",
subject: `Rechnung ${kPdfObjekt} für Datev`,
text: "", // No body text as requested
attachments: [
{
filename: `Rechnung${kPdfObjekt}.pdf`,
content: content,
contentType: "application/pdf",
},
],
};
try {
let info = await transporter.sendMail(mailOptions);
console.log("Message sent: %s", info.messageId);
await new sql.Request(transaction)
.input('kPdfObjekt', kPdfObjekt)
.input('datevlink', 'pending')
.query("UPDATE tPdfObjekt SET datevlink = @datevlink WHERE kPdfObjekt = @kPdfObjekt");
} catch (emailError) {
console.error("Error sending email:", emailError);
throw emailError;
}
}
});
res.json({ success: true, message: 'Datev sync processing completed' });
} catch (error) {
console.error('Error processing Datev sync:', error);
res.status(500).json({ error: 'Failed to process Datev sync: ' + error.message });
}
});
module.exports = router;

View File

@@ -0,0 +1,67 @@
const { executeQuery } = require('../../../config/database');
// Get database transactions for JTL comparison
async function getJTLTransactions() {
try {
const query = `
SELECT
cKonto, cKontozusatz, cName, dBuchungsdatum,
tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz,
cVerwendungszweck, fBetrag, tUmsatzKontierung.data
FROM [eazybusiness].[dbo].[tZahlungsabgleichUmsatz]
LEFT JOIN tUmsatzKontierung ON (tUmsatzKontierung.kZahlungsabgleichUmsatz = tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz)
ORDER BY dBuchungsdatum desc, tZahlungsabgleichUmsatz.kZahlungsabgleichUmsatz desc
`;
const result = await executeQuery(query);
const transactions = result.recordset || [];
// Get PDF documents for each transaction
const pdfQuery = `SELECT kUmsatzBeleg, kZahlungsabgleichUmsatz, textContent, markDown, extraction, datevlink FROM tUmsatzBeleg`;
const pdfResult = await executeQuery(pdfQuery);
for (const item of pdfResult.recordset) {
for (const transaction of transactions) {
if (item.kZahlungsabgleichUmsatz == transaction.kZahlungsabgleichUmsatz) {
if (!transaction.pdfs) transaction.pdfs = [];
transaction.pdfs.push({
kUmsatzBeleg: item.kUmsatzBeleg,
content: item.textContent,
markDown: item.markDown,
extraction: item.extraction,
datevlink: item.datevlink
});
}
}
}
// Get links for each transaction
const linksQuery = `
SELECT kZahlungsabgleichUmsatzLink, kZahlungsabgleichUmsatz, linktarget, linktype, note,
tPdfObjekt.kPdfObjekt, tPdfObjekt.textContent, tPdfObjekt.markDown,
tPdfObjekt.extraction
FROM tZahlungsabgleichUmsatzLink
LEFT JOIN tPdfObjekt ON (tZahlungsabgleichUmsatzLink.linktarget = tPdfObjekt.kLieferantenbestellung)
WHERE linktype = 'kLieferantenBestellung'
`;
const linksResult = await executeQuery(linksQuery);
for (const item of linksResult.recordset) {
for (const transaction of transactions) {
if (item.kZahlungsabgleichUmsatz == transaction.kZahlungsabgleichUmsatz) {
if (!transaction.links) transaction.links = [];
transaction.links.push(item);
}
}
}
return transactions;
} catch (error) {
console.error('Error fetching JTL transactions:', error);
throw error;
}
}
module.exports = {
getJTLTransactions
};

26
src/routes/data/index.js Normal file
View File

@@ -0,0 +1,26 @@
const express = require('express');
const months = require('./months');
const transactions = require('./transactions');
const datev = require('./datev');
const pdf = require('./pdf');
const kreditors = require('./kreditors');
const bankingTransactions = require('./bankingTransactions');
const accountingItems = require('./accountingItems');
const csvImport = require('./csvImport');
const documentProcessing = require('./documentProcessing');
const router = express.Router();
// Mount sub-routers preserving original paths
router.use(months);
router.use(transactions);
router.use(datev);
router.use(pdf);
router.use(kreditors);
router.use(bankingTransactions);
router.use(accountingItems);
router.use(csvImport);
router.use(documentProcessing);
module.exports = router;

View File

@@ -0,0 +1,183 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Get all kreditors
router.get('/kreditors', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `
SELECT id, iban, name, kreditorId
FROM fibdash.Kreditor
ORDER BY name ASC, iban ASC
`;
const result = await executeQuery(query);
res.json(result.recordset || []);
} catch (error) {
console.error('Error fetching kreditors:', error);
res.status(500).json({ error: 'Failed to fetch kreditors' });
}
});
// Get kreditor by ID
router.get('/kreditors/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const query = `
SELECT id, iban, name, kreditorId, is_banking
FROM fibdash.Kreditor
WHERE id = @id
`;
const result = await executeQuery(query, { id: parseInt(id, 10) });
if (result.recordset.length === 0) {
return res.status(404).json({ error: 'Kreditor not found' });
}
res.json(result.recordset[0]);
} catch (error) {
console.error('Error fetching kreditor:', error);
res.status(500).json({ error: 'Failed to fetch kreditor' });
}
});
// Create new kreditor
router.post('/kreditors', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { iban, name, kreditorId, is_banking } = req.body;
const isBanking = is_banking || false;
if (!name || !kreditorId) {
return res.status(400).json({ error: 'Name and kreditorId are required' });
}
// Business rule: IBAN is required for banking kreditors (proxies), not required for real kreditors
if (isBanking && (!iban || iban.trim() === '')) {
return res.status(400).json({ error: 'IBAN is required for banking kreditors' });
}
if (iban && iban.trim() !== '') {
const checkQuery = `
SELECT id FROM fibdash.Kreditor
WHERE iban = @iban
`;
const checkResult = await executeQuery(checkQuery, { iban });
if (checkResult.recordset.length > 0) {
return res.status(409).json({ error: 'Kreditor with this IBAN already exists' });
}
}
const insertQuery = `
INSERT INTO fibdash.Kreditor (iban, name, kreditorId, is_banking)
OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId, INSERTED.is_banking
VALUES (@iban, @name, @kreditorId, @is_banking)
`;
const result = await executeQuery(insertQuery, {
iban: iban || null,
name,
kreditorId,
is_banking: isBanking
});
res.status(201).json(result.recordset[0]);
} catch (error) {
console.error('Error creating kreditor:', error);
res.status(500).json({ error: 'Failed to create kreditor' });
}
});
// Update kreditor
router.put('/kreditors/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const { iban, name, kreditorId, is_banking } = req.body;
const isBanking = is_banking || false;
if (!name || !kreditorId) {
return res.status(400).json({ error: 'Name and kreditorId are required' });
}
// Business rule: IBAN is required for banking kreditors (proxies), not required for real kreditors
if (isBanking && (!iban || iban.trim() === '')) {
return res.status(400).json({ error: 'IBAN is required for banking kreditors' });
}
const checkQuery = `SELECT id FROM fibdash.Kreditor WHERE id = @id`;
const checkResult = await executeQuery(checkQuery, { id: parseInt(id, 10) });
if (checkResult.recordset.length === 0) {
return res.status(404).json({ error: 'Kreditor not found' });
}
if (iban && iban.trim() !== '') {
const conflictQuery = `
SELECT id FROM fibdash.Kreditor
WHERE iban = @iban AND id != @id
`;
const conflictResult = await executeQuery(conflictQuery, { iban, id: parseInt(id, 10) });
if (conflictResult.recordset.length > 0) {
return res.status(409).json({ error: 'Another kreditor with this IBAN already exists' });
}
}
const updateQuery = `
UPDATE fibdash.Kreditor
SET iban = @iban, name = @name, kreditorId = @kreditorId, is_banking = @is_banking
OUTPUT INSERTED.id, INSERTED.iban, INSERTED.name, INSERTED.kreditorId, INSERTED.is_banking
WHERE id = @id
`;
const result = await executeQuery(updateQuery, {
iban: iban || null,
name,
kreditorId,
is_banking: isBanking,
id: parseInt(id, 10)
});
res.json(result.recordset[0]);
} catch (error) {
console.error('Error updating kreditor:', error);
res.status(500).json({ error: 'Failed to update kreditor' });
}
});
// Delete kreditor (hard delete)
router.delete('/kreditors/:id', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const { id } = req.params;
const query = `
DELETE FROM fibdash.Kreditor
WHERE id = @id
`;
const result = await executeQuery(query, { id: parseInt(id, 10) });
if (result.rowsAffected[0] === 0) {
return res.status(404).json({ error: 'Kreditor not found' });
}
res.json({ message: 'Kreditor deleted successfully' });
} catch (error) {
console.error('Error deleting kreditor:', error);
res.status(500).json({ error: 'Failed to delete kreditor' });
}
});
module.exports = router;

33
src/routes/data/months.js Normal file
View File

@@ -0,0 +1,33 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Get available months from database
router.get('/months', authenticateToken, async (req, res) => {
try {
const { executeQuery } = require('../../config/database');
const query = `
SELECT DISTINCT
FORMAT(combined.date_col, 'yyyy-MM') as month_year
FROM (
SELECT buchungsdatum as date_col FROM fibdash.AccountingItems WHERE buchungsdatum IS NOT NULL
UNION ALL
SELECT parsed_date as date_col FROM fibdash.CSVTransactions WHERE parsed_date IS NOT NULL
) combined
WHERE combined.date_col IS NOT NULL
ORDER BY month_year DESC
`;
const result = await executeQuery(query);
const months = result.recordset.map(row => row.month_year);
res.json({ months });
} catch (error) {
console.error('Error getting months:', error);
res.status(500).json({ error: 'Failed to load months' });
}
});
module.exports = router;

72
src/routes/data/pdf.js Normal file
View File

@@ -0,0 +1,72 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const router = express.Router();
// Get PDF from tUmsatzBeleg
router.get('/pdf/umsatzbeleg/:kUmsatzBeleg', authenticateToken, async (req, res) => {
try {
const { kUmsatzBeleg } = req.params;
const { executeQuery } = require('../../config/database');
const query = `
SELECT content, datevlink
FROM dbo.tUmsatzBeleg
WHERE kUmsatzBeleg = @kUmsatzBeleg AND content IS NOT NULL
`;
const result = await executeQuery(query, {
kUmsatzBeleg: parseInt(kUmsatzBeleg, 10)
});
if (!result.recordset || result.recordset.length === 0) {
return res.status(404).json({ error: 'PDF not found' });
}
const pdfData = result.recordset[0];
const filename = 'Umsatzbeleg_' + kUmsatzBeleg + '_' + (pdfData.datevlink || 'document') + '.pdf';
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="' + filename + '"');
res.send(pdfData.content);
} catch (error) {
console.error('Error fetching PDF from tUmsatzBeleg:', error);
res.status(500).json({ error: 'Failed to fetch PDF' });
}
});
// Get PDF from tPdfObjekt
router.get('/pdf/pdfobject/:kPdfObjekt', authenticateToken, async (req, res) => {
try {
const { kPdfObjekt } = req.params;
const { executeQuery } = require('../../config/database');
const query = `
SELECT content, datevlink, kLieferantenbestellung
FROM dbo.tPdfObjekt
WHERE kPdfObjekt = @kPdfObjekt AND content IS NOT NULL
`;
const result = await executeQuery(query, {
kPdfObjekt: parseInt(kPdfObjekt, 10)
});
if (!result.recordset || result.recordset.length === 0) {
return res.status(404).json({ error: 'PDF not found' });
}
const pdfData = result.recordset[0];
const filename = 'PdfObjekt_' + kPdfObjekt + '_LB' + pdfData.kLieferantenbestellung + '_' + (pdfData.datevlink || 'document') + '.pdf';
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'inline; filename="' + filename + '"');
res.send(pdfData.content);
} catch (error) {
console.error('Error fetching PDF from tPdfObjekt:', error);
res.status(500).json({ error: 'Failed to fetch PDF' });
}
});
module.exports = router;

View File

@@ -0,0 +1,226 @@
const express = require('express');
const { authenticateToken } = require('../../middleware/auth');
const { getJTLTransactions } = require('./helpers/jtl');
const router = express.Router();
// Get transactions for a specific time period (month, quarter, or year)
router.get('/transactions/:timeRange', authenticateToken, async (req, res) => {
try {
const { timeRange } = req.params;
const { executeQuery } = require('../../config/database');
// Build WHERE clause based on timeRange format
let timeWhereClause = '';
if (timeRange.includes('-Q')) {
// Quarter format: 2025-Q2
const [year, quarterPart] = timeRange.split('-Q');
const quarter = parseInt(quarterPart, 10);
const startMonth = (quarter - 1) * 3 + 1;
const endMonth = startMonth + 2;
timeWhereClause = `WHERE YEAR(csv.parsed_date) = ${year} AND MONTH(csv.parsed_date) BETWEEN ${startMonth} AND ${endMonth}`;
} else if (timeRange.length === 4) {
// Year format: 2025
timeWhereClause = `WHERE YEAR(csv.parsed_date) = ${timeRange}`;
} else {
// Month format: 2025-07
const [year, month] = timeRange.split('-');
timeWhereClause = `WHERE YEAR(csv.parsed_date) = ${year} AND MONTH(csv.parsed_date) = ${parseInt(month, 10)}`;
}
const query = `
SELECT
csv.id as id,
csv.buchungstag as 'Buchungstag',
csv.wertstellung as 'Valutadatum',
csv.umsatzart as 'Buchungstext',
csv.verwendungszweck as 'Verwendungszweck',
csv.beguenstigter_zahlungspflichtiger as 'Beguenstigter/Zahlungspflichtiger',
csv.kontonummer_iban as 'Kontonummer/IBAN',
csv.bic as 'BIC (SWIFT-Code)',
csv.betrag_original as 'Betrag',
csv.waehrung as 'Waehrung',
csv.numeric_amount as numericAmount,
csv.parsed_date,
FORMAT(csv.parsed_date, 'yyyy-MM') as monthYear,
jtl.kZahlungsabgleichUmsatz as jtlId,
CASE WHEN jtl.kZahlungsabgleichUmsatz IS NOT NULL THEN 1 ELSE 0 END as hasJTL,
k.name as kreditor_name,
k.kreditorId as kreditor_id,
k.is_banking as kreditor_is_banking,
bat.assigned_kreditor_id,
ak.name as assigned_kreditor_name,
ak.kreditorId as assigned_kreditor_kreditorId,
0 as isJTLOnly,
1 as isFromCSV,
ub.textContent as jtl_document_data,
ub.kUmsatzBeleg,
ub.datevlink
FROM fibdash.CSVTransactions csv
LEFT JOIN eazybusiness.dbo.tZahlungsabgleichUmsatz jtl ON (
ABS(csv.numeric_amount - jtl.fBetrag) < 0.01 AND
ABS(DATEDIFF(day, csv.parsed_date, jtl.dBuchungsdatum)) <= 1
)
LEFT JOIN eazybusiness.dbo.tUmsatzBeleg ub ON ub.kZahlungsabgleichUmsatz = jtl.kZahlungsabgleichUmsatz
LEFT JOIN fibdash.Kreditor k ON csv.kontonummer_iban = k.iban
LEFT JOIN fibdash.BankingAccountTransactions bat ON csv.id = bat.csv_transaction_id
LEFT JOIN fibdash.Kreditor ak ON bat.assigned_kreditor_id = ak.id
${timeWhereClause}
UNION ALL
SELECT
jtl.kZahlungsabgleichUmsatz as id,
FORMAT(jtl.dBuchungsdatum, 'dd.MM.yy') as 'Buchungstag',
FORMAT(jtl.dBuchungsdatum, 'dd.MM.yy') as 'Valutadatum',
'JTL Transaction' as 'Buchungstext',
jtl.cVerwendungszweck as 'Verwendungszweck',
jtl.cName as 'Beguenstigter/Zahlungspflichtiger',
'' as 'Kontonummer/IBAN',
'' as 'BIC (SWIFT-Code)',
FORMAT(jtl.fBetrag, 'N2', 'de-DE') as 'Betrag',
'' as 'Waehrung',
jtl.fBetrag as numericAmount,
jtl.dBuchungsdatum as parsed_date,
FORMAT(jtl.dBuchungsdatum, 'yyyy-MM') as monthYear,
jtl.kZahlungsabgleichUmsatz as jtlId,
1 as hasJTL,
NULL as kreditor_name,
NULL as kreditor_id,
NULL as kreditor_is_banking,
NULL as assigned_kreditor_id,
NULL as assigned_kreditor_name,
NULL as assigned_kreditor_kreditorId,
1 as isJTLOnly,
0 as isFromCSV,
ub.textContent as jtl_document_data,
ub.kUmsatzBeleg,
ub.datevlink
FROM eazybusiness.dbo.tZahlungsabgleichUmsatz jtl
LEFT JOIN eazybusiness.dbo.tUmsatzBeleg ub ON ub.kZahlungsabgleichUmsatz = jtl.kZahlungsabgleichUmsatz
WHERE NOT EXISTS (
SELECT 1 FROM fibdash.CSVTransactions csv
WHERE ABS(csv.numeric_amount - jtl.fBetrag) < 0.01
AND ABS(DATEDIFF(day, csv.parsed_date, jtl.dBuchungsdatum)) <= 1
)
${timeRange.includes('-Q') ?
`AND YEAR(jtl.dBuchungsdatum) = ${timeRange.split('-Q')[0]} AND MONTH(jtl.dBuchungsdatum) BETWEEN ${(parseInt(timeRange.split('-Q')[1], 10) - 1) * 3 + 1} AND ${(parseInt(timeRange.split('-Q')[1], 10) - 1) * 3 + 3}` :
timeRange.length === 4 ?
`AND YEAR(jtl.dBuchungsdatum) = ${timeRange}` :
`AND YEAR(jtl.dBuchungsdatum) = ${timeRange.split('-')[0]} AND MONTH(jtl.dBuchungsdatum) = ${parseInt(timeRange.split('-')[1], 10)}`
}
ORDER BY parsed_date DESC
`;
const result = await executeQuery(query);
// Get links data separately to avoid duplicate rows
const linksQuery = `
SELECT
zul.kZahlungsabgleichUmsatz,
zul.linktarget,
zul.linktype,
zul.note,
po.kPdfObjekt,
po.textContent,
po.markDown,
po.extraction
FROM eazybusiness.dbo.tZahlungsabgleichUmsatzLink zul
LEFT JOIN eazybusiness.dbo.tPdfObjekt po ON zul.linktarget = po.kLieferantenbestellung
WHERE zul.linktype = 'kLieferantenBestellung'
`;
const linksResult = await executeQuery(linksQuery);
const linksData = linksResult.recordset || [];
// Group transactions by ID to handle multiple JTL matches
const transactionGroups = {};
result.recordset.forEach(row => {
const key = row.id;
if (!transactionGroups[key]) {
transactionGroups[key] = {
...row,
pdfs: [],
links: []
};
// Remove top-level kUmsatzBeleg and datevlink since they belong in pdfs array
delete transactionGroups[key].kUmsatzBeleg;
delete transactionGroups[key].datevlink;
delete transactionGroups[key].jtl_document_data;
}
// Add PDF data if present
if (row.jtl_document_data) {
transactionGroups[key].pdfs.push({
content: row.jtl_document_data,
kUmsatzBeleg: row.kUmsatzBeleg,
datevlink: row.datevlink
});
}
// Add links data if present
if (row.jtlId) {
const transactionLinks = linksData.filter(link =>
link.kZahlungsabgleichUmsatz === row.jtlId
);
transactionGroups[key].links.push(...transactionLinks);
}
});
const transactions = Object.values(transactionGroups).map(transaction => ({
...transaction,
parsedDate: new Date(transaction.parsed_date),
hasJTL: Boolean(transaction.hasJTL),
isFromCSV: Boolean(transaction.isFromCSV),
jtlDatabaseAvailable: true,
hasKreditor: !!transaction.kreditor_name,
kreditor: transaction.kreditor_name ? {
name: transaction.kreditor_name,
kreditorId: transaction.kreditor_id,
is_banking: Boolean(transaction.kreditor_is_banking)
} : null,
assignedKreditor: transaction.assigned_kreditor_name ? {
name: transaction.assigned_kreditor_name,
id: transaction.assigned_kreditor_id,
kreditorId: transaction.assigned_kreditor_kreditorId
} : null,
// Remove duplicate links
links: [...new Set(transaction.links.map(l => JSON.stringify(l)))].map(l => JSON.parse(l))
}));
// Transactions are already filtered by the SQL query, so we just need to sort them
const monthTransactions = transactions
.sort((a, b) => b.parsedDate - a.parsedDate);
// Since transactions are already filtered and joined with JTL data in SQL,
// we don't need the complex post-processing logic anymore
const summary = {
totalTransactions: transactions.length,
totalIncome: transactions
.filter(t => t.numericAmount > 0)
.reduce((sum, t) => sum + t.numericAmount, 0),
totalExpenses: transactions
.filter(t => t.numericAmount < 0)
.reduce((sum, t) => sum + Math.abs(t.numericAmount), 0),
netAmount: transactions.reduce((sum, t) => sum + t.numericAmount, 0),
timeRange: timeRange,
jtlDatabaseAvailable: true,
jtlMatches: transactions.filter(t => t.hasJTL === true && t.isFromCSV).length,
jtlMissing: transactions.filter(t => t.hasJTL === false && t.isFromCSV).length,
jtlOnly: transactions.filter(t => t.isJTLOnly === true).length,
csvOnly: transactions.filter(t => t.hasJTL === false && t.isFromCSV).length
};
res.json({
transactions: transactions,
summary
});
} catch (error) {
console.error('Error getting transactions:', error);
res.status(500).json({ error: 'Failed to load transactions' });
}
});
module.exports = router;

View File

@@ -5,12 +5,20 @@ const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'
require('dotenv').config();
module.exports = {
mode: process.env.NODE_ENV || 'development',
entry: './client/src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js',
publicPath: '/',
},
cache: {
type: 'filesystem',
buildDependencies: {
config: [__filename],
},
cacheDirectory: path.resolve(__dirname, 'node_modules/.cache/webpack'),
},
module: {
rules: [
{
@@ -36,7 +44,7 @@ module.exports = {
new HtmlWebpackPlugin({
template: './client/public/index.html',
templateParameters: {
REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID || 'your_google_client_id_here',
REACT_APP_GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
},
}),
new webpack.DefinePlugin({
@@ -45,7 +53,7 @@ module.exports = {
REACT_APP_GOOGLE_CLIENT_ID: JSON.stringify(process.env.GOOGLE_CLIENT_ID),
},
}),
new ReactRefreshWebpackPlugin(),
...(process.env.NODE_ENV === 'development' ? [new ReactRefreshWebpackPlugin()] : []),
],
devServer: {
static: {
@@ -67,11 +75,36 @@ module.exports = {
},
proxy: {
'/api': {
target: 'http://localhost:5000',
target: 'http://localhost:5500',
changeOrigin: true,
},
},
},
watchOptions: {
ignored: /node_modules/,
aggregateTimeout: 300,
poll: false,
},
snapshot: {
managedPaths: [path.resolve(__dirname, 'node_modules')],
immutablePaths: [],
buildDependencies: {
hash: true,
timestamp: true,
},
module: {
timestamp: true,
hash: true,
},
resolve: {
timestamp: true,
hash: true,
},
resolveBuildDependencies: {
timestamp: true,
hash: true,
},
},
resolve: {
extensions: ['.js', '.jsx'],
},

View File

@@ -72,8 +72,26 @@ module.exports = {
resolve: {
extensions: ['.js', '.jsx'],
},
devServer: {
port: 5001,
host: '0.0.0.0',
allowedHosts: 'all',
historyApiFallback: true,
hot: false,
liveReload: false,
client: false,
static: {
directory: path.join(__dirname, 'client/public'),
},
proxy: {
'/api': {
target: 'http://localhost:5500',
changeOrigin: true,
},
},
},
performance: {
maxAssetSize: 512000,
maxEntrypointSize: 512000,
},
};
};