Compare commits
34 Commits
9a0c985bfa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adfcd90dcf | ||
|
|
bb610e0480 | ||
|
|
44d6cf6352 | ||
|
|
74529d8b19 | ||
|
|
bd7c6dddbf | ||
|
|
8e8d93e4a6 | ||
|
|
fee9f02faa | ||
|
|
bcd7eea1b4 | ||
|
|
281754de22 | ||
|
|
d60da0a7aa | ||
|
|
46c9e9b97d | ||
|
|
096d4d0530 | ||
|
|
20cd0b34bc | ||
|
|
da435d2e66 | ||
|
|
5c416c77f0 | ||
|
|
3886e64ef6 | ||
|
|
3f2cad2426 | ||
|
|
89d481bbbf | ||
|
|
6218fc3c12 | ||
|
|
cbc826a4e1 | ||
|
|
fbfd918d81 | ||
|
|
6cde543938 | ||
|
|
481f4db389 | ||
|
|
976c802b11 | ||
|
|
5470bebfc4 | ||
|
|
092fa0f8bd | ||
|
|
fa6690135a | ||
|
|
1ec1e1e5f6 | ||
|
|
be7a928ce2 | ||
|
|
58f5bb4b4f | ||
|
|
429fd70497 | ||
|
|
2a43b7106d | ||
|
|
b9af7694a0 | ||
|
|
992adc7bcf |
@@ -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
|
||||
7
.cursor/rules/devserver.mdc
Normal file
7
.cursor/rules/devserver.mdc
Normal 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
10
.env
@@ -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
|
||||
@@ -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
31
.eslintrc.js
Normal 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
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
node_modules
|
||||
.env
|
||||
4
.kilocode/rules/mssql.md
Normal file
4
.kilocode/rules/mssql.md
Normal 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
478
README.md
@@ -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 won’t 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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
550
client/src/components/AccountingItemsManager.js
Normal file
550
client/src/components/AccountingItemsManager.js
Normal 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;
|
||||
429
client/src/components/BankingKreditorSelector.js
Normal file
429
client/src/components/BankingKreditorSelector.js
Normal 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;
|
||||
502
client/src/components/CSVImportDialog.js
Normal file
502
client/src/components/CSVImportDialog.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
345
client/src/components/KreditorSelector.js
Normal file
345
client/src/components/KreditorSelector.js
Normal 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;
|
||||
@@ -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 }}>
|
||||
|
||||
45
client/src/components/Navigation.js
Normal file
45
client/src/components/Navigation.js
Normal 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;
|
||||
133
client/src/components/OAuthCallback.js
Normal file
133
client/src/components/OAuthCallback.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
121
client/src/components/TableManagement.js
Normal file
121
client/src/components/TableManagement.js
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
385
client/src/components/admin/BUTable.js
Normal file
385
client/src/components/admin/BUTable.js
Normal 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;
|
||||
354
client/src/components/admin/KontoTable.js
Normal file
354
client/src/components/admin/KontoTable.js
Normal 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;
|
||||
411
client/src/components/admin/KreditorTable.js
Normal file
411
client/src/components/admin/KreditorTable.js
Normal 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;
|
||||
21
client/src/components/cellRenderers/AmountRenderer.js
Normal file
21
client/src/components/cellRenderers/AmountRenderer.js
Normal 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;
|
||||
11
client/src/components/cellRenderers/DescriptionRenderer.js
Normal file
11
client/src/components/cellRenderers/DescriptionRenderer.js
Normal 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;
|
||||
715
client/src/components/cellRenderers/DocumentRenderer.js
Normal file
715
client/src/components/cellRenderers/DocumentRenderer.js
Normal 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;
|
||||
53
client/src/components/cellRenderers/JtlRenderer.js
Normal file
53
client/src/components/cellRenderers/JtlRenderer.js
Normal 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;
|
||||
87
client/src/components/cellRenderers/RecipientRenderer.js
Normal file
87
client/src/components/cellRenderers/RecipientRenderer.js
Normal 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;
|
||||
50
client/src/components/cellRenderers/SelectionRenderer.js
Normal file
50
client/src/components/cellRenderers/SelectionRenderer.js
Normal 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;
|
||||
14
client/src/components/cellRenderers/TypeRenderer.js
Normal file
14
client/src/components/cellRenderers/TypeRenderer.js
Normal 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;
|
||||
232
client/src/components/config/gridConfig.js
Normal file
232
client/src/components/config/gridConfig.js
Normal 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
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
297
client/src/components/filters/IbanSelectionFilter.js
Normal file
297
client/src/components/filters/IbanSelectionFilter.js
Normal 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 />);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
64
client/src/components/headers/SelectionHeader.js
Normal file
64
client/src/components/headers/SelectionHeader.js
Normal 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;
|
||||
69
client/src/components/headers/SortHeader.js
Normal file
69
client/src/components/headers/SortHeader.js
Normal 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;
|
||||
@@ -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) {
|
||||
|
||||
61
client/src/components/utils/dataUtils.js
Normal file
61
client/src/components/utils/dataUtils.js
Normal 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);
|
||||
}
|
||||
};
|
||||
192
client/src/services/KreditorService.js
Normal file
192
client/src/services/KreditorService.js
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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
34
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
124
src/database/csv_transactions_schema.sql
Normal file
124
src/database/csv_transactions_schema.sql
Normal 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';
|
||||
@@ -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;
|
||||
11
src/index.js
11
src/index.js
@@ -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') {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
'', '', '', '', // 7‑10 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;
|
||||
273
src/routes/data/accountingItems.js
Normal file
273
src/routes/data/accountingItems.js
Normal 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;
|
||||
198
src/routes/data/bankingTransactions.js
Normal file
198
src/routes/data/bankingTransactions.js
Normal 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;
|
||||
635
src/routes/data/csvImport.js
Normal file
635
src/routes/data/csvImport.js
Normal 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
296
src/routes/data/datev.js
Normal 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,
|
||||
'', '', '', '', // 7‑10 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;
|
||||
416
src/routes/data/documentProcessing.js
Normal file
416
src/routes/data/documentProcessing.js
Normal 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;
|
||||
67
src/routes/data/helpers/jtl.js
Normal file
67
src/routes/data/helpers/jtl.js
Normal 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
26
src/routes/data/index.js
Normal 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;
|
||||
183
src/routes/data/kreditors.js
Normal file
183
src/routes/data/kreditors.js
Normal 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
33
src/routes/data/months.js
Normal 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
72
src/routes/data/pdf.js
Normal 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;
|
||||
226
src/routes/data/transactions.js
Normal file
226
src/routes/data/transactions.js
Normal 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;
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user