Various Changes

- Added support for Streamdeck Pedal
- Changed UI to Packed Theme
- Added preview for knobs (Loupedeck Live)
- Added Start to Tray
- Added Udev Rules for Streamdeck Pedal
This commit is contained in:
2026-02-28 00:00:07 +01:00
parent 93faae5cc8
commit 3ff9d0dce4
403 changed files with 982 additions and 185872 deletions

View File

@@ -20,6 +20,10 @@ Sidenote: Please excuse my terrible code, i usually only code in C / Cpp
- After downloading, make it executable and run it: - After downloading, make it executable and run it:
- `chmod +x Packed*.AppImage` - `chmod +x Packed*.AppImage`
- `./Packed*.AppImage` - `./Packed*.AppImage`
- Install the udev rules:
<sudo cp udev/50-loupedeck-user.rules /etc/udev/rules.d/
sudo cp udev/50-streamdeck-user.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules>
## Contribute ## Contribute

56
main.js
View File

@@ -10,6 +10,8 @@ import { PedalPageManager } from './src/streamdeck/pages.js';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
const START_IN_TRAY_ARG = '--start-in-tray';
const startInTray = process.argv.includes(START_IN_TRAY_ARG);
function getTrayIcon() { function getTrayIcon() {
const iconPath = path.join(__dirname, 'assets', 'icons', 'Icon.png'); const iconPath = path.join(__dirname, 'assets', 'icons', 'Icon.png');
@@ -25,13 +27,23 @@ let deviceStatus = { connected: false };
let pedalDevice; let pedalDevice;
let pedalPageManager; let pedalPageManager;
let pedalStatus = { connected: false }; let pedalStatus = { connected: false };
const startupWarnings = new Map();
function createWindow() { function pushRuntimeWarning(data) {
if (!data || !data.code) return;
startupWarnings.set(data.code, data);
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
mainWindow.webContents.send('runtime-warning', data);
}
}
function createWindow(startHidden = false) {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1580, width: 1580,
height: 800, height: 800,
minWidth: 1380, minWidth: 1380,
minHeight: 600, minHeight: 600,
show: !startHidden,
frame: false, frame: false,
icon: path.join(__dirname, 'assets', 'icons', 'Icon.png'), icon: path.join(__dirname, 'assets', 'icons', 'Icon.png'),
webPreferences: { webPreferences: {
@@ -47,6 +59,10 @@ function createWindow() {
mainWindow.webContents.on('did-finish-load', () => { mainWindow.webContents.on('did-finish-load', () => {
mainWindow.webContents.send('device-status', deviceStatus); mainWindow.webContents.send('device-status', deviceStatus);
mainWindow.webContents.send('pedal-status', pedalStatus);
startupWarnings.forEach((warning) => {
mainWindow.webContents.send('runtime-warning', warning);
});
}); });
mainWindow.on('close', (event) => { mainWindow.on('close', (event) => {
@@ -57,6 +73,19 @@ function createWindow() {
}); });
} }
function applyAutostartToTraySetting(enabled) {
const openAtLogin = !!enabled;
try {
app.setLoginItemSettings({
openAtLogin,
openAsHidden: openAtLogin,
args: openAtLogin ? [START_IN_TRAY_ARG] : []
});
} catch (error) {
console.warn('Could not set login item settings:', error.message);
}
}
function createTray() { function createTray() {
tray = new Tray(getTrayIcon()); tray = new Tray(getTrayIcon());
const contextMenu = Menu.buildFromTemplate([ const contextMenu = Menu.buildFromTemplate([
@@ -69,8 +98,11 @@ function createTray() {
} }
async function initializeLoupedeck() { async function initializeLoupedeck() {
configManager = new ConfigManager(); if (!configManager) {
await configManager.load(); configManager = new ConfigManager();
await configManager.load();
}
applyAutostartToTraySetting(configManager.getSetting('autostartToTray'));
pageManager = new PageManager(configManager); pageManager = new PageManager(configManager);
@@ -106,6 +138,10 @@ async function initializeLoupedeck() {
mainWindow.webContents.send('button-toggle', data); mainWindow.webContents.send('button-toggle', data);
}); });
loupedeckDevice.on('runtime-warning', (data) => {
pushRuntimeWarning(data);
});
await loupedeckDevice.connect(); await loupedeckDevice.connect();
} }
@@ -127,6 +163,10 @@ async function initializePedal() {
mainWindow.webContents.send('pedal-button-press', data); mainWindow.webContents.send('pedal-button-press', data);
}); });
pedalDevice.on('runtime-warning', (data) => {
pushRuntimeWarning(data);
});
await pedalDevice.connect(); await pedalDevice.connect();
} }
@@ -329,6 +369,9 @@ ipcMain.handle('reconnect-device', async () => {
ipcMain.handle('set-setting', async (event, { key, value }) => { ipcMain.handle('set-setting', async (event, { key, value }) => {
const previousAccent = key === 'accentColor' ? configManager.getSetting('accentColor') : null; const previousAccent = key === 'accentColor' ? configManager.getSetting('accentColor') : null;
configManager.setSetting(key, value); configManager.setSetting(key, value);
if (key === 'autostartToTray') {
applyAutostartToTraySetting(value);
}
if (key === 'accentColor') { if (key === 'accentColor') {
const config = configManager.getConfig(); const config = configManager.getConfig();
if (Array.isArray(config.pages)) { if (Array.isArray(config.pages)) {
@@ -413,7 +456,10 @@ ipcMain.handle('window-close', () => {
}); });
app.whenReady().then(async () => { app.whenReady().then(async () => {
createWindow(); configManager = new ConfigManager();
await configManager.load();
const startHidden = startInTray || !!configManager.getSetting('autostartToTray');
createWindow(startHidden);
// Tray nur bauen, wenn das Icon da ist // Tray nur bauen, wenn das Icon da ist
try { try {
@@ -427,7 +473,7 @@ app.whenReady().then(async () => {
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow(false);
} }
}); });
}); });

1098
node_modules/.package-lock.json generated vendored

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -419,9 +419,9 @@
"node_gyp": "/usr/lib/node_modules/node-gyp/bin/node-gyp.js", "node_gyp": "/usr/lib/node_modules/node-gyp/bin/node-gyp.js",
"target": "28.3.3", "target": "28.3.3",
"platform": "linux", "platform": "linux",
"user_agent": "npm/11.7.0 node/v25.2.1 linux x64 workspaces/false", "user_agent": "npm/11.10.1 node/v25.6.1 linux x64 workspaces/false",
"prefix": "/usr", "prefix": "/usr",
"npm_version": "11.7.0", "npm_version": "11.10.1",
"runtime": "electron", "runtime": "electron",
"target_platform": "linux", "target_platform": "linux",
"init_module": "/home/erik/.npm-init.js", "init_module": "/home/erik/.npm-init.js",

View File

@@ -16,6 +16,7 @@
"author": "Erik", "author": "Erik",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@elgato-stream-deck/node": "^7.5.2",
"canvas": "^2.11.2", "canvas": "^2.11.2",
"loupedeck": "^7.0.3" "loupedeck": "^7.0.3"
}, },
@@ -32,7 +33,9 @@
"asarUnpack": [ "asarUnpack": [
"**/*.node", "**/*.node",
"**/canvas/**", "**/canvas/**",
"**/loupedeck/**" "**/loupedeck/**",
"**/@elgato-stream-deck/**",
"**/node-hid/**"
], ],
"files": [ "files": [
"main.js", "main.js",
@@ -40,6 +43,7 @@
"src/**/*", "src/**/*",
"assets/**/*", "assets/**/*",
"config/**/*", "config/**/*",
"udev/**/*",
"package.json", "package.json",
"node_modules/**/*" "node_modules/**/*"
], ],

View File

@@ -1,9 +0,0 @@
{
"permissions": {
"allow": [
"Bash(done)",
"Bash(python3:*)",
"Bash(grep:*)"
]
}
}

View File

@@ -1,4 +0,0 @@
vendor/
public/uploads/expenses/
public/uploads/invoices/
.git/

View File

@@ -1,118 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
PIRP (Packed Internes Rechnungsprogramm) is a simple internal invoicing application written in PHP. It's a German-language business tool for managing invoices, customers, expenses, recurring subscriptions, and generating EÜR (Einnahmen-Überschuss-Rechnung / income-expenditure accounting) reports.
## Tech Stack
- **PHP** (vanilla, no framework)
- **PostgreSQL** database
- **Dompdf** for PDF invoice generation
- **Session-based authentication**
- **SMF-style UI theme** (CSS with gradients, tabs)
## Architecture
```
/var/www/pirp/
├── public/ # Web-accessible files (document root)
│ ├── assets/ # CSS (style.css - SMF-style theme)
│ ├── uploads/ # User-uploaded files
│ │ ├── logos/ # Company logos
│ │ ├── expenses/# Expense receipts (PDFs)
│ │ └── invoices/# Archived invoice PDFs (GoBD-compliant)
│ └── *.php # Page controllers
├── src/ # Business logic (not web-accessible)
│ ├── config.php # Database credentials, BASE_URL, session setup
│ ├── db.php # PDO connection singleton (get_db())
│ ├── auth.php # Login/logout/require_login functions
│ ├── invoice_functions.php # Settings + invoice number generation
│ ├── customer_functions.php # Customer CRUD
│ ├── expense_functions.php # Expense CRUD
│ ├── pdf_functions.php # GoBD-compliant PDF archiving
│ └── recurring_functions.php # Subscription invoice management
├── tools/ # CLI utilities
│ ├── hash.php # Password hash generator
│ ├── migrate_pdf.sql # DB migration for PDF archiving
│ ├── migrate_pdfs.php # Migrate existing invoices to archived PDFs
│ └── migrate_recurring.sql # DB migration for subscriptions
├── schema.sql # Full database schema
└── vendor/ # Composer dependencies
```
## Database
PostgreSQL with tables: `users`, `settings`, `customers`, `invoices`, `invoice_items`, `expenses`, `recurring_templates`, `recurring_template_items`, `recurring_log`. See `schema.sql` for complete schema.
Key relationships:
- `invoices``customers` (customer_id foreign key)
- `invoice_items``invoices` (invoice_id foreign key, CASCADE delete)
- `recurring_templates``customers`
- `recurring_template_items``recurring_templates` (CASCADE delete)
- `recurring_log``recurring_templates`, `invoices`
VAT modes: `klein` (Kleinunternehmer - VAT exempt) or `normal` (standard VAT).
## Development Commands
```bash
# Install dependencies
composer install
# Generate password hash for new user
php tools/hash.php YOUR_PASSWORD
# Initialize database (fresh install)
psql -U pirp_user -d pirp -f schema.sql
# Apply migrations (existing database)
psql -U pirp_user -d pirp -f tools/migrate_pdf.sql
psql -U pirp_user -d pirp -f tools/migrate_recurring.sql
# Migrate existing invoices to archived PDFs
php tools/migrate_pdfs.php
```
## Configuration
Database connection in `src/config.php`. Set `BASE_URL` if running in a subdirectory (e.g., `/pirp`).
## Key Patterns
- All public pages include `require_login()` from `src/auth.php`
- Database access via `get_db()` singleton returning PDO instance
- Invoice numbers: `PIRP-YYYY-NNNNN` (auto-generated)
- Customer numbers: `PIKN-NNNNNN` (auto-generated)
## GoBD-Compliant PDF Archiving
German tax law (GoBD) requires invoices to be stored immutably. PDFs are:
- Generated once at invoice creation
- Stored in `public/uploads/invoices/{year}/`
- Protected with chmod 444
- Verified via SHA-256 hash stored in `invoices.pdf_hash`
Key functions in `src/pdf_functions.php`:
- `archive_invoice_pdf($id)` - Generate and store PDF
- `get_archived_pdf_path($id)` - Get path to archived PDF
- `verify_invoice_pdf($id)` - Verify integrity via hash
## Recurring Invoices (Subscriptions)
Subscription invoices for recurring customers with intervals:
- `monthly` - Every month
- `quarterly` - Every 3 months
- `yearly` - Every year
Key pages:
- `recurring.php` - Overview of subscription templates
- `recurring_edit.php` - Create/edit templates
- `recurring_generate.php` - Generate due invoices
Key functions in `src/recurring_functions.php`:
- `get_pending_recurring_invoices()` - Get due subscriptions
- `generate_invoice_from_template($id)` - Create invoice from template
- `calculate_next_due_date($interval, $date)` - Calculate next due date

View File

@@ -1,13 +0,0 @@
FROM php:8.3-cli
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libpq-dev libpng-dev libjpeg-dev libfreetype6-dev \
&& docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install pdo_pgsql gd \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
EXPOSE 8080
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public"]

View File

@@ -1,13 +0,0 @@
{
"name": "pirp/packed-internes-rechnungsprogramm",
"description": "Einfaches internes Rechnungsprogramm (PIRP)",
"type": "project",
"require": {
"dompdf/dompdf": "^2.0"
},
"autoload": {
"psr-4": {
"": "src/"
}
}
}

304
pirp/composer.lock generated
View File

@@ -1,304 +0,0 @@
{
"_readme": [
"This file locks the dependencies of your project to a known state",
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "76bd9531733da7ca24f4b785b8fe430d",
"packages": [
{
"name": "dompdf/dompdf",
"version": "v2.0.8",
"source": {
"type": "git",
"url": "https://github.com/dompdf/dompdf.git",
"reference": "c20247574601700e1f7c8dab39310fca1964dc52"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/dompdf/zipball/c20247574601700e1f7c8dab39310fca1964dc52",
"reference": "c20247574601700e1f7c8dab39310fca1964dc52",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-mbstring": "*",
"masterminds/html5": "^2.0",
"phenx/php-font-lib": ">=0.5.4 <1.0.0",
"phenx/php-svg-lib": ">=0.5.2 <1.0.0",
"php": "^7.1 || ^8.0"
},
"require-dev": {
"ext-json": "*",
"ext-zip": "*",
"mockery/mockery": "^1.3",
"phpunit/phpunit": "^7.5 || ^8 || ^9",
"squizlabs/php_codesniffer": "^3.5"
},
"suggest": {
"ext-gd": "Needed to process images",
"ext-gmagick": "Improves image processing performance",
"ext-imagick": "Improves image processing performance",
"ext-zlib": "Needed for pdf stream compression"
},
"type": "library",
"autoload": {
"psr-4": {
"Dompdf\\": "src/"
},
"classmap": [
"lib/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "The Dompdf Community",
"homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md"
}
],
"description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter",
"homepage": "https://github.com/dompdf/dompdf",
"support": {
"issues": "https://github.com/dompdf/dompdf/issues",
"source": "https://github.com/dompdf/dompdf/tree/v2.0.8"
},
"time": "2024-04-29T13:06:17+00:00"
},
{
"name": "masterminds/html5",
"version": "2.10.0",
"source": {
"type": "git",
"url": "https://github.com/Masterminds/html5-php.git",
"reference": "fcf91eb64359852f00d921887b219479b4f21251"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Masterminds/html5-php/zipball/fcf91eb64359852f00d921887b219479b4f21251",
"reference": "fcf91eb64359852f00d921887b219479b4f21251",
"shasum": ""
},
"require": {
"ext-dom": "*",
"php": ">=5.3.0"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35 || ^5.7.21 || ^6 || ^7 || ^8 || ^9"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.7-dev"
}
},
"autoload": {
"psr-4": {
"Masterminds\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Matt Butcher",
"email": "technosophos@gmail.com"
},
{
"name": "Matt Farina",
"email": "matt@mattfarina.com"
},
{
"name": "Asmir Mustafic",
"email": "goetas@gmail.com"
}
],
"description": "An HTML5 parser and serializer.",
"homepage": "http://masterminds.github.io/html5-php",
"keywords": [
"HTML5",
"dom",
"html",
"parser",
"querypath",
"serializer",
"xml"
],
"support": {
"issues": "https://github.com/Masterminds/html5-php/issues",
"source": "https://github.com/Masterminds/html5-php/tree/2.10.0"
},
"time": "2025-07-25T09:04:22+00:00"
},
{
"name": "phenx/php-font-lib",
"version": "0.5.6",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-font-lib.git",
"reference": "a1681e9793040740a405ac5b189275059e2a9863"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a1681e9793040740a405ac5b189275059e2a9863",
"reference": "a1681e9793040740a405ac5b189275059e2a9863",
"shasum": ""
},
"require": {
"ext-mbstring": "*"
},
"require-dev": {
"symfony/phpunit-bridge": "^3 || ^4 || ^5 || ^6"
},
"type": "library",
"autoload": {
"psr-4": {
"FontLib\\": "src/FontLib"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Fabien Ménager",
"email": "fabien.menager@gmail.com"
}
],
"description": "A library to read, parse, export and make subsets of different types of font files.",
"homepage": "https://github.com/PhenX/php-font-lib",
"support": {
"issues": "https://github.com/dompdf/php-font-lib/issues",
"source": "https://github.com/dompdf/php-font-lib/tree/0.5.6"
},
"time": "2024-01-29T14:45:26+00:00"
},
{
"name": "phenx/php-svg-lib",
"version": "0.5.4",
"source": {
"type": "git",
"url": "https://github.com/dompdf/php-svg-lib.git",
"reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/46b25da81613a9cf43c83b2a8c2c1bdab27df691",
"reference": "46b25da81613a9cf43c83b2a8c2c1bdab27df691",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"php": "^7.1 || ^8.0",
"sabberworm/php-css-parser": "^8.4"
},
"require-dev": {
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5"
},
"type": "library",
"autoload": {
"psr-4": {
"Svg\\": "src/Svg"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0-or-later"
],
"authors": [
{
"name": "Fabien Ménager",
"email": "fabien.menager@gmail.com"
}
],
"description": "A library to read, parse and export to PDF SVG files.",
"homepage": "https://github.com/PhenX/php-svg-lib",
"support": {
"issues": "https://github.com/dompdf/php-svg-lib/issues",
"source": "https://github.com/dompdf/php-svg-lib/tree/0.5.4"
},
"time": "2024-04-08T12:52:34+00:00"
},
{
"name": "sabberworm/php-css-parser",
"version": "v8.9.0",
"source": {
"type": "git",
"url": "https://github.com/MyIntervals/PHP-CSS-Parser.git",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/d8e916507b88e389e26d4ab03c904a082aa66bb9",
"reference": "d8e916507b88e389e26d4ab03c904a082aa66bb9",
"shasum": ""
},
"require": {
"ext-iconv": "*",
"php": "^5.6.20 || ^7.0.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0"
},
"require-dev": {
"phpunit/phpunit": "5.7.27 || 6.5.14 || 7.5.20 || 8.5.41",
"rawr/cross-data-providers": "^2.0.0"
},
"suggest": {
"ext-mbstring": "for parsing UTF-8 CSS"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "9.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Sabberworm\\CSS\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Raphael Schweikert"
},
{
"name": "Oliver Klee",
"email": "github@oliverklee.de"
},
{
"name": "Jake Hotson",
"email": "jake.github@qzdesign.co.uk"
}
],
"description": "Parser for CSS Files written in PHP",
"homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser",
"keywords": [
"css",
"parser",
"stylesheet"
],
"support": {
"issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues",
"source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v8.9.0"
},
"time": "2025-07-11T13:20:48+00:00"
}
],
"packages-dev": [],
"aliases": [],
"minimum-stability": "stable",
"stability-flags": [],
"prefer-stable": false,
"prefer-lowest": false,
"platform": [],
"platform-dev": [],
"plugin-api-version": "2.6.0"
}

View File

@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
case "${1:-start}" in
start)
echo "PIRP Dev-Umgebung starten..."
echo "App: http://localhost:8080"
echo "Ctrl+C zum Stoppen."
docker compose up --build
;;
stop)
docker compose down
echo "Gestoppt."
;;
reset-db)
echo "Datenbank wird zurückgesetzt..."
docker compose down -v
docker compose up --build
;;
logs)
docker compose logs -f
;;
*)
echo "Verwendung: ./dev.sh [start|stop|reset-db|logs]"
echo ""
echo " start App + DB starten (Standard)"
echo " stop Alles stoppen"
echo " reset-db DB-Volume löschen und neu aufsetzen"
echo " logs Logs verfolgen"
;;
esac

View File

@@ -1,36 +0,0 @@
services:
web:
build: .
ports:
- "8080:8080"
volumes:
- .:/app
depends_on:
db:
condition: service_healthy
environment:
DB_HOST: db
DB_PORT: "5432"
DB_NAME: pirp
DB_USER: pirp_user
DB_PASS: PIRPdb2025!
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: pirp
POSTGRES_USER: pirp_user
POSTGRES_PASSWORD: PIRPdb2025!
volumes:
- pgdata:/var/lib/postgresql/data
- ./schema.sql:/docker-entrypoint-initdb.d/01-schema.sql
- ./tools/migrate_journal.sql:/docker-entrypoint-initdb.d/02-journal.sql
- ./tools/seed_dev.sql:/docker-entrypoint-initdb.d/03-seed.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pirp_user -d pirp"]
interval: 2s
timeout: 5s
retries: 10
volumes:
pgdata:

View File

@@ -1,184 +0,0 @@
/**
* PirpCombobox - Tippbare Dropdown-Auswahl als Ersatz für <select>
*
* Verwendung:
* new PirpCombobox(containerEl, accountOptions, {
* placeholder: '--Konto--',
* selectedValue: 'kasse_s|soll||',
* onSelect: function(opt) { ... }
* });
*
* accountOptions: { "Gruppenname": [ {value, label, side, rev_id, exp_id}, ... ], ... }
*/
class PirpCombobox {
constructor(container, options, config) {
this.container = container;
this.options = options; // grouped: { group: [{value, label, side, rev_id, exp_id}] }
this.config = config || {};
this.flatOptions = [];
this.highlighted = -1;
// Flatten options for filtering
for (const [group, opts] of Object.entries(this.options)) {
for (const opt of opts) {
this.flatOptions.push({ group, ...opt });
}
}
this.build();
this.bindEvents();
// Set initial selection
if (this.config.selectedValue) {
const parts = this.config.selectedValue.split('|');
const match = this.flatOptions.find(o =>
o.value === parts[0] && o.side === parts[1] &&
String(o.rev_id) === String(parts[2]) &&
String(o.exp_id) === String(parts[3])
);
if (match) {
this.input.value = match.label;
this.selectedOpt = match;
}
}
}
build() {
this.container.classList.add('pirp-combobox');
this.input = document.createElement('input');
this.input.type = 'text';
this.input.placeholder = this.config.placeholder || '--Konto--';
this.input.autocomplete = 'off';
this.dropdown = document.createElement('div');
this.dropdown.className = 'pirp-combobox-dropdown';
this.dropdown.style.display = 'none';
this.container.appendChild(this.input);
this.container.appendChild(this.dropdown);
this.selectedOpt = null;
}
renderDropdown(filter) {
this.dropdown.innerHTML = '';
this.highlighted = -1;
const f = (filter || '').toLowerCase();
let visibleItems = [];
let lastGroup = null;
for (const opt of this.flatOptions) {
if (f && opt.label.toLowerCase().indexOf(f) === -1) continue;
if (opt.group !== lastGroup) {
lastGroup = opt.group;
const groupEl = document.createElement('div');
groupEl.className = 'pirp-combobox-group';
groupEl.textContent = opt.group;
this.dropdown.appendChild(groupEl);
}
const optEl = document.createElement('div');
optEl.className = 'pirp-combobox-option';
optEl.textContent = opt.label;
optEl.dataset.index = visibleItems.length;
visibleItems.push({ el: optEl, opt });
this.dropdown.appendChild(optEl);
}
this.visibleItems = visibleItems;
}
open() {
this.renderDropdown(this.input.value === (this.selectedOpt ? this.selectedOpt.label : '') ? '' : this.input.value);
this.dropdown.style.display = 'block';
}
close() {
this.dropdown.style.display = 'none';
this.highlighted = -1;
}
select(opt) {
this.selectedOpt = opt;
this.input.value = opt.label;
this.close();
if (this.config.onSelect) {
this.config.onSelect(opt);
}
}
highlightIndex(idx) {
if (!this.visibleItems || !this.visibleItems.length) return;
if (this.highlighted >= 0 && this.highlighted < this.visibleItems.length) {
this.visibleItems[this.highlighted].el.classList.remove('highlighted');
}
this.highlighted = Math.max(0, Math.min(idx, this.visibleItems.length - 1));
this.visibleItems[this.highlighted].el.classList.add('highlighted');
this.visibleItems[this.highlighted].el.scrollIntoView({ block: 'nearest' });
}
bindEvents() {
this.input.addEventListener('focus', () => {
this.open();
});
this.input.addEventListener('input', () => {
this.renderDropdown(this.input.value);
this.dropdown.style.display = 'block';
if (this.visibleItems.length > 0) {
this.highlightIndex(0);
}
});
this.input.addEventListener('keydown', (e) => {
if (this.dropdown.style.display === 'none') {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
this.open();
e.preventDefault();
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.highlightIndex(this.highlighted + 1);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
this.highlightIndex(this.highlighted - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (this.highlighted >= 0 && this.visibleItems[this.highlighted]) {
this.select(this.visibleItems[this.highlighted].opt);
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (this.selectedOpt) {
this.input.value = this.selectedOpt.label;
}
this.close();
}
});
this.input.addEventListener('blur', () => {
// Delay to allow click on option to register
setTimeout(() => {
if (this.selectedOpt) {
this.input.value = this.selectedOpt.label;
}
this.close();
}, 150);
});
// Use mousedown to prevent blur race condition
this.dropdown.addEventListener('mousedown', (e) => {
e.preventDefault(); // Prevent blur
const optEl = e.target.closest('.pirp-combobox-option');
if (optEl) {
const idx = parseInt(optEl.dataset.index);
if (this.visibleItems[idx]) {
this.select(this.visibleItems[idx].opt);
}
}
});
}
}

View File

@@ -1,202 +0,0 @@
// PIRP Command Palette (Ctrl+K / Cmd+K)
(function() {
'use strict';
var overlay = null;
var input = null;
var resultsList = null;
var debounceTimer = null;
var activeIndex = -1;
var currentResults = [];
var typeLabels = {
invoice: 'Rechnungen',
customer: 'Kunden',
expense: 'Ausgaben',
journal: 'Journal'
};
var typeIcons = {
invoice: '\u25B8',
customer: '\u25CF',
expense: '\u25A0',
journal: '\u25C6'
};
function create() {
overlay = document.createElement('div');
overlay.className = 'cmd-palette-overlay hidden';
overlay.addEventListener('click', function(e) {
if (e.target === overlay) close();
});
var modal = document.createElement('div');
modal.className = 'cmd-palette';
input = document.createElement('input');
input.type = 'text';
input.placeholder = 'Suche nach Rechnungen, Kunden, Ausgaben, Journal...';
input.addEventListener('input', function() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(doSearch, 200);
});
input.addEventListener('keydown', handleKeydown);
resultsList = document.createElement('div');
resultsList.className = 'cmd-palette-results';
modal.appendChild(input);
modal.appendChild(resultsList);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
function open() {
if (!overlay) create();
overlay.classList.remove('hidden');
input.value = '';
resultsList.innerHTML = '';
activeIndex = -1;
currentResults = [];
setTimeout(function() { input.focus(); }, 10);
}
function close() {
if (overlay) overlay.classList.add('hidden');
}
function isOpen() {
return overlay && !overlay.classList.contains('hidden');
}
function doSearch() {
var q = input.value.trim();
if (q.length < 2) {
resultsList.innerHTML = '';
activeIndex = -1;
currentResults = [];
return;
}
fetch('search_api.php?q=' + encodeURIComponent(q))
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) return;
currentResults = data.results;
activeIndex = -1;
renderResults(data.results);
})
.catch(function() {});
}
function renderResults(results) {
resultsList.innerHTML = '';
if (!results.length) {
var empty = document.createElement('div');
empty.className = 'cmd-palette-empty';
empty.textContent = 'Keine Ergebnisse';
resultsList.appendChild(empty);
return;
}
// Group by type
var groups = {};
var order = [];
results.forEach(function(r) {
if (!groups[r.type]) {
groups[r.type] = [];
order.push(r.type);
}
groups[r.type].push(r);
});
var idx = 0;
order.forEach(function(type) {
var header = document.createElement('div');
header.className = 'cmd-palette-group';
header.textContent = typeLabels[type] || type;
resultsList.appendChild(header);
groups[type].forEach(function(r) {
var item = document.createElement('a');
item.className = 'cmd-palette-item';
item.href = r.url;
item.setAttribute('data-idx', idx);
var icon = document.createElement('span');
icon.className = 'cmd-palette-icon';
icon.textContent = typeIcons[r.type] || '\u25B8';
item.appendChild(icon);
var text = document.createElement('span');
text.className = 'cmd-palette-text';
var title = document.createElement('span');
title.className = 'cmd-palette-title';
title.textContent = r.title;
text.appendChild(title);
if (r.subtitle) {
var sub = document.createElement('span');
sub.className = 'cmd-palette-subtitle';
sub.textContent = r.subtitle;
text.appendChild(sub);
}
item.appendChild(text);
item.addEventListener('mouseenter', function() {
setActive(parseInt(item.getAttribute('data-idx')));
});
resultsList.appendChild(item);
idx++;
});
});
}
function setActive(idx) {
var items = resultsList.querySelectorAll('.cmd-palette-item');
items.forEach(function(el) { el.classList.remove('active'); });
activeIndex = idx;
if (idx >= 0 && idx < items.length) {
items[idx].classList.add('active');
items[idx].scrollIntoView({ block: 'nearest' });
}
}
function handleKeydown(e) {
var items = resultsList.querySelectorAll('.cmd-palette-item');
var count = items.length;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(activeIndex < count - 1 ? activeIndex + 1 : 0);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(activeIndex > 0 ? activeIndex - 1 : count - 1);
} else if (e.key === 'Enter') {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < count) {
window.location = items[activeIndex].href;
}
} else if (e.key === 'Escape') {
e.preventDefault();
close();
}
}
// Global keyboard listener
document.addEventListener('keydown', function(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
if (isOpen()) {
close();
} else {
open();
}
}
if (e.key === 'Escape' && isOpen()) {
e.preventDefault();
close();
}
});
})();

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
$filter_type = $_GET['type'] ?? 'all'; // all | rechnung | ausgabe | mahnung
$filter_from = trim($_GET['from'] ?? '');
$filter_to = trim($_GET['to'] ?? '');
$filter_q = trim($_GET['q'] ?? '');
$invoice_id = isset($_GET['invoice_id']) ? (int)$_GET['invoice_id'] : 0;
$belege = [];
// ---- Rechnungs-PDFs ----
if ($filter_type === 'all' || $filter_type === 'rechnung') {
$base = "FROM invoices i JOIN customers c ON c.id = i.customer_id WHERE i.pdf_path IS NOT NULL";
$params = [];
if ($filter_from) { $base .= " AND i.invoice_date >= :from"; $params[':from'] = $filter_from; }
if ($filter_to) { $base .= " AND i.invoice_date <= :to"; $params[':to'] = $filter_to; }
if ($filter_q) { $base .= " AND (i.invoice_number ILIKE :q OR c.name ILIKE :q2)";
$params[':q'] = '%' . $filter_q . '%'; $params[':q2'] = '%' . $filter_q . '%'; }
try {
$sql = "SELECT i.id, i.invoice_date AS beleg_date, i.invoice_number AS beleg_ref,
c.name AS kunde, i.total_gross AS betrag, i.pdf_path,
'rechnung' AS beleg_type, COALESCE(i.is_storno, FALSE) AS is_storno $base";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
} catch (\PDOException $e) {
// is_storno Spalte noch nicht migriert — Fallback ohne die Spalte
$sql = "SELECT i.id, i.invoice_date AS beleg_date, i.invoice_number AS beleg_ref,
c.name AS kunde, i.total_gross AS betrag, i.pdf_path,
'rechnung' AS beleg_type, FALSE AS is_storno $base";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
$belege = array_merge($belege, $stmt->fetchAll(PDO::FETCH_ASSOC));
}
// ---- Ausgaben-Belege ----
if ($filter_type === 'all' || $filter_type === 'ausgabe') {
$sql = "SELECT e.id, e.expense_date AS beleg_date, e.description AS beleg_ref,
'' AS kunde, e.amount AS betrag, e.attachment_path AS pdf_path,
'ausgabe' AS beleg_type, FALSE AS is_storno
FROM expenses e
WHERE e.attachment_path IS NOT NULL";
$params = [];
if ($filter_from) { $sql .= " AND e.expense_date >= :from"; $params[':from'] = $filter_from; }
if ($filter_to) { $sql .= " AND e.expense_date <= :to"; $params[':to'] = $filter_to; }
if ($filter_q) { $sql .= " AND e.description ILIKE :q"; $params[':q'] = '%' . $filter_q . '%'; }
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$belege = array_merge($belege, $stmt->fetchAll(PDO::FETCH_ASSOC));
}
// ---- Mahnungen ----
$show_mahnungen = ($filter_type === 'all' || $filter_type === 'mahnung');
if ($show_mahnungen) {
$sql = "SELECT m.id, m.mahnung_date AS beleg_date,
'MAHNUNG L' || m.level || ' ' || i.invoice_number AS beleg_ref,
c.name AS kunde, i.total_gross + m.fee_amount AS betrag,
m.pdf_path, 'mahnung' AS beleg_type, FALSE AS is_storno,
m.invoice_id
FROM mahnungen m
JOIN invoices i ON i.id = m.invoice_id
JOIN customers c ON c.id = i.customer_id
WHERE m.pdf_path IS NOT NULL";
$params = [];
if ($invoice_id) { $sql .= " AND m.invoice_id = :iid"; $params[':iid'] = $invoice_id; }
if ($filter_from) { $sql .= " AND m.mahnung_date >= :from"; $params[':from'] = $filter_from; }
if ($filter_to) { $sql .= " AND m.mahnung_date <= :to"; $params[':to'] = $filter_to; }
if ($filter_q) { $sql .= " AND (i.invoice_number ILIKE :q OR c.name ILIKE :q2)";
$params[':q'] = '%' . $filter_q . '%'; $params[':q2'] = '%' . $filter_q . '%'; }
try {
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$belege = array_merge($belege, $stmt->fetchAll(PDO::FETCH_ASSOC));
} catch (\PDOException $e) {
// Tabelle noch nicht migriert
}
}
// Sortieren: neueste zuerst
usort($belege, fn($a, $b) => strcmp($b['beleg_date'], $a['beleg_date']));
$type_labels = ['rechnung' => 'Rechnung', 'ausgabe' => 'Ausgabe', 'mahnung' => 'Mahnung'];
$type_colors = ['rechnung' => 'var(--accent)', 'ausgabe' => 'var(--info)', 'mahnung' => 'var(--warning)'];
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Belegarchiv</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>" class="active"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<form method="get" class="filters">
<label>Typ:
<select name="type">
<option value="all" <?= $filter_type === 'all' ? 'selected' : '' ?>>Alle</option>
<option value="rechnung" <?= $filter_type === 'rechnung' ? 'selected' : '' ?>>Rechnungen</option>
<option value="ausgabe" <?= $filter_type === 'ausgabe' ? 'selected' : '' ?>>Ausgaben</option>
<option value="mahnung" <?= $filter_type === 'mahnung' ? 'selected' : '' ?>>Mahnungen</option>
</select>
</label>
<label>Suche:
<input type="text" name="q" value="<?= htmlspecialchars($filter_q) ?>" placeholder="Nr., Beschreibung, Kunde...">
</label>
<label>Von:
<input type="date" name="from" value="<?= htmlspecialchars($filter_from) ?>">
</label>
<label>Bis:
<input type="date" name="to" value="<?= htmlspecialchars($filter_to) ?>">
</label>
<button type="submit">Filtern</button>
<a href="<?= url_for('belegarchiv.php') ?>">Zurücksetzen</a>
</form>
<section>
<h2>Belegarchiv
<span style="font-weight:normal;font-size:12px;color:var(--text-muted);"><?= count($belege) ?> Dokument(e)</span>
</h2>
<?php if (empty($belege)): ?>
<p style="color:var(--text-muted);">Keine Belege gefunden.</p>
<?php else: ?>
<table class="list">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Referenz / Beschreibung</th>
<th>Kunde</th>
<th style="text-align:right;">Betrag</th>
<th>PDF</th>
</tr>
</thead>
<tbody>
<?php foreach ($belege as $b): ?>
<tr>
<td><?= date('d.m.Y', strtotime($b['beleg_date'])) ?></td>
<td>
<span style="font-size:10px;color:<?= $type_colors[$b['beleg_type']] ?? 'var(--text)' ?>;">
<?= $type_labels[$b['beleg_type']] ?? $b['beleg_type'] ?>
<?php if (!empty($b['is_storno'])): ?>
<span style="color:var(--error);"> · STORNO</span>
<?php endif; ?>
</span>
</td>
<td><?= htmlspecialchars($b['beleg_ref']) ?></td>
<td style="color:var(--text-muted);"><?= htmlspecialchars($b['kunde']) ?></td>
<td style="text-align:right;font-family:var(--font-mono);font-size:12px;">
<?= number_format((float)$b['betrag'], 2, ',', '.') ?> €
</td>
<td>
<?php if ($b['beleg_type'] === 'rechnung'): ?>
<a href="<?= url_for('invoice_pdf.php?id=' . $b['id']) ?>" target="_blank">PDF</a>
<?php elseif ($b['beleg_type'] === 'ausgabe'): ?>
<a href="<?= url_for('expense_file.php?id=' . $b['id']) ?>" target="_blank">PDF</a>
<?php elseif ($b['beleg_type'] === 'mahnung'): ?>
<a href="<?= url_for('mahnung_pdf.php?id=' . $b['id']) ?>" target="_blank">PDF</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,139 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/customer_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$msg = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$idPost = isset($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'address' => $_POST['address'] ?? '',
'zip' => $_POST['zip'] ?? '',
'city' => $_POST['city'] ?? '',
'country' => $_POST['country'] ?? '',
];
if (trim($data['name']) === '') {
$error = 'Name darf nicht leer sein.';
} else {
save_customer($idPost, $data);
$msg = 'Kunde gespeichert.';
$action = '';
$id = null;
}
}
if ($action === 'delete' && $id) {
delete_customer($id);
$msg = 'Kunde gelöscht.';
$action = '';
$id = null;
}
$customers = get_customers();
$editCustomer = null;
if ($action === 'edit' && $id) {
$editCustomer = get_customer($id);
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Kunden</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>" class="active"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<section>
<h2><?= $editCustomer ? 'Kunde bearbeiten' : 'Neuer Kunde' ?></h2>
<form method="post">
<input type="hidden" name="id" value="<?= htmlspecialchars($editCustomer['id'] ?? '') ?>">
<label>Name:
<input type="text" name="name" value="<?= htmlspecialchars($editCustomer['name'] ?? '') ?>" required>
</label>
<label>Adresse:
<textarea name="address" rows="3"><?= htmlspecialchars($editCustomer['address'] ?? '') ?></textarea>
</label>
<div class="flex-row">
<label>PLZ:
<input type="text" name="zip" value="<?= htmlspecialchars($editCustomer['zip'] ?? '') ?>">
</label>
<label>Ort:
<input type="text" name="city" value="<?= htmlspecialchars($editCustomer['city'] ?? '') ?>">
</label>
<label>Land:
<input type="text" name="country" value="<?= htmlspecialchars($editCustomer['country'] ?? '') ?>">
</label>
</div>
<?php if (!empty($editCustomer['customer_number'])): ?>
<p>Kundennummer: <?= htmlspecialchars($editCustomer['customer_number']) ?></p>
<?php endif; ?>
<button type="submit">Speichern</button>
<?php if ($editCustomer): ?>
<a href="<?= url_for('customers.php') ?>">Abbrechen</a>
<?php endif; ?>
</form>
</section>
<section>
<h2>Alle Kunden</h2>
<table class="list">
<thead>
<tr>
<th>Kundennr.</th>
<th>Name</th>
<th>Adresse</th>
<th>Ort</th>
<th>Land</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($customers as $c): ?>
<tr>
<td><?= htmlspecialchars($c['customer_number'] ?? '') ?></td>
<td><?= htmlspecialchars($c['name']) ?></td>
<td><?= nl2br(htmlspecialchars($c['address'])) ?></td>
<td><?= htmlspecialchars($c['zip'] . ' ' . $c['city']) ?></td>
<td><?= htmlspecialchars($c['country']) ?></td>
<td>
<a href="<?= url_for('customers.php?action=edit&id=' . $c['id']) ?>">Bearbeiten</a>
<a href="<?= url_for('customers.php?action=delete&id=' . $c['id']) ?>" onclick="return confirm('Kunde wirklich löschen?');">Löschen</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($customers)): ?>
<tr><td colspan="6">Keine Kunden vorhanden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,336 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
// Jahr bestimmen
$years = get_journal_years();
$current_cal_year = (int)date('Y');
$year = isset($_GET['year']) ? (int)$_GET['year'] : $current_cal_year;
// Journal-Jahr finden (oder automatisch erstellen bei Bedarf)
$journal_year = get_journal_year_by_year($year);
// Fehlende Buchungen nachholen (Quick-Fix)
$fix_msg = '';
$fix_errors = [];
if (isset($_GET['fix_missing']) && $journal_year) {
$fixed_inv = 0;
$fixed_exp = 0;
$consistency = check_journal_consistency();
foreach ($consistency['unbooked_invoice_list'] as $inv) {
try {
create_journal_entry_from_invoice((int)$inv['id']);
$fixed_inv++;
} catch (Exception $e) {
$fix_errors[] = 'Rechnung ' . ($inv['invoice_number'] ?? '#' . $inv['id']) . ': ' . $e->getMessage();
}
}
foreach ($consistency['unbooked_expense_list'] as $exp) {
try {
create_journal_entry_from_expense((int)$exp['id']);
$fixed_exp++;
} catch (Exception $e) {
$fix_errors[] = 'Ausgabe "' . ($exp['description'] ?? '#' . $exp['id']) . '": ' . $e->getMessage();
}
}
$fix_msg = $fixed_inv . ' Rechnungen und ' . $fixed_exp . ' Ausgaben nachgebucht.';
if ($fix_errors) {
$fix_msg .= ' ' . count($fix_errors) . ' Fehler aufgetreten.';
}
}
// CSV-Export Journal
if (isset($_GET['csv_journal']) && $journal_year) {
$euer = generate_journal_euer((int)$journal_year['id']);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="eur_' . $year . '.csv"');
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['EÜR - ' . $year], ';');
fputcsv($out, [], ';');
fputcsv($out, ['Position', 'Betrag'], ';');
fputcsv($out, [], ';');
fputcsv($out, ['EINNAHMEN'], ';');
foreach ($euer['erloese_detail'] as $row) {
fputcsv($out, [$row['name'], number_format((float)$row['total'], 2, ',', '.')], ';');
}
if ($euer['sonstiges_einnahmen'] > 0) {
fputcsv($out, ['Sonstige Einnahmen', number_format($euer['sonstiges_einnahmen'], 2, ',', '.')], ';');
}
fputcsv($out, ['Einnahmen gesamt', number_format($euer['einnahmen_total'], 2, ',', '.')], ';');
fputcsv($out, [], ';');
fputcsv($out, ['AUSGABEN'], ';');
if ($euer['wareneingang'] > 0) {
fputcsv($out, ['Wareneingang', number_format($euer['wareneingang'], 2, ',', '.')], ';');
}
foreach ($euer['aufwand_detail'] as $row) {
fputcsv($out, [$row['name'], number_format((float)$row['total'], 2, ',', '.')], ';');
}
if ($euer['sonstiges_ausgaben'] > 0) {
fputcsv($out, ['Sonstige Ausgaben', number_format($euer['sonstiges_ausgaben'], 2, ',', '.')], ';');
}
fputcsv($out, ['Ausgaben gesamt', number_format($euer['ausgaben_total'], 2, ',', '.')], ';');
fputcsv($out, [], ';');
fputcsv($out, ['STEUER'], ';');
fputcsv($out, ['MwSt (eingenommen)', number_format($euer['mwst'], 2, ',', '.')], ';');
fputcsv($out, ['VorSt (gezahlt)', number_format($euer['vorst'], 2, ',', '.')], ';');
fputcsv($out, ['Steuer-Saldo', number_format($euer['steuer_saldo'], 2, ',', '.')], ';');
fputcsv($out, [], ';');
if ($euer['privat_entnahmen'] > 0 || $euer['privat_einlagen'] > 0) {
fputcsv($out, ['PRIVATKONTEN'], ';');
if ($euer['privat_einlagen'] > 0) {
fputcsv($out, ['Privateinlagen', number_format($euer['privat_einlagen'], 2, ',', '.')], ';');
}
if ($euer['privat_entnahmen'] > 0) {
fputcsv($out, ['Privatentnahmen', number_format($euer['privat_entnahmen'], 2, ',', '.')], ';');
}
fputcsv($out, [], ';');
}
fputcsv($out, ['GEWINN', number_format($euer['gewinn'], 2, ',', '.')], ';');
fclose($out);
exit;
}
// Journal-Daten laden
$journal_euer = null;
if ($journal_year) {
$journal_euer = generate_journal_euer((int)$journal_year['id']);
}
// Konsistenz-Check: Fehlende Buchungen
$consistency = check_journal_consistency();
$has_missing = ($consistency['unbooked_invoices'] > 0 || $consistency['unbooked_expenses'] > 0);
// Verfügbare Jahre sammeln (aus Journal + Rechnungen + Ausgaben)
$available_years = [];
foreach ($years as $y) {
$available_years[(int)$y['year']] = true;
}
$stmt = $pdo->query("SELECT DISTINCT EXTRACT(YEAR FROM invoice_date)::int AS y FROM invoices WHERE paid = TRUE ORDER BY y DESC");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$available_years[$row['y']] = true;
}
try {
$stmt = $pdo->query("SELECT DISTINCT EXTRACT(YEAR FROM expense_date)::int AS y FROM expenses WHERE paid = TRUE ORDER BY y DESC");
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$available_years[$row['y']] = true;
}
} catch (PDOException $e) {}
krsort($available_years);
$available_years = array_keys($available_years);
if (empty($available_years)) {
$available_years = [$current_cal_year];
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>EÜR <?= $year ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>" class="active"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<h2>Einnahmen-Überschuss-Rechnung <?= $year ?></h2>
<!-- Jahr-Auswahl -->
<form method="get" style="margin-bottom:16px;">
<div class="flex-row">
<label>Jahr:
<select name="year" onchange="this.form.submit();">
<?php foreach ($available_years as $y): ?>
<option value="<?= $y ?>" <?= $y == $year ? 'selected' : '' ?>><?= $y ?></option>
<?php endforeach; ?>
</select>
</label>
</div>
</form>
<?php if ($fix_msg): ?>
<p class="success"><?= htmlspecialchars($fix_msg) ?></p>
<?php if (!empty($fix_errors)): ?>
<div class="gobd-warning">
<h3>Fehler beim Nachbuchen</h3>
<ul>
<?php foreach ($fix_errors as $fe): ?>
<li><?= htmlspecialchars($fe) ?></li>
<?php endforeach; ?>
</ul>
<p style="font-size:11px;">Bitte die betroffenen Belege manuell prüfen und ggf. eine Aufwandskategorie zuweisen.</p>
</div>
<?php endif; ?>
<?php endif; ?>
<?php if ($has_missing): ?>
<div class="gobd-warning">
<h3>Fehlende Journalbuchungen</h3>
<p>Es gibt bezahlte Belege ohne Journaleintrag. Die EÜR ist dadurch unvollständig.</p>
<ul>
<?php if ($consistency['unbooked_invoices'] > 0): ?>
<li><strong><?= $consistency['unbooked_invoices'] ?></strong> bezahlte Rechnung(en) ohne Journalbuchung</li>
<?php endif; ?>
<?php if ($consistency['unbooked_expenses'] > 0): ?>
<li><strong><?= $consistency['unbooked_expenses'] ?></strong> bezahlte Ausgabe(n) ohne Journalbuchung</li>
<?php endif; ?>
</ul>
<a href="<?= url_for('euer.php?year=' . $year . '&fix_missing=1') ?>"
class="button" onclick="return confirm('Fehlende Journalbuchungen jetzt automatisch erstellen?');">
Fehlende Buchungen nachholen
</a>
</div>
<?php endif; ?>
<?php if ($journal_euer): ?>
<section class="euer-section">
<p class="euer-desc">Basierend auf Journalbuchungen (Zufluss-/Abflussprinzip)</p>
<h4>Einnahmen</h4>
<table class="list">
<tbody>
<?php foreach ($journal_euer['erloese_detail'] as $row): ?>
<tr>
<td><?= htmlspecialchars($row['name']) ?></td>
<td style="text-align:right;"><?= number_format((float)$row['total'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endforeach; ?>
<?php if ($journal_euer['sonstiges_einnahmen'] > 0): ?>
<tr>
<td>Sonstige Einnahmen</td>
<td style="text-align:right;"><?= number_format($journal_euer['sonstiges_einnahmen'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endif; ?>
<tr>
<td><strong>Einnahmen gesamt</strong></td>
<td style="text-align:right;"><strong><?= number_format($journal_euer['einnahmen_total'], 2, ',', '.') ?> &euro;</strong></td>
</tr>
</tbody>
</table>
<h4>Ausgaben</h4>
<table class="list">
<tbody>
<?php if ($journal_euer['wareneingang'] > 0): ?>
<tr>
<td>Wareneingang</td>
<td style="text-align:right;"><?= number_format($journal_euer['wareneingang'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endif; ?>
<?php foreach ($journal_euer['aufwand_detail'] as $row): ?>
<tr>
<td><?= htmlspecialchars($row['name']) ?></td>
<td style="text-align:right;"><?= number_format((float)$row['total'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endforeach; ?>
<?php if ($journal_euer['sonstiges_ausgaben'] > 0): ?>
<tr>
<td>Sonstige Ausgaben</td>
<td style="text-align:right;"><?= number_format($journal_euer['sonstiges_ausgaben'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endif; ?>
<tr>
<td><strong>Ausgaben gesamt</strong></td>
<td style="text-align:right;"><strong><?= number_format($journal_euer['ausgaben_total'], 2, ',', '.') ?> &euro;</strong></td>
</tr>
</tbody>
</table>
<h4>Steuer</h4>
<table class="list">
<tbody>
<tr>
<td>MwSt (eingenommen)</td>
<td style="text-align:right;"><?= number_format($journal_euer['mwst'], 2, ',', '.') ?> &euro;</td>
</tr>
<tr>
<td>VorSt (gezahlt)</td>
<td style="text-align:right;"><?= number_format($journal_euer['vorst'], 2, ',', '.') ?> &euro;</td>
</tr>
<tr>
<td><strong>Steuer-Saldo</strong></td>
<td style="text-align:right;"><strong><?= number_format($journal_euer['steuer_saldo'], 2, ',', '.') ?> &euro;</strong></td>
</tr>
</tbody>
</table>
<?php if ($journal_euer['privat_entnahmen'] > 0 || $journal_euer['privat_einlagen'] > 0): ?>
<h4>Privatkonten</h4>
<table class="list">
<tbody>
<?php if ($journal_euer['privat_einlagen'] > 0): ?>
<tr>
<td>Privateinlagen</td>
<td style="text-align:right;"><?= number_format($journal_euer['privat_einlagen'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endif; ?>
<?php if ($journal_euer['privat_entnahmen'] > 0): ?>
<tr>
<td>Privatentnahmen</td>
<td style="text-align:right;"><?= number_format($journal_euer['privat_entnahmen'], 2, ',', '.') ?> &euro;</td>
</tr>
<?php endif; ?>
</tbody>
</table>
<?php endif; ?>
<h4>Ergebnis</h4>
<table class="list">
<tbody>
<tr class="euer-result <?= $journal_euer['gewinn'] < 0 ? 'negative' : '' ?>">
<td><strong>Gewinn / Verlust</strong></td>
<td style="text-align:right;"><strong><?= number_format($journal_euer['gewinn'], 2, ',', '.') ?> &euro;</strong></td>
</tr>
</tbody>
</table>
<div style="margin-top:8px;">
<a href="<?= url_for('euer.php?year=' . $year . '&csv_journal=1') ?>" class="button-secondary">CSV Export</a>
</div>
</section>
<?php else: ?>
<section class="euer-section">
<p class="euer-desc">Kein Journal für <?= $year ?> vorhanden.</p>
<?php if ($has_missing): ?>
<p>Klicke oben auf "Fehlende Buchungen nachholen" um das Journal automatisch zu erstellen.</p>
<?php else: ?>
<p><a href="<?= url_for('settings.php?tab=journal') ?>">Jahr im Journal anlegen</a></p>
<?php endif; ?>
</section>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,10 +0,0 @@
<?php
// Redirect to new unified EÜR page
require_once __DIR__ . '/../src/config.php';
// Extract year from old date params for backwards compatibility
$from = $_GET['from'] ?? date('Y-01-01');
$year = date('Y', strtotime($from));
header('Location: ' . url_for('euer.php?year=' . $year));
exit;

View File

@@ -1,29 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/expense_functions.php';
require_login();
$id = (int)($_GET['id'] ?? 0);
$expense = get_expense($id);
if (!$expense || empty($expense['attachment_path'])) {
http_response_code(404);
echo 'Datei nicht gefunden.';
exit;
}
$fsPath = __DIR__ . '/' . $expense['attachment_path'];
if (!is_readable($fsPath)) {
http_response_code(404);
echo 'Datei nicht gefunden.';
exit;
}
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="Ausgabe-' . $id . '.pdf"');
header('Content-Length: ' . filesize($fsPath));
readfile($fsPath);
exit;

View File

@@ -1,389 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/expense_functions.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
$settings = get_settings();
$vat_mode = $settings['vat_mode'] ?? 'klein';
$msg = '';
$error = '';
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
// Aufwandskategorien für Dropdown laden
$expense_categories = get_journal_expense_categories(true);
// Ausgabe als bezahlt markieren (eigene Aktion)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_paid_id'])) {
$pay_id = (int)$_POST['mark_paid_id'];
$payment_date = $_POST['payment_date'] ?? date('Y-m-d');
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare('UPDATE expenses SET paid = TRUE, payment_date = :pd WHERE id = :id');
$stmt->execute([':id' => $pay_id, ':pd' => $payment_date]);
// Journal-Eintrag erstellen
$existing = get_journal_entry_for_expense($pay_id);
if (!$existing) {
$entry_id = create_journal_entry_from_expense($pay_id);
$msg = 'Ausgabe als bezahlt markiert. Journalbuchung #' . $entry_id . ' erstellt.';
} else {
$msg = 'Ausgabe als bezahlt markiert (Journalbuchung existierte bereits).';
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
$error = 'Fehler: ' . $e->getMessage();
}
}
// Normale Ausgabe speichern (neu oder Update)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['expense_date']) && !isset($_POST['mark_paid_id'])) {
$idPost = isset($_POST['id']) && $_POST['id'] !== '' ? (int)$_POST['id'] : null;
$data = [
'expense_date' => $_POST['expense_date'] ?? '',
'description' => $_POST['description'] ?? '',
'category' => $_POST['category'] ?? '',
'amount' => (float)($_POST['amount'] ?? 0),
'vat_rate' => (float)($_POST['vat_rate'] ?? 0),
'expense_category_id' => !empty($_POST['expense_category_id']) ? (int)$_POST['expense_category_id'] : null,
'paid' => !empty($_POST['paid']) ? 1 : 0,
'payment_date' => $_POST['payment_date'] ?? '',
];
if (!$data['expense_date'] || !$data['description'] || $data['amount'] <= 0) {
$error = 'Datum, Beschreibung und Betrag sind Pflichtfelder.';
} elseif (!empty($data['paid']) && empty($data['expense_category_id'])) {
$error = 'Bei bezahlten Ausgaben ist eine Aufwandskategorie für die Journalbuchung Pflicht.';
} else {
$pdo->beginTransaction();
try {
// War vorher schon bezahlt? (für Update-Check)
$was_paid = false;
$had_journal = false;
if ($idPost) {
$old_exp = get_expense($idPost);
$was_paid = $old_exp && $old_exp['paid'];
$had_journal = $old_exp ? (bool)get_journal_entry_for_expense($idPost) : false;
}
// Ausgabe speichern
$expense_id = save_expense($idPost, $data);
// Datei-Upload (PDF) verarbeiten
if (!empty($_FILES['attachment']['tmp_name'])) {
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $_FILES['attachment']['tmp_name']);
finfo_close($finfo);
if ($mime === 'application/pdf') {
$uploadDir = __DIR__ . '/uploads/expenses';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
$targetFile = $uploadDir . '/expense_' . $expense_id . '.pdf';
if (move_uploaded_file($_FILES['attachment']['tmp_name'], $targetFile)) {
$relPath = 'uploads/expenses/expense_' . $expense_id . '.pdf';
$stmt = $pdo->prepare("UPDATE expenses SET attachment_path = :p WHERE id = :id");
$stmt->execute([':p' => $relPath, ':id' => $expense_id]);
} else {
$error = 'Ausgabe gespeichert, aber Datei konnte nicht verschoben werden.';
}
} else {
$error = 'Bitte nur PDF-Dateien als Beleg hochladen.';
}
}
// Journal-Integration
if (!empty($data['paid']) && !$had_journal) {
// Neu bezahlt → Journal-Eintrag erstellen
$existing = get_journal_entry_for_expense($expense_id);
if (!$existing) {
$entry_id = create_journal_entry_from_expense($expense_id);
$msg = 'Ausgabe gespeichert. Journalbuchung #' . $entry_id . ' erstellt.';
} else {
$msg = 'Ausgabe gespeichert.';
}
} elseif (empty($data['paid']) && $had_journal) {
// War bezahlt, jetzt offen → Journal-Eintrag entfernen
delete_journal_entry_for_expense($expense_id);
$msg = 'Ausgabe gespeichert. Journalbuchung entfernt (Status: offen).';
} else {
$msg = $msg ?: 'Ausgabe gespeichert.';
}
$pdo->commit();
$action = '';
$id = null;
} catch (Exception $e) {
$pdo->rollBack();
$error = 'Fehler: ' . $e->getMessage();
}
}
}
if ($action === 'delete' && $id) {
$exp = get_expense($id);
if ($exp) {
// Verknüpften Journal-Eintrag löschen
delete_journal_entry_for_expense($id);
if (!empty($exp['attachment_path'])) {
$fsPath = __DIR__ . '/' . $exp['attachment_path'];
if (is_file($fsPath)) {
@unlink($fsPath);
}
}
delete_expense($id);
$msg = 'Ausgabe und zugehörige Journalbuchung gelöscht.';
}
$action = '';
$id = null;
}
$editExpense = null;
if ($action === 'edit' && $id) {
$editExpense = get_expense($id);
}
$filters = [
'from' => $_GET['from'] ?? '',
'to' => $_GET['to'] ?? '',
'paid' => $_GET['paid'] ?? '',
'search' => $_GET['search'] ?? '',
];
$expenses = get_expenses($filters);
// Journal-Verknüpfungen laden
$journal_linked_expenses = [];
try {
$stmt_jl = $pdo->query("SELECT expense_id, id FROM journal_entries WHERE expense_id IS NOT NULL");
foreach ($stmt_jl->fetchAll(PDO::FETCH_ASSOC) as $jl) {
$journal_linked_expenses[(int)$jl['expense_id']] = (int)$jl['id'];
}
} catch (PDOException $e) {
// Spalte existiert noch nicht
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Ausgaben</title>
<link rel="stylesheet" href="assets/style.css">
<script>
function updateVatCalc() {
const amount = parseFloat(document.getElementById('expense-amount').value) || 0;
const vatRate = parseFloat(document.getElementById('expense-vat-rate').value) || 0;
const infoEl = document.getElementById('vat-info');
if (vatRate > 0 && amount > 0) {
const net = amount / (1 + vatRate / 100);
const vat = amount - net;
infoEl.textContent = 'Netto: ' + net.toFixed(2).replace('.', ',') + ' € | VorSt: ' + vat.toFixed(2).replace('.', ',') + ' €';
infoEl.style.display = 'block';
} else {
infoEl.style.display = 'none';
}
}
</script>
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>" class="active"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<section>
<h2><?= $editExpense ? 'Ausgabe bearbeiten' : 'Neue Ausgabe' ?></h2>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="id" value="<?= htmlspecialchars($editExpense['id'] ?? '') ?>">
<div class="flex-row">
<label>Datum:
<input type="date" name="expense_date" value="<?= htmlspecialchars($editExpense['expense_date'] ?? date('Y-m-d')) ?>" required>
</label>
<label>Betrag (brutto):
<input type="number" step="0.01" name="amount" id="expense-amount"
value="<?= htmlspecialchars($editExpense['amount'] ?? '0.00') ?>"
required oninput="updateVatCalc()">
</label>
<?php if ($vat_mode === 'normal'): ?>
<label>MwSt-Satz:
<select name="vat_rate" id="expense-vat-rate" onchange="updateVatCalc()">
<option value="0" <?= (isset($editExpense['vat_rate']) && (float)$editExpense['vat_rate'] == 0) ? 'selected' : '' ?>>0% (keine MwSt)</option>
<option value="7" <?= (isset($editExpense['vat_rate']) && (float)$editExpense['vat_rate'] == 7) ? 'selected' : '' ?>>7%</option>
<option value="19" <?= (isset($editExpense['vat_rate']) && (float)$editExpense['vat_rate'] == 19) ? 'selected' : (!isset($editExpense) ? 'selected' : '') ?>>19%</option>
</select>
</label>
<?php else: ?>
<input type="hidden" name="vat_rate" value="0">
<?php endif; ?>
</div>
<div id="vat-info" style="display:none; font-size:11px; color:var(--text-dim); margin:-8px 0 8px;"></div>
<label>Beschreibung:
<input type="text" name="description" value="<?= htmlspecialchars($editExpense['description'] ?? '') ?>" required>
</label>
<div class="flex-row">
<label>Aufwandskategorie:
<select name="expense_category_id">
<option value="">-- keine --</option>
<?php foreach ($expense_categories as $cat): ?>
<option value="<?= $cat['id'] ?>" <?= (isset($editExpense['expense_category_id']) && (int)$editExpense['expense_category_id'] === (int)$cat['id']) ? 'selected' : '' ?>>
<?= htmlspecialchars($cat['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Notiz / Kategorie:
<input type="text" name="category" value="<?= htmlspecialchars($editExpense['category'] ?? '') ?>" placeholder="Freitext-Notiz">
</label>
</div>
<div class="flex-row">
<label>
<input type="checkbox" name="paid" value="1" <?= (isset($editExpense['paid']) ? $editExpense['paid'] : 1) ? 'checked' : '' ?>>
bezahlt
</label>
<label>Zahlungsdatum:
<input type="date" name="payment_date" value="<?= htmlspecialchars($editExpense['payment_date'] ?? date('Y-m-d')) ?>">
</label>
</div>
<label>Beleg (PDF):
<input type="file" name="attachment" accept="application/pdf">
<?php if (!empty($editExpense['attachment_path'])): ?>
<br>Aktueller Beleg:
<a href="<?= url_for('expense_file.php?id=' . $editExpense['id']) ?>" target="_blank">PDF öffnen</a>
<?php endif; ?>
</label>
<button type="submit">Speichern</button>
<?php if ($editExpense): ?>
<a href="<?= url_for('expenses.php') ?>">Abbrechen</a>
<?php endif; ?>
</form>
</section>
<section>
<h2>Übersicht Ausgaben</h2>
<form method="get" class="filters">
<label>Von:
<input type="date" name="from" value="<?= htmlspecialchars($filters['from']) ?>">
</label>
<label>Bis:
<input type="date" name="to" value="<?= htmlspecialchars($filters['to']) ?>">
</label>
<label>Status:
<select name="paid">
<option value="">alle</option>
<option value="1" <?= $filters['paid']==='1' ? 'selected' : '' ?>>bezahlt</option>
<option value="0" <?= $filters['paid']==='0' ? 'selected' : '' ?>>offen</option>
</select>
</label>
<label>Suche:
<input type="text" name="search" value="<?= htmlspecialchars($filters['search']) ?>">
</label>
<button type="submit">Filtern</button>
<a href="<?= url_for('expenses.php') ?>">Zurücksetzen</a>
</form>
<table class="list">
<thead>
<tr>
<th>Datum</th>
<th>Beschreibung</th>
<th>Kategorie</th>
<th>Betrag</th>
<th>Status</th>
<th>Beleg</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php $total = 0.0; ?>
<?php foreach ($expenses as $e): ?>
<?php $total += $e['amount']; ?>
<tr>
<td><?= htmlspecialchars(date('d.m.Y', strtotime($e['expense_date']))) ?></td>
<td><?= htmlspecialchars($e['description']) ?></td>
<td><?= htmlspecialchars($e['category']) ?></td>
<td style="text-align:right;"><?= number_format($e['amount'], 2, ',', '.') ?> €</td>
<td><?= $e['paid'] ? 'bezahlt' : 'offen' ?></td>
<td>
<?php if (!empty($e['attachment_path'])): ?>
<a href="<?= url_for('expense_file.php?id=' . $e['id']) ?>" target="_blank">PDF</a>
<?php else: ?>
-
<?php endif; ?>
</td>
<td>
<a href="<?= url_for('expenses.php?action=edit&id=' . $e['id']) ?>">Bearbeiten</a>
| <a href="<?= url_for('expenses.php?action=delete&id=' . $e['id']) ?>" onclick="return confirm('Ausgabe wirklich löschen?');">Löschen</a>
<?php if (!$e['paid']): ?>
| <a href="#" onclick="document.getElementById('pay-exp-<?= $e['id'] ?>').style.display='table-row'; return false;">als bezahlt</a>
<?php endif; ?>
<?php if (isset($journal_linked_expenses[$e['id']])): ?>
| <a href="<?= url_for('journal_entry.php?id=' . $journal_linked_expenses[$e['id']]) ?>">Journal</a>
<?php endif; ?>
</td>
</tr>
<?php if (!$e['paid']): ?>
<tr id="pay-exp-<?= $e['id'] ?>" style="display:none;" class="payment-form-row">
<td colspan="7">
<form method="post" style="display:inline-flex; gap:8px; align-items:center; padding:4px 0;">
<input type="hidden" name="mark_paid_id" value="<?= $e['id'] ?>">
<label style="margin:0;">Zahlungsdatum:
<input type="date" name="payment_date" value="<?= date('Y-m-d') ?>" required>
</label>
<button type="submit" onclick="return confirm('Ausgabe als bezahlt markieren und Journal buchen?');">Bezahlt + Journal buchen</button>
<a href="#" onclick="this.closest('tr').style.display='none'; return false;">Abbrechen</a>
</form>
</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php if (empty($expenses)): ?>
<tr><td colspan="7">Keine Ausgaben gefunden.</td></tr>
<?php else: ?>
<tr>
<td colspan="3"><strong>Summe</strong></td>
<td style="text-align:right;"><strong><?= number_format($total, 2, ',', '.') ?> €</strong></td>
<td colspan="3"></td>
</tr>
<?php endif; ?>
</tbody>
</table>
</section>
</main>
<script>
// MwSt-Info beim Laden aktualisieren (bei Edit)
document.addEventListener('DOMContentLoaded', updateVatCalc);
</script>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,422 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/recurring_functions.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
$year = date('Y');
$month = (int)date('m');
$prev_month = $month === 1 ? 12 : $month - 1;
$prev_month_year = $month === 1 ? (int)$year - 1 : (int)$year;
// === RECHNUNGEN/AUSGABEN ===
// Offene Rechnungen
$stmt = $pdo->prepare("SELECT COUNT(*) FROM invoices WHERE paid = FALSE");
$stmt->execute();
$open_invoices_count = (int)$stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COALESCE(SUM(total_gross),0) FROM invoices WHERE paid = FALSE");
$stmt->execute();
$open_invoices_sum = (float)$stmt->fetchColumn();
// Überfällige Rechnungen (> 14 Tage offen)
$stmt = $pdo->prepare("SELECT i.*, c.name AS customer_name FROM invoices i JOIN customers c ON c.id = i.customer_id WHERE i.paid = FALSE AND i.invoice_date < NOW() - INTERVAL '14 days' ORDER BY i.invoice_date ASC LIMIT 5");
$stmt->execute();
$overdue_invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Fällige Abo-Rechnungen
$pending_recurring = count_pending_recurring_invoices();
// Konsistenz-Check: Fehlende Buchungen
$consistency = check_journal_consistency();
$has_unbooked = ($consistency['unbooked_invoices'] > 0 || $consistency['unbooked_expenses'] > 0);
// GoBD PDF-Status
$gobd_status = check_pdf_integrity_status();
$gobd_has_problems = ($gobd_status['unarchived'] > 0 || $gobd_status['invalid'] > 0 || $gobd_status['missing_files'] > 0);
// Letzte 5 Rechnungen
$stmt = $pdo->prepare("SELECT i.*, c.name AS customer_name FROM invoices i JOIN customers c ON c.id = i.customer_id ORDER BY i.created_at DESC LIMIT 5");
$stmt->execute();
$recent_invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
// === JOURNAL MODUL ===
$journal_year = get_journal_year_by_year((int)$year);
$journal_year_id = $journal_year ? (int)$journal_year['id'] : null;
$journal_erloese_monat = 0;
$journal_wareneingang_monat = 0;
$journal_gewinn_monat = 0;
$journal_erloese_jahr = 0;
$journal_wareneingang_jahr = 0;
$journal_gewinn_jahr = 0;
$journal_entries_month = 0;
$journal_gewinn_prev = 0;
if ($journal_year_id) {
$month_profit = calculate_yearly_profitability($journal_year_id);
if (isset($month_profit[$month])) {
$journal_erloese_monat = $month_profit[$month]['erloese'];
$journal_wareneingang_monat = $month_profit[$month]['wareneingang'];
$journal_gewinn_monat = $month_profit[$month]['gewinn'];
}
if (isset($month_profit[$prev_month])) {
$journal_gewinn_prev = $month_profit[$prev_month]['gewinn'];
}
foreach ($month_profit as $m => $p) {
$journal_erloese_jahr += $p['erloese'];
$journal_wareneingang_jahr += $p['wareneingang'];
$journal_gewinn_jahr += $p['gewinn'];
}
$stmt = $pdo->prepare("SELECT COUNT(*) FROM journal_entries WHERE year_id = :y AND month = :m");
$stmt->execute([':y' => $journal_year_id, ':m' => $month]);
$journal_entries_month = (int)$stmt->fetchColumn();
$mt = calculate_monthly_totals($journal_year_id, $month);
$journal_kasse_balance = ($mt['kasse_s'] ?? 0) - ($mt['kasse_h'] ?? 0);
$journal_bank_balance = ($mt['bank_s'] ?? 0) - ($mt['bank_h'] ?? 0);
} else {
$journal_kasse_balance = 0;
$journal_bank_balance = 0;
}
// Sparkline-Daten
$sparkline_erloese = [];
$sparkline_gewinn = [];
$sparkline_wareneingang = [];
if ($journal_year_id && !empty($month_profit)) {
for ($m = 1; $m <= $month; $m++) {
$sparkline_erloese[] = $month_profit[$m]['erloese'] ?? 0;
$sparkline_gewinn[] = $month_profit[$m]['gewinn'] ?? 0;
$sparkline_wareneingang[] = $month_profit[$m]['wareneingang'] ?? 0;
}
}
// Letzte 5 Journal-Einträge
$recent_journal = get_recent_journal_entries(5);
$month_names_full = [
1 => 'Januar', 2 => 'Februar', 3 => 'März', 4 => 'April',
5 => 'Mai', 6 => 'Juni', 7 => 'Juli', 8 => 'August',
9 => 'September', 10 => 'Oktober', 11 => 'November', 12 => 'Dezember',
];
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PIRP Dashboard</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>" class="active"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<?php if ($has_unbooked): ?>
<div class="gobd-warning">
<strong>Fehlende Journalbuchungen:</strong>
<?php if ($consistency['unbooked_invoices'] > 0): ?>
<?= $consistency['unbooked_invoices'] ?> bezahlte Rechnung(en)
<?php endif; ?>
<?php if ($consistency['unbooked_invoices'] > 0 && $consistency['unbooked_expenses'] > 0): ?> und <?php endif; ?>
<?php if ($consistency['unbooked_expenses'] > 0): ?>
<?= $consistency['unbooked_expenses'] ?> bezahlte Ausgabe(n)
<?php endif; ?>
ohne Journaleintrag.
<a href="<?= url_for('euer.php?year=' . $year . '&fix_missing=1') ?>">Jetzt nachholen</a>
</div>
<?php endif; ?>
<!-- Finanz-Übersicht -->
<h2><?= htmlspecialchars($year) ?> &middot; <?= $month_names_full[$month] ?></h2>
<?php if ($journal_year_id): ?>
<!-- Rechnungen / Allgemein -->
<div class="dashboard-grid" style="margin-bottom:6px;">
<div class="card">
<h3>Offen</h3>
<p class="big"><?= number_format($open_invoices_sum, 2, ',', '.') ?> &euro;</p>
<span style="font-size:10px;color:var(--text-dim);"><?= $open_invoices_count ?> Rechnungen</span>
</div>
<div class="card">
<h3>Abo-Rechnungen</h3>
<p class="big"><?= $pending_recurring ?> fällig</p>
<?php if ($pending_recurring > 0): ?>
<a href="<?= url_for('recurring_generate.php') ?>">Generieren</a>
<?php endif; ?>
</div>
<div class="card">
<h3>Erlöse (Jahr)</h3>
<p class="big"><?= number_format($journal_erloese_jahr, 2, ',', '.') ?> &euro;</p>
<?php if (count($sparkline_erloese) >= 2): ?>
<?= generate_sparkline_svg($sparkline_erloese, '#22c55e') ?>
<?php endif; ?>
<a href="<?= url_for('journal_summary.php?year_id=' . $journal_year_id) ?>" style="font-size:10px;">Jahresübersicht →</a>
</div>
<div class="card">
<h3>Gewinn (Jahr)</h3>
<p class="big" <?= $journal_gewinn_jahr < 0 ? 'style="color:var(--error);"' : '' ?>><?= number_format($journal_gewinn_jahr, 2, ',', '.') ?> &euro;</p>
<?php if (count($sparkline_gewinn) >= 2): ?>
<?= generate_sparkline_svg($sparkline_gewinn, '#d4882a') ?>
<?php endif; ?>
<a href="<?= url_for('euer.php?year=' . $year) ?>" style="font-size:10px;">EÜR →</a>
</div>
</div>
<!-- Journal Monat -->
<h2 style="margin-bottom:4px;">
Journal &middot; <?= $month_names_full[$month] ?>
<a href="<?= url_for('journal.php?year_id=' . $journal_year_id . '&month=' . $month) ?>"
style="font-size:10px;font-weight:400;text-transform:none;letter-spacing:0;color:var(--accent);margin-left:10px;">
<?= $journal_entries_month ?> Buchungen →
</a>
</h2>
<div class="dashboard-grid" style="margin-bottom:4px;">
<div class="card">
<h3>Erlöse</h3>
<p class="big"><?= number_format($journal_erloese_monat, 2, ',', '.') ?> &euro;</p>
<?php if ($journal_gewinn_prev != 0): ?>
<?php $gwdiff = $journal_gewinn_monat - $journal_gewinn_prev; ?>
<span style="font-size:10px;color:<?= $gwdiff >= 0 ? 'var(--success)' : 'var(--error)' ?>;">
<?= $gwdiff >= 0 ? '+' : '' ?><?= number_format($gwdiff, 2, ',', '.') ?> Gewinn gg. Vormonat
</span>
<?php endif; ?>
</div>
<div class="card">
<h3>Gewinn</h3>
<p class="big" <?= $journal_gewinn_monat < 0 ? 'style="color:var(--error);"' : '' ?>><?= number_format($journal_gewinn_monat, 2, ',', '.') ?> &euro;</p>
<?php if (count($sparkline_wareneingang) >= 2): ?>
<?= generate_sparkline_svg($sparkline_wareneingang, '#737373') ?>
<?php endif; ?>
</div>
<div class="card">
<h3>Kasse</h3>
<p class="big" style="color:<?= $journal_kasse_balance < 0 ? 'var(--error)' : 'var(--accent)' ?>;">
<?= number_format($journal_kasse_balance, 2, ',', '.') ?> &euro;
</p>
<a href="<?= url_for('journal.php?year_id=' . $journal_year_id . '&month=' . $month) ?>" style="font-size:10px;">Journal →</a>
</div>
<div class="card">
<h3>Bank</h3>
<p class="big" style="color:<?= $journal_bank_balance < 0 ? 'var(--error)' : 'var(--warning)' ?>;">
<?= number_format($journal_bank_balance, 2, ',', '.') ?> &euro;
</p>
<a href="<?= url_for('journal_entry.php?year_id=' . $journal_year_id . '&month=' . $month) ?>" style="font-size:10px;">+ Neue Buchung</a>
</div>
</div>
<!-- Monatsvergleich -->
<?php if (!empty($month_profit)): ?>
<section style="margin-bottom:4px;">
<h2>Monatsvergleich <?= $year ?></h2>
<div style="padding:8px 0;">
<?= generate_monthly_bar_chart_svg($month_profit, $month) ?>
</div>
</section>
<?php endif; ?>
<?php else: ?>
<div class="dashboard-grid">
<div class="card">
<h3>Offen</h3>
<p class="big"><?= number_format($open_invoices_sum, 2, ',', '.') ?> &euro;</p>
<span style="font-size:10px;color:var(--text-dim);"><?= $open_invoices_count ?> Rechnungen</span>
</div>
<div class="card">
<h3>Abo-Rechnungen</h3>
<p class="big"><?= $pending_recurring ?> fällig</p>
<?php if ($pending_recurring > 0): ?>
<a href="<?= url_for('recurring_generate.php') ?>">Generieren</a>
<?php endif; ?>
</div>
</div>
<section>
<h2>Journal</h2>
<div>
<p>Kein Journal für <?= $year ?> angelegt.
<a href="<?= url_for('settings.php?tab=journal') ?>">Jahr erstellen</a>
</p>
</div>
</section>
<?php endif; ?>
<!-- Überfällige Rechnungen -->
<?php if ($overdue_invoices): ?>
<section>
<h2>Überfällige Rechnungen</h2>
<div>
<table class="list">
<thead>
<tr>
<th>Datum</th>
<th>Nr.</th>
<th>Kunde</th>
<th style="text-align:right;">Betrag</th>
<th>Tage offen</th>
</tr>
</thead>
<tbody>
<?php foreach ($overdue_invoices as $oi): ?>
<?php $days = (int)((time() - strtotime($oi['invoice_date'])) / 86400); ?>
<tr>
<td><?= date('d.m.Y', strtotime($oi['invoice_date'])) ?></td>
<td><a href="<?= url_for('invoice_pdf.php?id=' . $oi['id']) ?>" target="_blank"><?= htmlspecialchars($oi['invoice_number']) ?></a></td>
<td><?= htmlspecialchars($oi['customer_name']) ?></td>
<td style="text-align:right;"><?= number_format((float)$oi['total_gross'], 2, ',', '.') ?> &euro;</td>
<td style="color:var(--error);"><strong><?= $days ?> Tage</strong></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<?php endif; ?>
<!-- Zwei-Spalten: Letzte Rechnungen + Letzte Buchungen -->
<div class="dashboard-two-col">
<section>
<h2>Letzte Rechnungen</h2>
<div>
<table class="list">
<thead><tr><th>Datum</th><th>Kunde</th><th style="text-align:right;">Betrag</th><th>Status</th></tr></thead>
<tbody>
<?php foreach ($recent_invoices as $ri): ?>
<tr>
<td><?= date('d.m', strtotime($ri['invoice_date'])) ?></td>
<td><?= htmlspecialchars($ri['customer_name']) ?></td>
<td style="text-align:right;"><?= number_format((float)$ri['total_gross'], 2, ',', '.') ?></td>
<td><?= $ri['paid'] ? 'bezahlt' : 'offen' ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_invoices)): ?>
<tr><td colspan="4" style="color:var(--text-dim);">Keine Rechnungen</td></tr>
<?php endif; ?>
</tbody>
</table>
<a href="<?= url_for('invoices.php') ?>" style="font-size:10px;">Alle Rechnungen</a>
</div>
</section>
<section>
<h2>Letzte Buchungen</h2>
<div>
<table class="list">
<thead><tr><th>Datum</th><th>Text</th><th style="text-align:right;">Betrag</th></tr></thead>
<tbody>
<?php foreach ($recent_journal as $rj): ?>
<tr>
<td><?= date('d.m', strtotime($rj['entry_date'])) ?></td>
<td>
<a href="<?= url_for('journal_entry.php?id=' . $rj['id']) ?>" style="color:inherit;">
<?= htmlspecialchars(mb_strimwidth($rj['description'], 0, 40, '...')) ?>
</a>
</td>
<td style="text-align:right;font-family:var(--font-mono);font-size:12px;"><?= number_format((float)$rj['amount'], 2, ',', '.') ?></td>
</tr>
<?php endforeach; ?>
<?php if (empty($recent_journal)): ?>
<tr><td colspan="3" style="color:var(--text-dim);">Keine Buchungen</td></tr>
<?php endif; ?>
</tbody>
</table>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:6px;">
<a href="<?= url_for('journal.php') ?>" style="font-size:10px;">Alle Buchungen →</a>
<?php if ($journal_year_id): ?>
<a href="<?= url_for('journal_entry.php?year_id=' . $journal_year_id . '&month=' . $month) ?>" class="button" style="font-size:11px;padding:3px 10px;">+ Neue Buchung</a>
<?php endif; ?>
</div>
</div>
</section>
</div>
<!-- GoBD PDF-Status -->
<?php if ($gobd_has_problems): ?>
<div class="gobd-warning">
<h3>GoBD-Warnung: PDF-Archivierung unvollständig</h3>
<ul>
<?php if ($gobd_status['unarchived'] > 0): ?>
<li><strong><?= $gobd_status['unarchived'] ?></strong> Rechnung(en) ohne archivierte PDF</li>
<?php endif; ?>
<?php if ($gobd_status['missing_files'] > 0): ?>
<li style="color:var(--error);"><strong><?= $gobd_status['missing_files'] ?></strong> PDF-Datei(en) fehlen auf dem Server</li>
<?php endif; ?>
<?php if ($gobd_status['invalid'] > 0): ?>
<li style="color:var(--error);"><strong><?= $gobd_status['invalid'] ?></strong> PDF(s) mit fehlgeschlagener Integritätsprüfung</li>
<?php endif; ?>
</ul>
<?php if (!empty($gobd_status['problems'])): ?>
<details>
<summary>Betroffene Rechnungen anzeigen</summary>
<table class="list">
<thead><tr><th>Rechnungsnr.</th><th>Problem</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach (array_slice($gobd_status['problems'], 0, 10) as $prob): ?>
<tr>
<td><?= htmlspecialchars($prob['invoice_number']) ?></td>
<td><?= htmlspecialchars($prob['message']) ?></td>
<td><a href="<?= url_for('invoice_pdf.php?id=' . $prob['id']) ?>">PDF neu generieren</a></td>
</tr>
<?php endforeach; ?>
<?php if (count($gobd_status['problems']) > 10): ?>
<tr><td colspan="3"><em>... und <?= count($gobd_status['problems']) - 10 ?> weitere</em></td></tr>
<?php endif; ?>
</tbody>
</table>
</details>
<?php endif; ?>
<?php if (!empty($gobd_status['migration_needed'])): ?>
<p style="margin-top:4px;margin-bottom:0;">
<strong>Datenbank-Migration erforderlich:</strong><br>
1. <code>sudo -u postgres psql -d pirp -f /var/www/pirp/tools/migrate_pdf.sql</code><br>
2. <code>php /var/www/pirp/tools/run_migration.php</code>
</p>
<?php elseif ($gobd_status['unarchived'] > 0): ?>
<p style="margin-top:4px;margin-bottom:0;">
<strong>Migration:</strong> <code>php tools/run_migration.php</code>
</p>
<?php endif; ?>
</div>
<?php else: ?>
<div class="gobd-ok">
<strong>GoBD OK:</strong>
<?= $gobd_status['archived'] ?>/<?= $gobd_status['total_invoices'] ?> archiviert.
</div>
<?php endif; ?>
<section>
<h2>Schnellzugriff</h2>
<div class="flex-row" style="gap:8px;flex-wrap:wrap;">
<a href="<?= url_for('invoice_new.php') ?>" class="button">Neue Rechnung</a>
<a href="<?= url_for('expenses.php') ?>" class="button secondary">Neue Ausgabe</a>
<?php if ($journal_year_id): ?>
<a href="<?= url_for('journal_entry.php?year_id=' . $journal_year_id . '&month=' . $month) ?>" class="button secondary">Neue Buchung</a>
<?php endif; ?>
<a href="<?= url_for('euer.php') ?>" class="button secondary">EÜR</a>
</div>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,224 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/customer_functions.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
$settings = get_settings();
$customers = get_customers();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$customer_id = (int)($_POST['customer_id'] ?? 0);
$invoice_date = $_POST['invoice_date'] ?: date('Y-m-d');
$service_date = $_POST['service_date'] ?: null;
$notes_internal = $_POST['notes_internal'] ?? '';
if ($customer_id <= 0) {
$error = 'Bitte einen Kunden auswählen.';
} else {
$vat_mode = $settings['vat_mode'] ?? 'klein';
$vat_rate = (float)($settings['default_vat_rate'] ?? 19.0);
$items = [];
$total_net = 0.0;
$count = isset($_POST['item_desc']) ? count($_POST['item_desc']) : 0;
for ($i = 0; $i < $count; $i++) {
$desc = trim($_POST['item_desc'][$i] ?? '');
$qty = (float)($_POST['item_qty'][$i] ?? 0);
$price= (float)($_POST['item_price'][$i] ?? 0);
if ($desc !== '' && $qty > 0 && $price >= 0) {
$line_net = $qty * $price;
$total_net += $line_net;
$items[] = [
'position_no' => count($items) + 1,
'description' => $desc,
'quantity' => $qty,
'unit_price' => $price,
];
}
}
if (empty($items)) {
$error = 'Bitte mindestens eine Position ausfüllen.';
} else {
if ($vat_mode === 'normal') {
$total_vat = round($total_net * $vat_rate / 100, 2);
} else {
$total_vat = 0.0;
}
$total_gross = $total_net + $total_vat;
$pdo->beginTransaction();
try {
$invoice_number = generate_invoice_number();
$stmt = $pdo->prepare("INSERT INTO invoices
(invoice_number, customer_id, invoice_date, service_date, vat_mode, vat_rate,
payment_terms, notes_internal, total_net, total_vat, total_gross, paid)
VALUES (:in, :cid, :idate, :sdate, :vm, :vr, :pt, :ni, :tn, :tv, :tg, FALSE)
RETURNING id");
$stmt->execute([
':in' => $invoice_number,
':cid' => $customer_id,
':idate'=> $invoice_date,
':sdate'=> $service_date,
':vm' => $vat_mode,
':vr' => $vat_rate,
':pt' => $settings['payment_terms'] ?? null,
':ni' => $notes_internal,
':tn' => $total_net,
':tv' => $total_vat,
':tg' => $total_gross,
]);
$invoice_id = $stmt->fetchColumn();
$stmtItem = $pdo->prepare("INSERT INTO invoice_items
(invoice_id, position_no, description, quantity, unit_price, vat_rate)
VALUES (:iid, :pn, :d, :q, :up, :vr)");
foreach ($items as $it) {
$stmtItem->execute([
':iid' => $invoice_id,
':pn' => $it['position_no'],
':d' => $it['description'],
':q' => $it['quantity'],
':up' => $it['unit_price'],
':vr' => $vat_rate,
]);
}
$pdo->commit();
// PDF sofort archivieren (GoBD-konform)
archive_invoice_pdf($invoice_id);
header('Location: ' . url_for('invoice_pdf.php?id=' . $invoice_id));
exit;
} catch (Exception $e) {
$pdo->rollBack();
$error = 'Fehler beim Speichern der Rechnung: ' . $e->getMessage();
}
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Neue Rechnung</title>
<link rel="stylesheet" href="assets/style.css">
<script>
function addRow() {
const tbody = document.getElementById('items-body');
const index = tbody.children.length;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${index+1}</td>
<td><input type="text" name="item_desc[${index}]" size="40"></td>
<td><input type="number" step="0.01" name="item_qty[${index}]" value="1"></td>
<td><input type="number" step="0.01" name="item_price[${index}]" value="0.00"></td>
`;
tbody.appendChild(tr);
}
// Enter springt zum nächsten Feld statt Formular abzuschicken
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
form.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && e.target.type !== 'submit') {
e.preventDefault();
const inputs = Array.from(form.querySelectorAll('input, select, textarea, button[type="submit"]'));
const currentIndex = inputs.indexOf(e.target);
if (currentIndex > -1 && currentIndex < inputs.length - 1) {
inputs[currentIndex + 1].focus();
}
}
});
});
</script>
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">Übersicht</a>
<a href="<?= url_for('invoice_new.php') ?>" class="active">Neue Rechnung</a>
<a href="<?= url_for('recurring.php') ?>">Abo-Rechnungen</a>
</div>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<form method="post">
<label>Kunde:
<select name="customer_id" required>
<option value="">-- wählen --</option>
<?php foreach ($customers as $c): ?>
<option value="<?= $c['id'] ?>"><?= htmlspecialchars($c['name']) ?></option>
<?php endforeach; ?>
</select>
</label>
<div class="flex-row">
<label>Rechnungsdatum:
<input type="date" name="invoice_date" value="<?= htmlspecialchars(date('Y-m-d')) ?>">
</label>
<label>Leistungsdatum:
<input type="date" name="service_date">
</label>
</div>
<h2>Positionen</h2>
<table class="list">
<thead>
<tr>
<th>Pos.</th>
<th>Beschreibung</th>
<th>Menge</th>
<th>Einzelpreis (netto)</th>
</tr>
</thead>
<tbody id="items-body">
<?php for ($i = 0; $i < 3; $i++): ?>
<tr>
<td><?= $i+1 ?></td>
<td><input type="text" name="item_desc[<?= $i ?>]" size="40"></td>
<td><input type="number" step="0.01" name="item_qty[<?= $i ?>]" value="1"></td>
<td><input type="number" step="0.01" name="item_price[<?= $i ?>]" value="0.00"></td>
</tr>
<?php endfor; ?>
</tbody>
</table>
<button type="button" onclick="addRow()">Position hinzufügen</button>
<label>Interne Notizen (nicht auf Rechnung):
<textarea name="notes_internal" rows="3"></textarea>
</label>
<button type="submit">Rechnung speichern &amp; PDF anzeigen</button>
</form>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,85 +0,0 @@
<?php
/**
* GoBD-konforme PDF-Auslieferung
*
* Diese Datei liefert archivierte, unveränderliche Rechnungs-PDFs aus.
* Bei erstmaligem Aufruf wird die PDF generiert und permanent gespeichert.
*/
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_login();
$id = (int)($_GET['id'] ?? 0);
if ($id <= 0) {
die('Ungültige Rechnungs-ID.');
}
// Prüfe ob archivierte PDF existiert
$pdfPath = get_archived_pdf_path($id);
if (!$pdfPath) {
// Noch keine archivierte PDF - jetzt erstellen
$pdfPath = archive_invoice_pdf($id);
if (!$pdfPath) {
// Diagnose: Warum konnte die PDF nicht erstellt werden?
$pdo = get_db();
$stmt = $pdo->prepare("SELECT i.id, i.invoice_number, i.customer_id, c.id AS cust_exists
FROM invoices i
LEFT JOIN customers c ON c.id = i.customer_id
WHERE i.id = :id");
$stmt->execute([':id' => $id]);
$diag = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$diag) {
die('Fehler: Rechnung nicht gefunden (ID: ' . $id . ')');
}
if (!$diag['cust_exists']) {
die('Fehler: Kunde zur Rechnung ' . htmlspecialchars($diag['invoice_number']) . ' existiert nicht mehr.');
}
// Prüfe Verzeichnis-Berechtigungen
$uploadBase = __DIR__ . '/uploads/invoices';
if (!is_dir($uploadBase)) {
die('Fehler: Upload-Verzeichnis fehlt (' . $uploadBase . '). Bitte erstellen mit: mkdir -p ' . $uploadBase);
}
if (!is_writable($uploadBase)) {
die('Fehler: Upload-Verzeichnis nicht beschreibbar (' . $uploadBase . ')');
}
die('Fehler beim Generieren der PDF. Bitte Logs prüfen.');
}
}
// Vollständiger Dateipfad
$fullPath = __DIR__ . '/' . $pdfPath;
if (!file_exists($fullPath) || !is_readable($fullPath)) {
die('PDF-Datei nicht gefunden.');
}
// Integritätsprüfung
$isValid = verify_invoice_pdf($id);
if ($isValid === false) {
// WARNUNG: PDF wurde möglicherweise manipuliert!
error_log("WARNUNG: Integritätsprüfung fehlgeschlagen für Rechnung ID $id");
// Optional: Warnung anzeigen statt die PDF auszuliefern
// die('PDF-Integritätsfehler! Die Datei wurde möglicherweise manipuliert.');
}
// Rechnungsnummer für Dateinamen holen
$pdo = get_db();
$stmt = $pdo->prepare("SELECT invoice_number FROM invoices WHERE id = :id");
$stmt->execute([':id' => $id]);
$invoiceNumber = $stmt->fetchColumn() ?: 'Rechnung';
// PDF ausliefern
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="Rechnung-' . $invoiceNumber . '.pdf"');
header('Content-Length: ' . filesize($fullPath));
header('Cache-Control: private, max-age=0, must-revalidate');
readfile($fullPath);
exit;

View File

@@ -1,129 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$id) {
header('Location: ' . url_for('invoices.php'));
exit;
}
$inv = get_invoice_with_customer($id);
if (!$inv) {
header('Location: ' . url_for('invoices.php?msg=' . urlencode('Rechnung nicht gefunden.')));
exit;
}
// Bereits storniert?
if (!empty($inv['is_storno'])) {
header('Location: ' . url_for('invoices.php?msg=' . urlencode('Diese Rechnung ist selbst eine Stornorechnung.')));
exit;
}
// Prüfe ob schon Storno existiert
$pdo = get_db();
try {
$stmt = $pdo->prepare("SELECT id FROM invoices WHERE storno_of = :id LIMIT 1");
$stmt->execute([':id' => $id]);
$existing_storno = $stmt->fetchColumn();
} catch (\PDOException $e) {
$existing_storno = false;
}
if ($existing_storno) {
header('Location: ' . url_for('invoices.php?msg=' . urlencode('Für diese Rechnung existiert bereits eine Stornorechnung.')));
exit;
}
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
$storno_id = create_storno_invoice($id);
archive_invoice_pdf($storno_id);
// Journalbuchung stornieren falls vorhanden
$original_entry = get_journal_entry_for_invoice($id);
if ($original_entry) {
create_storno_journal_entry($id, $storno_id);
}
$stmt = $pdo->prepare("SELECT invoice_number FROM invoices WHERE id = :id");
$stmt->execute([':id' => $storno_id]);
$storno_number = $stmt->fetchColumn();
header('Location: ' . url_for('invoices.php?msg=' . urlencode('Stornorechnung ' . $storno_number . ' erstellt.')));
exit;
} catch (\Exception $e) {
$error = $e->getMessage();
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Storno <?= htmlspecialchars($inv['invoice_number']) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">← Zurück zu Rechnungen</a>
</div>
<?php if ($error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<section>
<h2>Stornorechnung erstellen</h2>
<div style="max-width:520px;">
<table class="list" style="margin-bottom:16px;">
<tr><td style="color:var(--text-muted);width:140px;">Rechnungsnummer</td><td><strong><?= htmlspecialchars($inv['invoice_number']) ?></strong></td></tr>
<tr><td style="color:var(--text-muted);">Kunde</td><td><?= htmlspecialchars($inv['customer_name']) ?></td></tr>
<tr><td style="color:var(--text-muted);">Datum</td><td><?= date('d.m.Y', strtotime($inv['invoice_date'])) ?></td></tr>
<tr><td style="color:var(--text-muted);">Betrag</td><td><?= number_format($inv['total_gross'], 2, ',', '.') ?> €</td></tr>
<tr><td style="color:var(--text-muted);">Status</td><td><?= $inv['paid'] ? 'bezahlt' : 'offen' ?></td></tr>
</table>
<div class="gobd-warning" style="margin-bottom:16px;">
<strong>Achtung:</strong> Es wird eine neue Rechnung mit negativen Beträgen (Stornorechnung) erstellt.
<?php if ($inv['paid']): ?>
Die bestehende Journalbuchung wird automatisch storniert (Gegenbuchung).
<?php else: ?>
Die Rechnung ist noch nicht bezahlt es wird keine Journalbuchung storniert.
<?php endif; ?>
</div>
<form method="post">
<button type="submit" class="button-danger">Stornorechnung erstellen</button>
<a href="<?= url_for('invoices.php') ?>" style="margin-left:12px;">Abbrechen</a>
</form>
</div>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,248 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$pdo = get_db();
$msg = '';
// Rechnung als bezahlt markieren + automatische Journalbuchung
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['mark_paid_id'])) {
$id = (int)$_POST['mark_paid_id'];
$payment_date = $_POST['payment_date'] ?? date('Y-m-d');
$pdo->beginTransaction();
try {
// Bezahlt-Status und Zahlungsdatum setzen
$stmt = $pdo->prepare('UPDATE invoices SET paid = TRUE, payment_date = :pd WHERE id = :id');
$stmt->execute([':id' => $id, ':pd' => $payment_date]);
// Automatisch Journalbuchung erstellen
$existing = get_journal_entry_for_invoice($id);
if (!$existing) {
$entry_id = create_journal_entry_from_invoice($id);
$msg = 'Rechnung als bezahlt markiert. Journalbuchung #' . $entry_id . ' erstellt.';
} else {
$msg = 'Rechnung als bezahlt markiert (Journalbuchung existierte bereits).';
}
$pdo->commit();
} catch (Exception $e) {
$pdo->rollBack();
$msg = 'Fehler: ' . $e->getMessage();
}
header('Location: ' . url_for('invoices.php?msg=' . urlencode($msg)));
exit;
}
// Nachricht aus Redirect anzeigen
if (isset($_GET['msg'])) {
$msg = $_GET['msg'];
}
$filter_number = trim($_GET['number'] ?? '');
$filter_customer = trim($_GET['customer'] ?? '');
$filter_from = trim($_GET['from'] ?? '');
$filter_to = trim($_GET['to'] ?? '');
$where = "WHERE 1=1";
$params = [];
if ($filter_number !== '') {
$where .= " AND i.invoice_number ILIKE :num";
$params[':num'] = '%' . $filter_number . '%';
}
if ($filter_customer !== '') {
$where .= " AND c.name ILIKE :cust";
$params[':cust'] = '%' . $filter_customer . '%';
}
if ($filter_from !== '') {
$where .= " AND i.invoice_date >= :from";
$params[':from'] = $filter_from;
}
if ($filter_to !== '') {
$where .= " AND i.invoice_date <= :to";
$params[':to'] = $filter_to;
}
try {
$sql = "SELECT i.*, c.name AS customer_name, c.customer_number,
COALESCE(i.is_storno, FALSE) AS is_storno,
i.storno_of,
storno_child.invoice_number AS storno_child_number,
storno_child.id AS storno_child_id
FROM invoices i
JOIN customers c ON c.id = i.customer_id
LEFT JOIN invoices storno_child ON storno_child.storno_of = i.id
$where ORDER BY i.invoice_date DESC, i.id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
} catch (\PDOException $e) {
// is_storno/storno_of noch nicht migriert — Fallback
$sql = "SELECT i.*, c.name AS customer_name, c.customer_number,
FALSE AS is_storno, NULL AS storno_of,
NULL AS storno_child_number, NULL AS storno_child_id
FROM invoices i
JOIN customers c ON c.id = i.customer_id
$where ORDER BY i.invoice_date DESC, i.id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
$invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Verknüpfte Journal-Einträge laden (nur wenn Spalte existiert)
$journal_linked = [];
try {
$stmt_jl = $pdo->query("SELECT invoice_id, id FROM journal_entries WHERE invoice_id IS NOT NULL");
foreach ($stmt_jl->fetchAll(PDO::FETCH_ASSOC) as $jl) {
$journal_linked[(int)$jl['invoice_id']] = (int)$jl['id'];
}
} catch (PDOException $e) {
// Spalte invoice_id existiert noch nicht - ignorieren
}
// Mahnungen pro Rechnung laden
$mahnungen_count = [];
try {
$stmt_m = $pdo->query("SELECT invoice_id, COUNT(*) AS cnt, MAX(level) AS max_level FROM mahnungen GROUP BY invoice_id");
foreach ($stmt_m->fetchAll(PDO::FETCH_ASSOC) as $m) {
$mahnungen_count[(int)$m['invoice_id']] = ['cnt' => $m['cnt'], 'level' => $m['max_level']];
}
} catch (PDOException $e) {
// Tabelle noch nicht migriert
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Rechnungen</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>" class="active">Übersicht</a>
<a href="<?= url_for('invoice_new.php') ?>">Neue Rechnung</a>
<a href="<?= url_for('recurring.php') ?>">Abo-Rechnungen</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<form method="get" class="filters">
<label>Rechnungsnummer:
<input type="text" name="number" value="<?= htmlspecialchars($filter_number) ?>">
</label>
<label>Kunde:
<input type="text" name="customer" value="<?= htmlspecialchars($filter_customer) ?>">
</label>
<label>Von:
<input type="date" name="from" value="<?= htmlspecialchars($filter_from) ?>">
</label>
<label>Bis:
<input type="date" name="to" value="<?= htmlspecialchars($filter_to) ?>">
</label>
<button type="submit">Filtern</button>
<a href="<?= url_for('invoices.php') ?>">Zurücksetzen</a>
<a href="<?= url_for('invoices_csv.php') . '?number=' . urlencode($filter_number) . '&customer=' . urlencode($filter_customer) . '&from=' . urlencode($filter_from) . '&to=' . urlencode($filter_to) ?>" class="button-secondary">Export CSV</a>
</form>
<table class="list">
<thead>
<tr>
<th>Datum</th>
<th>Nr.</th>
<th>Kunde</th>
<th>Betrag (brutto)</th>
<th>Status</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($invoices as $inv): ?>
<?php
$is_storno = !empty($inv['is_storno']);
$has_storno_child = !empty($inv['storno_child_id']);
$mahnung_info = $mahnungen_count[(int)$inv['id']] ?? null;
?>
<tr <?= $is_storno ? 'style="opacity:0.6;"' : '' ?>>
<td><?= htmlspecialchars(date('d.m.Y', strtotime($inv['invoice_date']))) ?></td>
<td>
<?= htmlspecialchars($inv['invoice_number']) ?>
<?php if ($is_storno): ?>
<span style="font-size:9px;color:var(--error);font-weight:600;margin-left:4px;">STORNO</span>
<?php endif; ?>
<?php if ($has_storno_child): ?>
<span style="font-size:9px;color:var(--text-muted);margin-left:4px;" title="Storniert durch <?= htmlspecialchars($inv['storno_child_number']) ?>">storniert</span>
<?php endif; ?>
</td>
<td><?= htmlspecialchars($inv['customer_name']) ?></td>
<td style="text-align:right;"><?= number_format($inv['total_gross'], 2, ',', '.') ?> €</td>
<td>
<?= $inv['paid'] ? 'bezahlt' : 'offen' ?>
<?php if ($mahnung_info): ?>
<a href="<?= url_for('belegarchiv.php?invoice_id=' . $inv['id'] . '&type=mahnung') ?>"
style="font-size:9px;color:var(--warning);margin-left:4px;font-weight:600;">
M<?= $mahnung_info['level'] ?>
</a>
<?php endif; ?>
</td>
<td>
<a href="<?= url_for('invoice_pdf.php?id=' . $inv['id']) ?>" target="_blank">PDF</a>
<?php if (!$inv['paid']): ?>
| <a href="#" onclick="document.getElementById('pay-form-<?= $inv['id'] ?>').style.display='block'; return false;">bezahlt</a>
| <a href="<?= url_for('mahnung_new.php?invoice_id=' . $inv['id']) ?>">Mahnung</a>
<?php endif; ?>
<?php if ($inv['paid'] && isset($journal_linked[$inv['id']])): ?>
| <a href="<?= url_for('journal_entry.php?id=' . $journal_linked[$inv['id']]) ?>">Journal</a>
<?php endif; ?>
<?php if (!$is_storno && !$has_storno_child): ?>
| <a href="<?= url_for('invoice_storno.php?id=' . $inv['id']) ?>" style="color:var(--error);">Storno</a>
<?php endif; ?>
</td>
</tr>
<?php if (!$inv['paid']): ?>
<tr id="pay-form-<?= $inv['id'] ?>" style="display:none;" class="payment-form-row">
<td colspan="6">
<form method="post" style="display:inline-flex; gap:8px; align-items:center; padding:4px 0;">
<input type="hidden" name="mark_paid_id" value="<?= $inv['id'] ?>">
<label style="margin:0;">Zahlungsdatum:
<input type="date" name="payment_date" value="<?= date('Y-m-d') ?>" required>
</label>
<button type="submit" onclick="return confirm('Rechnung als bezahlt markieren und Journalbuchung erstellen?');">Bezahlt + Journal buchen</button>
<a href="#" onclick="this.closest('tr').style.display='none'; return false;">Abbrechen</a>
</form>
</td>
</tr>
<?php endif; ?>
</tr>
<?php endforeach; ?>
<?php if (empty($invoices)): ?>
<tr><td colspan="6">Keine Rechnungen gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,60 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_login();
$pdo = get_db();
$filter_number = trim($_GET['number'] ?? '');
$filter_customer = trim($_GET['customer'] ?? '');
$filter_from = trim($_GET['from'] ?? '');
$filter_to = trim($_GET['to'] ?? '');
$sql = "SELECT i.*, c.name AS customer_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE 1=1";
$params = [];
if ($filter_number !== '') {
$sql .= " AND i.invoice_number ILIKE :num";
$params[':num'] = '%' . $filter_number . '%';
}
if ($filter_customer !== '') {
$sql .= " AND c.name ILIKE :cust";
$params[':cust'] = '%' . $filter_customer . '%';
}
if ($filter_from !== '') {
$sql .= " AND i.invoice_date >= :from";
$params[':from'] = $filter_from;
}
if ($filter_to !== '') {
$sql .= " AND i.invoice_date <= :to";
$params[':to'] = $filter_to;
}
$sql .= " ORDER BY i.invoice_date ASC, i.id ASC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="invoices_export_' . date('Y-m-d') . '.csv"');
$out = fopen('php://output', 'w');
fputcsv($out, ['Datum', 'Rechnungsnummer', 'Kunde', 'Netto', 'USt', 'Brutto', 'Status'], ';');
foreach ($rows as $r) {
fputcsv($out, [
date('d.m.Y', strtotime($r['invoice_date'])),
$r['invoice_number'],
$r['customer_name'],
number_format($r['total_net'], 2, ',', ''),
number_format($r['total_vat'], 2, ',', ''),
number_format($r['total_gross'], 2, ',', ''),
$r['paid'] ? 'bezahlt' : 'offen',
], ';');
}
fclose($out);
exit;

View File

@@ -1,559 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$msg = '';
$error = '';
// ---- Aktionen ----
if (isset($_GET['delete'])) {
$del_id = (int)$_GET['delete'];
delete_journal_entry($del_id);
$msg = 'Buchung gelöscht.';
}
// Umsatz-Zusammenfassung speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['form'] ?? '') === 'summary_values') {
$sv_year_id = (int)$_POST['year_id'];
$sv_month = (int)$_POST['month'];
$values = $_POST['summary_val'] ?? [];
save_journal_monthly_summary_values($sv_year_id, $sv_month, $values);
$msg = 'Umsatzübersicht gespeichert.';
}
// ---- Jahr und Monat bestimmen ----
$years = get_journal_years();
$year_id = isset($_GET['year_id']) ? (int)$_GET['year_id'] : null;
$month = isset($_GET['month']) ? (int)$_GET['month'] : (int)date('n');
if (!$year_id && $years) {
$current_cal_year = (int)date('Y');
foreach ($years as $y) {
if ($y['year'] == $current_cal_year) {
$year_id = $y['id'];
break;
}
}
if (!$year_id) $year_id = $years[0]['id'];
}
$current_year = null;
foreach ($years as $y) {
if ($y['id'] == $year_id) {
$current_year = $y;
break;
}
}
// ---- Daten laden ----
$columns = [];
$entries = [];
$totals = [];
$summary_items = [];
$summary_values = [];
if ($year_id) {
$columns = get_journal_columns();
$entries = get_journal_entries($year_id, $month);
$totals = calculate_monthly_totals($year_id, $month);
$summary_items = get_journal_summary_items(true);
$summary_values_raw = get_journal_monthly_summary_values($year_id, $month);
foreach ($summary_values_raw as $sv) {
$summary_values[$sv['summary_item_id']] = $sv['amount'];
}
}
// Spaltengruppen für colspan
$groups = [];
foreach ($columns as $col) {
$g = $col['group'];
if (!isset($groups[$g])) $groups[$g] = 0;
$groups[$g]++;
}
// Konto-Optionen als JSON
$acct_options_json = json_encode(get_journal_account_options());
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Journal <?= $current_year ? (int)$current_year['year'] : '' ?> - <?= journal_month_name_full($month) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>" class="active"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="journal-subnav">
<a href="<?= url_for('journal.php') ?>" class="active">Monatsansicht</a>
<a href="<?= url_for('journal_summary.php?year_id=' . $year_id) ?>">Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>">Suche</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<?php if (empty($years)): ?>
<p class="warning">Bitte zuerst ein <a href="<?= url_for('settings.php?tab=journal') ?>">Jahr anlegen</a>.</p>
<?php else: ?>
<!-- Jahr-Dropdown + Monatstabs -->
<div class="journal-controls">
<form method="get" class="journal-year-select">
<label style="margin:0;font-size:10px;">Jahr:
<select name="year_id" onchange="this.form.submit();" style="width:auto;min-width:70px;">
<?php foreach ($years as $y): ?>
<option value="<?= $y['id'] ?>" <?= $y['id'] == $year_id ? 'selected' : '' ?>>
<?= (int)$y['year'] ?><?= $y['is_closed'] ? ' (geschl.)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<input type="hidden" name="month" value="<?= $month ?>">
</form>
<div class="journal-month-tabs">
<?php for ($m = 1; $m <= 12; $m++): ?>
<a href="<?= url_for('journal.php?year_id=' . $year_id . '&month=' . $m) ?>"
class="<?= $m === $month ? 'active' : '' ?>">
<?= journal_month_name($m) ?>
</a>
<?php endfor; ?>
</div>
</div>
<!-- Monatssummen -->
<div class="journal-month-summary">
<span class="journal-soll">S: <?= journal_format_amount($totals['_soll'] ?? 0) ?></span>
<span class="journal-haben">H: <?= journal_format_amount($totals['_haben'] ?? 0) ?></span>
<?php
$diff = ($totals['_soll'] ?? 0) - ($totals['_haben'] ?? 0);
if (abs($diff) > 0.005):
?>
<span class="journal-diff-warning">Diff: <?= journal_format_amount($diff) ?></span>
<?php endif; ?>
<span class="journal-sep"></span>
<?php
$kasse_balance = ($totals['kasse_s'] ?? 0) - ($totals['kasse_h'] ?? 0);
$bank_balance = ($totals['bank_s'] ?? 0) - ($totals['bank_h'] ?? 0);
?>
<span class="journal-kasse">Kasse: <?= journal_format_amount($kasse_balance) ?></span>
<span class="journal-bank">Bank: <?= journal_format_amount($bank_balance) ?></span>
<span style="color:var(--text-dim);margin-left:auto;"><?= count($entries) ?> Buchungen</span>
</div>
<?php if (isset($diff) && abs($diff) > 0.005): ?>
<div class="journal-diff-banner">
<strong>⚠ Buchung nicht ausgeglichen</strong>
&mdash; Differenz: <strong><?= journal_format_amount($diff) ?></strong>
<span style="margin-left:10px;font-size:10px;opacity:0.65;">Soll: <?= journal_format_amount($totals['_soll'] ?? 0) ?> &middot; Haben: <?= journal_format_amount($totals['_haben'] ?? 0) ?></span>
</div>
<?php endif; ?>
<!-- Journal-Tabelle -->
<div class="journal-table-wrap">
<table class="journal-table" id="journal-table">
<thead>
<!-- Gruppenheader -->
<tr class="journal-group-header">
<th></th>
<th colspan="4"></th>
<?php foreach ($groups as $gname => $gcount): ?>
<th colspan="<?= $gcount ?>"><?= htmlspecialchars($gname) ?></th>
<?php endforeach; ?>
</tr>
<!-- Spaltenheader -->
<tr>
<th class="journal-col-action"></th>
<th class="journal-col-date">Tag</th>
<th class="journal-col-att">B</th>
<th class="journal-col-text">Text</th>
<th class="journal-col-betrag">Betrag</th>
<?php foreach ($columns as $col): ?>
<th class="journal-col-amount journal-<?= $col['side'] ?>"><?= htmlspecialchars($col['label']) ?></th>
<?php endforeach; ?>
</tr>
<!-- Monatssummen oben (wie Excel Zeile 2+3) -->
<tr class="journal-top-sums">
<td></td>
<td colspan="4" style="text-align:right;"><strong>S</strong></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?php if ($col['side'] === 'soll'): ?>
<strong><?= journal_format_amount($totals[$col['key']] ?? 0) ?></strong>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<tr class="journal-top-sums">
<td></td>
<td colspan="4" style="text-align:right;"><strong>H</strong></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?php if ($col['side'] === 'haben'): ?>
<strong><?= journal_format_amount($totals[$col['key']] ?? 0) ?></strong>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
</thead>
<tbody id="journal-body">
<?php if (empty($entries)): ?>
<tr id="empty-row"><td colspan="<?= 6 + count($columns) ?>" style="color:var(--text-dim);padding:6px;">Keine Buchungen.</td></tr>
<?php else: ?>
<?php foreach ($entries as $entry_row): ?>
<tr data-id="<?= $entry_row['id'] ?>">
<td class="journal-col-action">
<a href="<?= url_for('journal_entry.php?id=' . $entry_row['id']) ?>" title="Bearbeiten">B</a>
<a href="#" onclick="deleteEntry(<?= $entry_row['id'] ?>);return false;" title="Löschen">X</a>
<a href="#" onclick="duplicateEntry(<?= $entry_row['id'] ?>);return false;" title="Duplizieren">D</a>
</td>
<td class="journal-col-date"><?= htmlspecialchars(date('d.m', strtotime($entry_row['entry_date']))) ?></td>
<td class="journal-col-att"><?= htmlspecialchars($entry_row['attachment_note'] ?? '') ?></td>
<td class="journal-col-text">
<?= htmlspecialchars($entry_row['description']) ?>
<?php if (($entry_row['source_type'] ?? 'manual') === 'invoice_payment' && !empty($entry_row['invoice_id'])): ?>
<a href="<?= url_for('invoice_pdf.php?id=' . $entry_row['invoice_id']) ?>" target="_blank" class="source-badge source-invoice" title="Automatisch aus Rechnungszahlung">RE</a>
<?php elseif (($entry_row['source_type'] ?? 'manual') === 'expense_payment' && !empty($entry_row['expense_id'])): ?>
<a href="<?= url_for('expenses.php?action=edit&id=' . $entry_row['expense_id']) ?>" class="source-badge source-expense" title="Automatisch aus Ausgabe">AU</a>
<?php endif; ?>
</td>
<td class="journal-col-betrag"><?= journal_format_amount($entry_row['amount']) ?></td>
<?php
$row_amounts = [];
if (!empty($entry_row['accounts'])) {
foreach ($entry_row['accounts'] as $acct) {
$key = map_account_to_column_key($acct, $columns);
if ($key) {
$row_amounts[$key] = ($row_amounts[$key] ?? 0) + (float)$acct['amount'];
}
}
}
?>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?= isset($row_amounts[$col['key']]) ? journal_format_amount($row_amounts[$col['key']]) : '' ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<tfoot>
<tr class="journal-totals-row">
<td></td>
<td colspan="4"><strong>Summen</strong></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<strong><?= journal_format_amount($totals[$col['key']] ?? 0) ?></strong>
</td>
<?php endforeach; ?>
</tr>
</tfoot>
</table>
</div>
<!-- Inline-Eingabe -->
<div id="inline-editor" style="margin-bottom:6px;">
<div style="display:flex;gap:4px;align-items:center;margin-bottom:3px;">
<button type="button" onclick="toggleInlineForm()" id="btn-new">+ Neue Buchung</button>
<a href="<?= url_for('journal_entry.php?year_id=' . $year_id . '&month=' . $month) ?>" style="font-size:10px;color:var(--text-dim);">Vollformular</a>
</div>
<div id="inline-form" style="display:none;">
<form id="inline-entry-form" onsubmit="return saveInlineEntry(event);">
<input type="hidden" name="year_id" value="<?= $year_id ?>">
<input type="hidden" name="id" id="inline-id" value="">
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:3px;">
<?php
$last_date = date('Y-m-d');
if (!empty($entries)) {
$last_entry = end($entries);
$last_date = $last_entry['entry_date'];
}
?>
<input type="date" name="entry_date" id="ie-date" value="<?= htmlspecialchars($last_date) ?>" required style="width:130px;">
<input type="text" name="description" id="ie-desc" placeholder="Text" required style="flex:2;min-width:150px;">
<input type="text" name="attachment_note" id="ie-att" placeholder="Beleg" style="width:60px;">
<input type="number" step="0.01" name="amount" id="ie-amount" placeholder="Betrag" style="width:80px;">
</div>
<div class="journal-inline-accounts" id="ie-accounts">
<table>
<thead><tr><th>Konto</th><th style="width:20px;">S/H</th><th style="width:80px;">Betrag</th><th style="width:80px;">Notiz</th><th style="width:20px;"></th></tr></thead>
<tbody id="ie-acct-body"></tbody>
</table>
<button type="button" onclick="addInlineAccountRow('','soll','','','','',true);" style="padding:1px 6px;font-size:9px;margin-top:2px;">+Konto</button>
</div>
<div style="display:flex;gap:4px;margin-top:3px;">
<button type="submit">Speichern</button>
<button type="button" onclick="saveAndNew();" class="secondary">Speichern+Neu</button>
<button type="button" onclick="cancelInline();" class="secondary">Abbrechen</button>
<span id="inline-status" style="font-size:10px;color:var(--text-dim);align-self:center;"></span>
</div>
</form>
</div>
</div>
<!-- Umsatzübersicht -->
<?php if ($summary_items): ?>
<section>
<h2>Umsatzübersicht <?= journal_month_name_full($month) ?></h2>
<div>
<?php
$er_cats = get_journal_revenue_categories('erloese', true);
foreach ($er_cats as $cat):
$cat_key = 'er_' . $cat['id'];
$cat_total = $totals[$cat_key] ?? 0;
?>
<span style="margin-right:12px;font-size:11px;">
<?= htmlspecialchars($cat['name']) ?>: <strong><?= journal_format_amount($cat_total) ?></strong>
</span>
<?php endforeach; ?>
<form method="post" style="margin-top:4px;">
<input type="hidden" name="form" value="summary_values">
<input type="hidden" name="year_id" value="<?= $year_id ?>">
<input type="hidden" name="month" value="<?= $month ?>">
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:flex-end;">
<?php foreach ($summary_items as $item): ?>
<label style="margin:0;font-size:10px;"><?= htmlspecialchars($item['name']) ?>:
<input type="number" step="0.01" name="summary_val[<?= $item['id'] ?>]"
value="<?= htmlspecialchars($summary_values[$item['id']] ?? '0.00') ?>"
style="max-width:80px;">
</label>
<?php endforeach; ?>
<button type="submit" style="padding:3px 8px;">Speichern</button>
</div>
</form>
</div>
</section>
<?php endif; ?>
<?php endif; ?>
</main>
<script src="assets/combobox.js"></script>
<script>
const accountOptions = <?= $acct_options_json ?>;
const yearId = <?= $year_id ?: 0 ?>;
const currentMonth = <?= $month ?>;
const lastEntryDate = '<?= $last_date ?>';
function addInlineAccountRow(type, side, amount, revId, expId, note, autoFocus) {
type = type || ''; side = side || 'soll'; amount = amount || '';
revId = revId || ''; expId = expId || ''; note = note || '';
const tbody = document.getElementById('ie-acct-body');
const tr = document.createElement('tr');
tr.innerHTML =
'<td><div class="pirp-combobox-wrap"></div>' +
'<input type="hidden" name="acct_type[]" class="h-type" value="' + type + '">' +
'<input type="hidden" name="acct_side[]" class="h-side" value="' + side + '">' +
'<input type="hidden" name="acct_rev_id[]" class="h-rev" value="' + revId + '">' +
'<input type="hidden" name="acct_exp_id[]" class="h-exp" value="' + expId + '">' +
'</td>' +
'<td class="side-label" style="text-align:center;font-weight:700;font-size:10px;">' +
(side === 'haben' ? 'H' : (type ? 'S' : '')) + '</td>' +
'<td><input type="number" step="0.01" name="acct_amount[]" value="' + amount + '" style="width:70px;"></td>' +
'<td><input type="text" name="acct_note[]" value="' + note + '" style="width:70px;"></td>' +
'<td><a href="#" onclick="this.closest(\'tr\').remove();return false;" style="color:var(--error);font-weight:700;font-size:10px;">X</a></td>';
tbody.appendChild(tr);
const wrap = tr.querySelector('.pirp-combobox-wrap');
const selectedValue = type ? (type + '|' + side + '|' + revId + '|' + expId) : '';
var cb = new PirpCombobox(wrap, accountOptions, {
placeholder: '--Konto--',
selectedValue: selectedValue,
onSelect: function(opt) {
tr.querySelector('.h-type').value = opt.value;
tr.querySelector('.h-side').value = opt.side;
tr.querySelector('.h-rev').value = opt.rev_id;
tr.querySelector('.h-exp').value = opt.exp_id;
tr.querySelector('.side-label').textContent = opt.side === 'haben' ? 'H' : 'S';
}
});
if (!type && autoFocus) cb.input.focus();
}
function toggleInlineForm() {
const form = document.getElementById('inline-form');
const tableWrap = document.querySelector('.journal-table-wrap');
if (form.style.display === 'none') {
form.style.display = 'block';
resetInlineForm();
// Formhöhe messen und Tabelle entsprechend verkleinern
const formHeight = form.offsetHeight;
tableWrap.style.maxHeight = 'calc(70vh - ' + formHeight + 'px)';
document.getElementById('ie-date').focus();
} else {
form.style.display = 'none';
tableWrap.style.maxHeight = '70vh';
}
}
function resetInlineForm() {
document.getElementById('inline-id').value = '';
document.getElementById('ie-date').value = lastEntryDate;
document.getElementById('ie-desc').value = '';
document.getElementById('ie-att').value = '';
document.getElementById('ie-amount').value = '';
document.getElementById('ie-acct-body').innerHTML = '';
addInlineAccountRow();
addInlineAccountRow();
document.getElementById('inline-status').textContent = '';
}
function cancelInline() {
document.getElementById('inline-form').style.display = 'none';
document.querySelector('.journal-table-wrap').style.maxHeight = '70vh';
}
function saveInlineEntry(e) {
if (e) e.preventDefault();
const form = document.getElementById('inline-entry-form');
const fd = new FormData(form);
fd.append('action', 'save_entry');
document.getElementById('inline-status').textContent = 'Speichert...';
fetch('<?= url_for("journal_api.php") ?>', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.ok) {
location.reload();
} else {
document.getElementById('inline-status').textContent = data.error || 'Fehler';
document.getElementById('inline-status').style.color = 'var(--error)';
}
})
.catch(err => {
document.getElementById('inline-status').textContent = 'Netzwerkfehler';
document.getElementById('inline-status').style.color = 'var(--error)';
});
return false;
}
function saveAndNew() {
const form = document.getElementById('inline-entry-form');
const fd = new FormData(form);
fd.append('action', 'save_entry');
document.getElementById('inline-status').textContent = 'Speichert...';
fetch('<?= url_for("journal_api.php") ?>', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.ok) {
// Seite neu laden mit Parameter um Formular offen zu halten
const url = new URL(window.location.href);
url.searchParams.set('new', '1');
window.location.href = url.toString();
} else {
document.getElementById('inline-status').textContent = data.error || 'Fehler';
document.getElementById('inline-status').style.color = 'var(--error)';
}
})
.catch(err => {
document.getElementById('inline-status').textContent = 'Netzwerkfehler';
});
}
function pirpConfirm(message, confirmLabel, confirmClass) {
confirmLabel = confirmLabel || 'OK';
confirmClass = confirmClass || 'danger';
return new Promise(function(resolve) {
var overlay = document.createElement('div');
overlay.className = 'pirp-confirm-overlay';
overlay.innerHTML =
'<div class="pirp-confirm-box">' +
'<p class="pirp-confirm-msg">' + message + '</p>' +
'<div class="pirp-confirm-btns">' +
'<button class="pirp-confirm-ok ' + confirmClass + '">' + confirmLabel + '</button>' +
'<button class="pirp-confirm-cancel secondary">Abbrechen</button>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
function cleanup(result) {
document.removeEventListener('keydown', onKey);
overlay.remove();
resolve(result);
}
overlay.querySelector('.pirp-confirm-ok').onclick = function() { cleanup(true); };
overlay.querySelector('.pirp-confirm-cancel').onclick = function() { cleanup(false); };
overlay.onclick = function(e) { if (e.target === overlay) cleanup(false); };
function onKey(e) {
if (e.key === 'Enter') cleanup(true);
if (e.key === 'Escape') cleanup(false);
}
document.addEventListener('keydown', onKey);
overlay.querySelector('.pirp-confirm-cancel').focus();
});
}
async function deleteEntry(id) {
const ok = await pirpConfirm('Buchung löschen?', 'Löschen', 'danger');
if (!ok) return;
const fd = new FormData();
fd.append('action', 'delete_entry');
fd.append('id', id);
fetch('<?= url_for("journal_api.php") ?>', { method: 'POST', body: fd })
.then(r => r.json())
.then(data => {
if (data.ok) location.reload();
else alert(data.error || 'Fehler');
});
}
function duplicateEntry(id) {
window.location = '<?= url_for("journal_entry.php") ?>?duplicate=' + id;
}
// Sticky header: top-Werte dynamisch berechnen
(function() {
const thead = document.querySelector('#journal-table thead');
if (!thead) return;
const rows = thead.querySelectorAll('tr');
let top = 0;
rows.forEach(function(row) {
row.querySelectorAll('th, td').forEach(function(cell) {
cell.style.top = top + 'px';
});
top += row.offsetHeight;
});
})();
// Auto-open form wenn ?new=1 in URL (nach Speichern+Neu)
if (new URLSearchParams(window.location.search).get('new') === '1') {
// URL-Parameter entfernen ohne Reload
const url = new URL(window.location.href);
url.searchParams.delete('new');
history.replaceState(null, '', url.toString());
// Formular öffnen
toggleInlineForm();
}
</script>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,81 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_login();
header('Content-Type: application/json');
$action = $_POST['action'] ?? $_GET['action'] ?? '';
try {
switch ($action) {
case 'save_entry':
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'year_id' => (int)$_POST['year_id'],
'entry_date' => $_POST['entry_date'] ?? '',
'description' => $_POST['description'] ?? '',
'attachment_note' => $_POST['attachment_note'] ?? '',
'amount' => $_POST['amount'] ?? 0,
'supplier_id' => !empty($_POST['supplier_id']) ? (int)$_POST['supplier_id'] : null,
'sort_order' => (int)($_POST['sort_order'] ?? 0),
];
if (!$data['entry_date'] || !$data['description']) {
echo json_encode(['ok' => false, 'error' => 'Datum und Text sind Pflichtfelder.']);
exit;
}
$accounts = [];
$acct_types = $_POST['acct_type'] ?? [];
$acct_sides = $_POST['acct_side'] ?? [];
$acct_amounts = $_POST['acct_amount'] ?? [];
$acct_rev_ids = $_POST['acct_rev_id'] ?? [];
$acct_exp_ids = $_POST['acct_exp_id'] ?? [];
$acct_notes = $_POST['acct_note'] ?? [];
for ($i = 0; $i < count($acct_types); $i++) {
if (empty($acct_types[$i]) || (float)($acct_amounts[$i] ?? 0) == 0) continue;
$accounts[] = [
'account_type' => $acct_types[$i],
'side' => $acct_sides[$i] ?? 'soll',
'amount' => (float)($acct_amounts[$i] ?? 0),
'revenue_category_id' => !empty($acct_rev_ids[$i]) ? (int)$acct_rev_ids[$i] : null,
'expense_category_id' => !empty($acct_exp_ids[$i]) ? (int)$acct_exp_ids[$i] : null,
'note' => $acct_notes[$i] ?? '',
];
}
if (empty($accounts)) {
echo json_encode(['ok' => false, 'error' => 'Mindestens eine Kontenbuchung erforderlich.']);
exit;
}
$saved_id = save_journal_entry($id, $data, $accounts);
echo json_encode(['ok' => true, 'id' => $saved_id]);
break;
case 'delete_entry':
$del_id = (int)($_POST['id'] ?? 0);
if ($del_id) {
delete_journal_entry($del_id);
echo json_encode(['ok' => true]);
} else {
echo json_encode(['ok' => false, 'error' => 'Keine ID.']);
}
break;
case 'get_entry':
$get_id = (int)($_GET['id'] ?? 0);
$entry = get_journal_entry($get_id);
echo json_encode(['ok' => true, 'entry' => $entry]);
break;
default:
echo json_encode(['ok' => false, 'error' => 'Unbekannte Aktion.']);
}
} catch (\Exception $e) {
echo json_encode(['ok' => false, 'error' => $e->getMessage()]);
}

View File

@@ -1,168 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$year_id = isset($_GET['year_id']) ? (int)$_GET['year_id'] : 0;
$month = isset($_GET['month']) ? (int)$_GET['month'] : 0;
$col_key = $_GET['col_key'] ?? '';
if (!$year_id || !$month || !$col_key) {
header('Location: ' . url_for('journal_summary.php'));
exit;
}
// Jahr laden
$years = get_journal_years();
$current_year = null;
foreach ($years as $y) {
if ($y['id'] == $year_id) { $current_year = $y; break; }
}
// Spalte finden
$columns = get_journal_columns();
$col = null;
foreach ($columns as $c) {
if ($c['key'] === $col_key) { $col = $c; break; }
}
if (!$col) {
header('Location: ' . url_for('journal_summary.php?year_id=' . $year_id));
exit;
}
// SQL aufbauen
$pdo = get_db();
$where = "e.year_id = :year_id AND e.month = :month AND a.account_type = :account_type AND a.side = :side";
$params = [
':year_id' => $year_id,
':month' => $month,
':account_type' => $col['account_type'],
':side' => $col['side'],
];
if (($col['account_type'] === 'wareneingang' || $col['account_type'] === 'erloese') && $col['category_id']) {
$where .= " AND a.revenue_category_id = :rev_cat_id";
$params[':rev_cat_id'] = $col['category_id'];
} elseif (($col['account_type'] === 'expense' || $col['account_type'] === 'deduction') && $col['category_id']) {
$where .= " AND a.expense_category_id = :exp_cat_id";
$params[':exp_cat_id'] = $col['category_id'];
}
$sql = "SELECT e.id, e.entry_date, e.description, e.attachment_note, e.amount AS entry_amount,
a.amount AS col_amount, a.note AS acct_note,
s.name AS supplier_name
FROM journal_entries e
JOIN journal_entry_accounts a ON a.entry_id = e.id
LEFT JOIN journal_suppliers s ON e.supplier_id = s.id
WHERE $where
ORDER BY e.entry_date, e.sort_order, e.id";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll(PDO::FETCH_ASSOC);
$total = array_sum(array_column($entries, 'col_amount'));
$month_name = journal_month_name_full($month);
$year_val = $current_year ? (int)$current_year['year'] : $year_id;
$back_url = url_for('journal_summary.php?year_id=' . $year_id);
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title><?= htmlspecialchars($col['label']) ?> <?= $month_name ?> <?= $year_val ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>" class="active"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="journal-subnav">
<a href="<?= url_for('journal.php?year_id=' . $year_id) ?>">Monatsansicht</a>
<a href="<?= $back_url ?>">Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>">Suche</a>
</div>
<div style="margin-bottom:8px;">
<a href="<?= $back_url ?>" style="font-size:11px;color:var(--text-dim);">← Zurück zu Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>">Suche</a>
</div>
<section>
<h2>
<?= htmlspecialchars($col['label']) ?>
<span style="font-weight:normal;font-size:13px;color:var(--text-dim);">
<?= $month_name ?> <?= $year_val ?>
(<?= htmlspecialchars($col['group']) ?>, <?= $col['side'] === 'soll' ? 'Soll' : 'Haben' ?>)
</span>
</h2>
<?php if (empty($entries)): ?>
<p style="color:var(--text-dim);">Keine Buchungen in diesem Zeitraum.</p>
<?php else: ?>
<div class="journal-table-wrap">
<table class="journal-table">
<thead>
<tr>
<th class="journal-col-date">Datum</th>
<th class="journal-col-att">B</th>
<th class="journal-col-text">Text</th>
<th class="journal-col-betrag">Buchungsbetrag</th>
<th class="journal-col-amount journal-<?= $col['side'] ?>"><?= htmlspecialchars($col['label']) ?></th>
<th class="journal-col-text" style="min-width:80px;">Notiz</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $row): ?>
<tr>
<td class="journal-col-date"><?= htmlspecialchars(date('d.m.Y', strtotime($row['entry_date']))) ?></td>
<td class="journal-col-att"><?= htmlspecialchars($row['attachment_note'] ?? '') ?></td>
<td class="journal-col-text">
<a href="<?= url_for('journal_entry.php?id=' . $row['id']) ?>" style="color:inherit;">
<?= htmlspecialchars($row['description']) ?>
</a>
<?php if ($row['supplier_name']): ?>
<span style="color:var(--text-dim);font-size:10px;"> · <?= htmlspecialchars($row['supplier_name']) ?></span>
<?php endif; ?>
</td>
<td class="journal-col-betrag"><?= journal_format_amount((float)$row['entry_amount']) ?></td>
<td class="journal-col-amount journal-<?= $col['side'] ?>"><?= journal_format_amount((float)$row['col_amount']) ?></td>
<td style="font-size:10px;color:var(--text-dim);"><?= htmlspecialchars($row['acct_note'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
<tfoot>
<tr class="journal-totals-row">
<td colspan="4"><strong>Summe</strong></td>
<td class="journal-col-amount journal-<?= $col['side'] ?>"><strong><?= journal_format_amount($total) ?></strong></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<p style="font-size:11px;color:var(--text-dim);margin-top:4px;"><?= count($entries) ?> Buchung(en)</p>
<?php endif; ?>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,311 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$msg = '';
$error = '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$year_id = isset($_GET['year_id']) ? (int)$_GET['year_id'] : null;
$month = isset($_GET['month']) ? (int)$_GET['month'] : (int)date('n');
$save_and_new = false;
// Duplizieren
if (isset($_GET['duplicate'])) {
$source_id = (int)$_GET['duplicate'];
try {
$new_id = duplicate_journal_entry($source_id);
header('Location: ' . url_for('journal_entry.php?id=' . $new_id));
exit;
} catch (\Exception $e) {
$error = 'Fehler beim Duplizieren: ' . $e->getMessage();
}
}
$entry = null;
if ($id) {
$entry = get_journal_entry($id);
if ($entry) {
$year_id = $entry['year_id'];
$month = $entry['month'];
}
}
// POST: Speichern
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$year_id = (int)$_POST['year_id'];
$save_and_new = isset($_POST['save_and_new']);
$id_post = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'year_id' => $year_id,
'entry_date' => $_POST['entry_date'] ?? '',
'description' => $_POST['description'] ?? '',
'attachment_note' => $_POST['attachment_note'] ?? '',
'amount' => $_POST['amount'] ?? 0,
'supplier_id' => $_POST['supplier_id'] ?? null,
'sort_order' => $_POST['sort_order'] ?? 0,
];
if (!$data['entry_date'] || !$data['description']) {
$error = 'Datum und Text sind Pflichtfelder.';
} else {
// Kontenverteilung aus POST parsen
$accounts = [];
$acct_types = $_POST['acct_type'] ?? [];
$acct_sides = $_POST['acct_side'] ?? [];
$acct_amounts = $_POST['acct_amount'] ?? [];
$acct_rev_ids = $_POST['acct_rev_id'] ?? [];
$acct_exp_ids = $_POST['acct_exp_id'] ?? [];
$acct_notes = $_POST['acct_note'] ?? [];
for ($i = 0; $i < count($acct_types); $i++) {
if (empty($acct_types[$i]) || (float)($acct_amounts[$i] ?? 0) == 0) continue;
$accounts[] = [
'account_type' => $acct_types[$i],
'side' => $acct_sides[$i] ?? 'soll',
'amount' => (float)($acct_amounts[$i] ?? 0),
'revenue_category_id' => !empty($acct_rev_ids[$i]) ? (int)$acct_rev_ids[$i] : null,
'expense_category_id' => !empty($acct_exp_ids[$i]) ? (int)$acct_exp_ids[$i] : null,
'note' => $acct_notes[$i] ?? '',
];
}
if (empty($accounts)) {
$error = 'Mindestens eine Kontenbuchung ist erforderlich.';
} else {
try {
$saved_id = save_journal_entry($id_post, $data, $accounts);
if ($save_and_new) {
$month = (int)date('n', strtotime($data['entry_date']));
header('Location: ' . url_for('journal_entry.php?year_id=' . $year_id . '&month=' . $month . '&msg=gespeichert'));
exit;
} else {
header('Location: ' . url_for('journal.php?year_id=' . $year_id . '&month=' . (int)date('n', strtotime($data['entry_date']))));
exit;
}
} catch (\Exception $e) {
$error = 'Fehler beim Speichern: ' . $e->getMessage();
}
}
}
}
if (isset($_GET['msg'])) {
$msg = 'Buchung gespeichert.';
}
// Daten laden
$years = get_journal_years();
$account_options = get_journal_account_options();
// Wenn kein year_id, das erste offene Jahr nehmen
if (!$year_id && $years) {
foreach ($years as $y) {
if (!$y['is_closed']) {
$year_id = $y['id'];
break;
}
}
if (!$year_id) $year_id = $years[0]['id'];
}
$current_year = null;
foreach ($years as $y) {
if ($y['id'] == $year_id) {
$current_year = $y;
break;
}
}
// Konto-Optionen als JSON für JavaScript
$acct_options_json = json_encode($account_options);
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Buchung <?= $entry ? 'bearbeiten' : 'erstellen' ?></title>
<link rel="stylesheet" href="assets/style.css">
<script src="assets/combobox.js"></script>
<script>
const accountOptions = <?= $acct_options_json ?>;
function addAccountRow(type, side, amount, revId, expId, note) {
type = type || '';
side = side || 'soll';
amount = amount || '';
revId = revId || '';
expId = expId || '';
note = note || '';
const tbody = document.getElementById('accounts-body');
const tr = document.createElement('tr');
tr.innerHTML =
'<td><div class="pirp-combobox-wrap"></div>' +
'<input type="hidden" name="acct_type[]" class="acct-type-hidden" value="' + type + '">' +
'<input type="hidden" name="acct_side[]" class="acct-side-hidden" value="' + side + '">' +
'<input type="hidden" name="acct_rev_id[]" class="acct-rev-hidden" value="' + revId + '">' +
'<input type="hidden" name="acct_exp_id[]" class="acct-exp-hidden" value="' + expId + '">' +
'</td>' +
'<td class="acct-side-display">' + (side === 'haben' ? 'H' : (type ? 'S' : '')) + '</td>' +
'<td><input type="number" step="0.01" name="acct_amount[]" value="' + amount + '" style="max-width:120px;"></td>' +
'<td><input type="text" name="acct_note[]" value="' + note + '" style="max-width:150px;"></td>' +
'<td><button type="button" class="danger" style="padding:2px 8px;font-size:10px;" onclick="this.closest(\'tr\').remove();">X</button></td>';
tbody.appendChild(tr);
const wrap = tr.querySelector('.pirp-combobox-wrap');
const selectedValue = type ? (type + '|' + side + '|' + revId + '|' + expId) : '';
new PirpCombobox(wrap, accountOptions, {
placeholder: '-- Konto wählen --',
selectedValue: selectedValue,
onSelect: function(opt) {
tr.querySelector('.acct-type-hidden').value = opt.value;
tr.querySelector('.acct-side-hidden').value = opt.side;
tr.querySelector('.acct-rev-hidden').value = opt.rev_id;
tr.querySelector('.acct-exp-hidden').value = opt.exp_id;
tr.querySelector('.acct-side-display').textContent = opt.side === 'soll' ? 'S' : 'H';
}
});
}
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('form');
form.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA' && e.target.type !== 'submit') {
e.preventDefault();
const inputs = Array.from(form.querySelectorAll('input:not([type="hidden"]), select, textarea, button[type="submit"]'));
const currentIndex = inputs.indexOf(e.target);
if (currentIndex > -1 && currentIndex < inputs.length - 1) {
inputs[currentIndex + 1].focus();
}
}
});
});
</script>
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>" class="active"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="journal-subnav">
<a href="<?= url_for('journal.php') ?>">Monatsansicht</a>
<a href="<?= url_for('journal_summary.php') ?>">Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>">Suche</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<?php if (empty($years)): ?>
<p class="warning">Bitte zuerst ein <a href="<?= url_for('settings.php?tab=journal') ?>">Jahr anlegen</a>.</p>
<?php else: ?>
<section>
<h2><?= $entry ? 'Buchung bearbeiten' : 'Neue Buchung' ?></h2>
<?php if ($entry && !empty($entry['source_type']) && $entry['source_type'] !== 'manual'): ?>
<p style="font-size:10px;color:var(--accent);margin-bottom:4px;">
<?php if ($entry['source_type'] === 'invoice_payment' && !empty($entry['invoice_id'])): ?>
Automatisch erstellt aus Rechnungszahlung:
<a href="<?= url_for('invoice_pdf.php?id=' . $entry['invoice_id']) ?>" target="_blank">Rechnung PDF</a>
<?php elseif ($entry['source_type'] === 'expense_payment' && !empty($entry['expense_id'])): ?>
Automatisch erstellt aus Ausgabe:
<a href="<?= url_for('expenses.php?action=edit&id=' . $entry['expense_id']) ?>">Ausgabe bearbeiten</a>
<?php endif; ?>
</p>
<?php endif; ?>
<form method="post">
<input type="hidden" name="id" value="<?= htmlspecialchars($entry['id'] ?? '') ?>">
<div class="flex-row">
<label>Jahr:
<select name="year_id" required>
<?php foreach ($years as $y): ?>
<option value="<?= $y['id'] ?>" <?= $y['id'] == $year_id ? 'selected' : '' ?> <?= $y['is_closed'] ? 'disabled' : '' ?>>
<?= (int)$y['year'] ?><?= $y['is_closed'] ? ' (geschlossen)' : '' ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Datum:
<input type="date" name="entry_date" value="<?= htmlspecialchars($entry['entry_date'] ?? date('Y-m-d')) ?>" required>
</label>
</div>
<label>Text:
<input type="text" name="description" value="<?= htmlspecialchars($entry['description'] ?? '') ?>" required style="max-width:600px;">
</label>
<label>Beleg:
<input type="text" name="attachment_note" value="<?= htmlspecialchars($entry['attachment_note'] ?? '') ?>" style="max-width:200px;">
</label>
<div class="flex-row">
<label>Betrag:
<input type="number" step="0.01" name="amount" value="<?= htmlspecialchars($entry['amount'] ?? '0.00') ?>" required style="max-width:150px;">
</label>
<label>Sortierung:
<input type="number" name="sort_order" value="<?= htmlspecialchars($entry['sort_order'] ?? '0') ?>" style="max-width:80px;">
</label>
</div>
<h2>Kontenverteilung</h2>
<table class="list">
<thead>
<tr>
<th>Konto</th>
<th>S/H</th>
<th>Betrag</th>
<th>Notiz</th>
<th></th>
</tr>
</thead>
<tbody id="accounts-body">
</tbody>
</table>
<button type="button" onclick="addAccountRow();" style="margin-top:8px;">+ Zeile hinzufügen</button>
<div style="margin-top:16px;">
<button type="submit">Speichern</button>
<button type="submit" name="save_and_new" value="1" class="secondary">Speichern &amp; Neu</button>
<a href="<?= url_for('journal.php?year_id=' . $year_id . '&month=' . $month) ?>" class="button-secondary">Abbrechen</a>
</div>
</form>
</section>
<script>
// Bestehende Konten laden oder leere Zeilen
<?php if ($entry && !empty($entry['accounts'])): ?>
<?php foreach ($entry['accounts'] as $acct): ?>
addAccountRow(
<?= json_encode($acct['account_type']) ?>,
<?= json_encode($acct['side']) ?>,
<?= json_encode($acct['amount']) ?>,
<?= json_encode($acct['revenue_category_id'] ?? '') ?>,
<?= json_encode($acct['expense_category_id'] ?? '') ?>,
<?= json_encode($acct['note'] ?? '') ?>
);
<?php endforeach; ?>
<?php else: ?>
addAccountRow();
addAccountRow();
<?php endif; ?>
</script>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,20 +0,0 @@
<?php
// Redirect to new unified EÜR page
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/journal_functions.php';
// Get year from year_id parameter for backwards compatibility
$year = date('Y');
if (isset($_GET['year_id'])) {
$year_id = (int)$_GET['year_id'];
$years = get_journal_years();
foreach ($years as $y) {
if ($y['id'] == $year_id) {
$year = (int)$y['year'];
break;
}
}
}
header('Location: ' . url_for('euer.php?year=' . $year));
exit;

View File

@@ -1,159 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$q = trim($_GET['q'] ?? '');
$from = trim($_GET['from'] ?? '');
$to = trim($_GET['to'] ?? '');
$year_id = isset($_GET['year_id']) ? (int)$_GET['year_id'] : 0;
$years = get_journal_years();
$entries = [];
$searched = false;
if ($q !== '' || $from !== '' || $to !== '' || $year_id) {
$searched = true;
$pdo = get_db();
$sql = "SELECT e.id, e.entry_date, e.month, e.description, e.attachment_note,
e.amount, e.year_id, y.year AS journal_year,
s.name AS supplier_name
FROM journal_entries e
JOIN journal_years y ON y.id = e.year_id
LEFT JOIN journal_suppliers s ON s.id = e.supplier_id
WHERE 1=1";
$params = [];
if ($q !== '') {
$sql .= " AND (e.description ILIKE :q OR e.attachment_note ILIKE :q2)";
$params[':q'] = '%' . $q . '%';
$params[':q2'] = '%' . $q . '%';
}
if ($from !== '') {
$sql .= " AND e.entry_date >= :from";
$params[':from'] = $from;
}
if ($to !== '') {
$sql .= " AND e.entry_date <= :to";
$params[':to'] = $to;
}
if ($year_id) {
$sql .= " AND e.year_id = :year_id";
$params[':year_id'] = $year_id;
}
$sql .= " ORDER BY e.entry_date DESC, e.id DESC LIMIT 200";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$entries = $stmt->fetchAll(PDO::FETCH_ASSOC);
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Journal-Suche</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>" class="active"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="journal-subnav">
<a href="<?= url_for('journal.php') ?>">Monatsansicht</a>
<a href="<?= url_for('journal_summary.php') ?>">Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>" class="active">Suche</a>
</div>
<form method="get" class="filters">
<label>Suche:
<input type="text" name="q" value="<?= htmlspecialchars($q) ?>" placeholder="Beschreibung, Beleg..." autofocus>
</label>
<label>Von:
<input type="date" name="from" value="<?= htmlspecialchars($from) ?>">
</label>
<label>Bis:
<input type="date" name="to" value="<?= htmlspecialchars($to) ?>">
</label>
<?php if ($years): ?>
<label>Jahr:
<select name="year_id">
<option value="">Alle Jahre</option>
<?php foreach ($years as $y): ?>
<option value="<?= $y['id'] ?>" <?= $y['id'] == $year_id ? 'selected' : '' ?>><?= (int)$y['year'] ?></option>
<?php endforeach; ?>
</select>
</label>
<?php endif; ?>
<button type="submit">Suchen</button>
<a href="<?= url_for('journal_search.php') ?>">Zurücksetzen</a>
</form>
<?php if ($searched): ?>
<section>
<h2>Suchergebnisse <?php if ($entries): ?><span style="font-weight:normal;font-size:12px;color:var(--text-muted);">(<?= count($entries) ?> Treffer<?= count($entries) >= 200 ? ', max. 200' : '' ?>)</span><?php endif; ?></h2>
<?php if (empty($entries)): ?>
<p style="color:var(--text-muted);">Keine Buchungen gefunden.</p>
<?php else: ?>
<div class="journal-table-wrap">
<table class="journal-table">
<thead>
<tr>
<th class="journal-col-date">Datum</th>
<th class="journal-col-att">B</th>
<th class="journal-col-text">Beschreibung</th>
<th class="journal-col-betrag">Betrag</th>
<th>Jahr / Monat</th>
</tr>
</thead>
<tbody>
<?php foreach ($entries as $e): ?>
<tr>
<td class="journal-col-date"><?= date('d.m.Y', strtotime($e['entry_date'])) ?></td>
<td class="journal-col-att"><?= htmlspecialchars($e['attachment_note'] ?? '') ?></td>
<td class="journal-col-text">
<a href="<?= url_for('journal_entry.php?id=' . $e['id']) ?>" style="color:inherit;">
<?= htmlspecialchars($e['description']) ?>
</a>
<?php if ($e['supplier_name']): ?>
<span style="color:var(--text-dim);font-size:10px;"> · <?= htmlspecialchars($e['supplier_name']) ?></span>
<?php endif; ?>
</td>
<td class="journal-col-betrag"><?= number_format((float)$e['amount'], 2, ',', '.') ?></td>
<td>
<a href="<?= url_for('journal.php?year_id=' . $e['year_id'] . '&month=' . $e['month']) ?>"
style="font-size:11px;color:var(--text-muted);">
<?= (int)$e['journal_year'] ?> / <?= journal_month_name_full((int)$e['month']) ?>
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</section>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,5 +0,0 @@
<?php
// Redirect to main settings with journal tab
require_once __DIR__ . '/../src/config.php';
header('Location: ' . url_for('settings.php?tab=journal'));
exit;

View File

@@ -1,248 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$years = get_journal_years();
$year_id = isset($_GET['year_id']) ? (int)$_GET['year_id'] : null;
if (!$year_id && $years) {
$current_cal_year = (int)date('Y');
foreach ($years as $y) {
if ($y['year'] == $current_cal_year) {
$year_id = $y['id'];
break;
}
}
if (!$year_id) $year_id = $years[0]['id'];
}
$current_year = null;
foreach ($years as $y) {
if ($y['id'] == $year_id) {
$current_year = $y;
break;
}
}
$summary = null;
$profitability = null;
if ($year_id) {
$summary = calculate_yearly_summary($year_id);
$profitability = calculate_yearly_profitability($year_id);
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Journal Jahresübersicht <?= $current_year ? (int)$current_year['year'] : '' ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>" class="active"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="journal-subnav">
<a href="<?= url_for('journal.php?year_id=' . $year_id) ?>">Monatsansicht</a>
<a href="<?= url_for('journal_summary.php') ?>" class="active">Jahressummen</a>
<a href="<?= url_for('journal_search.php') ?>">Suche</a>
</div>
<?php if (empty($years)): ?>
<p class="warning">Bitte zuerst ein <a href="<?= url_for('settings.php?tab=journal') ?>">Jahr anlegen</a>.</p>
<?php elseif ($summary): ?>
<!-- Jahr-Dropdown -->
<div class="journal-controls">
<form method="get" class="journal-year-select">
<label>Jahr:
<select name="year_id" onchange="this.form.submit();">
<?php foreach ($years as $y): ?>
<option value="<?= $y['id'] ?>" <?= $y['id'] == $year_id ? 'selected' : '' ?>>
<?= (int)$y['year'] ?>
</option>
<?php endforeach; ?>
</select>
</label>
</form>
</div>
<!-- Spaltengruppen berechnen -->
<?php
$columns = $summary['columns'];
$groups = [];
foreach ($columns as $col) {
$g = $col['group'];
if (!isset($groups[$g])) $groups[$g] = 0;
$groups[$g]++;
}
?>
<!-- Jahresübersicht Tabelle -->
<section>
<h2>Monatssummen <?= $current_year ? (int)$current_year['year'] : '' ?></h2>
<div class="journal-table-wrap">
<table class="journal-table">
<thead>
<tr class="journal-group-header">
<th colspan="2"></th>
<th colspan="2">Summen</th>
<?php foreach ($groups as $gname => $gcount): ?>
<th colspan="<?= $gcount ?>"><?= htmlspecialchars($gname) ?></th>
<?php endforeach; ?>
</tr>
<tr>
<th>Monat</th>
<th class="journal-col-betrag">Betrag</th>
<th class="journal-soll">Soll</th>
<th class="journal-haben">Haben</th>
<?php foreach ($columns as $col): ?>
<th class="journal-col-amount journal-<?= $col['side'] ?>"><?= htmlspecialchars($col['label']) ?></th>
<?php endforeach; ?>
</tr>
<!-- Jahressummen oben (wie Excel) -->
<tr class="journal-top-sums">
<td style="text-align:right;"><strong>S</strong></td>
<td class="journal-col-betrag"></td>
<td class="journal-col-amount journal-soll"><strong><?= journal_format_amount($summary['yearly_soll']) ?></strong></td>
<td class="journal-col-amount journal-haben"></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?php if ($col['side'] === 'soll'): ?>
<strong><?= journal_format_amount($summary['yearly_totals'][$col['key']] ?? 0) ?></strong>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<tr class="journal-top-sums">
<td style="text-align:right;"><strong>H</strong></td>
<td class="journal-col-betrag"></td>
<td class="journal-col-amount journal-soll"></td>
<td class="journal-col-amount journal-haben"><strong><?= journal_format_amount($summary['yearly_haben']) ?></strong></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?php if ($col['side'] === 'haben'): ?>
<strong><?= journal_format_amount($summary['yearly_totals'][$col['key']] ?? 0) ?></strong>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php for ($m = 1; $m <= 12; $m++): ?>
<?php $mt = $summary['months'][$m]; ?>
<tr>
<td>
<a href="<?= url_for('journal.php?year_id=' . $year_id . '&month=' . $m) ?>">
<?= journal_month_name_full($m) ?>
</a>
</td>
<td class="journal-col-betrag"><?= journal_format_amount($mt['_amount'] ?? 0) ?></td>
<td class="journal-col-amount journal-soll"><?= journal_format_amount($mt['_soll'] ?? 0) ?></td>
<td class="journal-col-amount journal-haben"><?= journal_format_amount($mt['_haben'] ?? 0) ?></td>
<?php foreach ($columns as $col): ?>
<?php $col_val = $mt[$col['key']] ?? 0; ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<?php if ($col_val != 0): ?>
<a href="<?= url_for('journal_detail.php?year_id=' . $year_id . '&month=' . $m . '&col_key=' . urlencode($col['key'])) ?>" style="color:inherit;text-decoration:none;" title="Details anzeigen">
<?= journal_format_amount($col_val) ?>
</a>
<?php else: ?>
<?= journal_format_amount($col_val) ?>
<?php endif; ?>
</td>
<?php endforeach; ?>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr class="journal-totals-row">
<td><strong>Jahressumme</strong></td>
<td class="journal-col-betrag"><strong><?= journal_format_amount($summary['yearly_amount']) ?></strong></td>
<td class="journal-col-amount journal-soll"><strong><?= journal_format_amount($summary['yearly_soll']) ?></strong></td>
<td class="journal-col-amount journal-haben"><strong><?= journal_format_amount($summary['yearly_haben']) ?></strong></td>
<?php foreach ($columns as $col): ?>
<td class="journal-col-amount journal-<?= $col['side'] ?>">
<strong><?= journal_format_amount($summary['yearly_totals'][$col['key']] ?? 0) ?></strong>
</td>
<?php endforeach; ?>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- Gewinnberechnung -->
<?php if ($profitability): ?>
<section>
<h2>Gewinnberechnung <?= $current_year ? (int)$current_year['year'] : '' ?></h2>
<div>
<table class="list">
<thead>
<tr>
<th>Monat</th>
<th style="text-align:right;">Erlöse</th>
<th style="text-align:right;">Wareneingang</th>
<th style="text-align:right;">Aufwand</th>
<th style="text-align:right;">Gewinn</th>
</tr>
</thead>
<tbody>
<?php
$sum_er = 0; $sum_we = 0; $sum_au = 0; $sum_gw = 0;
for ($m = 1; $m <= 12; $m++):
$p = $profitability[$m];
$sum_er += $p['erloese'];
$sum_we += $p['wareneingang'];
$sum_au += $p['aufwand'];
$sum_gw += $p['gewinn'];
?>
<tr>
<td><?= journal_month_name_full($m) ?></td>
<td style="text-align:right;"><?= journal_format_amount($p['erloese']) ?></td>
<td style="text-align:right;"><?= journal_format_amount($p['wareneingang']) ?></td>
<td style="text-align:right;"><?= journal_format_amount($p['aufwand']) ?></td>
<td style="text-align:right;<?= $p['gewinn'] < 0 ? 'color:var(--error);' : '' ?>">
<strong><?= journal_format_amount($p['gewinn']) ?></strong>
</td>
</tr>
<?php endfor; ?>
</tbody>
<tfoot>
<tr>
<td><strong>Jahressumme</strong></td>
<td style="text-align:right;"><strong><?= journal_format_amount($sum_er) ?></strong></td>
<td style="text-align:right;"><strong><?= journal_format_amount($sum_we) ?></strong></td>
<td style="text-align:right;"><strong><?= journal_format_amount($sum_au) ?></strong></td>
<td style="text-align:right;<?= $sum_gw < 0 ? 'color:var(--error);' : '' ?>">
<strong><?= journal_format_amount($sum_gw) ?></strong>
</td>
</tr>
</tfoot>
</table>
</div>
</section>
<?php endif; ?>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,42 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$user = $_POST['username'] ?? '';
$pass = $_POST['password'] ?? '';
if (login($user, $pass)) {
header('Location: ' . url_for('index.php'));
exit;
} else {
$error = 'Login fehlgeschlagen.';
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>PIRP Login</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<div class="login-wrap">
<div class="login-box">
<h1>PIRP</h1>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<form method="post">
<label>Benutzer:
<input type="text" name="username" required autofocus>
</label>
<label>Passwort:
<input type="password" name="password" required>
</label>
<button type="submit" style="width:100%;margin-top:4px;">Login</button>
</form>
</div>
</div>
</body>
</html>

View File

@@ -1,6 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
logout();
header('Location: ' . url_for('login.php'));
exit;

View File

@@ -1,128 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$invoice_id = isset($_GET['invoice_id']) ? (int)$_GET['invoice_id'] : 0;
if (!$invoice_id) {
header('Location: ' . url_for('invoices.php'));
exit;
}
$inv = get_invoice_with_customer($invoice_id);
if (!$inv || $inv['paid']) {
header('Location: ' . url_for('invoices.php?msg=' . urlencode('Mahnung nur für offene Rechnungen möglich.')));
exit;
}
$pdo = get_db();
// Mahnstufe auto-bestimmen
$stmt = $pdo->prepare("SELECT COUNT(*) FROM mahnungen WHERE invoice_id = :id");
$stmt->execute([':id' => $invoice_id]);
$existing_count = (int)$stmt->fetchColumn();
$next_level = min($existing_count + 1, 3);
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$mahnung_date = $_POST['mahnung_date'] ?? date('Y-m-d');
$level = (int)($_POST['level'] ?? $next_level);
$fee_amount = (float)str_replace(',', '.', $_POST['fee_amount'] ?? '0');
if ($level < 1 || $level > 3) $level = $next_level;
try {
$stmt = $pdo->prepare("INSERT INTO mahnungen (invoice_id, mahnung_date, level, fee_amount)
VALUES (:iid, :mdate, :level, :fee)
RETURNING id");
$stmt->execute([
':iid' => $invoice_id,
':mdate' => $mahnung_date,
':level' => $level,
':fee' => $fee_amount,
]);
$mahnung_id = (int)$stmt->fetchColumn();
archive_mahnung_pdf($mahnung_id);
header('Location: ' . url_for('mahnung_pdf.php?id=' . $mahnung_id));
exit;
} catch (\Exception $e) {
$error = $e->getMessage();
}
}
$level_labels = [1 => 'Mahnung', 2 => '2. Mahnung', 3 => '3. Mahnung (Letzte)'];
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Mahnung <?= htmlspecialchars($inv['invoice_number']) ?></title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">← Zurück zu Rechnungen</a>
</div>
<?php if ($error): ?>
<p class="error"><?= htmlspecialchars($error) ?></p>
<?php endif; ?>
<section>
<h2>Mahnung erstellen</h2>
<div style="max-width:460px;">
<table class="list" style="margin-bottom:16px;">
<tr><td style="color:var(--text-muted);width:140px;">Rechnung</td><td><strong><?= htmlspecialchars($inv['invoice_number']) ?></strong></td></tr>
<tr><td style="color:var(--text-muted);">Kunde</td><td><?= htmlspecialchars($inv['customer_name']) ?></td></tr>
<tr><td style="color:var(--text-muted);">Rechnungsdatum</td><td><?= date('d.m.Y', strtotime($inv['invoice_date'])) ?></td></tr>
<tr><td style="color:var(--text-muted);">Betrag</td><td><?= number_format($inv['total_gross'], 2, ',', '.') ?> €</td></tr>
</table>
<form method="post" style="display:flex;flex-direction:column;gap:12px;">
<label>Mahnstufe:
<select name="level">
<?php foreach ($level_labels as $l => $lbl): ?>
<option value="<?= $l ?>" <?= $l == $next_level ? 'selected' : '' ?>><?= $lbl ?></option>
<?php endforeach; ?>
</select>
</label>
<label>Mahndatum:
<input type="date" name="mahnung_date" value="<?= date('Y-m-d') ?>" required>
</label>
<label>Mahngebühr (€):
<input type="text" name="fee_amount" value="0,00" style="width:120px;">
</label>
<div>
<button type="submit">Mahnung erstellen &amp; PDF öffnen</button>
<a href="<?= url_for('invoices.php') ?>" style="margin-left:12px;">Abbrechen</a>
</div>
</form>
</div>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,45 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/pdf_functions.php';
require_login();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if (!$id) {
header('Location: ' . url_for('invoices.php'));
exit;
}
$pdo = get_db();
$stmt = $pdo->prepare("SELECT m.*, i.invoice_number FROM mahnungen m JOIN invoices i ON i.id = m.invoice_id WHERE m.id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
header('Location: ' . url_for('invoices.php'));
exit;
}
// PDF vorhanden?
if (empty($row['pdf_path'])) {
archive_mahnung_pdf($id);
// Neu laden
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
}
$fullPath = __DIR__ . '/' . ($row['pdf_path'] ?? '');
if (!file_exists($fullPath)) {
http_response_code(404);
echo 'PDF nicht gefunden.';
exit;
}
$safe_name = 'MAHNUNG-' . preg_replace('/[^A-Za-z0-9\-]/', '_', $row['invoice_number']) . '-L' . $row['level'] . '.pdf';
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . $safe_name . '"');
header('Content-Length: ' . filesize($fullPath));
readfile($fullPath);
exit;

View File

@@ -1,147 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/recurring_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$msg = '';
$error = '';
$action = $_GET['action'] ?? '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
// Aktionen verarbeiten
if ($action === 'delete' && $id) {
delete_recurring_template($id);
$msg = 'Abo-Vorlage gelöscht.';
}
if ($action === 'toggle' && $id) {
$pdo = get_db();
$stmt = $pdo->prepare("UPDATE recurring_templates SET is_active = NOT is_active WHERE id = :id");
$stmt->execute([':id' => $id]);
$msg = 'Status geändert.';
}
// Filter
$show_all = isset($_GET['show_all']);
$templates = get_recurring_templates(!$show_all);
$pending_count = count_pending_recurring_invoices();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Abo-Rechnungen</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">Übersicht</a>
<a href="<?= url_for('invoice_new.php') ?>">Neue Rechnung</a>
<a href="<?= url_for('recurring.php') ?>" class="active">Abo-Rechnungen</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<?php if ($pending_count > 0): ?>
<div class="warning">
<strong><?= $pending_count ?> Abo-Rechnung<?= $pending_count > 1 ? 'en' : '' ?> fällig!</strong>
<a href="<?= url_for('recurring_generate.php') ?>" class="button" style="margin-left:10px;">Jetzt generieren</a>
</div>
<?php endif; ?>
<section>
<h2>Abo-Vorlagen</h2>
<div>
<p>
<a href="<?= url_for('recurring_edit.php') ?>" class="button">Neue Abo-Vorlage</a>
<a href="<?= url_for('recurring_generate.php') ?>" class="button-secondary">Rechnungen generieren</a>
<?php if ($show_all): ?>
<a href="<?= url_for('recurring.php') ?>" class="button-secondary">Nur aktive anzeigen</a>
<?php else: ?>
<a href="<?= url_for('recurring.php?show_all=1') ?>" class="button-secondary">Alle anzeigen</a>
<?php endif; ?>
</p>
<table class="list">
<thead>
<tr>
<th>Name</th>
<th>Kunde</th>
<th>Intervall</th>
<th>Nächste Fälligkeit</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
<?php foreach ($templates as $t): ?>
<?php
$is_due = $t['is_active'] && strtotime($t['next_due_date']) <= time();
?>
<tr>
<td><?= htmlspecialchars($t['template_name']) ?></td>
<td>
<?= htmlspecialchars($t['customer_name']) ?>
<?php if ($t['customer_number']): ?>
<br><small><?= htmlspecialchars($t['customer_number']) ?></small>
<?php endif; ?>
</td>
<td>
<span class="interval-badge interval-<?= $t['interval_type'] ?>">
<?= get_interval_label($t['interval_type']) ?>
</span>
</td>
<td>
<?= date('d.m.Y', strtotime($t['next_due_date'])) ?>
<?php if ($is_due): ?>
<span class="badge badge-warning">Fällig</span>
<?php endif; ?>
</td>
<td>
<?php if ($t['is_active']): ?>
<span class="badge badge-success">Aktiv</span>
<?php else: ?>
<span class="badge badge-danger">Inaktiv</span>
<?php endif; ?>
</td>
<td>
<a href="<?= url_for('recurring_edit.php?id=' . $t['id']) ?>">Bearbeiten</a>
<a href="<?= url_for('recurring.php?action=toggle&id=' . $t['id']) ?>">
<?= $t['is_active'] ? 'Deaktivieren' : 'Aktivieren' ?>
</a>
<a href="<?= url_for('recurring.php?action=delete&id=' . $t['id']) ?>"
onclick="return confirm('Abo-Vorlage wirklich löschen?');">Löschen</a>
</td>
</tr>
<?php endforeach; ?>
<?php if (empty($templates)): ?>
<tr><td colspan="6">Keine Abo-Vorlagen gefunden.</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,277 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/customer_functions.php';
require_once __DIR__ . '/../src/recurring_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$settings = get_settings();
$customers = get_customers();
$error = '';
$msg = '';
$id = isset($_GET['id']) ? (int)$_GET['id'] : null;
$template = null;
$items = [];
if ($id) {
$template = get_recurring_template($id);
if (!$template) {
header('Location: ' . url_for('recurring.php'));
exit;
}
$items = get_recurring_template_items($id);
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [
'template_name' => trim($_POST['template_name'] ?? ''),
'customer_id' => (int)($_POST['customer_id'] ?? 0),
'interval_type' => $_POST['interval_type'] ?? 'monthly',
'start_date' => $_POST['start_date'] ?? date('Y-m-d'),
'end_date' => $_POST['end_date'] ?: null,
'next_due_date' => $_POST['next_due_date'] ?? date('Y-m-d'),
'vat_mode' => $settings['vat_mode'] ?? 'klein',
'vat_rate' => (float)($settings['default_vat_rate'] ?? 19.0),
'is_active' => !empty($_POST['is_active']),
'notes_internal' => $_POST['notes_internal'] ?? ''
];
// Validierung
if (empty($data['template_name'])) {
$error = 'Bitte einen Namen eingeben.';
} elseif ($data['customer_id'] <= 0) {
$error = 'Bitte einen Kunden auswählen.';
} else {
// Positionen sammeln
$newItems = [];
$count = isset($_POST['item_desc']) ? count($_POST['item_desc']) : 0;
for ($i = 0; $i < $count; $i++) {
$desc = trim($_POST['item_desc'][$i] ?? '');
$qty = (float)($_POST['item_qty'][$i] ?? 0);
$price = (float)($_POST['item_price'][$i] ?? 0);
if ($desc !== '' && $qty > 0) {
$newItems[] = [
'position_no' => count($newItems) + 1,
'description' => $desc,
'quantity' => $qty,
'unit_price' => $price
];
}
}
if (empty($newItems)) {
$error = 'Bitte mindestens eine Position ausfüllen.';
} else {
$template_id = save_recurring_template($id, $data);
save_recurring_template_items($template_id, $newItems);
header('Location: ' . url_for('recurring.php'));
exit;
}
}
}
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title><?= $id ? 'Abo-Vorlage bearbeiten' : 'Neue Abo-Vorlage' ?></title>
<link rel="stylesheet" href="assets/style.css">
<script>
function addRow() {
const tbody = document.getElementById('items-body');
const index = tbody.children.length;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${index+1}</td>
<td><input type="text" name="item_desc[${index}]" size="40"></td>
<td><input type="number" step="0.01" name="item_qty[${index}]" value="1"></td>
<td><input type="number" step="0.01" name="item_price[${index}]" value="0.00"></td>
<td><button type="button" onclick="this.closest('tr').remove()">X</button></td>
`;
tbody.appendChild(tr);
}
</script>
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">Übersicht</a>
<a href="<?= url_for('invoice_new.php') ?>">Neue Rechnung</a>
<a href="<?= url_for('recurring.php') ?>" class="active">Abo-Rechnungen</a>
</div>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<form method="post">
<section>
<h2>Abo-Details</h2>
<div>
<label>Name der Vorlage:
<input type="text" name="template_name"
value="<?= htmlspecialchars($template['template_name'] ?? '') ?>" required>
</label>
<label>Kunde:
<select name="customer_id" required>
<option value="">-- wählen --</option>
<?php foreach ($customers as $c): ?>
<option value="<?= $c['id'] ?>"
<?= ($template['customer_id'] ?? 0) == $c['id'] ? 'selected' : '' ?>>
<?= htmlspecialchars($c['name']) ?>
</option>
<?php endforeach; ?>
</select>
</label>
<label>Intervall:
<select name="interval_type" required>
<option value="monthly" <?= ($template['interval_type'] ?? '') === 'monthly' ? 'selected' : '' ?>>
Monatlich
</option>
<option value="quarterly" <?= ($template['interval_type'] ?? '') === 'quarterly' ? 'selected' : '' ?>>
Quartalsweise (alle 3 Monate)
</option>
<option value="yearly" <?= ($template['interval_type'] ?? '') === 'yearly' ? 'selected' : '' ?>>
Jährlich
</option>
</select>
</label>
<div class="flex-row">
<label>Startdatum:
<input type="date" name="start_date"
value="<?= htmlspecialchars($template['start_date'] ?? date('Y-m-d')) ?>" required>
</label>
<label>Enddatum (optional):
<input type="date" name="end_date"
value="<?= htmlspecialchars($template['end_date'] ?? '') ?>">
</label>
<label>Nächste Fälligkeit:
<input type="date" name="next_due_date"
value="<?= htmlspecialchars($template['next_due_date'] ?? date('Y-m-d')) ?>" required>
</label>
</div>
<label>
<input type="checkbox" name="is_active" value="1"
<?= ($template['is_active'] ?? true) ? 'checked' : '' ?>>
Aktiv (Rechnungen werden generiert)
</label>
<label>Interne Notizen:
<textarea name="notes_internal" rows="2"><?= htmlspecialchars($template['notes_internal'] ?? '') ?></textarea>
</label>
</div>
</section>
<section>
<h2>Positionen</h2>
<div>
<table class="list" id="items-table">
<thead>
<tr>
<th style="width:5%">Pos.</th>
<th>Beschreibung</th>
<th style="width:12%">Menge</th>
<th style="width:15%">Einzelpreis (netto)</th>
<th style="width:5%"></th>
</tr>
</thead>
<tbody id="items-body">
<?php
// Bestehende Positionen oder leere Zeilen
$displayItems = !empty($items) ? $items : [
['description' => '', 'quantity' => 1, 'unit_price' => 0],
['description' => '', 'quantity' => 1, 'unit_price' => 0],
['description' => '', 'quantity' => 1, 'unit_price' => 0]
];
foreach ($displayItems as $i => $item):
?>
<tr>
<td><?= $i + 1 ?></td>
<td><input type="text" name="item_desc[<?= $i ?>]"
value="<?= htmlspecialchars($item['description']) ?>" size="40"></td>
<td><input type="number" step="0.01" name="item_qty[<?= $i ?>]"
value="<?= number_format($item['quantity'], 2, '.', '') ?>"></td>
<td><input type="number" step="0.01" name="item_price[<?= $i ?>]"
value="<?= number_format($item['unit_price'], 2, '.', '') ?>"></td>
<td><button type="button" onclick="this.closest('tr').remove()">X</button></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p>
<button type="button" onclick="addRow()" class="button-secondary">Position hinzufügen</button>
</p>
</div>
</section>
<p>
<button type="submit">Speichern</button>
<a href="<?= url_for('recurring.php') ?>" class="button-secondary">Abbrechen</a>
</p>
</form>
<?php if ($id): ?>
<section>
<h2>Generierte Rechnungen</h2>
<div>
<?php $log = get_recurring_log($id); ?>
<?php if (empty($log)): ?>
<p>Noch keine Rechnungen generiert.</p>
<?php else: ?>
<table class="list">
<thead>
<tr>
<th>Datum</th>
<th>Fälligkeit</th>
<th>Rechnungsnr.</th>
<th>Betrag</th>
<th>Aktion</th>
</tr>
</thead>
<tbody>
<?php foreach ($log as $entry): ?>
<tr>
<td><?= date('d.m.Y H:i', strtotime($entry['generated_at'])) ?></td>
<td><?= date('d.m.Y', strtotime($entry['due_date'])) ?></td>
<td><?= htmlspecialchars($entry['invoice_number'] ?? '-') ?></td>
<td><?= $entry['total_gross'] ? number_format($entry['total_gross'], 2, ',', '.') . ' €' : '-' ?></td>
<td>
<?php if ($entry['invoice_id']): ?>
<a href="<?= url_for('invoice_pdf.php?id=' . $entry['invoice_id']) ?>" target="_blank">PDF</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,158 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/recurring_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$msg = '';
$error = '';
$generated = [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$selected = $_POST['template_ids'] ?? [];
if (empty($selected)) {
$error = 'Bitte mindestens eine Vorlage auswählen.';
} else {
$success = 0;
$failed = 0;
foreach ($selected as $tid) {
$tid = (int)$tid;
$invoice_id = generate_invoice_from_template($tid);
if ($invoice_id) {
$success++;
$generated[] = $invoice_id;
} else {
$failed++;
}
}
if ($success > 0) {
$msg = "$success Rechnung(en) erfolgreich generiert.";
}
if ($failed > 0) {
$error = "$failed Rechnung(en) konnten nicht generiert werden.";
}
}
}
$pending = get_pending_recurring_invoices();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Abo-Rechnungen generieren</title>
<link rel="stylesheet" href="assets/style.css">
<script>
function toggleAll(checkbox) {
document.querySelectorAll('input[name="template_ids[]"]').forEach(cb => {
cb.checked = checkbox.checked;
});
}
</script>
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>" class="active"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<div class="module-subnav">
<a href="<?= url_for('invoices.php') ?>">Übersicht</a>
<a href="<?= url_for('invoice_new.php') ?>">Neue Rechnung</a>
<a href="<?= url_for('recurring.php') ?>" class="active">Abo-Rechnungen</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<?php if (!empty($generated)): ?>
<section>
<h2>Generierte Rechnungen</h2>
<div>
<ul>
<?php foreach ($generated as $inv_id): ?>
<li><a href="<?= url_for('invoice_pdf.php?id=' . $inv_id) ?>" target="_blank">Rechnung #<?= $inv_id ?> anzeigen</a></li>
<?php endforeach; ?>
</ul>
</div>
</section>
<?php endif; ?>
<section>
<h2>Fällige Abo-Rechnungen</h2>
<div>
<?php if (empty($pending)): ?>
<p class="success">Keine fälligen Abo-Rechnungen. Alles erledigt!</p>
<p><a href="<?= url_for('recurring.php') ?>" class="button-secondary">Zurück zur Übersicht</a></p>
<?php else: ?>
<form method="post">
<table class="list">
<thead>
<tr>
<th style="width:30px"><input type="checkbox" onclick="toggleAll(this)" checked></th>
<th>Vorlage</th>
<th>Kunde</th>
<th>Intervall</th>
<th>Fällig seit</th>
</tr>
</thead>
<tbody>
<?php foreach ($pending as $p): ?>
<?php
$items = get_recurring_template_items($p['id']);
$total = 0;
foreach ($items as $item) {
$total += $item['quantity'] * $item['unit_price'];
}
?>
<tr>
<td><input type="checkbox" name="template_ids[]" value="<?= $p['id'] ?>" checked></td>
<td>
<?= htmlspecialchars($p['template_name']) ?>
<br><small>ca. <?= number_format($total, 2, ',', '.') ?> € netto</small>
</td>
<td>
<?= htmlspecialchars($p['customer_name']) ?>
<?php if ($p['customer_number']): ?>
<br><small><?= htmlspecialchars($p['customer_number']) ?></small>
<?php endif; ?>
</td>
<td>
<span class="interval-badge interval-<?= $p['interval_type'] ?>">
<?= get_interval_label($p['interval_type']) ?>
</span>
</td>
<td><?= date('d.m.Y', strtotime($p['next_due_date'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<p style="margin-top:15px">
<button type="submit">Ausgewählte Rechnungen generieren</button>
<a href="<?= url_for('recurring.php') ?>" class="button-secondary">Abbrechen</a>
</p>
</form>
<?php endif; ?>
</div>
</section>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

View File

@@ -1,81 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_login();
header('Content-Type: application/json');
$q = trim($_GET['q'] ?? '');
if (mb_strlen($q) < 2) {
echo json_encode(['ok' => true, 'results' => []]);
exit;
}
$pdo = get_db();
$like = '%' . $q . '%';
$results = [];
// Rechnungen
$stmt = $pdo->prepare("SELECT i.id, i.invoice_number, i.total_gross, c.name AS customer_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.invoice_number ILIKE :q OR c.name ILIKE :q2
ORDER BY i.created_at DESC
LIMIT 5");
$stmt->execute([':q' => $like, ':q2' => $like]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$results[] = [
'type' => 'invoice',
'title' => $row['invoice_number'],
'subtitle' => $row['customer_name'] . ' - ' . number_format((float)$row['total_gross'], 2, ',', '.') . ' €',
'url' => url_for('invoice_pdf.php?id=' . $row['id']),
];
}
// Kunden
$stmt = $pdo->prepare("SELECT id, name, city FROM customers
WHERE name ILIKE :q OR city ILIKE :q2
ORDER BY name
LIMIT 5");
$stmt->execute([':q' => $like, ':q2' => $like]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$results[] = [
'type' => 'customer',
'title' => $row['name'],
'subtitle' => $row['city'] ?? '',
'url' => url_for('customers.php?action=edit&id=' . $row['id']),
];
}
// Ausgaben
$stmt = $pdo->prepare("SELECT id, description, amount, category FROM expenses
WHERE description ILIKE :q OR category ILIKE :q2
ORDER BY expense_date DESC
LIMIT 5");
$stmt->execute([':q' => $like, ':q2' => $like]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$results[] = [
'type' => 'expense',
'title' => $row['description'],
'subtitle' => ($row['category'] ?? '') . ' - ' . number_format((float)$row['amount'], 2, ',', '.') . ' €',
'url' => url_for('expenses.php?action=edit&id=' . $row['id']),
];
}
// Journal-Einträge
$stmt = $pdo->prepare("SELECT id, description, amount, entry_date FROM journal_entries
WHERE description ILIKE :q
ORDER BY entry_date DESC
LIMIT 5");
$stmt->execute([':q' => $like]);
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
$results[] = [
'type' => 'journal',
'title' => $row['description'],
'subtitle' => date('d.m.Y', strtotime($row['entry_date'])) . ' - ' . number_format((float)$row['amount'], 2, ',', '.') . ' €',
'url' => url_for('journal_entry.php?id=' . $row['id']),
];
}
echo json_encode(['ok' => true, 'results' => $results]);

View File

@@ -1,802 +0,0 @@
<?php
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/auth.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/icons.php';
require_login();
$settings = get_settings();
$msg = '';
$error = '';
// Aktiver Tab
$tab = $_GET['tab'] ?? 'allgemein';
$journal_sub = $_GET['jsub'] ?? 'jahre';
// ---- POST-Aktionen ----
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$form = $_POST['form'] ?? '';
// === ALLGEMEIN TAB ===
if ($form === 'allgemein') {
$data = [
'company_name' => $_POST['company_name'] ?? '',
'company_address' => $_POST['company_address'] ?? '',
'company_zip' => $_POST['company_zip'] ?? '',
'company_city' => $_POST['company_city'] ?? '',
'company_country' => $_POST['company_country'] ?? '',
'tax_id' => $_POST['tax_id'] ?? '',
'vat_mode' => $_POST['vat_mode'] ?? 'klein',
'default_vat_rate'=> $_POST['default_vat_rate'] ?? 19.0,
'payment_terms' => $_POST['payment_terms'] ?? '',
'footer_text' => $_POST['footer_text'] ?? '',
'logo_path' => $settings['logo_path'] ?? null,
'iban' => $_POST['iban'] ?? '',
'phone' => $_POST['phone'] ?? '',
'email' => $_POST['email'] ?? '',
'website' => $_POST['website'] ?? '',
];
if (!empty($_FILES['logo']['tmp_name'])) {
$targetDir = __DIR__ . '/uploads/';
if (!is_dir($targetDir)) {
mkdir($targetDir, 0775, true);
}
$targetFile = $targetDir . 'logo.png';
if (move_uploaded_file($_FILES['logo']['tmp_name'], $targetFile)) {
$data['logo_path'] = 'uploads/logo.png';
}
}
save_settings($data);
$settings = get_settings();
$msg = 'Einstellungen gespeichert.';
}
// === JOURNAL TAB ===
// Jahr erstellen
if ($form === 'year') {
$y = (int)($_POST['year'] ?? 0);
if ($y >= 2000 && $y <= 2099) {
$existing = get_journal_year_by_year($y);
if ($existing) {
$error = "Jahr $y existiert bereits.";
} else {
create_journal_year($y, $_POST['notes'] ?? '');
$msg = "Jahr $y erstellt.";
}
} else {
$error = 'Ungültiges Jahr.';
}
$tab = 'journal';
}
// Jahr öffnen/schließen
if ($form === 'toggle_year') {
$id = (int)($_POST['id'] ?? 0);
if ($id) {
toggle_journal_year_closed($id);
$msg = 'Jahresstatus geändert.';
}
$tab = 'journal';
}
// Lieferant speichern
if ($form === 'supplier') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'sort_order' => $_POST['sort_order'] ?? 0,
'is_active' => isset($_POST['is_active']),
];
if ($data['name']) {
save_journal_supplier($id, $data);
$msg = 'Lieferant gespeichert.';
} else {
$error = 'Name ist Pflichtfeld.';
}
$tab = 'journal';
}
// Lieferant löschen
if ($form === 'delete_supplier') {
delete_journal_supplier((int)$_POST['id']);
$msg = 'Lieferant gelöscht.';
$tab = 'journal';
}
// Wareneingang-Kategorie speichern
if ($form === 'rev_cat') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'category_type' => $_POST['category_type'] ?? 'wareneingang',
'vat_rate' => $_POST['vat_rate'] ?? 19,
'sort_order' => $_POST['sort_order'] ?? 0,
'is_active' => isset($_POST['is_active']),
];
if ($data['name']) {
save_journal_revenue_category($id, $data);
$msg = 'Kategorie gespeichert.';
} else {
$error = 'Name ist Pflichtfeld.';
}
$tab = 'journal';
}
// Wareneingang-/Erlös-Kategorie löschen
if ($form === 'delete_rev_cat') {
delete_journal_revenue_category((int)$_POST['id']);
$msg = 'Kategorie gelöscht.';
$tab = 'journal';
}
// Aufwandskategorie speichern
if ($form === 'exp_cat') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'side' => $_POST['side'] ?? 'soll',
'sort_order' => $_POST['sort_order'] ?? 0,
'is_active' => isset($_POST['is_active']),
];
if ($data['name']) {
save_journal_expense_category($id, $data);
$msg = 'Aufwandskategorie gespeichert.';
} else {
$error = 'Name ist Pflichtfeld.';
}
$tab = 'journal';
}
// Aufwandskategorie löschen
if ($form === 'delete_exp_cat') {
delete_journal_expense_category((int)$_POST['id']);
$msg = 'Aufwandskategorie gelöscht.';
$tab = 'journal';
}
// Abzug speichern
if ($form === 'ded_cat') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'sort_order' => $_POST['sort_order'] ?? 0,
'is_active' => isset($_POST['is_active']),
];
if ($data['name']) {
save_journal_deduction_category($id, $data);
$msg = 'Abzug gespeichert.';
} else {
$error = 'Name ist Pflichtfeld.';
}
$tab = 'journal';
}
// Abzug löschen
if ($form === 'delete_ded_cat') {
delete_journal_deduction_category((int)$_POST['id']);
$msg = 'Abzug gelöscht.';
$tab = 'journal';
}
// Zusammenfassungsposten speichern
if ($form === 'summary_item') {
$id = !empty($_POST['id']) ? (int)$_POST['id'] : null;
$data = [
'name' => $_POST['name'] ?? '',
'sort_order' => $_POST['sort_order'] ?? 0,
'is_active' => isset($_POST['is_active']),
];
if ($data['name']) {
save_journal_summary_item($id, $data);
$msg = 'Posten gespeichert.';
} else {
$error = 'Name ist Pflichtfeld.';
}
$tab = 'journal';
}
// Zusammenfassungsposten löschen
if ($form === 'delete_summary_item') {
delete_journal_summary_item((int)$_POST['id']);
$msg = 'Posten gelöscht.';
$tab = 'journal';
}
// === KONTO TAB ===
// Benutzername ändern
if ($form === 'change_username') {
$new_username = trim($_POST['new_username'] ?? '');
if (strlen($new_username) < 3) {
$error = 'Benutzername muss mindestens 3 Zeichen haben.';
} elseif (!update_username($_SESSION['user_id'], $new_username)) {
$error = 'Benutzername existiert bereits.';
} else {
$msg = 'Benutzername geändert.';
}
$tab = 'konto';
}
// Passwort ändern
if ($form === 'change_password') {
$current_pw = $_POST['current_password'] ?? '';
$new_pw = $_POST['new_password'] ?? '';
$confirm_pw = $_POST['confirm_password'] ?? '';
if (strlen($new_pw) < 6) {
$error = 'Neues Passwort muss mindestens 6 Zeichen haben.';
} elseif ($new_pw !== $confirm_pw) {
$error = 'Passwörter stimmen nicht überein.';
} elseif (!update_password($_SESSION['user_id'], $current_pw, $new_pw)) {
$error = 'Aktuelles Passwort ist falsch.';
} else {
$msg = 'Passwort geändert.';
}
$tab = 'konto';
}
}
// Journal-Daten laden
$years = get_journal_years();
$suppliers = get_journal_suppliers();
$we_cats = get_journal_revenue_categories('wareneingang');
$er_cats = get_journal_revenue_categories('erloese');
$exp_cats = get_journal_expense_categories();
$ded_cats = get_journal_deduction_categories();
$summary_items = get_journal_summary_items();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Einstellungen</title>
<link rel="stylesheet" href="assets/style.css">
</head>
<body>
<header>
<h1>PIRP</h1>
<nav>
<a href="<?= url_for('index.php') ?>"><?= icon_dashboard() ?>Dashboard</a>
<a href="<?= url_for('invoices.php') ?>"><?= icon_invoices() ?>Rechnungen</a>
<a href="<?= url_for('customers.php') ?>"><?= icon_customers() ?>Kunden</a>
<a href="<?= url_for('expenses.php') ?>"><?= icon_expenses() ?>Ausgaben</a>
<a href="<?= url_for('belegarchiv.php') ?>"><?= icon_archive() ?>Belege</a>
<a href="<?= url_for('journal.php') ?>"><?= icon_journal() ?>Journal</a>
<a href="<?= url_for('euer.php') ?>"><?= icon_euer() ?>EÜR</a>
<a href="<?= url_for('settings.php') ?>" class="active"><?= icon_settings() ?>Einstellungen</a>
<a href="<?= url_for('logout.php') ?>"><?= icon_logout() ?>Logout (<?= htmlspecialchars($_SESSION['username'] ?? '') ?>)</a>
<span class="cmd-k-hint" onclick="document.dispatchEvent(new KeyboardEvent('keydown',{key:'k',ctrlKey:true}))"><kbd>Ctrl+K</kbd></span>
</nav>
</header>
<main>
<!-- Tab-Navigation -->
<div class="settings-tabs">
<a href="<?= url_for('settings.php?tab=allgemein') ?>" class="<?= $tab === 'allgemein' ? 'active' : '' ?>">Allgemein</a>
<a href="<?= url_for('settings.php?tab=journal') ?>" class="<?= $tab === 'journal' ? 'active' : '' ?>">Journal</a>
<a href="<?= url_for('settings.php?tab=konto') ?>" class="<?= $tab === 'konto' ? 'active' : '' ?>">Konto</a>
</div>
<?php if ($msg): ?><p class="success"><?= htmlspecialchars($msg) ?></p><?php endif; ?>
<?php if ($error): ?><p class="error"><?= htmlspecialchars($error) ?></p><?php endif; ?>
<?php if ($tab === 'allgemein'): ?>
<!-- ==================== ALLGEMEIN TAB ==================== -->
<section>
<h2>Firmeneinstellungen</h2>
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="form" value="allgemein">
<label>Firmenname:
<input type="text" name="company_name" value="<?= htmlspecialchars($settings['company_name'] ?? '') ?>">
</label>
<label>Adresse (mehrzeilig):
<textarea name="company_address" rows="3"><?= htmlspecialchars($settings['company_address'] ?? '') ?></textarea>
</label>
<div class="flex-row">
<label>PLZ:
<input type="text" name="company_zip" value="<?= htmlspecialchars($settings['company_zip'] ?? '') ?>">
</label>
<label>Ort:
<input type="text" name="company_city" value="<?= htmlspecialchars($settings['company_city'] ?? '') ?>">
</label>
<label>Land:
<input type="text" name="company_country" value="<?= htmlspecialchars($settings['company_country'] ?? '') ?>">
</label>
</div>
<label>Steuernummer/USt-IdNr:
<input type="text" name="tax_id" value="<?= htmlspecialchars($settings['tax_id'] ?? '') ?>">
</label>
<label>Umsatzsteuer-Modus:
<select name="vat_mode">
<option value="klein" <?= ($settings['vat_mode'] ?? '') === 'klein' ? 'selected' : '' ?>>Kleinunternehmer</option>
<option value="normal" <?= ($settings['vat_mode'] ?? '') === 'normal' ? 'selected' : '' ?>>Normal</option>
</select>
</label>
<label>Standard-USt-Satz (%):
<input type="number" step="0.01" name="default_vat_rate" value="<?= htmlspecialchars($settings['default_vat_rate'] ?? '19.00') ?>">
</label>
<label>IBAN:
<input type="text" name="iban" value="<?= htmlspecialchars($settings['iban'] ?? '') ?>">
</label>
<label>Telefon:
<input type="text" name="phone" value="<?= htmlspecialchars($settings['phone'] ?? '') ?>">
</label>
<label>E-Mail:
<input type="email" name="email" value="<?= htmlspecialchars($settings['email'] ?? '') ?>">
</label>
<label>Website:
<input type="text" name="website" value="<?= htmlspecialchars($settings['website'] ?? '') ?>">
</label>
<label>Zahlungsbedingungen:
<textarea name="payment_terms" rows="2"><?= htmlspecialchars($settings['payment_terms'] ?? '') ?></textarea>
</label>
<label>Fußtext:
<textarea name="footer_text" rows="2"><?= htmlspecialchars($settings['footer_text'] ?? '') ?></textarea>
</label>
<label>Logo (PNG):
<input type="file" name="logo" accept="image/png">
</label>
<?php if (!empty($settings['logo_path'])): ?>
<p>Aktuelles Logo:<br>
<img src="<?= htmlspecialchars($settings['logo_path']) ?>" style="max-height:60px;"></p>
<?php endif; ?>
<button type="submit">Speichern</button>
</form>
</section>
<?php elseif ($tab === 'journal'): ?>
<!-- ==================== JOURNAL TAB ==================== -->
<!-- Journal Sub-Tabs -->
<div class="journal-settings-subtabs">
<a href="<?= url_for('settings.php?tab=journal&jsub=jahre') ?>" class="<?= $journal_sub === 'jahre' ? 'active' : '' ?>">Jahre</a>
<a href="<?= url_for('settings.php?tab=journal&jsub=einnahmen') ?>" class="<?= $journal_sub === 'einnahmen' ? 'active' : '' ?>">Einnahmen</a>
<a href="<?= url_for('settings.php?tab=journal&jsub=ausgaben') ?>" class="<?= $journal_sub === 'ausgaben' ? 'active' : '' ?>">Ausgaben</a>
<a href="<?= url_for('settings.php?tab=journal&jsub=stammdaten') ?>" class="<?= $journal_sub === 'stammdaten' ? 'active' : '' ?>">Sonstiges</a>
</div>
<?php if ($journal_sub === 'jahre'): ?>
<!-- ========== JAHRE ========== -->
<section>
<h2>Journal-Jahre</h2>
<p class="settings-help">Hier verwalten Sie die Buchungsjahre. Ein geschlossenes Jahr kann nicht mehr bearbeitet werden.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="year">
<label>Jahr:
<input type="number" name="year" value="<?= date('Y') ?>" min="2000" max="2099" required style="max-width:100px;">
</label>
<label>Notizen:
<input type="text" name="notes" value="">
</label>
<label>&nbsp;
<button type="submit">Jahr erstellen</button>
</label>
</form>
<?php if ($years): ?>
<table class="list">
<thead><tr><th>Jahr</th><th>Status</th><th>Notizen</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($years as $y): ?>
<tr>
<td><strong><?= (int)$y['year'] ?></strong></td>
<td><?= $y['is_closed'] ? '<span class="badge badge-danger">Geschlossen</span>' : '<span class="badge badge-success">Offen</span>' ?></td>
<td><?= htmlspecialchars($y['notes'] ?? '') ?></td>
<td>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="toggle_year">
<input type="hidden" name="id" value="<?= $y['id'] ?>">
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">
<?= $y['is_closed'] ? 'Öffnen' : 'Schließen' ?>
</button>
</form>
<a href="<?= url_for('journal.php?year_id=' . $y['id']) ?>" style="margin-left:6px;font-size:10px;">Zum Journal</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p class="info">Noch keine Jahre angelegt. Erstellen Sie ein Jahr, um mit der Buchführung zu beginnen.</p>
<?php endif; ?>
</div>
</section>
<?php elseif ($journal_sub === 'einnahmen'): ?>
<!-- ========== EINNAHMEN ========== -->
<!-- Erlös-Kategorien -->
<section>
<h2>Erlös-Kategorien</h2>
<p class="settings-help">Kategorien für Einnahmen/Erlöse (z.B. "Umsatz 7%", "Umsatz 19%"). Diese erscheinen als Spalten im Journal.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="rev_cat">
<input type="hidden" name="category_type" value="erloese">
<label>Name:
<input type="text" name="name" required placeholder="z.B. Umsatz 19%">
</label>
<label>MwSt %:
<input type="number" step="0.01" name="vat_rate" value="19" style="max-width:80px;">
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($er_cats): ?>
<table class="list">
<thead><tr><th>Name</th><th>MwSt</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($er_cats as $cat): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="rev_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<input type="hidden" name="category_type" value="erloese">
<input type="text" name="name" value="<?= htmlspecialchars($cat['name']) ?>" style="max-width:150px;">
</td>
<td><input type="number" step="0.01" name="vat_rate" value="<?= htmlspecialchars($cat['vat_rate']) ?>" style="max-width:70px;"></td>
<td><input type="number" name="sort_order" value="<?= (int)$cat['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $cat['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_rev_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<!-- Wareneingang-Kategorien -->
<section>
<h2>Wareneingang-Kategorien</h2>
<p class="settings-help">Kategorien für Wareneinkauf (z.B. "WE 7%", "WE 19%"). Diese erscheinen als Spalten im Journal.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="rev_cat">
<input type="hidden" name="category_type" value="wareneingang">
<label>Name:
<input type="text" name="name" required placeholder="z.B. WE 19%">
</label>
<label>MwSt %:
<input type="number" step="0.01" name="vat_rate" value="19" style="max-width:80px;">
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($we_cats): ?>
<table class="list">
<thead><tr><th>Name</th><th>MwSt</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($we_cats as $cat): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="rev_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<input type="hidden" name="category_type" value="wareneingang">
<input type="text" name="name" value="<?= htmlspecialchars($cat['name']) ?>" style="max-width:150px;">
</td>
<td><input type="number" step="0.01" name="vat_rate" value="<?= htmlspecialchars($cat['vat_rate']) ?>" style="max-width:70px;"></td>
<td><input type="number" name="sort_order" value="<?= (int)$cat['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $cat['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_rev_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<?php elseif ($journal_sub === 'ausgaben'): ?>
<!-- ========== AUSGABEN / ABZÜGE ========== -->
<!-- Aufwandskategorien -->
<section>
<h2>Aufwandskategorien</h2>
<p class="settings-help">Kategorien für Betriebsausgaben (z.B. "Miete", "Versicherung", "Telefon"). Diese erscheinen als Spalten im Journal und werden in der EÜR berücksichtigt.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="exp_cat">
<label>Name:
<input type="text" name="name" required placeholder="z.B. Miete">
</label>
<label>Typ:
<select name="side" style="max-width:120px;">
<option value="soll">Soll</option>
<option value="soll_haben">Soll+Haben</option>
</select>
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($exp_cats): ?>
<table class="list">
<thead><tr><th>Name</th><th>Typ</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($exp_cats as $cat): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="exp_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<input type="text" name="name" value="<?= htmlspecialchars($cat['name']) ?>" style="max-width:150px;">
</td>
<td>
<select name="side" style="max-width:100px;">
<option value="soll" <?= $cat['side'] === 'soll' ? 'selected' : '' ?>>Soll</option>
<option value="soll_haben" <?= $cat['side'] === 'soll_haben' ? 'selected' : '' ?>>Soll+Haben</option>
</select>
</td>
<td><input type="number" name="sort_order" value="<?= (int)$cat['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $cat['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_exp_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<!-- Abzüge -->
<section>
<h2>Abzüge</h2>
<p class="settings-help">Abzugskategorien (z.B. "Skonto", "Lotto"). Diese erscheinen als Haben-Spalten im Journal und werden in der EÜR berücksichtigt.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="ded_cat">
<label>Name:
<input type="text" name="name" required placeholder="z.B. Skonto">
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($ded_cats): ?>
<table class="list">
<thead><tr><th>Name</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($ded_cats as $cat): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="ded_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<input type="text" name="name" value="<?= htmlspecialchars($cat['name']) ?>" style="max-width:200px;">
</td>
<td><input type="number" name="sort_order" value="<?= (int)$cat['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $cat['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_ded_cat">
<input type="hidden" name="id" value="<?= $cat['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<?php elseif ($journal_sub === 'stammdaten'): ?>
<!-- ========== STAMMDATEN ========== -->
<!-- Lieferanten -->
<section>
<h2>Lieferanten</h2>
<p class="settings-help">Lieferanten können bei Buchungen ausgewählt werden, um die Zuordnung zu erleichtern.</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="supplier">
<label>Name:
<input type="text" name="name" required placeholder="z.B. Metro">
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($suppliers): ?>
<table class="list">
<thead><tr><th>Name</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($suppliers as $sup): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="supplier">
<input type="hidden" name="id" value="<?= $sup['id'] ?>">
<input type="text" name="name" value="<?= htmlspecialchars($sup['name']) ?>" style="max-width:200px;">
</td>
<td><input type="number" name="sort_order" value="<?= (int)$sup['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $sup['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_supplier">
<input type="hidden" name="id" value="<?= $sup['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<!-- Umsatz-Zusammenfassungsposten -->
<section>
<h2>Umsatz-Zusammenfassungsposten</h2>
<p class="settings-help">Zusätzliche Posten für die monatliche Umsatzübersicht im Journal (z.B. "Reinigung", "RMV").</p>
<div>
<form method="post" class="flex-row" style="margin-bottom:12px;">
<input type="hidden" name="form" value="summary_item">
<label>Name:
<input type="text" name="name" required placeholder="z.B. Reinigung">
</label>
<label>Sort.:
<input type="number" name="sort_order" value="0" style="max-width:60px;">
</label>
<label>
<input type="checkbox" name="is_active" checked> Aktiv
</label>
<label>&nbsp;
<button type="submit">Hinzufügen</button>
</label>
</form>
<?php if ($summary_items): ?>
<table class="list">
<thead><tr><th>Name</th><th>Sort.</th><th>Aktiv</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($summary_items as $item): ?>
<tr>
<td>
<form method="post" style="display:inline;" class="flex-row">
<input type="hidden" name="form" value="summary_item">
<input type="hidden" name="id" value="<?= $item['id'] ?>">
<input type="text" name="name" value="<?= htmlspecialchars($item['name']) ?>" style="max-width:200px;">
</td>
<td><input type="number" name="sort_order" value="<?= (int)$item['sort_order'] ?>" style="max-width:50px;"></td>
<td><input type="checkbox" name="is_active" <?= $item['is_active'] ? 'checked' : '' ?>></td>
<td>
<button type="submit" class="secondary" style="padding:3px 8px;font-size:10px;">Speichern</button>
</form>
<form method="post" style="display:inline;">
<input type="hidden" name="form" value="delete_summary_item">
<input type="hidden" name="id" value="<?= $item['id'] ?>">
<button type="submit" class="danger" style="padding:3px 8px;font-size:10px;" onclick="return confirm('Wirklich löschen?');">X</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
</section>
<?php endif; ?>
<?php elseif ($tab === 'konto'): ?>
<!-- ==================== KONTO TAB ==================== -->
<?php $current_user = get_logged_in_user(); ?>
<section>
<h2>Benutzername ändern</h2>
<div>
<form method="post">
<input type="hidden" name="form" value="change_username">
<label>Aktueller Benutzername:
<input type="text" value="<?= htmlspecialchars($current_user['username'] ?? '') ?>" disabled>
</label>
<label>Neuer Benutzername:
<input type="text" name="new_username" required minlength="3" style="max-width:300px;">
</label>
<button type="submit">Benutzername ändern</button>
</form>
</div>
</section>
<section>
<h2>Passwort ändern</h2>
<div>
<form method="post">
<input type="hidden" name="form" value="change_password">
<label>Aktuelles Passwort:
<input type="password" name="current_password" required style="max-width:300px;">
</label>
<label>Neues Passwort:
<input type="password" name="new_password" required minlength="6" style="max-width:300px;">
</label>
<label>Neues Passwort bestätigen:
<input type="password" name="confirm_password" required minlength="6" style="max-width:300px;">
</label>
<button type="submit">Passwort ändern</button>
</form>
</div>
</section>
<?php endif; ?>
</main>
<script src="assets/command-palette.js"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

View File

@@ -1,113 +0,0 @@
#!/bin/bash
# PIRP Reset Script - Setzt DB und Uploads komplett zurück
# Default Login nach Reset: admin:admin
#
# Verwendung:
# ./reset.sh - Interaktiv (fragt nach Docker/Lokal)
# ./reset.sh docker - Nur Docker
# ./reset.sh local - Nur Lokal
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
# DB-Verbindungsdaten für lokalen Betrieb (aus config.php oder Env)
DB_HOST="${DB_HOST:-127.0.0.1}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-pirp}"
DB_USER="${DB_USER:-pirp_user}"
DB_PASS="${DB_PASS:-PIRPdb2025!}"
# admin:admin Password Hash
ADMIN_HASH='$2y$10$YourHashHere'
echo "=== PIRP Reset ==="
echo "WARNUNG: Dies löscht ALLE Daten (Datenbank + Uploads)!"
echo ""
# Modus bestimmen
MODE="$1"
if [ -z "$MODE" ]; then
echo "Welchen Modus verwenden?"
echo " 1) Docker (docker-compose)"
echo " 2) Lokal (PostgreSQL direkt)"
echo ""
read -p "Auswahl (1/2): " choice
case "$choice" in
1) MODE="docker" ;;
2) MODE="local" ;;
*) echo "Ungültige Auswahl."; exit 1 ;;
esac
fi
echo ""
read -p "Wirklich ALLE Daten löschen? (ja/nein): " confirm
if [ "$confirm" != "ja" ]; then
echo "Abgebrochen."
exit 0
fi
# Uploads löschen
echo ""
echo "=> Uploads löschen..."
rm -rf public/uploads/logos/* 2>/dev/null || true
rm -rf public/uploads/expenses/* 2>/dev/null || true
rm -rf public/uploads/invoices/* 2>/dev/null || true
mkdir -p public/uploads/logos public/uploads/expenses public/uploads/invoices
echo " Uploads gelöscht."
if [ "$MODE" = "docker" ]; then
# === DOCKER MODUS ===
echo ""
echo "=> Docker Container stoppen und Volume löschen..."
docker compose down -v 2>/dev/null || docker-compose down -v 2>/dev/null || true
echo ""
echo "=> Docker Container neu starten..."
docker compose up -d 2>/dev/null || docker-compose up -d
echo ""
echo "=> Warte auf Datenbank..."
sleep 5
echo ""
echo "=> Migrationen ausführen..."
docker compose exec -T db psql -U "$DB_USER" -d "$DB_NAME" -f /docker-entrypoint-initdb.d/02-journal.sql 2>/dev/null || true
else
# === LOKAL MODUS ===
export PGPASSWORD="$DB_PASS"
echo ""
echo "=> Datenbank zurücksetzen..."
# Alle Tabellen droppen und Schema neu erstellen
echo " Schema anwenden..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f schema.sql
echo " Journal-Migration..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -f tools/migrate_journal.sql
# Admin-User erstellen (admin:admin)
echo " Admin-User erstellen..."
ADMIN_HASH=$(php -r "echo password_hash('admin', PASSWORD_DEFAULT);")
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" <<EOF
INSERT INTO users (username, password_hash)
VALUES ('admin', '$ADMIN_HASH')
ON CONFLICT (username) DO UPDATE SET password_hash = EXCLUDED.password_hash;
EOF
unset PGPASSWORD
fi
echo ""
echo "=== Reset abgeschlossen ==="
echo ""
echo "Login: admin / admin"
if [ "$MODE" = "docker" ]; then
echo "URL: http://localhost:8080"
else
echo "Starte Server mit: php -S localhost:8080 -t public"
fi
echo ""

View File

@@ -1,270 +0,0 @@
-- PIRP vollständiges Schema (inkl. Journal-Modul)
-- Reihenfolge beachten wegen Foreign Keys
DROP TABLE IF EXISTS journal_monthly_summary_values CASCADE;
DROP TABLE IF EXISTS journal_monthly_summary CASCADE;
DROP TABLE IF EXISTS journal_entry_accounts CASCADE;
DROP TABLE IF EXISTS journal_entries CASCADE;
DROP TABLE IF EXISTS journal_summary_items CASCADE;
DROP TABLE IF EXISTS journal_deduction_categories CASCADE;
DROP TABLE IF EXISTS journal_expense_categories CASCADE;
DROP TABLE IF EXISTS journal_revenue_categories CASCADE;
DROP TABLE IF EXISTS journal_suppliers CASCADE;
DROP TABLE IF EXISTS journal_years CASCADE;
DROP TABLE IF EXISTS recurring_log CASCADE;
DROP TABLE IF EXISTS recurring_template_items CASCADE;
DROP TABLE IF EXISTS recurring_templates CASCADE;
DROP TABLE IF EXISTS invoice_items CASCADE;
DROP TABLE IF EXISTS invoices CASCADE;
DROP TABLE IF EXISTS expenses CASCADE;
DROP TABLE IF EXISTS customers CASCADE;
DROP TABLE IF EXISTS settings CASCADE;
DROP TABLE IF EXISTS users CASCADE;
-- ============================================================
-- Basis-Tabellen
-- ============================================================
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE settings (
id SERIAL PRIMARY KEY,
company_name TEXT,
company_address TEXT,
company_zip TEXT,
company_city TEXT,
company_country TEXT,
tax_id TEXT,
vat_mode VARCHAR(10) DEFAULT 'klein',
default_vat_rate NUMERIC(5,2) DEFAULT 19.00,
payment_terms TEXT,
footer_text TEXT,
logo_path TEXT,
iban TEXT,
phone TEXT,
email TEXT,
website TEXT
);
CREATE TABLE customers (
id SERIAL PRIMARY KEY,
customer_number VARCHAR(20) UNIQUE,
name TEXT NOT NULL,
address TEXT,
zip TEXT,
city TEXT,
country TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- Rechnungen
-- ============================================================
CREATE TABLE invoices (
id SERIAL PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
invoice_date DATE NOT NULL,
service_date DATE,
vat_mode VARCHAR(10) NOT NULL DEFAULT 'klein',
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00,
payment_terms TEXT,
notes_internal TEXT,
total_net NUMERIC(12,2) NOT NULL DEFAULT 0,
total_vat NUMERIC(12,2) NOT NULL DEFAULT 0,
total_gross NUMERIC(12,2) NOT NULL DEFAULT 0,
paid BOOLEAN NOT NULL DEFAULT FALSE,
payment_date DATE,
pdf_path TEXT,
pdf_hash VARCHAR(64),
pdf_generated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE invoice_items (
id SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
position_no INTEGER NOT NULL,
description TEXT NOT NULL,
quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL DEFAULT 0,
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00
);
-- ============================================================
-- Ausgaben
-- ============================================================
CREATE TABLE expenses (
id SERIAL PRIMARY KEY,
expense_date DATE NOT NULL,
description TEXT NOT NULL,
category TEXT,
amount NUMERIC(12,2) NOT NULL,
vat_rate NUMERIC(5,2) DEFAULT 0,
total_net NUMERIC(12,2),
total_vat NUMERIC(12,2) DEFAULT 0,
expense_category_id INTEGER,
paid BOOLEAN NOT NULL DEFAULT TRUE,
payment_date DATE,
attachment_path TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- ============================================================
-- Wiederkehrende Rechnungen (Abo-Rechnungen)
-- ============================================================
CREATE TABLE recurring_templates (
id SERIAL PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
interval_type VARCHAR(20) NOT NULL CHECK (interval_type IN ('monthly', 'quarterly', 'yearly')),
start_date DATE NOT NULL,
end_date DATE,
next_due_date DATE NOT NULL,
vat_mode VARCHAR(10) NOT NULL DEFAULT 'klein',
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
notes_internal TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE recurring_template_items (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES recurring_templates(id) ON DELETE CASCADE,
position_no INTEGER NOT NULL,
description TEXT NOT NULL,
quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL DEFAULT 0
);
CREATE TABLE recurring_log (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES recurring_templates(id) ON DELETE CASCADE,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ DEFAULT now(),
due_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'generated'
);
-- ============================================================
-- Journal-Modul: Doppelte Buchführung
-- ============================================================
CREATE TABLE journal_years (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL UNIQUE,
is_closed BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE journal_suppliers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE journal_revenue_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_type VARCHAR(20) NOT NULL CHECK (category_type IN ('wareneingang', 'erloese')),
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE journal_expense_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
side VARCHAR(10) NOT NULL DEFAULT 'soll' CHECK (side IN ('soll', 'soll_haben')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE journal_deduction_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE journal_entries (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE RESTRICT,
entry_date DATE NOT NULL,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
description TEXT NOT NULL,
attachment_note TEXT,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
supplier_id INTEGER REFERENCES journal_suppliers(id) ON DELETE SET NULL,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
expense_id INTEGER REFERENCES expenses(id) ON DELETE SET NULL,
source_type VARCHAR(20) DEFAULT 'manual',
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE journal_entry_accounts (
id SERIAL PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
account_type VARCHAR(20) NOT NULL,
side VARCHAR(5) NOT NULL CHECK (side IN ('soll', 'haben')),
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
revenue_category_id INTEGER REFERENCES journal_revenue_categories(id) ON DELETE SET NULL,
expense_category_id INTEGER REFERENCES journal_expense_categories(id) ON DELETE SET NULL,
note TEXT
);
CREATE TABLE journal_monthly_summary (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE CASCADE,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
manual_corrections JSONB DEFAULT '{}',
notes TEXT,
UNIQUE(year_id, month)
);
CREATE TABLE journal_summary_items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
CREATE TABLE journal_monthly_summary_values (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE CASCADE,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
summary_item_id INTEGER NOT NULL REFERENCES journal_summary_items(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
UNIQUE(year_id, month, summary_item_id)
);
-- Foreign Key für expenses -> journal_expense_categories (nachträglich, da Tabelle erst jetzt existiert)
ALTER TABLE expenses ADD CONSTRAINT fk_expenses_category
FOREIGN KEY (expense_category_id) REFERENCES journal_expense_categories(id) ON DELETE SET NULL;
-- ============================================================
-- Indizes
-- ============================================================
CREATE INDEX idx_journal_entries_year_month ON journal_entries(year_id, month);
CREATE INDEX idx_journal_entries_date ON journal_entries(entry_date);
CREATE INDEX idx_journal_entries_invoice ON journal_entries(invoice_id);
CREATE INDEX idx_journal_entries_expense ON journal_entries(expense_id);
CREATE INDEX idx_journal_entries_source ON journal_entries(source_type);
-- Unique-Constraints: max. 1 Journal-Eintrag pro Rechnung/Ausgabe (verhindert Doppelbuchungen)
CREATE UNIQUE INDEX idx_unique_journal_invoice ON journal_entries(invoice_id) WHERE invoice_id IS NOT NULL;
CREATE UNIQUE INDEX idx_unique_journal_expense ON journal_entries(expense_id) WHERE expense_id IS NOT NULL;
CREATE INDEX idx_journal_entry_accounts_entry ON journal_entry_accounts(entry_id);
CREATE INDEX idx_journal_entry_accounts_type ON journal_entry_accounts(account_type);

View File

@@ -1,70 +0,0 @@
<?php
require_once __DIR__ . '/db.php';
function login(string $username, string $password): bool {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id, username, password_hash FROM users WHERE username = :u');
$stmt->execute([':u' => $username]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user && password_verify($password, $user['password_hash'])) {
$_SESSION['user_id'] = $user['id'];
$_SESSION['username'] = $user['username'];
return true;
}
return false;
}
function require_login(): void {
if (empty($_SESSION['user_id'])) {
header('Location: ' . url_for('login.php'));
exit;
}
}
function logout(): void {
$_SESSION = [];
if (ini_get("session.use_cookies")) {
$params = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$params["path"], $params["domain"],
$params["secure"], $params["httponly"]
);
}
session_destroy();
}
function get_logged_in_user(): ?array {
if (empty($_SESSION['user_id'])) return null;
$pdo = get_db();
$stmt = $pdo->prepare('SELECT id, username FROM users WHERE id = :id');
$stmt->execute([':id' => $_SESSION['user_id']]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
function update_username(int $user_id, string $new_username): bool {
$pdo = get_db();
// Prüfen ob Username schon vergeben
$stmt = $pdo->prepare('SELECT id FROM users WHERE username = :u AND id != :id');
$stmt->execute([':u' => $new_username, ':id' => $user_id]);
if ($stmt->fetch()) {
return false; // Username existiert bereits
}
$stmt = $pdo->prepare('UPDATE users SET username = :u WHERE id = :id');
$stmt->execute([':u' => $new_username, ':id' => $user_id]);
$_SESSION['username'] = $new_username;
return true;
}
function update_password(int $user_id, string $current_password, string $new_password): bool {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT password_hash FROM users WHERE id = :id');
$stmt->execute([':id' => $user_id]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($current_password, $user['password_hash'])) {
return false; // Aktuelles Passwort falsch
}
$new_hash = password_hash($new_password, PASSWORD_DEFAULT);
$stmt = $pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id');
$stmt->execute([':h' => $new_hash, ':id' => $user_id]);
return true;
}

View File

@@ -1,29 +0,0 @@
<?php
// Grundkonfiguration für PIRP
// Datenbank-Parameter (per Env-Variable oder Fallback)
define('DB_HOST', getenv('DB_HOST') ?: '127.0.0.1');
define('DB_PORT', getenv('DB_PORT') ?: '5432');
define('DB_NAME', getenv('DB_NAME') ?: 'pirp');
define('DB_USER', getenv('DB_USER') ?: 'pirp_user');
define('DB_PASS', getenv('DB_PASS') ?: 'PIRPdb2025!');
// BASE_URL: wenn PIRP direkt unter http://server/ läuft -> leer
// wenn unter Unterverzeichnis, z.B. /pirp, dann BASE_URL auf '/pirp' setzen
define('BASE_URL', '');
// Fehleranzeige (DEV_MODE=1 zeigt Fehler im Browser)
error_reporting(E_ALL);
ini_set('display_errors', getenv('DEV_MODE') ? '1' : '0');
ini_set('log_errors', '1');
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
// Helfer für URLs
function url_for(string $path): string {
$base = rtrim(BASE_URL, '/');
$path = '/' . ltrim($path, '/');
return $base . $path;
}

View File

@@ -1,61 +0,0 @@
<?php
// src/customer_functions.php
require_once __DIR__ . '/db.php';
function generate_customer_number(): string {
$pdo = get_db();
$stmt = $pdo->query("SELECT customer_number FROM customers WHERE customer_number LIKE 'PIKN-%' ORDER BY customer_number DESC LIMIT 1");
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row && preg_match('/^PIKN-(\d{6})$/', $row['customer_number'], $m)) {
$next = (int)$m[1] + 1;
} else {
$next = 1;
}
return sprintf('PIKN-%06d', $next);
}
function get_customers(): array {
$pdo = get_db();
$stmt = $pdo->query('SELECT * FROM customers ORDER BY name');
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function get_customer(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM customers WHERE id = :id');
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
function save_customer(?int $id, array $data): void {
$pdo = get_db();
if ($id) {
$sql = 'UPDATE customers SET name=:n, address=:a, zip=:z, city=:c, country=:co WHERE id=:id';
$pdo->prepare($sql)->execute([
':n' => $data['name'] ?? '',
':a' => $data['address'] ?? '',
':z' => $data['zip'] ?? '',
':c' => $data['city'] ?? '',
':co'=> $data['country'] ?? '',
':id'=> $id,
]);
} else {
$sql = 'INSERT INTO customers (name, address, zip, city, country, customer_number)
VALUES (:n,:a,:z,:c,:co,:cn)';
$pdo->prepare($sql)->execute([
':n' => $data['name'] ?? '',
':a' => $data['address'] ?? '',
':z' => $data['zip'] ?? '',
':c' => $data['city'] ?? '',
':co' => $data['country'] ?? '',
':cn' => generate_customer_number(),
]);
}
}
function delete_customer(int $id): void {
$pdo = get_db();
$stmt = $pdo->prepare('DELETE FROM customers WHERE id = :id');
$stmt->execute([':id' => $id]);
}

View File

@@ -1,14 +0,0 @@
<?php
require_once __DIR__ . '/config.php';
function get_db(): PDO {
static $pdo = null;
if ($pdo === null) {
$dsn = 'pgsql:host=' . DB_HOST . ';port=' . DB_PORT . ';dbname=' . DB_NAME . ';';
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
}
return $pdo;
}

View File

@@ -1,117 +0,0 @@
<?php
// src/expense_functions.php
require_once __DIR__ . '/db.php';
function get_expenses(array $filters = []): array {
$pdo = get_db();
$sql = "SELECT * FROM expenses WHERE 1=1";
$params = [];
if (!empty($filters['from'])) {
$sql .= " AND expense_date >= :from";
$params[':from'] = $filters['from'];
}
if (!empty($filters['to'])) {
$sql .= " AND expense_date <= :to";
$params[':to'] = $filters['to'];
}
if (isset($filters['paid']) && $filters['paid'] !== '') {
$sql .= " AND paid = :paid";
$params[':paid'] = (bool)$filters['paid'];
}
if (!empty($filters['search'])) {
$sql .= " AND (description ILIKE :search OR category ILIKE :search)";
$params[':search'] = '%' . $filters['search'] . '%';
}
$sql .= " ORDER BY expense_date DESC, id DESC";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function get_expense(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM expenses WHERE id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Speichert eine Ausgabe (mit MwSt-Feldern und Aufwandskategorie).
* Gibt die ID der Ausgabe zurück (neu oder aktualisiert).
*/
function save_expense(?int $id, array $data): int {
$pdo = get_db();
$paid = !empty($data['paid']);
// MwSt-Berechnung: Brutto → Netto + VorSt
$amount = (float)$data['amount'];
$vat_rate = (float)($data['vat_rate'] ?? 0);
if ($vat_rate > 0) {
$total_net = round($amount / (1 + $vat_rate / 100), 2);
$total_vat = round($amount - $total_net, 2);
} else {
$total_net = $amount;
$total_vat = 0.0;
}
$expense_category_id = !empty($data['expense_category_id']) ? (int)$data['expense_category_id'] : null;
$payment_date = $paid ? ($data['payment_date'] ?? $data['expense_date']) : null;
if ($id) {
$sql = "UPDATE expenses
SET expense_date = :d,
description = :desc,
category = :cat,
amount = :amt,
vat_rate = :vr,
total_net = :tn,
total_vat = :tv,
expense_category_id = :ecid,
paid = :paid,
payment_date = :pd
WHERE id = :id";
$pdo->prepare($sql)->execute([
':d' => $data['expense_date'],
':desc' => $data['description'],
':cat' => $data['category'],
':amt' => $amount,
':vr' => $vat_rate,
':tn' => $total_net,
':tv' => $total_vat,
':ecid' => $expense_category_id,
':paid' => $paid ? 't' : 'f',
':pd' => $payment_date,
':id' => $id,
]);
return $id;
} else {
$sql = "INSERT INTO expenses
(expense_date, description, category, amount, vat_rate, total_net, total_vat, expense_category_id, paid, payment_date)
VALUES (:d, :desc, :cat, :amt, :vr, :tn, :tv, :ecid, :paid, :pd)
RETURNING id";
$stmt = $pdo->prepare($sql);
$stmt->execute([
':d' => $data['expense_date'],
':desc' => $data['description'],
':cat' => $data['category'],
':amt' => $amount,
':vr' => $vat_rate,
':tn' => $total_net,
':tv' => $total_vat,
':ecid' => $expense_category_id,
':paid' => $paid ? 't' : 'f',
':pd' => $payment_date,
]);
return (int)$stmt->fetchColumn();
}
}
function delete_expense(int $id): void {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM expenses WHERE id = :id");
$stmt->execute([':id' => $id]);
}

View File

@@ -1,39 +0,0 @@
<?php
// Monochrome SVG icons for navigation (Hammer Editor style)
// Simple, geometric, 16x16 viewBox
function icon_dashboard(): string {
return '<svg viewBox="0 0 16 16"><path d="M1 1h6v6H1zM9 1h6v6H9zM1 9h6v6H1zM9 9h6v6H9z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>';
}
function icon_invoices(): string {
return '<svg viewBox="0 0 16 16"><path d="M3 1h7l3 3v11H3z" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M5 6h6M5 8.5h6M5 11h4" stroke="currentColor" stroke-width="1" opacity=".6"/></svg>';
}
function icon_journal(): string {
return '<svg viewBox="0 0 16 16"><path d="M2 2h12v12H2z" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 5h12M2 8h12M2 11h12M6 2v12" stroke="currentColor" stroke-width=".8" opacity=".5"/></svg>';
}
function icon_settings(): string {
return '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.4 1.4M11.55 11.55l1.4 1.4M3.05 12.95l1.4-1.4M11.55 4.45l1.4-1.4" stroke="currentColor" stroke-width="1.2"/></svg>';
}
function icon_euer(): string {
return '<svg viewBox="0 0 16 16"><path d="M2 14V2h4l2 3h6v9H2z" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M5 7h6M5 10h4" stroke="currentColor" stroke-width="1" opacity=".6"/></svg>';
}
function icon_customers(): string {
return '<svg viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M3 14c0-3 2.2-5 5-5s5 2 5 5" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>';
}
function icon_expenses(): string {
return '<svg viewBox="0 0 16 16"><path d="M2 3h12v10H2z" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M5 8h6M8 5v6" stroke="currentColor" stroke-width="1" opacity=".6"/><path d="M2 6h12" stroke="currentColor" stroke-width=".8" opacity=".5"/></svg>';
}
function icon_logout(): string {
return '<svg viewBox="0 0 16 16"><path d="M6 2H3v12h3M6 8h8M11 5l3 3-3 3" fill="none" stroke="currentColor" stroke-width="1.3"/></svg>';
}
function icon_archive(): string {
return '<svg viewBox="0 0 16 16"><path d="M1 3h14v3H1z" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M2 6v8h12V6" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M6 9.5h4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/></svg>';
}

View File

@@ -1,245 +0,0 @@
<?php
// src/invoice_functions.php
require_once __DIR__ . '/db.php';
function get_settings(): array {
$pdo = get_db();
$stmt = $pdo->query('SELECT * FROM settings ORDER BY id LIMIT 1');
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
// Default-Einstellungen
return [
'company_name' => 'Meine Firma',
'company_address' => '',
'company_zip' => '',
'company_city' => '',
'company_country' => '',
'tax_id' => '',
'vat_mode' => 'klein',
'default_vat_rate' => 19.00,
'payment_terms' => 'Zahlbar innerhalb von 14 Tagen.',
'footer_text' => '',
'logo_path' => '',
'iban' => '',
'phone' => '',
'email' => '',
'website' => '',
];
}
return $row;
}
function save_settings(array $data): void {
$pdo = get_db();
$stmt = $pdo->query('SELECT id FROM settings ORDER BY id LIMIT 1');
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$sql = 'UPDATE settings SET company_name=:cn, company_address=:ca, company_zip=:cz,
company_city=:cc, company_country=:ccountry, tax_id=:ti, vat_mode=:vm,
default_vat_rate=:vr, payment_terms=:pt, footer_text=:ft, logo_path=:lp,
iban=:iban, phone=:phone, email=:email, website=:website
WHERE id=:id';
$pdo->prepare($sql)->execute([
':cn' => $data['company_name'] ?? null,
':ca' => $data['company_address'] ?? null,
':cz' => $data['company_zip'] ?? null,
':cc' => $data['company_city'] ?? null,
':ccountry' => $data['company_country'] ?? null,
':ti' => $data['tax_id'] ?? null,
':vm' => $data['vat_mode'] ?? 'klein',
':vr' => $data['default_vat_rate'] ?? 19.0,
':pt' => $data['payment_terms'] ?? null,
':ft' => $data['footer_text'] ?? null,
':lp' => $data['logo_path'] ?? null,
':iban' => $data['iban'] ?? null,
':phone' => $data['phone'] ?? null,
':email' => $data['email'] ?? null,
':website' => $data['website'] ?? null,
':id' => $row['id'],
]);
} else {
$sql = 'INSERT INTO settings (company_name, company_address, company_zip,
company_city, company_country, tax_id, vat_mode,
default_vat_rate, payment_terms, footer_text, logo_path,
iban, phone, email, website)
VALUES (:cn,:ca,:cz,:cc,:ccountry,:ti,:vm,:vr,:pt,:ft,:lp,:iban,:phone,:email,:website)';
$pdo->prepare($sql)->execute([
':cn' => $data['company_name'] ?? null,
':ca' => $data['company_address'] ?? null,
':cz' => $data['company_zip'] ?? null,
':cc' => $data['company_city'] ?? null,
':ccountry' => $data['company_country'] ?? null,
':ti' => $data['tax_id'] ?? null,
':vm' => $data['vat_mode'] ?? 'klein',
':vr' => $data['default_vat_rate'] ?? 19.0,
':pt' => $data['payment_terms'] ?? null,
':ft' => $data['footer_text'] ?? null,
':lp' => $data['logo_path'] ?? null,
':iban' => $data['iban'] ?? null,
':phone' => $data['phone'] ?? null,
':email' => $data['email'] ?? null,
':website' => $data['website'] ?? null,
]);
}
}
function generate_invoice_number(): string {
$year = date('Y');
$pdo = get_db();
$prefix = "PIRP-$year-";
$stmt = $pdo->prepare("SELECT invoice_number FROM invoices
WHERE invoice_number LIKE :prefix
ORDER BY invoice_number DESC
LIMIT 1");
$stmt->execute([':prefix' => $prefix . '%']);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$last = $row['invoice_number'];
$numPart = (int)substr($last, -5);
$next = $numPart + 1;
} else {
$next = 1;
}
return sprintf("PIRP-%s-%05d", $year, $next);
}
/**
* Lädt eine Rechnung mit Kundendaten.
*/
function get_invoice_with_customer(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT i.*, c.name AS customer_name, c.address AS customer_address,
c.zip AS customer_zip, c.city AS customer_city,
c.country AS customer_country, c.customer_number
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.id = :id");
$stmt->execute([':id' => $id]);
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
}
/**
* Erstellt eine Stornorechnung zu einer bestehenden Rechnung.
* Gibt die ID der neuen Stornorechnung zurück.
*/
function create_storno_invoice(int $original_id): int {
$pdo = get_db();
$inv = get_invoice_with_customer($original_id);
if (!$inv) throw new \RuntimeException('Rechnung nicht gefunden');
// Positionen laden
$stmt = $pdo->prepare("SELECT * FROM invoice_items WHERE invoice_id = :id ORDER BY position_no");
$stmt->execute([':id' => $original_id]);
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
$storno_number = generate_invoice_number();
$today = date('Y-m-d');
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare("INSERT INTO invoices
(invoice_number, customer_id, invoice_date, service_date,
vat_mode, vat_rate, payment_terms, notes_internal,
total_net, total_vat, total_gross,
paid, payment_date, is_storno, storno_of)
VALUES (:num, :cid, :idate, :sdate,
:vat_mode, :vat_rate, :pt, :notes,
:total_net, :total_vat, :total_gross,
TRUE, :pdate, TRUE, :storno_of)
RETURNING id");
$stmt->execute([
':num' => $storno_number,
':cid' => $inv['customer_id'],
':idate' => $today,
':sdate' => $inv['service_date'],
':vat_mode' => $inv['vat_mode'],
':vat_rate' => $inv['vat_rate'],
':pt' => $inv['payment_terms'],
':notes' => 'Storno von ' . $inv['invoice_number'],
':total_net' => -(float)$inv['total_net'],
':total_vat' => -(float)$inv['total_vat'],
':total_gross' => -(float)$inv['total_gross'],
':pdate' => $today,
':storno_of' => $original_id,
]);
$storno_id = (int)$stmt->fetchColumn();
// Positionen mit negativen Mengen
foreach ($items as $pos) {
$pdo->prepare("INSERT INTO invoice_items
(invoice_id, position_no, description, quantity, unit_price, vat_rate)
VALUES (:iid, :pos, :desc, :qty, :price, :vr)")
->execute([
':iid' => $storno_id,
':pos' => $pos['position_no'],
':desc' => $pos['description'],
':qty' => -(float)$pos['quantity'],
':price' => (float)$pos['unit_price'],
':vr' => (float)$pos['vat_rate'],
]);
}
$pdo->commit();
} catch (\Exception $e) {
$pdo->rollBack();
throw $e;
}
return $storno_id;
}
/**
* Erstellt eine stornierte Journalbuchung (Umkehr der Originalkonten).
* Nur aufrufen wenn die originale Rechnung bereits gebucht war.
*/
function create_storno_journal_entry(int $original_invoice_id, int $storno_invoice_id): int {
$pdo = get_db();
require_once __DIR__ . '/journal_functions.php';
// Originaljournal laden
$orig_entry = get_journal_entry_for_invoice($original_invoice_id);
if (!$orig_entry) throw new \RuntimeException('Keine Journalbuchung für die Originalrechnung gefunden');
// Konten des Originaleintrags laden
$stmt = $pdo->prepare("SELECT * FROM journal_entry_accounts WHERE entry_id = :id ORDER BY id");
$stmt->execute([':id' => $orig_entry['id']]);
$orig_accounts = $stmt->fetchAll(PDO::FETCH_ASSOC);
// Storno-Rechnung laden
$storno_inv = get_invoice_with_customer($storno_invoice_id);
$today = date('Y-m-d');
$booking_year = (int)date('Y');
$year_id = ensure_journal_year($booking_year);
$data = [
'year_id' => $year_id,
'entry_date' => $today,
'description' => 'Storno: ' . $orig_entry['description'],
'attachment_note' => $storno_inv['invoice_number'],
'amount' => abs((float)$orig_entry['amount']),
'supplier_id' => null,
'sort_order' => 0,
];
// Konten mit getauschten Seiten
$accounts = [];
foreach ($orig_accounts as $acct) {
$accounts[] = [
'account_type' => $acct['account_type'],
'side' => $acct['side'] === 'soll' ? 'haben' : 'soll',
'amount' => (float)$acct['amount'],
'revenue_category_id' => $acct['revenue_category_id'],
'expense_category_id' => $acct['expense_category_id'],
'note' => $acct['note'],
];
}
$entry_id = save_journal_entry(null, $data, $accounts);
$pdo->prepare('UPDATE journal_entries SET invoice_id = :iid, source_type = :st WHERE id = :eid')
->execute([':iid' => $storno_invoice_id, ':st' => 'invoice_payment', ':eid' => $entry_id]);
return $entry_id;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,635 +0,0 @@
<?php
/**
* PDF-Archivierungsfunktionen für GoBD-konforme Rechnungsspeicherung
*
* In Deutschland müssen Rechnungen unveränderbar gespeichert werden (GoBD).
* Diese Funktionen generieren PDFs einmalig und speichern sie permanent.
*/
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/invoice_functions.php';
/**
* Generiert das HTML für eine Rechnung (Snapshot der Daten).
*/
function generate_invoice_html(array $invoice, array $items, array $settings): string {
// Logo als Base64
$logoDataUri = '';
if (!empty($settings['logo_path'])) {
$fsPath = dirname(__DIR__) . '/public/' . $settings['logo_path'];
if (is_readable($fsPath)) {
$imageData = file_get_contents($fsPath);
if ($imageData !== false) {
$logoDataUri = 'data:image/png;base64,' . base64_encode($imageData);
}
}
}
ob_start();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
@page { margin: 1.8cm 2cm 3.8cm 2cm; }
body { font-family: DejaVu Sans, sans-serif; font-size: 8pt; }
.header-box { padding: 4px 0 8px 0; text-align: center; }
.header-logo { max-height: 1.3cm; }
.address-window { position: absolute; top: 3.5cm; left: 0; width: 9cm; }
.address-sender { font-size: 6pt; margin-bottom: 0.2cm; white-space: nowrap; }
.address-recipient { font-weight: bold; }
.invoice-info-box { margin-top: 5.6cm; border: 1px solid #000; padding: 10px; }
.items-box { margin-top: 10px; border: 1px solid #000; padding: 10px; }
table.items { width: 100%; border-collapse: collapse; }
table.items th, table.items td { border-bottom: 1px solid #ccc; padding: 4px; }
table.items th { text-align: left; }
.footer-box { position: fixed; bottom: 0.7cm; left: 0; right: 0; padding: 0; border: 1px solid #000; font-size: 7pt; }
.footer-table { width: 100%; border-collapse: collapse; }
.footer-table td { vertical-align: top; padding: 6px 8px; border: none; }
.footer-left { width: 50%; text-align: left; }
.footer-right { width: 50%; text-align: right; line-height: 1.3; }
</style>
</head>
<body>
<!-- HEADER -->
<div class="header-box">
<?php if ($logoDataUri): ?>
<img src="<?= $logoDataUri ?>" class="header-logo">
<?php endif; ?>
</div>
<!-- ADRESSEN -->
<div class="address-window">
<div class="address-sender">
<?= htmlspecialchars($settings['company_name']) ?>
· <?= htmlspecialchars($settings['company_address']) ?>
· <?= htmlspecialchars($settings['company_zip'] . ' ' . $settings['company_city']) ?>
</div>
<div class="address-recipient">
<?= htmlspecialchars($invoice['customer_name']) ?><br>
<?= nl2br(htmlspecialchars($invoice['customer_address'])) ?><br>
<?= htmlspecialchars($invoice['customer_zip'] . ' ' . $invoice['customer_city']) ?><br>
<?= htmlspecialchars($invoice['customer_country']) ?>
</div>
</div>
<!-- RECHNUNGSINFO -->
<div class="invoice-info-box">
<?php if (!empty($invoice['is_storno'])): ?>
<p style="color:#cc2222;font-weight:bold;font-size:10pt;margin-bottom:4px;">STORNORECHNUNG</p>
<p style="font-size:7pt;margin-bottom:6px;">Storno von <?= htmlspecialchars($invoice['notes_internal'] ?? '') ?></p>
<?php endif; ?>
<h2>Rechnung <?= htmlspecialchars($invoice['invoice_number']) ?></h2>
<?php if (!empty($invoice['customer_number'])): ?>
Kundennummer: <?= htmlspecialchars($invoice['customer_number']) ?><br>
<?php endif; ?>
Rechnungsdatum: <?= date('d.m.Y', strtotime($invoice['invoice_date'])) ?><br>
<?php if (!empty($invoice['service_date'])): ?>
Leistungsdatum: <?= date('d.m.Y', strtotime($invoice['service_date'])) ?><br>
<?php endif; ?>
</div>
<!-- POSITIONEN -->
<div class="items-box">
<table class="items">
<thead>
<tr>
<th style="width:5%;">Pos.</th>
<th>Beschreibung</th>
<th style="width:10%;">Menge</th>
<th style="width:15%;">Einzelpreis</th>
<th style="width:15%;">Gesamt</th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $pos): ?>
<tr>
<td><?= (int)$pos['position_no'] ?></td>
<td><?= nl2br(htmlspecialchars($pos['description'])) ?></td>
<td><?= number_format($pos['quantity'], 2, ',', '.') ?></td>
<td><?= number_format($pos['unit_price'], 2, ',', '.') ?> €</td>
<td><?= number_format($pos['quantity'] * $pos['unit_price'], 2, ',', '.') ?> €</td>
</tr>
<?php endforeach; ?>
<tr>
<td colspan="4" style="text-align:right;"><strong>Zwischensumme:</strong></td>
<td style="text-align:right;"><strong><?= number_format($invoice['total_net'], 2, ',', '.') ?> €</strong></td>
</tr>
<?php if ($invoice['vat_mode'] === 'normal'): ?>
<tr>
<td colspan="4" style="text-align:right;">Umsatzsteuer (<?= number_format($invoice['vat_rate'], 2, ',', '.') ?> %):</td>
<td style="text-align:right;"><?= number_format($invoice['total_vat'], 2, ',', '.') ?> €</td>
</tr>
<?php endif; ?>
<tr>
<td colspan="4" style="text-align:right; font-size:9pt;"><strong>Gesamtbetrag:</strong></td>
<td style="text-align:right; font-size:9pt;"><strong><?= number_format($invoice['total_gross'], 2, ',', '.') ?> €</strong></td>
</tr>
<?php if (!empty($settings['payment_terms']) || $invoice['vat_mode'] === 'klein'): ?>
<tr>
<td colspan="5" style="text-align:right;">
<?php if (!empty($settings['payment_terms'])): ?>
<?= nl2br(htmlspecialchars($settings['payment_terms'])) ?><br>
<?php endif; ?>
<?php if ($invoice['vat_mode'] === 'klein'): ?>
Umsatzsteuerbefreit aufgrund Kleingewerbe
<?php endif; ?>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<!-- FUSSZEILE -->
<div class="footer-box">
<table class="footer-table">
<tr>
<td class="footer-left">
<strong><?= htmlspecialchars($settings['company_name']) ?></strong><br>
<?= nl2br(htmlspecialchars($settings['company_address'])) ?><br>
<?= htmlspecialchars($settings['company_zip'] . ' ' . $settings['company_city']) ?><br>
<?= htmlspecialchars($settings['company_country']) ?>
</td>
<td class="footer-right">
<?php if (!empty($settings['iban'])): ?>
IBAN: <?= htmlspecialchars($settings['iban']) ?><br>
<?php endif; ?>
<?php
$line2 = [];
if (!empty($settings['phone'])) $line2[] = 'Tel: ' . $settings['phone'];
if (!empty($settings['email'])) $line2[] = 'E-Mail: ' . $settings['email'];
if (!empty($line2)) echo htmlspecialchars(implode(' · ', $line2)) . '<br>';
?>
<?php
$line3 = [];
if (!empty($settings['website'])) $line3[] = 'Web: ' . $settings['website'];
if (!empty($settings['tax_id'])) $line3[] = 'StNr/USt: ' . $settings['tax_id'];
if (!empty($line3)) echo htmlspecialchars(implode(' · ', $line3));
?>
</td>
</tr>
</table>
</div>
</body>
</html>
<?php
return ob_get_clean();
}
/**
* Generiert eine PDF aus HTML und gibt den Inhalt zurück.
*/
function generate_pdf_content(string $html): string {
require_once dirname(__DIR__) . '/vendor/autoload.php';
$options = new \Dompdf\Options();
$options->set('isRemoteEnabled', true);
$dompdf = new \Dompdf\Dompdf($options);
$dompdf->loadHtml($html, 'UTF-8');
$dompdf->setPaper('A4', 'portrait');
$dompdf->render();
return $dompdf->output();
}
/**
* Speichert die PDF-Datei unveränderlich im Archiv.
* Gibt den relativen Pfad zurück oder false bei Fehler.
*/
function archive_invoice_pdf(int $invoice_id): string|false {
$pdo = get_db();
// Prüfe ob bereits archiviert
$stmt = $pdo->prepare("SELECT pdf_path FROM invoices WHERE id = :id");
$stmt->execute([':id' => $invoice_id]);
$existing = $stmt->fetchColumn();
if ($existing) {
// Bereits archiviert - nicht überschreiben!
return $existing;
}
// Rechnungsdaten laden
$stmt = $pdo->prepare("SELECT i.*,
c.name AS customer_name,
c.address AS customer_address,
c.zip AS customer_zip,
c.city AS customer_city,
c.country AS customer_country,
c.customer_number
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.id = :id");
$stmt->execute([':id' => $invoice_id]);
$invoice = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$invoice) {
return false;
}
// Positionen laden
$stmtItems = $pdo->prepare("SELECT * FROM invoice_items WHERE invoice_id = :id ORDER BY position_no");
$stmtItems->execute([':id' => $invoice_id]);
$items = $stmtItems->fetchAll(PDO::FETCH_ASSOC);
// Aktuelle Einstellungen für Snapshot
$settings = get_settings();
// PDF generieren
$html = generate_invoice_html($invoice, $items, $settings);
$pdfContent = generate_pdf_content($html);
// Dateipfad erstellen (nach Jahr organisiert)
$year = date('Y', strtotime($invoice['invoice_date']));
$uploadDir = dirname(__DIR__) . '/public/uploads/invoices/' . $year;
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
}
// Sicherer Dateiname
$safeInvoiceNumber = preg_replace('/[^A-Za-z0-9\-]/', '_', $invoice['invoice_number']);
$filename = $safeInvoiceNumber . '.pdf';
$fullPath = $uploadDir . '/' . $filename;
$relativePath = 'uploads/invoices/' . $year . '/' . $filename;
// Prüfe ob Datei bereits existiert (z.B. von abgebrochener Migration)
if (file_exists($fullPath)) {
// Datei existiert - Hash berechnen und DB aktualisieren
$existingContent = file_get_contents($fullPath);
if ($existingContent !== false) {
$hash = hash('sha256', $existingContent);
$stmt = $pdo->prepare("UPDATE invoices
SET pdf_path = :path,
pdf_hash = :hash,
pdf_generated_at = NOW()
WHERE id = :id AND pdf_path IS NULL");
$stmt->execute([
':path' => $relativePath,
':hash' => $hash,
':id' => $invoice_id
]);
return $relativePath;
}
return false;
}
// PDF speichern
$bytesWritten = file_put_contents($fullPath, $pdfContent);
if ($bytesWritten === false) {
return false;
}
// Schreibschutz setzen (chmod 444)
chmod($fullPath, 0444);
// Hash berechnen
$hash = hash('sha256', $pdfContent);
// Datenbank aktualisieren
$stmt = $pdo->prepare("UPDATE invoices
SET pdf_path = :path,
pdf_hash = :hash,
pdf_generated_at = NOW()
WHERE id = :id AND pdf_path IS NULL");
$stmt->execute([
':path' => $relativePath,
':hash' => $hash,
':id' => $invoice_id
]);
return $relativePath;
}
/**
* Prüft die Integrität einer archivierten PDF.
* Gibt true zurück wenn Hash übereinstimmt, false sonst.
*/
function verify_invoice_pdf(int $invoice_id): ?bool {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT pdf_path, pdf_hash FROM invoices WHERE id = :id");
$stmt->execute([':id' => $invoice_id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || empty($row['pdf_path']) || empty($row['pdf_hash'])) {
return null; // Keine archivierte PDF
}
$fullPath = dirname(__DIR__) . '/public/' . $row['pdf_path'];
if (!file_exists($fullPath)) {
return false; // Datei fehlt
}
$currentHash = hash_file('sha256', $fullPath);
return $currentHash === $row['pdf_hash'];
}
/**
* Liefert den Pfad zur archivierten PDF oder null.
*/
function get_archived_pdf_path(int $invoice_id): ?string {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT pdf_path FROM invoices WHERE id = :id");
$stmt->execute([':id' => $invoice_id]);
$path = $stmt->fetchColumn();
return $path ?: null;
}
/**
* Zählt Rechnungen ohne archivierte PDF.
*/
function count_unarchived_invoices(): int {
$pdo = get_db();
$stmt = $pdo->query("SELECT COUNT(*) FROM invoices WHERE pdf_path IS NULL");
return (int)$stmt->fetchColumn();
}
/**
* Prüft den GoBD-Status aller archivierten PDFs.
* Gibt ein Array mit Statistiken und Problemen zurück.
*/
function check_pdf_integrity_status(): array {
$pdo = get_db();
$result = [
'total_invoices' => 0,
'archived' => 0,
'unarchived' => 0,
'valid' => 0,
'invalid' => 0,
'missing_files' => 0,
'problems' => [],
'migration_needed' => false
];
// Gesamtzahl
$stmt = $pdo->query("SELECT COUNT(*) FROM invoices");
$result['total_invoices'] = (int)$stmt->fetchColumn();
// Prüfe ob pdf_path Spalte existiert
try {
$pdo->query("SELECT pdf_path FROM invoices LIMIT 1");
} catch (PDOException $e) {
// Spalte existiert nicht - Migration erforderlich
$result['unarchived'] = $result['total_invoices'];
$result['migration_needed'] = true;
return $result;
}
// Nicht archiviert
$stmt = $pdo->query("SELECT COUNT(*) FROM invoices WHERE pdf_path IS NULL");
$result['unarchived'] = (int)$stmt->fetchColumn();
// Archivierte PDFs prüfen
$stmt = $pdo->query("SELECT id, invoice_number, pdf_path, pdf_hash FROM invoices WHERE pdf_path IS NOT NULL");
$archived = $stmt->fetchAll(PDO::FETCH_ASSOC);
$result['archived'] = count($archived);
foreach ($archived as $inv) {
$fullPath = dirname(__DIR__) . '/public/' . $inv['pdf_path'];
if (!file_exists($fullPath)) {
$result['missing_files']++;
$result['problems'][] = [
'type' => 'missing',
'invoice_number' => $inv['invoice_number'],
'id' => $inv['id'],
'message' => 'PDF-Datei fehlt'
];
} elseif (!empty($inv['pdf_hash'])) {
$currentHash = hash_file('sha256', $fullPath);
if ($currentHash !== $inv['pdf_hash']) {
$result['invalid']++;
$result['problems'][] = [
'type' => 'corrupted',
'invoice_number' => $inv['invoice_number'],
'id' => $inv['id'],
'message' => 'Hash-Prüfung fehlgeschlagen (Datei manipuliert?)'
];
} else {
$result['valid']++;
}
} else {
// Kein Hash vorhanden
$result['valid']++;
}
}
return $result;
}
/**
* Generiert das HTML für eine Mahnung.
*/
function generate_mahnung_html(array $mahnung, array $invoice, array $settings): string {
$level_labels = [1 => 'MAHNUNG', 2 => '2. MAHNUNG', 3 => '3. MAHNUNG / LETZTE MAHNUNG'];
$level_label = $level_labels[$mahnung['level']] ?? 'MAHNUNG';
$logoDataUri = '';
if (!empty($settings['logo_path'])) {
$fsPath = dirname(__DIR__) . '/public/' . $settings['logo_path'];
if (is_readable($fsPath)) {
$imageData = file_get_contents($fsPath);
if ($imageData !== false) {
$logoDataUri = 'data:image/png;base64,' . base64_encode($imageData);
}
}
}
$days_overdue = (int)ceil((strtotime($mahnung['mahnung_date']) - strtotime($invoice['invoice_date'])) / 86400);
$total_due = (float)$invoice['total_gross'] + (float)$mahnung['fee_amount'];
ob_start();
?>
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<style>
@page { margin: 1.8cm 2cm 3.8cm 2cm; }
body { font-family: DejaVu Sans, sans-serif; font-size: 8pt; }
.header-box { padding: 4px 0 8px 0; text-align: center; }
.header-logo { max-height: 1.3cm; }
.address-window { position: absolute; top: 3.5cm; left: 0; width: 9cm; }
.address-sender { font-size: 6pt; margin-bottom: 0.2cm; white-space: nowrap; }
.address-recipient { font-weight: bold; }
.mahnung-info-box { margin-top: 5.6cm; padding: 10px; }
.mahnung-title { font-size: 14pt; font-weight: bold; color: #cc2222; margin-bottom: 8px; }
.details-box { margin-top: 10px; border: 1px solid #000; padding: 10px; }
table.details { width: 100%; border-collapse: collapse; }
table.details td { padding: 4px 6px; border-bottom: 1px solid #eee; }
.total-row td { font-weight: bold; font-size: 9pt; border-top: 2px solid #000; border-bottom: none; }
.footer-box { position: fixed; bottom: 0.7cm; left: 0; right: 0; padding: 0; border: 1px solid #000; font-size: 7pt; }
.footer-table { width: 100%; border-collapse: collapse; }
.footer-table td { vertical-align: top; padding: 6px 8px; }
.footer-left { width: 50%; }
.footer-right { width: 50%; text-align: right; line-height: 1.3; }
</style>
</head>
<body>
<div class="header-box">
<?php if ($logoDataUri): ?>
<img src="<?= $logoDataUri ?>" class="header-logo">
<?php endif; ?>
</div>
<div class="address-window">
<div class="address-sender">
<?= htmlspecialchars($settings['company_name']) ?>
· <?= htmlspecialchars($settings['company_address']) ?>
· <?= htmlspecialchars($settings['company_zip'] . ' ' . $settings['company_city']) ?>
</div>
<div class="address-recipient">
<?= htmlspecialchars($invoice['customer_name']) ?><br>
<?= nl2br(htmlspecialchars($invoice['customer_address'])) ?><br>
<?= htmlspecialchars($invoice['customer_zip'] . ' ' . $invoice['customer_city']) ?><br>
<?= htmlspecialchars($invoice['customer_country']) ?>
</div>
</div>
<div class="mahnung-info-box">
<div class="mahnung-title"><?= $level_label ?></div>
<p><?= htmlspecialchars($settings['company_city'] ?: $settings['company_city']) ?>, <?= date('d.m.Y', strtotime($mahnung['mahnung_date'])) ?></p>
<p style="margin-top:10px;">
Sehr geehrte Damen und Herren,<br><br>
trotz unserer Zahlungserinnerung ist der folgende Betrag noch nicht bei uns eingegangen.
Wir bitten Sie, die offene Forderung umgehend zu begleichen.
</p>
</div>
<div class="details-box">
<table class="details">
<tr>
<td>Rechnungsnummer:</td>
<td><strong><?= htmlspecialchars($invoice['invoice_number']) ?></strong></td>
</tr>
<tr>
<td>Rechnungsdatum:</td>
<td><?= date('d.m.Y', strtotime($invoice['invoice_date'])) ?></td>
</tr>
<tr>
<td>Rechnungsbetrag:</td>
<td><?= number_format((float)$invoice['total_gross'], 2, ',', '.') ?> €</td>
</tr>
<?php if ((float)$mahnung['fee_amount'] > 0): ?>
<tr>
<td>Mahngebühr:</td>
<td><?= number_format((float)$mahnung['fee_amount'], 2, ',', '.') ?> €</td>
</tr>
<?php endif; ?>
<tr class="total-row">
<td>Offener Betrag:</td>
<td><?= number_format($total_due, 2, ',', '.') ?> €</td>
</tr>
</table>
<p style="margin-top:10px;">
Bitte überweisen Sie den Betrag von <strong><?= number_format($total_due, 2, ',', '.') ?> €</strong>
innerhalb von 7 Tagen auf unser Konto.
</p>
<?php if (!empty($settings['iban'])): ?>
<p style="font-size:7.5pt; margin-top:6px;">
IBAN: <?= htmlspecialchars($settings['iban']) ?>
· Verwendungszweck: <?= htmlspecialchars($invoice['invoice_number']) ?>
</p>
<?php endif; ?>
</div>
<div class="footer-box">
<table class="footer-table">
<tr>
<td class="footer-left">
<strong><?= htmlspecialchars($settings['company_name']) ?></strong><br>
<?= nl2br(htmlspecialchars($settings['company_address'])) ?><br>
<?= htmlspecialchars($settings['company_zip'] . ' ' . $settings['company_city']) ?>
</td>
<td class="footer-right">
<?php if (!empty($settings['iban'])): ?>
IBAN: <?= htmlspecialchars($settings['iban']) ?><br>
<?php endif; ?>
<?php
$line2 = [];
if (!empty($settings['phone'])) $line2[] = 'Tel: ' . $settings['phone'];
if (!empty($settings['email'])) $line2[] = 'E-Mail: ' . $settings['email'];
if (!empty($line2)) echo htmlspecialchars(implode(' · ', $line2)) . '<br>';
?>
<?php if (!empty($settings['tax_id'])): ?>
StNr/USt: <?= htmlspecialchars($settings['tax_id']) ?>
<?php endif; ?>
</td>
</tr>
</table>
</div>
</body>
</html>
<?php
return ob_get_clean();
}
/**
* Generiert + speichert eine Mahnung als PDF und aktualisiert die DB.
*/
function archive_mahnung_pdf(int $mahnung_id): string|false {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT m.*, i.invoice_number, i.invoice_date, i.total_gross,
i.customer_id, c.name AS customer_name,
c.address AS customer_address, c.zip AS customer_zip,
c.city AS customer_city, c.country AS customer_country
FROM mahnungen m
JOIN invoices i ON i.id = m.invoice_id
JOIN customers c ON c.id = i.customer_id
WHERE m.id = :id");
$stmt->execute([':id' => $mahnung_id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) return false;
$mahnung = [
'id' => $row['id'],
'level' => $row['level'],
'mahnung_date' => $row['mahnung_date'],
'fee_amount' => $row['fee_amount'],
];
$invoice = [
'invoice_number' => $row['invoice_number'],
'invoice_date' => $row['invoice_date'],
'total_gross' => $row['total_gross'],
'customer_name' => $row['customer_name'],
'customer_address' => $row['customer_address'],
'customer_zip' => $row['customer_zip'],
'customer_city' => $row['customer_city'],
'customer_country' => $row['customer_country'],
];
$settings = get_settings();
$html = generate_mahnung_html($mahnung, $invoice, $settings);
$pdfContent = generate_pdf_content($html);
$year = date('Y', strtotime($row['mahnung_date']));
$dir = dirname(__DIR__) . '/public/uploads/mahnungen/' . $year;
if (!is_dir($dir)) mkdir($dir, 0775, true);
$safe_num = preg_replace('/[^A-Za-z0-9\-]/', '_', $row['invoice_number']);
$filename = 'MAHNUNG-' . $safe_num . '-L' . $row['level'] . '.pdf';
$fullPath = $dir . '/' . $filename;
$relPath = 'uploads/mahnungen/' . $year . '/' . $filename;
if (file_put_contents($fullPath, $pdfContent) === false) return false;
chmod($fullPath, 0444);
$pdo->prepare("UPDATE mahnungen SET pdf_path = :p WHERE id = :id")
->execute([':p' => $relPath, ':id' => $mahnung_id]);
return $relPath;
}

View File

@@ -1,327 +0,0 @@
<?php
/**
* Funktionen für wiederkehrende Rechnungen (Abo-Rechnungen)
*/
require_once __DIR__ . '/db.php';
require_once __DIR__ . '/invoice_functions.php';
/**
* Speichert eine Abo-Vorlage (neu oder Update).
* Gibt die Template-ID zurück.
*/
function save_recurring_template(?int $id, array $data): int {
$pdo = get_db();
if ($id) {
$stmt = $pdo->prepare("UPDATE recurring_templates SET
template_name = :name,
customer_id = :cid,
interval_type = :interval,
start_date = :start,
end_date = :end,
next_due_date = :next,
vat_mode = :vm,
vat_rate = :vr,
is_active = :active,
notes_internal = :notes
WHERE id = :id");
$stmt->execute([
':name' => $data['template_name'],
':cid' => $data['customer_id'],
':interval' => $data['interval_type'],
':start' => $data['start_date'],
':end' => $data['end_date'] ?: null,
':next' => $data['next_due_date'],
':vm' => $data['vat_mode'],
':vr' => $data['vat_rate'],
':active' => $data['is_active'] ? 1 : 0,
':notes' => $data['notes_internal'] ?? null,
':id' => $id
]);
return $id;
} else {
$stmt = $pdo->prepare("INSERT INTO recurring_templates
(template_name, customer_id, interval_type, start_date, end_date,
next_due_date, vat_mode, vat_rate, is_active, notes_internal)
VALUES (:name, :cid, :interval, :start, :end, :next, :vm, :vr, :active, :notes)
RETURNING id");
$stmt->execute([
':name' => $data['template_name'],
':cid' => $data['customer_id'],
':interval' => $data['interval_type'],
':start' => $data['start_date'],
':end' => $data['end_date'] ?: null,
':next' => $data['next_due_date'],
':vm' => $data['vat_mode'],
':vr' => $data['vat_rate'],
':active' => $data['is_active'] ? 1 : 0,
':notes' => $data['notes_internal'] ?? null
]);
return (int)$stmt->fetchColumn();
}
}
/**
* Holt eine einzelne Abo-Vorlage.
*/
function get_recurring_template(int $id): ?array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT rt.*, c.name AS customer_name, c.customer_number
FROM recurring_templates rt
JOIN customers c ON c.id = rt.customer_id
WHERE rt.id = :id");
$stmt->execute([':id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ?: null;
}
/**
* Holt alle Abo-Vorlagen.
*/
function get_recurring_templates(bool $active_only = false): array {
$pdo = get_db();
$sql = "SELECT rt.*, c.name AS customer_name, c.customer_number
FROM recurring_templates rt
JOIN customers c ON c.id = rt.customer_id";
if ($active_only) {
$sql .= " WHERE rt.is_active = TRUE";
}
$sql .= " ORDER BY rt.next_due_date ASC";
$stmt = $pdo->query($sql);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Holt die Positionen einer Abo-Vorlage.
*/
function get_recurring_template_items(int $template_id): array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT * FROM recurring_template_items
WHERE template_id = :tid ORDER BY position_no");
$stmt->execute([':tid' => $template_id]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Speichert die Positionen einer Abo-Vorlage (ersetzt alle).
*/
function save_recurring_template_items(int $template_id, array $items): void {
$pdo = get_db();
// Alte löschen
$stmt = $pdo->prepare("DELETE FROM recurring_template_items WHERE template_id = :tid");
$stmt->execute([':tid' => $template_id]);
// Neue einfügen
$stmt = $pdo->prepare("INSERT INTO recurring_template_items
(template_id, position_no, description, quantity, unit_price)
VALUES (:tid, :pn, :desc, :qty, :price)");
foreach ($items as $item) {
$stmt->execute([
':tid' => $template_id,
':pn' => $item['position_no'],
':desc' => $item['description'],
':qty' => $item['quantity'],
':price' => $item['unit_price']
]);
}
}
/**
* Löscht eine Abo-Vorlage.
*/
function delete_recurring_template(int $id): void {
$pdo = get_db();
$stmt = $pdo->prepare("DELETE FROM recurring_templates WHERE id = :id");
$stmt->execute([':id' => $id]);
}
/**
* Berechnet das nächste Fälligkeitsdatum.
*/
function calculate_next_due_date(string $interval_type, string $current_date): string {
$date = new DateTime($current_date);
switch ($interval_type) {
case 'monthly':
$date->modify('+1 month');
break;
case 'quarterly':
$date->modify('+3 months');
break;
case 'yearly':
$date->modify('+1 year');
break;
}
return $date->format('Y-m-d');
}
/**
* Holt alle fälligen Abo-Rechnungen.
*/
function get_pending_recurring_invoices(): array {
$pdo = get_db();
$today = date('Y-m-d');
$stmt = $pdo->prepare("SELECT rt.*, c.name AS customer_name, c.customer_number
FROM recurring_templates rt
JOIN customers c ON c.id = rt.customer_id
WHERE rt.is_active = TRUE
AND rt.next_due_date <= :today
AND (rt.end_date IS NULL OR rt.end_date >= :today)
ORDER BY rt.next_due_date ASC");
$stmt->execute([':today' => $today]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Zählt fällige Abo-Rechnungen.
*/
function count_pending_recurring_invoices(): int {
$pdo = get_db();
$today = date('Y-m-d');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM recurring_templates
WHERE is_active = TRUE
AND next_due_date <= :today
AND (end_date IS NULL OR end_date >= :today)");
$stmt->execute([':today' => $today]);
return (int)$stmt->fetchColumn();
}
/**
* Generiert eine Rechnung aus einer Abo-Vorlage.
* Gibt die neue Rechnungs-ID zurück oder false bei Fehler.
*/
function generate_invoice_from_template(int $template_id): int|false {
$pdo = get_db();
$template = get_recurring_template($template_id);
if (!$template || !$template['is_active']) {
return false;
}
$items = get_recurring_template_items($template_id);
if (empty($items)) {
return false;
}
// Summen berechnen
$total_net = 0.0;
foreach ($items as $item) {
$total_net += $item['quantity'] * $item['unit_price'];
}
$vat_mode = $template['vat_mode'];
$vat_rate = (float)$template['vat_rate'];
if ($vat_mode === 'normal') {
$total_vat = round($total_net * $vat_rate / 100, 2);
} else {
$total_vat = 0.0;
}
$total_gross = $total_net + $total_vat;
$settings = get_settings();
$pdo->beginTransaction();
try {
// Rechnung erstellen
$invoice_number = generate_invoice_number();
$invoice_date = date('Y-m-d');
$stmt = $pdo->prepare("INSERT INTO invoices
(invoice_number, customer_id, invoice_date, service_date, vat_mode, vat_rate,
payment_terms, notes_internal, total_net, total_vat, total_gross, paid)
VALUES (:in, :cid, :idate, :sdate, :vm, :vr, :pt, :ni, :tn, :tv, :tg, FALSE)
RETURNING id");
$stmt->execute([
':in' => $invoice_number,
':cid' => $template['customer_id'],
':idate' => $invoice_date,
':sdate' => $invoice_date,
':vm' => $vat_mode,
':vr' => $vat_rate,
':pt' => $settings['payment_terms'] ?? null,
':ni' => 'Generiert aus Abo-Vorlage: ' . $template['template_name'],
':tn' => $total_net,
':tv' => $total_vat,
':tg' => $total_gross
]);
$invoice_id = (int)$stmt->fetchColumn();
// Positionen einfügen
$stmtItem = $pdo->prepare("INSERT INTO invoice_items
(invoice_id, position_no, description, quantity, unit_price, vat_rate)
VALUES (:iid, :pn, :d, :q, :up, :vr)");
foreach ($items as $item) {
$stmtItem->execute([
':iid' => $invoice_id,
':pn' => $item['position_no'],
':d' => $item['description'],
':q' => $item['quantity'],
':up' => $item['unit_price'],
':vr' => $vat_rate
]);
}
// Log-Eintrag
$stmtLog = $pdo->prepare("INSERT INTO recurring_log
(template_id, invoice_id, due_date, status)
VALUES (:tid, :iid, :due, 'generated')");
$stmtLog->execute([
':tid' => $template_id,
':iid' => $invoice_id,
':due' => $template['next_due_date']
]);
// Nächstes Fälligkeitsdatum setzen
$next_due = calculate_next_due_date($template['interval_type'], $template['next_due_date']);
$stmtUpdate = $pdo->prepare("UPDATE recurring_templates
SET next_due_date = :next WHERE id = :id");
$stmtUpdate->execute([':next' => $next_due, ':id' => $template_id]);
$pdo->commit();
// PDF archivieren
require_once __DIR__ . '/pdf_functions.php';
archive_invoice_pdf($invoice_id);
return $invoice_id;
} catch (Exception $e) {
$pdo->rollBack();
error_log("Fehler bei Rechnungsgenerierung aus Vorlage $template_id: " . $e->getMessage());
return false;
}
}
/**
* Holt das Log einer Abo-Vorlage.
*/
function get_recurring_log(int $template_id, int $limit = 20): array {
$pdo = get_db();
$stmt = $pdo->prepare("SELECT rl.*, i.invoice_number, i.total_gross
FROM recurring_log rl
LEFT JOIN invoices i ON i.id = rl.invoice_id
WHERE rl.template_id = :tid
ORDER BY rl.generated_at DESC
LIMIT :limit");
$stmt->bindValue(':tid', $template_id, PDO::PARAM_INT);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* Gibt die Intervall-Bezeichnung auf Deutsch zurück.
*/
function get_interval_label(string $interval_type): string {
return match($interval_type) {
'monthly' => 'Monatlich',
'quarterly' => 'Quartalsweise',
'yearly' => 'Jährlich',
default => $interval_type
};
}

View File

@@ -1,13 +0,0 @@
<?php
// Passwort-Hash-Generator für Admin-User
if (PHP_SAPI !== 'cli') {
echo "Nur CLI.\n";
exit(1);
}
if ($argc < 2) {
echo "Verwendung: php tools/hash.php SUPERGEHEIMES_PASSWORT\n";
exit(1);
}
$password = $argv[1];
$hash = password_hash($password, PASSWORD_DEFAULT);
echo $hash . "\n";

View File

@@ -1,149 +0,0 @@
<?php
/**
* Import: Kategorien aus "Journal 2021.xlsx" in die Datenbank importieren.
* Prüft vor jedem Insert auf Duplikate (Name + Type/Side).
*
* Ausführen: php tools/import_excel_categories.php
*/
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
$pdo = get_db();
echo "Import: Kategorien aus Excel-Tabelle\n";
echo str_repeat('=', 50) . "\n";
$inserted = 0;
$skipped = 0;
// ============================================================
// 1. journal_revenue_categories - Wareneingang
// ============================================================
echo "\n--- Wareneingang-Kategorien ---\n";
$wareneingang = [
['name' => 'Handy 0%', 'vat_rate' => 0, 'sort_order' => 1],
['name' => 'PVG 7%', 'vat_rate' => 7, 'sort_order' => 2],
['name' => 'Zig. 19%', 'vat_rate' => 19, 'sort_order' => 3],
['name' => 'Allg. 19%', 'vat_rate' => 19, 'sort_order' => 4],
];
$check_rev = $pdo->prepare('SELECT COUNT(*) FROM journal_revenue_categories WHERE name = :name AND category_type = :type');
$insert_rev = $pdo->prepare('INSERT INTO journal_revenue_categories (name, category_type, vat_rate, sort_order, is_active) VALUES (:name, :type, :vat, :sort, TRUE)');
foreach ($wareneingang as $cat) {
$check_rev->execute([':name' => $cat['name'], ':type' => 'wareneingang']);
if ($check_rev->fetchColumn() > 0) {
echo " SKIP: {$cat['name']} (existiert bereits)\n";
$skipped++;
continue;
}
$insert_rev->execute([
':name' => $cat['name'],
':type' => 'wareneingang',
':vat' => $cat['vat_rate'],
':sort' => $cat['sort_order'],
]);
echo " OK: {$cat['name']}\n";
$inserted++;
}
// ============================================================
// 2. journal_revenue_categories - Erlöse
// ============================================================
echo "\n--- Erlös-Kategorien ---\n";
$erloese = [
['name' => 'DP/Handy 0%', 'vat_rate' => 0, 'sort_order' => 1],
['name' => 'PVG 7%', 'vat_rate' => 7, 'sort_order' => 2],
['name' => 'Zig. 19%', 'vat_rate' => 19, 'sort_order' => 3],
['name' => 'Allg. 19%', 'vat_rate' => 19, 'sort_order' => 4],
['name' => 'Sonst. 19%', 'vat_rate' => 19, 'sort_order' => 5],
];
foreach ($erloese as $cat) {
$check_rev->execute([':name' => $cat['name'], ':type' => 'erloese']);
if ($check_rev->fetchColumn() > 0) {
echo " SKIP: {$cat['name']} (existiert bereits)\n";
$skipped++;
continue;
}
$insert_rev->execute([
':name' => $cat['name'],
':type' => 'erloese',
':vat' => $cat['vat_rate'],
':sort' => $cat['sort_order'],
]);
echo " OK: {$cat['name']}\n";
$inserted++;
}
// ============================================================
// 3. journal_deduction_categories
// ============================================================
echo "\n--- Abzugs-Kategorien ---\n";
$deductions = [
['name' => 'Skonto', 'sort_order' => 1],
['name' => 'Lotto', 'sort_order' => 2],
];
$check_ded = $pdo->prepare('SELECT COUNT(*) FROM journal_deduction_categories WHERE name = :name');
$insert_ded = $pdo->prepare('INSERT INTO journal_deduction_categories (name, sort_order, is_active) VALUES (:name, :sort, TRUE)');
foreach ($deductions as $cat) {
$check_ded->execute([':name' => $cat['name']]);
if ($check_ded->fetchColumn() > 0) {
echo " SKIP: {$cat['name']} (existiert bereits)\n";
$skipped++;
continue;
}
$insert_ded->execute([
':name' => $cat['name'],
':sort' => $cat['sort_order'],
]);
echo " OK: {$cat['name']}\n";
$inserted++;
}
// ============================================================
// 4. journal_expense_categories (alle side=soll)
// ============================================================
echo "\n--- Aufwands-Kategorien ---\n";
$expenses = [
['name' => 'Bankunkosten', 'sort_order' => 1],
['name' => 'KFZ-kosten', 'sort_order' => 2],
['name' => 'Personalkosten', 'sort_order' => 3],
['name' => 'Raumkosten', 'sort_order' => 4],
['name' => 'Tel./Fax', 'sort_order' => 5],
['name' => 'Allg./Werbekosten', 'sort_order' => 6],
['name' => 'Sonst. Deko', 'sort_order' => 7],
['name' => 'Bezugsnebenko.', 'sort_order' => 8],
];
$check_exp = $pdo->prepare('SELECT COUNT(*) FROM journal_expense_categories WHERE name = :name AND side = :side');
$insert_exp = $pdo->prepare('INSERT INTO journal_expense_categories (name, side, sort_order, is_active) VALUES (:name, :side, :sort, TRUE)');
foreach ($expenses as $cat) {
$check_exp->execute([':name' => $cat['name'], ':side' => 'soll']);
if ($check_exp->fetchColumn() > 0) {
echo " SKIP: {$cat['name']} (existiert bereits)\n";
$skipped++;
continue;
}
$insert_exp->execute([
':name' => $cat['name'],
':side' => 'soll',
':sort' => $cat['sort_order'],
]);
echo " OK: {$cat['name']}\n";
$inserted++;
}
// ============================================================
// Ergebnis
// ============================================================
echo "\n" . str_repeat('=', 50) . "\n";
echo "Fertig! Eingefügt: $inserted, Übersprungen: $skipped\n";

View File

@@ -1,44 +0,0 @@
<?php
/**
* Migration: Erstellt journal_deduction_categories Tabelle für customizable Abzüge
* Ausführen: php tools/migrate_deductions.php
*/
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
$pdo = get_db();
echo "Migration: journal_deduction_categories\n";
try {
// Prüfen ob Tabelle existiert
$stmt = $pdo->query("SELECT to_regclass('public.journal_deduction_categories')");
$exists = $stmt->fetchColumn();
if ($exists) {
echo "Tabelle journal_deduction_categories existiert bereits.\n";
} else {
// Tabelle erstellen
$pdo->exec("CREATE TABLE journal_deduction_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
sort_order INTEGER DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)");
echo "Tabelle journal_deduction_categories erstellt.\n";
// Standard-Abzüge einfügen
$pdo->exec("INSERT INTO journal_deduction_categories (name, sort_order, is_active) VALUES
('Skonto', 1, TRUE),
('Lotto', 2, TRUE)
");
echo "Standard-Abzüge (Skonto, Lotto) eingefügt.\n";
}
echo "Migration erfolgreich!\n";
} catch (PDOException $e) {
echo "Fehler: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -1,72 +0,0 @@
-- PIRP Vollständige Migration (pirp -> pirp-dev)
-- Erstellt am: 2026-02-07
--
-- Führt alle neuen Migrationen in der richtigen Reihenfolge aus:
-- 1. invoice_id für journal_entries (Rechnungsverknüpfung)
-- 2. Zahlungsdaten und MwSt-Felder
-- 3. Unique Constraints
BEGIN;
-- ============================================================
-- 1. Journal-Rechnungen-Verknüpfung (migrate_journal_invoice_link.sql)
-- ============================================================
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_journal_entries_invoice ON journal_entries(invoice_id);
-- ============================================================
-- 2. Journal-Auto-Buchung (migrate_journal_auto.sql)
-- ============================================================
-- 2a. Rechnungen: Zahlungsdatum hinzufügen
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS payment_date DATE;
-- Bestehende bezahlte Rechnungen: invoice_date als Fallback
UPDATE invoices SET payment_date = invoice_date WHERE paid = TRUE AND payment_date IS NULL;
-- 2b. Ausgaben: MwSt-Felder und Kategorien-Verknüpfung
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS vat_rate NUMERIC(5,2) DEFAULT 0;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS total_net NUMERIC(12,2);
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS total_vat NUMERIC(12,2) DEFAULT 0;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS expense_category_id INTEGER REFERENCES journal_expense_categories(id) ON DELETE SET NULL;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS payment_date DATE;
-- Bestehende Ausgaben: amount als Netto (kein MwSt-Split bekannt), Zahlungsdatum = Ausgabedatum
UPDATE expenses SET total_net = amount, total_vat = 0 WHERE total_net IS NULL;
UPDATE expenses SET payment_date = expense_date WHERE paid = TRUE AND payment_date IS NULL;
-- 2c. Journal-Einträge: Ausgaben-Verknüpfung und Quell-Typ
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS expense_id INTEGER REFERENCES expenses(id) ON DELETE SET NULL;
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'manual';
CREATE INDEX IF NOT EXISTS idx_journal_entries_expense ON journal_entries(expense_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_source ON journal_entries(source_type);
-- Bestehende Rechnungs-Buchungen als source_type markieren
UPDATE journal_entries SET source_type = 'invoice_payment' WHERE invoice_id IS NOT NULL AND source_type = 'manual';
-- ============================================================
-- 3. Unique Constraints (migrate_unique_constraints.sql)
-- ============================================================
-- Zuerst evtl. vorhandene Duplikate bereinigen (behalte nur den neuesten)
DELETE FROM journal_entries a
USING journal_entries b
WHERE a.invoice_id IS NOT NULL
AND a.invoice_id = b.invoice_id
AND a.id < b.id;
DELETE FROM journal_entries a
USING journal_entries b
WHERE a.expense_id IS NOT NULL
AND a.expense_id = b.expense_id
AND a.id < b.id;
-- Unique-Indexes erstellen (nur wenn nicht vorhanden)
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_journal_invoice ON journal_entries(invoice_id) WHERE invoice_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_journal_expense ON journal_entries(expense_id) WHERE expense_id IS NOT NULL;
COMMIT;
-- Erfolgsmeldung
SELECT 'Migration erfolgreich abgeschlossen' AS status;

View File

@@ -1,98 +0,0 @@
-- Journal-Modul: Buchführungsjournal für PIRP
-- Migration: Alle Journal-Tabellen erstellen
-- Jahre
CREATE TABLE IF NOT EXISTS journal_years (
id SERIAL PRIMARY KEY,
year INTEGER NOT NULL UNIQUE,
is_closed BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Lieferanten
CREATE TABLE IF NOT EXISTS journal_suppliers (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Erlös-/Wareneingang-Kategorien
CREATE TABLE IF NOT EXISTS journal_revenue_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
category_type VARCHAR(20) NOT NULL CHECK (category_type IN ('wareneingang', 'erloese')),
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Aufwandskategorien
CREATE TABLE IF NOT EXISTS journal_expense_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
side VARCHAR(10) NOT NULL DEFAULT 'soll' CHECK (side IN ('soll', 'soll_haben')),
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Journal-Einträge (Buchungen)
CREATE TABLE IF NOT EXISTS journal_entries (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE RESTRICT,
entry_date DATE NOT NULL,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
description TEXT NOT NULL,
attachment_note TEXT,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
supplier_id INTEGER REFERENCES journal_suppliers(id) ON DELETE SET NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Kontenverteilung pro Buchung
CREATE TABLE IF NOT EXISTS journal_entry_accounts (
id SERIAL PRIMARY KEY,
entry_id INTEGER NOT NULL REFERENCES journal_entries(id) ON DELETE CASCADE,
account_type VARCHAR(20) NOT NULL,
side VARCHAR(5) NOT NULL CHECK (side IN ('soll', 'haben')),
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
revenue_category_id INTEGER REFERENCES journal_revenue_categories(id) ON DELETE SET NULL,
expense_category_id INTEGER REFERENCES journal_expense_categories(id) ON DELETE SET NULL,
note TEXT
);
-- Monatliche Zusammenfassung (manuelle Korrekturen)
CREATE TABLE IF NOT EXISTS journal_monthly_summary (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE CASCADE,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
manual_corrections JSONB DEFAULT '{}',
notes TEXT,
UNIQUE(year_id, month)
);
-- Umsatz-Zusammenfassungsposten (z.B. "Reinigung", "RMV", "Handy")
CREATE TABLE IF NOT EXISTS journal_summary_items (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE
);
-- Monatliche Werte für Zusammenfassungsposten
CREATE TABLE IF NOT EXISTS journal_monthly_summary_values (
id SERIAL PRIMARY KEY,
year_id INTEGER NOT NULL REFERENCES journal_years(id) ON DELETE CASCADE,
month INTEGER NOT NULL CHECK (month BETWEEN 1 AND 12),
summary_item_id INTEGER NOT NULL REFERENCES journal_summary_items(id) ON DELETE CASCADE,
amount NUMERIC(12,2) NOT NULL DEFAULT 0,
UNIQUE(year_id, month, summary_item_id)
);
-- Indizes für Performance
CREATE INDEX IF NOT EXISTS idx_journal_entries_year_month ON journal_entries(year_id, month);
CREATE INDEX IF NOT EXISTS idx_journal_entries_date ON journal_entries(entry_date);
CREATE INDEX IF NOT EXISTS idx_journal_entry_accounts_entry ON journal_entry_accounts(entry_id);
CREATE INDEX IF NOT EXISTS idx_journal_entry_accounts_type ON journal_entry_accounts(account_type);

View File

@@ -1,30 +0,0 @@
-- Journal-Auto-Buchung: Datenbank-Migration
-- Erweitert invoices, expenses und journal_entries für automatische Journaleinträge
-- Zufluss-/Abflussprinzip (§ 11 EStG)
-- 1. Rechnungen: Zahlungsdatum hinzufügen
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS payment_date DATE;
-- Bestehende bezahlte Rechnungen: invoice_date als Fallback
UPDATE invoices SET payment_date = invoice_date WHERE paid = TRUE AND payment_date IS NULL;
-- 2. Ausgaben: MwSt-Felder und Kategorien-Verknüpfung
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS vat_rate NUMERIC(5,2) DEFAULT 0;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS total_net NUMERIC(12,2);
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS total_vat NUMERIC(12,2) DEFAULT 0;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS expense_category_id INTEGER REFERENCES journal_expense_categories(id) ON DELETE SET NULL;
ALTER TABLE expenses ADD COLUMN IF NOT EXISTS payment_date DATE;
-- Bestehende Ausgaben: amount als Netto (kein MwSt-Split bekannt), Zahlungsdatum = Ausgabedatum
UPDATE expenses SET total_net = amount, total_vat = 0 WHERE total_net IS NULL;
UPDATE expenses SET payment_date = expense_date WHERE paid = TRUE AND payment_date IS NULL;
-- 3. Journal-Einträge: Ausgaben-Verknüpfung und Quell-Typ
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS expense_id INTEGER REFERENCES expenses(id) ON DELETE SET NULL;
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS source_type VARCHAR(20) DEFAULT 'manual';
CREATE INDEX IF NOT EXISTS idx_journal_entries_expense ON journal_entries(expense_id);
CREATE INDEX IF NOT EXISTS idx_journal_entries_source ON journal_entries(source_type);
-- Bestehende Rechnungs-Buchungen als source_type markieren
UPDATE journal_entries SET source_type = 'invoice_payment' WHERE invoice_id IS NOT NULL AND source_type = 'manual';

View File

@@ -1,96 +0,0 @@
<?php
/**
* Migrationstool: Erstellt nachträglich Journalbuchungen für alle bezahlten
* Rechnungen und Ausgaben, die noch keinen Journaleintrag haben.
*
* Setzt außerdem payment_date für bestehende bezahlte Belege, falls noch nicht gesetzt.
*
* Ausführung: php tools/migrate_journal_entries.php
*/
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/invoice_functions.php';
require_once __DIR__ . '/../src/journal_functions.php';
require_once __DIR__ . '/../src/expense_functions.php';
echo "=== PIRP Journal-Migration ===\n\n";
$pdo = get_db();
// 1. payment_date für bezahlte Rechnungen setzen (Fallback: invoice_date)
echo "1. Payment-Dates für Rechnungen setzen...\n";
$stmt = $pdo->query("UPDATE invoices SET payment_date = invoice_date WHERE paid = TRUE AND payment_date IS NULL");
$count = $stmt->rowCount();
echo " $count Rechnungen aktualisiert.\n\n";
// 2. payment_date für bezahlte Ausgaben setzen (Fallback: expense_date)
echo "2. Payment-Dates für Ausgaben setzen...\n";
$stmt = $pdo->query("UPDATE expenses SET payment_date = expense_date WHERE paid = TRUE AND payment_date IS NULL");
$count = $stmt->rowCount();
echo " $count Ausgaben aktualisiert.\n\n";
// 3. total_net für Ausgaben setzen (Fallback: amount, keine MwSt)
echo "3. Netto-Beträge für Ausgaben setzen...\n";
$stmt = $pdo->query("UPDATE expenses SET total_net = amount, total_vat = 0 WHERE total_net IS NULL");
$count = $stmt->rowCount();
echo " $count Ausgaben aktualisiert.\n\n";
// 4. Bezahlte Rechnungen ohne Journaleintrag
echo "4. Bezahlte Rechnungen ohne Journalbuchung...\n";
$stmt = $pdo->query("SELECT i.id, i.invoice_number, i.total_gross, i.payment_date, c.name AS customer_name
FROM invoices i
JOIN customers c ON c.id = i.customer_id
LEFT JOIN journal_entries je ON je.invoice_id = i.id
WHERE i.paid = TRUE AND je.id IS NULL
ORDER BY i.invoice_date ASC");
$unbooked_invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
$inv_ok = 0;
$inv_err = 0;
foreach ($unbooked_invoices as $inv) {
try {
$entry_id = create_journal_entry_from_invoice((int)$inv['id']);
echo " OK: {$inv['invoice_number']} ({$inv['customer_name']}) -> Journal #{$entry_id}\n";
$inv_ok++;
} catch (Exception $e) {
echo " FEHLER: {$inv['invoice_number']}: {$e->getMessage()}\n";
$inv_err++;
}
}
echo " Ergebnis: $inv_ok erstellt, $inv_err Fehler.\n\n";
// 5. Bezahlte Ausgaben ohne Journaleintrag
echo "5. Bezahlte Ausgaben ohne Journalbuchung...\n";
$stmt = $pdo->query("SELECT e.id, e.description, e.amount, e.payment_date
FROM expenses e
LEFT JOIN journal_entries je ON je.expense_id = e.id
WHERE e.paid = TRUE AND je.id IS NULL
ORDER BY e.expense_date ASC");
$unbooked_expenses = $stmt->fetchAll(PDO::FETCH_ASSOC);
$exp_ok = 0;
$exp_err = 0;
foreach ($unbooked_expenses as $exp) {
try {
$entry_id = create_journal_entry_from_expense((int)$exp['id']);
echo " OK: \"{$exp['description']}\" ({$exp['amount']} EUR) -> Journal #{$entry_id}\n";
$exp_ok++;
} catch (Exception $e) {
echo " FEHLER: \"{$exp['description']}\": {$e->getMessage()}\n";
$exp_err++;
}
}
echo " Ergebnis: $exp_ok erstellt, $exp_err Fehler.\n\n";
// Zusammenfassung
echo "=== Zusammenfassung ===\n";
echo "Rechnungen: $inv_ok gebucht" . ($inv_err > 0 ? ", $inv_err Fehler" : '') . "\n";
echo "Ausgaben: $exp_ok gebucht" . ($exp_err > 0 ? ", $exp_err Fehler" : '') . "\n";
$total = $inv_ok + $exp_ok;
if ($total === 0) {
echo "\nKeine fehlenden Buchungen gefunden. Alles aktuell.\n";
} else {
echo "\n$total Buchungen insgesamt erstellt.\n";
}

View File

@@ -1,36 +0,0 @@
<?php
/**
* Migration: Fügt invoice_id Spalte zu journal_entries hinzu
* Ausführen: php tools/migrate_journal_invoice_link.php
*/
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
$pdo = get_db();
echo "Migration: journal_entries.invoice_id\n";
try {
// Prüfen ob Spalte existiert
$stmt = $pdo->query("SELECT column_name FROM information_schema.columns
WHERE table_name = 'journal_entries' AND column_name = 'invoice_id'");
$exists = $stmt->fetch();
if ($exists) {
echo "Spalte invoice_id existiert bereits.\n";
} else {
// Spalte hinzufügen
$pdo->exec("ALTER TABLE journal_entries ADD COLUMN invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL");
echo "Spalte invoice_id hinzugefügt.\n";
// Index erstellen
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_journal_entries_invoice ON journal_entries(invoice_id)");
echo "Index erstellt.\n";
}
echo "Migration erfolgreich!\n";
} catch (PDOException $e) {
echo "Fehler: " . $e->getMessage() . "\n";
exit(1);
}

View File

@@ -1,5 +0,0 @@
-- Journal-Rechnungen-Verknüpfung
-- Fügt invoice_id zu journal_entries hinzu
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_journal_entries_invoice ON journal_entries(invoice_id);

View File

@@ -1,15 +0,0 @@
-- GoBD-konforme PDF-Speicherung: Erweiterung der invoices-Tabelle
-- Migration für unveränderliche PDF-Archivierung
-- Ausführen mit: psql -U pirp_user -d pirp -f tools/migrate_pdf.sql
-- Pfad zur archivierten PDF-Datei (relativ zu public/uploads/invoices/)
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_path TEXT;
-- SHA-256 Hash des PDF-Inhalts zur Integritätsprüfung
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_hash VARCHAR(64);
-- Zeitstempel der PDF-Generierung
ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_generated_at TIMESTAMPTZ;
-- Index für schnelle Abfragen nach fehlenden PDFs
CREATE INDEX IF NOT EXISTS idx_invoices_pdf_path ON invoices(pdf_path) WHERE pdf_path IS NULL;

View File

@@ -1,53 +0,0 @@
<?php
/**
* Migrationsscript: Generiert PDFs für alle bestehenden Rechnungen
*
* ACHTUNG: Einmalig ausführen!
* Bei bestehenden Rechnungen werden die AKTUELLEN Firmen-/Kundendaten verwendet.
*
* Ausführung: php tools/migrate_pdfs.php
*/
if (PHP_SAPI !== 'cli') {
echo "Nur CLI.\n";
exit(1);
}
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
require_once __DIR__ . '/../src/pdf_functions.php';
echo "PDF-Migration gestartet...\n";
// Verzeichnis erstellen falls nicht vorhanden
$uploadDir = __DIR__ . '/../public/uploads/invoices';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
echo "Verzeichnis erstellt: $uploadDir\n";
}
$pdo = get_db();
$stmt = $pdo->query("SELECT id, invoice_number FROM invoices WHERE pdf_path IS NULL ORDER BY id");
$invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo "Gefunden: " . count($invoices) . " Rechnungen ohne archivierte PDF\n";
$success = 0;
$failed = 0;
foreach ($invoices as $inv) {
echo "Verarbeite {$inv['invoice_number']}... ";
$result = archive_invoice_pdf($inv['id']);
if ($result) {
echo "OK -> $result\n";
$success++;
} else {
echo "FEHLER\n";
$failed++;
}
}
echo "\nMigration abgeschlossen.\n";
echo "Erfolgreich: $success\n";
echo "Fehlgeschlagen: $failed\n";

View File

@@ -1,42 +0,0 @@
-- Wiederkehrende Rechnungen (Abo-Rechnungen)
-- Migration ausführen mit: psql -U pirp_user -d pirp -f tools/migrate_recurring.sql
-- Abo-Vorlagen
CREATE TABLE IF NOT EXISTS recurring_templates (
id SERIAL PRIMARY KEY,
template_name VARCHAR(100) NOT NULL,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE RESTRICT,
interval_type VARCHAR(20) NOT NULL CHECK (interval_type IN ('monthly', 'quarterly', 'yearly')),
start_date DATE NOT NULL,
end_date DATE,
next_due_date DATE NOT NULL,
vat_mode VARCHAR(10) NOT NULL DEFAULT 'klein',
vat_rate NUMERIC(5,2) NOT NULL DEFAULT 19.00,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
notes_internal TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Positionen der Abo-Vorlage
CREATE TABLE IF NOT EXISTS recurring_template_items (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES recurring_templates(id) ON DELETE CASCADE,
position_no INTEGER NOT NULL,
description TEXT NOT NULL,
quantity NUMERIC(12,2) NOT NULL DEFAULT 1,
unit_price NUMERIC(12,2) NOT NULL DEFAULT 0
);
-- Log der generierten Rechnungen
CREATE TABLE IF NOT EXISTS recurring_log (
id SERIAL PRIMARY KEY,
template_id INTEGER NOT NULL REFERENCES recurring_templates(id) ON DELETE CASCADE,
invoice_id INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
generated_at TIMESTAMPTZ DEFAULT now(),
due_date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'generated'
);
-- Indices
CREATE INDEX IF NOT EXISTS idx_recurring_next_due ON recurring_templates(next_due_date) WHERE is_active = TRUE;
CREATE INDEX IF NOT EXISTS idx_recurring_log_template ON recurring_log(template_id);

View File

@@ -1,20 +0,0 @@
-- Migration: Storno-Rechnungen + Mahnungen
-- Anwendung: psql -U pirp_user -d pirp -f tools/migrate_storno_mahnung.sql
-- Storno: zwei neue Spalten in invoices
ALTER TABLE invoices
ADD COLUMN IF NOT EXISTS storno_of INTEGER REFERENCES invoices(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS is_storno BOOLEAN NOT NULL DEFAULT FALSE;
-- Mahnungen: neue Tabelle
CREATE TABLE IF NOT EXISTS mahnungen (
id SERIAL PRIMARY KEY,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
mahnung_date DATE NOT NULL,
level INTEGER NOT NULL DEFAULT 1 CHECK (level IN (1,2,3)),
fee_amount NUMERIC(12,2) NOT NULL DEFAULT 0,
pdf_path TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_mahnungen_invoice_id ON mahnungen(invoice_id);

View File

@@ -1,19 +0,0 @@
-- Migration: Unique-Constraints für Journal-Einträge
-- Verhindert Doppelbuchungen (max. 1 Journal-Eintrag pro Rechnung/Ausgabe)
-- Zuerst evtl. vorhandene Duplikate bereinigen (behalte nur den neuesten)
DELETE FROM journal_entries a
USING journal_entries b
WHERE a.invoice_id IS NOT NULL
AND a.invoice_id = b.invoice_id
AND a.id < b.id;
DELETE FROM journal_entries a
USING journal_entries b
WHERE a.expense_id IS NOT NULL
AND a.expense_id = b.expense_id
AND a.id < b.id;
-- Unique-Indexes erstellen
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_journal_invoice ON journal_entries(invoice_id) WHERE invoice_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_unique_journal_expense ON journal_entries(expense_id) WHERE expense_id IS NOT NULL;

View File

@@ -1,75 +0,0 @@
<?php
/**
* Einmalige Migration: Spalten hinzufügen und PDFs generieren
*/
if (PHP_SAPI !== 'cli') {
die("Nur CLI.\n");
}
require_once __DIR__ . '/../src/config.php';
require_once __DIR__ . '/../src/db.php';
$pdo = get_db();
echo "1. Prüfe/erstelle Spalten...\n";
// Spalten hinzufügen falls nicht vorhanden
$migrations = [
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_path TEXT",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_hash VARCHAR(64)",
"ALTER TABLE invoices ADD COLUMN IF NOT EXISTS pdf_generated_at TIMESTAMPTZ",
];
foreach ($migrations as $sql) {
try {
$pdo->exec($sql);
echo " OK: $sql\n";
} catch (PDOException $e) {
echo " FEHLER: " . $e->getMessage() . "\n";
}
}
// Index erstellen
try {
$pdo->exec("CREATE INDEX IF NOT EXISTS idx_invoices_pdf_path ON invoices(pdf_path) WHERE pdf_path IS NULL");
echo " OK: Index erstellt\n";
} catch (PDOException $e) {
// Index existiert möglicherweise schon
}
echo "\n2. Generiere PDFs für alte Rechnungen...\n";
require_once __DIR__ . '/../src/pdf_functions.php';
// Verzeichnis erstellen
$uploadDir = __DIR__ . '/../public/uploads/invoices';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0775, true);
echo " Verzeichnis erstellt: $uploadDir\n";
}
$stmt = $pdo->query("SELECT id, invoice_number FROM invoices WHERE pdf_path IS NULL ORDER BY id");
$invoices = $stmt->fetchAll(PDO::FETCH_ASSOC);
echo " Gefunden: " . count($invoices) . " Rechnungen ohne PDF\n\n";
$success = 0;
$failed = 0;
foreach ($invoices as $inv) {
echo " Verarbeite {$inv['invoice_number']}... ";
$result = archive_invoice_pdf($inv['id']);
if ($result) {
echo "OK -> $result\n";
$success++;
} else {
echo "FEHLER\n";
$failed++;
}
}
echo "\n=== Migration abgeschlossen ===\n";
echo "Erfolgreich: $success\n";
echo "Fehlgeschlagen: $failed\n";

View File

@@ -1,4 +0,0 @@
-- Dev-Seed: Default-Login admin:admin
INSERT INTO users (username, password_hash)
VALUES ('admin', '$2b$10$PDnwnHN/2V3HrYqnfZtZUOtSHkIynW9olAp9W9RB4FZsh7KbKW5jq')
ON CONFLICT (username) DO NOTHING;

View File

@@ -1,25 +0,0 @@
<?php
// autoload.php @generated by Composer
if (PHP_VERSION_ID < 50600) {
if (!headers_sent()) {
header('HTTP/1.1 500 Internal Server Error');
}
$err = 'Composer 2.3.0 dropped support for autoloading on PHP <5.6 and you are running '.PHP_VERSION.', please upgrade PHP or use Composer 2.2 LTS via "composer self-update --2.2". Aborting.'.PHP_EOL;
if (!ini_get('display_errors')) {
if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') {
fwrite(STDERR, $err);
} elseif (!headers_sent()) {
echo $err;
}
}
trigger_error(
$err,
E_USER_ERROR
);
}
require_once __DIR__ . '/composer/autoload_real.php';
return ComposerAutoloaderInit76bd9531733da7ca24f4b785b8fe430d::getLoader();

View File

@@ -1,579 +0,0 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer\Autoload;
/**
* ClassLoader implements a PSR-0, PSR-4 and classmap class loader.
*
* $loader = new \Composer\Autoload\ClassLoader();
*
* // register classes with namespaces
* $loader->add('Symfony\Component', __DIR__.'/component');
* $loader->add('Symfony', __DIR__.'/framework');
*
* // activate the autoloader
* $loader->register();
*
* // to enable searching the include path (eg. for PEAR packages)
* $loader->setUseIncludePath(true);
*
* In this example, if you try to use a class in the Symfony\Component
* namespace or one of its children (Symfony\Component\Console for instance),
* the autoloader will first look for the class under the component/
* directory, and it will then fallback to the framework/ directory if not
* found before giving up.
*
* This class is loosely based on the Symfony UniversalClassLoader.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Jordi Boggiano <j.boggiano@seld.be>
* @see https://www.php-fig.org/psr/psr-0/
* @see https://www.php-fig.org/psr/psr-4/
*/
class ClassLoader
{
/** @var \Closure(string):void */
private static $includeFile;
/** @var string|null */
private $vendorDir;
// PSR-4
/**
* @var array<string, array<string, int>>
*/
private $prefixLengthsPsr4 = array();
/**
* @var array<string, list<string>>
*/
private $prefixDirsPsr4 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr4 = array();
// PSR-0
/**
* List of PSR-0 prefixes
*
* Structured as array('F (first letter)' => array('Foo\Bar (full prefix)' => array('path', 'path2')))
*
* @var array<string, array<string, list<string>>>
*/
private $prefixesPsr0 = array();
/**
* @var list<string>
*/
private $fallbackDirsPsr0 = array();
/** @var bool */
private $useIncludePath = false;
/**
* @var array<string, string>
*/
private $classMap = array();
/** @var bool */
private $classMapAuthoritative = false;
/**
* @var array<string, bool>
*/
private $missingClasses = array();
/** @var string|null */
private $apcuPrefix;
/**
* @var array<string, self>
*/
private static $registeredLoaders = array();
/**
* @param string|null $vendorDir
*/
public function __construct($vendorDir = null)
{
$this->vendorDir = $vendorDir;
self::initializeIncludeClosure();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixes()
{
if (!empty($this->prefixesPsr0)) {
return call_user_func_array('array_merge', array_values($this->prefixesPsr0));
}
return array();
}
/**
* @return array<string, list<string>>
*/
public function getPrefixesPsr4()
{
return $this->prefixDirsPsr4;
}
/**
* @return list<string>
*/
public function getFallbackDirs()
{
return $this->fallbackDirsPsr0;
}
/**
* @return list<string>
*/
public function getFallbackDirsPsr4()
{
return $this->fallbackDirsPsr4;
}
/**
* @return array<string, string> Array of classname => path
*/
public function getClassMap()
{
return $this->classMap;
}
/**
* @param array<string, string> $classMap Class to filename map
*
* @return void
*/
public function addClassMap(array $classMap)
{
if ($this->classMap) {
$this->classMap = array_merge($this->classMap, $classMap);
} else {
$this->classMap = $classMap;
}
}
/**
* Registers a set of PSR-0 directories for a given prefix, either
* appending or prepending to the ones previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 root directories
* @param bool $prepend Whether to prepend the directories
*
* @return void
*/
public function add($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
if ($prepend) {
$this->fallbackDirsPsr0 = array_merge(
$paths,
$this->fallbackDirsPsr0
);
} else {
$this->fallbackDirsPsr0 = array_merge(
$this->fallbackDirsPsr0,
$paths
);
}
return;
}
$first = $prefix[0];
if (!isset($this->prefixesPsr0[$first][$prefix])) {
$this->prefixesPsr0[$first][$prefix] = $paths;
return;
}
if ($prepend) {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$paths,
$this->prefixesPsr0[$first][$prefix]
);
} else {
$this->prefixesPsr0[$first][$prefix] = array_merge(
$this->prefixesPsr0[$first][$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-4 directories for a given namespace, either
* appending or prepending to the ones previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
* @param bool $prepend Whether to prepend the directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function addPsr4($prefix, $paths, $prepend = false)
{
$paths = (array) $paths;
if (!$prefix) {
// Register directories for the root namespace.
if ($prepend) {
$this->fallbackDirsPsr4 = array_merge(
$paths,
$this->fallbackDirsPsr4
);
} else {
$this->fallbackDirsPsr4 = array_merge(
$this->fallbackDirsPsr4,
$paths
);
}
} elseif (!isset($this->prefixDirsPsr4[$prefix])) {
// Register directories for a new namespace.
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = $paths;
} elseif ($prepend) {
// Prepend directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$paths,
$this->prefixDirsPsr4[$prefix]
);
} else {
// Append directories for an already registered namespace.
$this->prefixDirsPsr4[$prefix] = array_merge(
$this->prefixDirsPsr4[$prefix],
$paths
);
}
}
/**
* Registers a set of PSR-0 directories for a given prefix,
* replacing any others previously set for this prefix.
*
* @param string $prefix The prefix
* @param list<string>|string $paths The PSR-0 base directories
*
* @return void
*/
public function set($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr0 = (array) $paths;
} else {
$this->prefixesPsr0[$prefix[0]][$prefix] = (array) $paths;
}
}
/**
* Registers a set of PSR-4 directories for a given namespace,
* replacing any others previously set for this namespace.
*
* @param string $prefix The prefix/namespace, with trailing '\\'
* @param list<string>|string $paths The PSR-4 base directories
*
* @throws \InvalidArgumentException
*
* @return void
*/
public function setPsr4($prefix, $paths)
{
if (!$prefix) {
$this->fallbackDirsPsr4 = (array) $paths;
} else {
$length = strlen($prefix);
if ('\\' !== $prefix[$length - 1]) {
throw new \InvalidArgumentException("A non-empty PSR-4 prefix must end with a namespace separator.");
}
$this->prefixLengthsPsr4[$prefix[0]][$prefix] = $length;
$this->prefixDirsPsr4[$prefix] = (array) $paths;
}
}
/**
* Turns on searching the include path for class files.
*
* @param bool $useIncludePath
*
* @return void
*/
public function setUseIncludePath($useIncludePath)
{
$this->useIncludePath = $useIncludePath;
}
/**
* Can be used to check if the autoloader uses the include path to check
* for classes.
*
* @return bool
*/
public function getUseIncludePath()
{
return $this->useIncludePath;
}
/**
* Turns off searching the prefix and fallback directories for classes
* that have not been registered with the class map.
*
* @param bool $classMapAuthoritative
*
* @return void
*/
public function setClassMapAuthoritative($classMapAuthoritative)
{
$this->classMapAuthoritative = $classMapAuthoritative;
}
/**
* Should class lookup fail if not found in the current class map?
*
* @return bool
*/
public function isClassMapAuthoritative()
{
return $this->classMapAuthoritative;
}
/**
* APCu prefix to use to cache found/not-found classes, if the extension is enabled.
*
* @param string|null $apcuPrefix
*
* @return void
*/
public function setApcuPrefix($apcuPrefix)
{
$this->apcuPrefix = function_exists('apcu_fetch') && filter_var(ini_get('apc.enabled'), FILTER_VALIDATE_BOOLEAN) ? $apcuPrefix : null;
}
/**
* The APCu prefix in use, or null if APCu caching is not enabled.
*
* @return string|null
*/
public function getApcuPrefix()
{
return $this->apcuPrefix;
}
/**
* Registers this instance as an autoloader.
*
* @param bool $prepend Whether to prepend the autoloader or not
*
* @return void
*/
public function register($prepend = false)
{
spl_autoload_register(array($this, 'loadClass'), true, $prepend);
if (null === $this->vendorDir) {
return;
}
if ($prepend) {
self::$registeredLoaders = array($this->vendorDir => $this) + self::$registeredLoaders;
} else {
unset(self::$registeredLoaders[$this->vendorDir]);
self::$registeredLoaders[$this->vendorDir] = $this;
}
}
/**
* Unregisters this instance as an autoloader.
*
* @return void
*/
public function unregister()
{
spl_autoload_unregister(array($this, 'loadClass'));
if (null !== $this->vendorDir) {
unset(self::$registeredLoaders[$this->vendorDir]);
}
}
/**
* Loads the given class or interface.
*
* @param string $class The name of the class
* @return true|null True if loaded, null otherwise
*/
public function loadClass($class)
{
if ($file = $this->findFile($class)) {
$includeFile = self::$includeFile;
$includeFile($file);
return true;
}
return null;
}
/**
* Finds the path to the file where the class is defined.
*
* @param string $class The name of the class
*
* @return string|false The path if found, false otherwise
*/
public function findFile($class)
{
// class map lookup
if (isset($this->classMap[$class])) {
return $this->classMap[$class];
}
if ($this->classMapAuthoritative || isset($this->missingClasses[$class])) {
return false;
}
if (null !== $this->apcuPrefix) {
$file = apcu_fetch($this->apcuPrefix.$class, $hit);
if ($hit) {
return $file;
}
}
$file = $this->findFileWithExtension($class, '.php');
// Search for Hack files if we are running on HHVM
if (false === $file && defined('HHVM_VERSION')) {
$file = $this->findFileWithExtension($class, '.hh');
}
if (null !== $this->apcuPrefix) {
apcu_add($this->apcuPrefix.$class, $file);
}
if (false === $file) {
// Remember that this class does not exist.
$this->missingClasses[$class] = true;
}
return $file;
}
/**
* Returns the currently registered loaders keyed by their corresponding vendor directories.
*
* @return array<string, self>
*/
public static function getRegisteredLoaders()
{
return self::$registeredLoaders;
}
/**
* @param string $class
* @param string $ext
* @return string|false
*/
private function findFileWithExtension($class, $ext)
{
// PSR-4 lookup
$logicalPathPsr4 = strtr($class, '\\', DIRECTORY_SEPARATOR) . $ext;
$first = $class[0];
if (isset($this->prefixLengthsPsr4[$first])) {
$subPath = $class;
while (false !== $lastPos = strrpos($subPath, '\\')) {
$subPath = substr($subPath, 0, $lastPos);
$search = $subPath . '\\';
if (isset($this->prefixDirsPsr4[$search])) {
$pathEnd = DIRECTORY_SEPARATOR . substr($logicalPathPsr4, $lastPos + 1);
foreach ($this->prefixDirsPsr4[$search] as $dir) {
if (file_exists($file = $dir . $pathEnd)) {
return $file;
}
}
}
}
}
// PSR-4 fallback dirs
foreach ($this->fallbackDirsPsr4 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr4)) {
return $file;
}
}
// PSR-0 lookup
if (false !== $pos = strrpos($class, '\\')) {
// namespaced class name
$logicalPathPsr0 = substr($logicalPathPsr4, 0, $pos + 1)
. strtr(substr($logicalPathPsr4, $pos + 1), '_', DIRECTORY_SEPARATOR);
} else {
// PEAR-like class name
$logicalPathPsr0 = strtr($class, '_', DIRECTORY_SEPARATOR) . $ext;
}
if (isset($this->prefixesPsr0[$first])) {
foreach ($this->prefixesPsr0[$first] as $prefix => $dirs) {
if (0 === strpos($class, $prefix)) {
foreach ($dirs as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
}
}
}
// PSR-0 fallback dirs
foreach ($this->fallbackDirsPsr0 as $dir) {
if (file_exists($file = $dir . DIRECTORY_SEPARATOR . $logicalPathPsr0)) {
return $file;
}
}
// PSR-0 include paths.
if ($this->useIncludePath && $file = stream_resolve_include_path($logicalPathPsr0)) {
return $file;
}
return false;
}
/**
* @return void
*/
private static function initializeIncludeClosure()
{
if (self::$includeFile !== null) {
return;
}
/**
* Scope isolated include.
*
* Prevents access to $this/self from included files.
*
* @param string $file
* @return void
*/
self::$includeFile = \Closure::bind(static function($file) {
include $file;
}, null, null);
}
}

View File

@@ -1,359 +0,0 @@
<?php
/*
* This file is part of Composer.
*
* (c) Nils Adermann <naderman@naderman.de>
* Jordi Boggiano <j.boggiano@seld.be>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Composer;
use Composer\Autoload\ClassLoader;
use Composer\Semver\VersionParser;
/**
* This class is copied in every Composer installed project and available to all
*
* See also https://getcomposer.org/doc/07-runtime.md#installed-versions
*
* To require its presence, you can require `composer-runtime-api ^2.0`
*
* @final
*/
class InstalledVersions
{
/**
* @var mixed[]|null
* @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}|array{}|null
*/
private static $installed;
/**
* @var bool|null
*/
private static $canGetVendors;
/**
* @var array[]
* @psalm-var array<string, array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static $installedByVendor = array();
/**
* Returns a list of all package names which are present, either by being installed, replaced or provided
*
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackages()
{
$packages = array();
foreach (self::getInstalled() as $installed) {
$packages[] = array_keys($installed['versions']);
}
if (1 === \count($packages)) {
return $packages[0];
}
return array_keys(array_flip(\call_user_func_array('array_merge', $packages)));
}
/**
* Returns a list of all package names with a specific type e.g. 'library'
*
* @param string $type
* @return string[]
* @psalm-return list<string>
*/
public static function getInstalledPackagesByType($type)
{
$packagesByType = array();
foreach (self::getInstalled() as $installed) {
foreach ($installed['versions'] as $name => $package) {
if (isset($package['type']) && $package['type'] === $type) {
$packagesByType[] = $name;
}
}
}
return $packagesByType;
}
/**
* Checks whether the given package is installed
*
* This also returns true if the package name is provided or replaced by another package
*
* @param string $packageName
* @param bool $includeDevRequirements
* @return bool
*/
public static function isInstalled($packageName, $includeDevRequirements = true)
{
foreach (self::getInstalled() as $installed) {
if (isset($installed['versions'][$packageName])) {
return $includeDevRequirements || !isset($installed['versions'][$packageName]['dev_requirement']) || $installed['versions'][$packageName]['dev_requirement'] === false;
}
}
return false;
}
/**
* Checks whether the given package satisfies a version constraint
*
* e.g. If you want to know whether version 2.3+ of package foo/bar is installed, you would call:
*
* Composer\InstalledVersions::satisfies(new VersionParser, 'foo/bar', '^2.3')
*
* @param VersionParser $parser Install composer/semver to have access to this class and functionality
* @param string $packageName
* @param string|null $constraint A version constraint to check for, if you pass one you have to make sure composer/semver is required by your package
* @return bool
*/
public static function satisfies(VersionParser $parser, $packageName, $constraint)
{
$constraint = $parser->parseConstraints((string) $constraint);
$provided = $parser->parseConstraints(self::getVersionRanges($packageName));
return $provided->matches($constraint);
}
/**
* Returns a version constraint representing all the range(s) which are installed for a given package
*
* It is easier to use this via isInstalled() with the $constraint argument if you need to check
* whether a given version of a package is installed, and not just whether it exists
*
* @param string $packageName
* @return string Version constraint usable with composer/semver
*/
public static function getVersionRanges($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
$ranges = array();
if (isset($installed['versions'][$packageName]['pretty_version'])) {
$ranges[] = $installed['versions'][$packageName]['pretty_version'];
}
if (array_key_exists('aliases', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['aliases']);
}
if (array_key_exists('replaced', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['replaced']);
}
if (array_key_exists('provided', $installed['versions'][$packageName])) {
$ranges = array_merge($ranges, $installed['versions'][$packageName]['provided']);
}
return implode(' || ', $ranges);
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['version'])) {
return null;
}
return $installed['versions'][$packageName]['version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as version, use satisfies or getVersionRanges if you need to know if a given version is present
*/
public static function getPrettyVersion($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['pretty_version'])) {
return null;
}
return $installed['versions'][$packageName]['pretty_version'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as reference
*/
public static function getReference($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
if (!isset($installed['versions'][$packageName]['reference'])) {
return null;
}
return $installed['versions'][$packageName]['reference'];
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @param string $packageName
* @return string|null If the package is being replaced or provided but is not really installed, null will be returned as install path. Packages of type metapackages also have a null install path.
*/
public static function getInstallPath($packageName)
{
foreach (self::getInstalled() as $installed) {
if (!isset($installed['versions'][$packageName])) {
continue;
}
return isset($installed['versions'][$packageName]['install_path']) ? $installed['versions'][$packageName]['install_path'] : null;
}
throw new \OutOfBoundsException('Package "' . $packageName . '" is not installed');
}
/**
* @return array
* @psalm-return array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}
*/
public static function getRootPackage()
{
$installed = self::getInstalled();
return $installed[0]['root'];
}
/**
* Returns the raw installed.php data for custom implementations
*
* @deprecated Use getAllRawData() instead which returns all datasets for all autoloaders present in the process. getRawData only returns the first dataset loaded, which may not be what you expect.
* @return array[]
* @psalm-return array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}
*/
public static function getRawData()
{
@trigger_error('getRawData only returns the first dataset loaded, which may not be what you expect. Use getAllRawData() instead which returns all datasets for all autoloaders present in the process.', E_USER_DEPRECATED);
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
self::$installed = include __DIR__ . '/installed.php';
} else {
self::$installed = array();
}
}
return self::$installed;
}
/**
* Returns the raw data of all installed.php which are currently loaded for custom implementations
*
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
public static function getAllRawData()
{
return self::getInstalled();
}
/**
* Lets you reload the static array from another file
*
* This is only useful for complex integrations in which a project needs to use
* this class but then also needs to execute another project's autoloader in process,
* and wants to ensure both projects have access to their version of installed.php.
*
* A typical case would be PHPUnit, where it would need to make sure it reads all
* the data it needs from this class, then call reload() with
* `require $CWD/vendor/composer/installed.php` (or similar) as input to make sure
* the project in which it runs can then also use this class safely, without
* interference between PHPUnit's dependencies and the project's dependencies.
*
* @param array[] $data A vendor/composer/installed.php data set
* @return void
*
* @psalm-param array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $data
*/
public static function reload($data)
{
self::$installed = $data;
self::$installedByVendor = array();
}
/**
* @return array[]
* @psalm-return list<array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>}>
*/
private static function getInstalled()
{
if (null === self::$canGetVendors) {
self::$canGetVendors = method_exists('Composer\Autoload\ClassLoader', 'getRegisteredLoaders');
}
$installed = array();
if (self::$canGetVendors) {
foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) {
if (isset(self::$installedByVendor[$vendorDir])) {
$installed[] = self::$installedByVendor[$vendorDir];
} elseif (is_file($vendorDir.'/composer/installed.php')) {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require $vendorDir.'/composer/installed.php';
$installed[] = self::$installedByVendor[$vendorDir] = $required;
if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) {
self::$installed = $installed[count($installed) - 1];
}
}
}
}
if (null === self::$installed) {
// only require the installed.php file if this file is loaded from its dumped location,
// and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
if (substr(__DIR__, -8, 1) !== 'C') {
/** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array<string, array{pretty_version?: string, version?: string, reference?: string|null, type?: string, install_path?: string, aliases?: string[], dev_requirement: bool, replaced?: string[], provided?: string[]}>} $required */
$required = require __DIR__ . '/installed.php';
self::$installed = $required;
} else {
self::$installed = array();
}
}
if (self::$installed !== array()) {
$installed[] = self::$installed;
}
return $installed;
}
}

View File

@@ -1,19 +0,0 @@
Copyright (c) Nils Adermann, Jordi Boggiano
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@@ -1,209 +0,0 @@
<?php
// autoload_classmap.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php',
'Dompdf\\Adapter\\CPDF' => $vendorDir . '/dompdf/dompdf/src/Adapter/CPDF.php',
'Dompdf\\Adapter\\GD' => $vendorDir . '/dompdf/dompdf/src/Adapter/GD.php',
'Dompdf\\Adapter\\PDFLib' => $vendorDir . '/dompdf/dompdf/src/Adapter/PDFLib.php',
'Dompdf\\Canvas' => $vendorDir . '/dompdf/dompdf/src/Canvas.php',
'Dompdf\\CanvasFactory' => $vendorDir . '/dompdf/dompdf/src/CanvasFactory.php',
'Dompdf\\Cellmap' => $vendorDir . '/dompdf/dompdf/src/Cellmap.php',
'Dompdf\\Cpdf' => $vendorDir . '/dompdf/dompdf/lib/Cpdf.php',
'Dompdf\\Css\\AttributeTranslator' => $vendorDir . '/dompdf/dompdf/src/Css/AttributeTranslator.php',
'Dompdf\\Css\\Color' => $vendorDir . '/dompdf/dompdf/src/Css/Color.php',
'Dompdf\\Css\\Style' => $vendorDir . '/dompdf/dompdf/src/Css/Style.php',
'Dompdf\\Css\\Stylesheet' => $vendorDir . '/dompdf/dompdf/src/Css/Stylesheet.php',
'Dompdf\\Dompdf' => $vendorDir . '/dompdf/dompdf/src/Dompdf.php',
'Dompdf\\Exception' => $vendorDir . '/dompdf/dompdf/src/Exception.php',
'Dompdf\\Exception\\ImageException' => $vendorDir . '/dompdf/dompdf/src/Exception/ImageException.php',
'Dompdf\\FontMetrics' => $vendorDir . '/dompdf/dompdf/src/FontMetrics.php',
'Dompdf\\Frame' => $vendorDir . '/dompdf/dompdf/src/Frame.php',
'Dompdf\\FrameDecorator\\AbstractFrameDecorator' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/AbstractFrameDecorator.php',
'Dompdf\\FrameDecorator\\Block' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Block.php',
'Dompdf\\FrameDecorator\\Image' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Image.php',
'Dompdf\\FrameDecorator\\Inline' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Inline.php',
'Dompdf\\FrameDecorator\\ListBullet' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/ListBullet.php',
'Dompdf\\FrameDecorator\\ListBulletImage' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/ListBulletImage.php',
'Dompdf\\FrameDecorator\\NullFrameDecorator' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/NullFrameDecorator.php',
'Dompdf\\FrameDecorator\\Page' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Page.php',
'Dompdf\\FrameDecorator\\Table' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Table.php',
'Dompdf\\FrameDecorator\\TableCell' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/TableCell.php',
'Dompdf\\FrameDecorator\\TableRow' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/TableRow.php',
'Dompdf\\FrameDecorator\\TableRowGroup' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/TableRowGroup.php',
'Dompdf\\FrameDecorator\\Text' => $vendorDir . '/dompdf/dompdf/src/FrameDecorator/Text.php',
'Dompdf\\FrameReflower\\AbstractFrameReflower' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/AbstractFrameReflower.php',
'Dompdf\\FrameReflower\\Block' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Block.php',
'Dompdf\\FrameReflower\\Image' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Image.php',
'Dompdf\\FrameReflower\\Inline' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Inline.php',
'Dompdf\\FrameReflower\\ListBullet' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/ListBullet.php',
'Dompdf\\FrameReflower\\NullFrameReflower' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/NullFrameReflower.php',
'Dompdf\\FrameReflower\\Page' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Page.php',
'Dompdf\\FrameReflower\\Table' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Table.php',
'Dompdf\\FrameReflower\\TableCell' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/TableCell.php',
'Dompdf\\FrameReflower\\TableRow' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/TableRow.php',
'Dompdf\\FrameReflower\\TableRowGroup' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/TableRowGroup.php',
'Dompdf\\FrameReflower\\Text' => $vendorDir . '/dompdf/dompdf/src/FrameReflower/Text.php',
'Dompdf\\Frame\\Factory' => $vendorDir . '/dompdf/dompdf/src/Frame/Factory.php',
'Dompdf\\Frame\\FrameListIterator' => $vendorDir . '/dompdf/dompdf/src/Frame/FrameListIterator.php',
'Dompdf\\Frame\\FrameTree' => $vendorDir . '/dompdf/dompdf/src/Frame/FrameTree.php',
'Dompdf\\Frame\\FrameTreeIterator' => $vendorDir . '/dompdf/dompdf/src/Frame/FrameTreeIterator.php',
'Dompdf\\Helpers' => $vendorDir . '/dompdf/dompdf/src/Helpers.php',
'Dompdf\\Image\\Cache' => $vendorDir . '/dompdf/dompdf/src/Image/Cache.php',
'Dompdf\\JavascriptEmbedder' => $vendorDir . '/dompdf/dompdf/src/JavascriptEmbedder.php',
'Dompdf\\LineBox' => $vendorDir . '/dompdf/dompdf/src/LineBox.php',
'Dompdf\\Options' => $vendorDir . '/dompdf/dompdf/src/Options.php',
'Dompdf\\PhpEvaluator' => $vendorDir . '/dompdf/dompdf/src/PhpEvaluator.php',
'Dompdf\\Positioner\\Absolute' => $vendorDir . '/dompdf/dompdf/src/Positioner/Absolute.php',
'Dompdf\\Positioner\\AbstractPositioner' => $vendorDir . '/dompdf/dompdf/src/Positioner/AbstractPositioner.php',
'Dompdf\\Positioner\\Block' => $vendorDir . '/dompdf/dompdf/src/Positioner/Block.php',
'Dompdf\\Positioner\\Fixed' => $vendorDir . '/dompdf/dompdf/src/Positioner/Fixed.php',
'Dompdf\\Positioner\\Inline' => $vendorDir . '/dompdf/dompdf/src/Positioner/Inline.php',
'Dompdf\\Positioner\\ListBullet' => $vendorDir . '/dompdf/dompdf/src/Positioner/ListBullet.php',
'Dompdf\\Positioner\\NullPositioner' => $vendorDir . '/dompdf/dompdf/src/Positioner/NullPositioner.php',
'Dompdf\\Positioner\\TableCell' => $vendorDir . '/dompdf/dompdf/src/Positioner/TableCell.php',
'Dompdf\\Positioner\\TableRow' => $vendorDir . '/dompdf/dompdf/src/Positioner/TableRow.php',
'Dompdf\\Renderer' => $vendorDir . '/dompdf/dompdf/src/Renderer.php',
'Dompdf\\Renderer\\AbstractRenderer' => $vendorDir . '/dompdf/dompdf/src/Renderer/AbstractRenderer.php',
'Dompdf\\Renderer\\Block' => $vendorDir . '/dompdf/dompdf/src/Renderer/Block.php',
'Dompdf\\Renderer\\Image' => $vendorDir . '/dompdf/dompdf/src/Renderer/Image.php',
'Dompdf\\Renderer\\Inline' => $vendorDir . '/dompdf/dompdf/src/Renderer/Inline.php',
'Dompdf\\Renderer\\ListBullet' => $vendorDir . '/dompdf/dompdf/src/Renderer/ListBullet.php',
'Dompdf\\Renderer\\TableCell' => $vendorDir . '/dompdf/dompdf/src/Renderer/TableCell.php',
'Dompdf\\Renderer\\TableRowGroup' => $vendorDir . '/dompdf/dompdf/src/Renderer/TableRowGroup.php',
'Dompdf\\Renderer\\Text' => $vendorDir . '/dompdf/dompdf/src/Renderer/Text.php',
'FontLib\\AdobeFontMetrics' => $vendorDir . '/phenx/php-font-lib/src/FontLib/AdobeFontMetrics.php',
'FontLib\\BinaryStream' => $vendorDir . '/phenx/php-font-lib/src/FontLib/BinaryStream.php',
'FontLib\\EOT\\File' => $vendorDir . '/phenx/php-font-lib/src/FontLib/EOT/File.php',
'FontLib\\EOT\\Header' => $vendorDir . '/phenx/php-font-lib/src/FontLib/EOT/Header.php',
'FontLib\\EncodingMap' => $vendorDir . '/phenx/php-font-lib/src/FontLib/EncodingMap.php',
'FontLib\\Exception\\FontNotFoundException' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Exception/FontNotFoundException.php',
'FontLib\\Font' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Font.php',
'FontLib\\Glyph\\Outline' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Glyph/Outline.php',
'FontLib\\Glyph\\OutlineComponent' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Glyph/OutlineComponent.php',
'FontLib\\Glyph\\OutlineComposite' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Glyph/OutlineComposite.php',
'FontLib\\Glyph\\OutlineSimple' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Glyph/OutlineSimple.php',
'FontLib\\Header' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Header.php',
'FontLib\\OpenType\\File' => $vendorDir . '/phenx/php-font-lib/src/FontLib/OpenType/File.php',
'FontLib\\OpenType\\TableDirectoryEntry' => $vendorDir . '/phenx/php-font-lib/src/FontLib/OpenType/TableDirectoryEntry.php',
'FontLib\\Table\\DirectoryEntry' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/DirectoryEntry.php',
'FontLib\\Table\\Table' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Table.php',
'FontLib\\Table\\Type\\cmap' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/cmap.php',
'FontLib\\Table\\Type\\cvt' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/cvt.php',
'FontLib\\Table\\Type\\fpgm' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/fpgm.php',
'FontLib\\Table\\Type\\glyf' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/glyf.php',
'FontLib\\Table\\Type\\head' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/head.php',
'FontLib\\Table\\Type\\hhea' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/hhea.php',
'FontLib\\Table\\Type\\hmtx' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/hmtx.php',
'FontLib\\Table\\Type\\kern' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/kern.php',
'FontLib\\Table\\Type\\loca' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/loca.php',
'FontLib\\Table\\Type\\maxp' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/maxp.php',
'FontLib\\Table\\Type\\name' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/name.php',
'FontLib\\Table\\Type\\nameRecord' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/nameRecord.php',
'FontLib\\Table\\Type\\os2' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/os2.php',
'FontLib\\Table\\Type\\post' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/post.php',
'FontLib\\Table\\Type\\prep' => $vendorDir . '/phenx/php-font-lib/src/FontLib/Table/Type/prep.php',
'FontLib\\TrueType\\Collection' => $vendorDir . '/phenx/php-font-lib/src/FontLib/TrueType/Collection.php',
'FontLib\\TrueType\\File' => $vendorDir . '/phenx/php-font-lib/src/FontLib/TrueType/File.php',
'FontLib\\TrueType\\Header' => $vendorDir . '/phenx/php-font-lib/src/FontLib/TrueType/Header.php',
'FontLib\\TrueType\\TableDirectoryEntry' => $vendorDir . '/phenx/php-font-lib/src/FontLib/TrueType/TableDirectoryEntry.php',
'FontLib\\WOFF\\File' => $vendorDir . '/phenx/php-font-lib/src/FontLib/WOFF/File.php',
'FontLib\\WOFF\\Header' => $vendorDir . '/phenx/php-font-lib/src/FontLib/WOFF/Header.php',
'FontLib\\WOFF\\TableDirectoryEntry' => $vendorDir . '/phenx/php-font-lib/src/FontLib/WOFF/TableDirectoryEntry.php',
'Masterminds\\HTML5' => $vendorDir . '/masterminds/html5/src/HTML5.php',
'Masterminds\\HTML5\\Elements' => $vendorDir . '/masterminds/html5/src/HTML5/Elements.php',
'Masterminds\\HTML5\\Entities' => $vendorDir . '/masterminds/html5/src/HTML5/Entities.php',
'Masterminds\\HTML5\\Exception' => $vendorDir . '/masterminds/html5/src/HTML5/Exception.php',
'Masterminds\\HTML5\\InstructionProcessor' => $vendorDir . '/masterminds/html5/src/HTML5/InstructionProcessor.php',
'Masterminds\\HTML5\\Parser\\CharacterReference' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/CharacterReference.php',
'Masterminds\\HTML5\\Parser\\DOMTreeBuilder' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/DOMTreeBuilder.php',
'Masterminds\\HTML5\\Parser\\EventHandler' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/EventHandler.php',
'Masterminds\\HTML5\\Parser\\FileInputStream' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/FileInputStream.php',
'Masterminds\\HTML5\\Parser\\InputStream' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/InputStream.php',
'Masterminds\\HTML5\\Parser\\ParseError' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/ParseError.php',
'Masterminds\\HTML5\\Parser\\Scanner' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/Scanner.php',
'Masterminds\\HTML5\\Parser\\StringInputStream' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/StringInputStream.php',
'Masterminds\\HTML5\\Parser\\Tokenizer' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/Tokenizer.php',
'Masterminds\\HTML5\\Parser\\TreeBuildingRules' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/TreeBuildingRules.php',
'Masterminds\\HTML5\\Parser\\UTF8Utils' => $vendorDir . '/masterminds/html5/src/HTML5/Parser/UTF8Utils.php',
'Masterminds\\HTML5\\Serializer\\HTML5Entities' => $vendorDir . '/masterminds/html5/src/HTML5/Serializer/HTML5Entities.php',
'Masterminds\\HTML5\\Serializer\\OutputRules' => $vendorDir . '/masterminds/html5/src/HTML5/Serializer/OutputRules.php',
'Masterminds\\HTML5\\Serializer\\RulesInterface' => $vendorDir . '/masterminds/html5/src/HTML5/Serializer/RulesInterface.php',
'Masterminds\\HTML5\\Serializer\\Traverser' => $vendorDir . '/masterminds/html5/src/HTML5/Serializer/Traverser.php',
'Sabberworm\\CSS\\CSSElement' => $vendorDir . '/sabberworm/php-css-parser/src/CSSElement.php',
'Sabberworm\\CSS\\CSSList\\AtRuleBlockList' => $vendorDir . '/sabberworm/php-css-parser/src/CSSList/AtRuleBlockList.php',
'Sabberworm\\CSS\\CSSList\\CSSBlockList' => $vendorDir . '/sabberworm/php-css-parser/src/CSSList/CSSBlockList.php',
'Sabberworm\\CSS\\CSSList\\CSSList' => $vendorDir . '/sabberworm/php-css-parser/src/CSSList/CSSList.php',
'Sabberworm\\CSS\\CSSList\\Document' => $vendorDir . '/sabberworm/php-css-parser/src/CSSList/Document.php',
'Sabberworm\\CSS\\CSSList\\KeyFrame' => $vendorDir . '/sabberworm/php-css-parser/src/CSSList/KeyFrame.php',
'Sabberworm\\CSS\\Comment\\Comment' => $vendorDir . '/sabberworm/php-css-parser/src/Comment/Comment.php',
'Sabberworm\\CSS\\Comment\\Commentable' => $vendorDir . '/sabberworm/php-css-parser/src/Comment/Commentable.php',
'Sabberworm\\CSS\\OutputFormat' => $vendorDir . '/sabberworm/php-css-parser/src/OutputFormat.php',
'Sabberworm\\CSS\\OutputFormatter' => $vendorDir . '/sabberworm/php-css-parser/src/OutputFormatter.php',
'Sabberworm\\CSS\\Parser' => $vendorDir . '/sabberworm/php-css-parser/src/Parser.php',
'Sabberworm\\CSS\\Parsing\\Anchor' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/Anchor.php',
'Sabberworm\\CSS\\Parsing\\OutputException' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/OutputException.php',
'Sabberworm\\CSS\\Parsing\\ParserState' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/ParserState.php',
'Sabberworm\\CSS\\Parsing\\SourceException' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/SourceException.php',
'Sabberworm\\CSS\\Parsing\\UnexpectedEOFException' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/UnexpectedEOFException.php',
'Sabberworm\\CSS\\Parsing\\UnexpectedTokenException' => $vendorDir . '/sabberworm/php-css-parser/src/Parsing/UnexpectedTokenException.php',
'Sabberworm\\CSS\\Position\\Position' => $vendorDir . '/sabberworm/php-css-parser/src/Position/Position.php',
'Sabberworm\\CSS\\Position\\Positionable' => $vendorDir . '/sabberworm/php-css-parser/src/Position/Positionable.php',
'Sabberworm\\CSS\\Property\\AtRule' => $vendorDir . '/sabberworm/php-css-parser/src/Property/AtRule.php',
'Sabberworm\\CSS\\Property\\CSSNamespace' => $vendorDir . '/sabberworm/php-css-parser/src/Property/CSSNamespace.php',
'Sabberworm\\CSS\\Property\\Charset' => $vendorDir . '/sabberworm/php-css-parser/src/Property/Charset.php',
'Sabberworm\\CSS\\Property\\Import' => $vendorDir . '/sabberworm/php-css-parser/src/Property/Import.php',
'Sabberworm\\CSS\\Property\\KeyframeSelector' => $vendorDir . '/sabberworm/php-css-parser/src/Property/KeyframeSelector.php',
'Sabberworm\\CSS\\Property\\Selector' => $vendorDir . '/sabberworm/php-css-parser/src/Property/Selector.php',
'Sabberworm\\CSS\\Renderable' => $vendorDir . '/sabberworm/php-css-parser/src/Renderable.php',
'Sabberworm\\CSS\\RuleSet\\AtRuleSet' => $vendorDir . '/sabberworm/php-css-parser/src/RuleSet/AtRuleSet.php',
'Sabberworm\\CSS\\RuleSet\\DeclarationBlock' => $vendorDir . '/sabberworm/php-css-parser/src/RuleSet/DeclarationBlock.php',
'Sabberworm\\CSS\\RuleSet\\RuleSet' => $vendorDir . '/sabberworm/php-css-parser/src/RuleSet/RuleSet.php',
'Sabberworm\\CSS\\Rule\\Rule' => $vendorDir . '/sabberworm/php-css-parser/src/Rule/Rule.php',
'Sabberworm\\CSS\\Settings' => $vendorDir . '/sabberworm/php-css-parser/src/Settings.php',
'Sabberworm\\CSS\\Value\\CSSFunction' => $vendorDir . '/sabberworm/php-css-parser/src/Value/CSSFunction.php',
'Sabberworm\\CSS\\Value\\CSSString' => $vendorDir . '/sabberworm/php-css-parser/src/Value/CSSString.php',
'Sabberworm\\CSS\\Value\\CalcFunction' => $vendorDir . '/sabberworm/php-css-parser/src/Value/CalcFunction.php',
'Sabberworm\\CSS\\Value\\CalcRuleValueList' => $vendorDir . '/sabberworm/php-css-parser/src/Value/CalcRuleValueList.php',
'Sabberworm\\CSS\\Value\\Color' => $vendorDir . '/sabberworm/php-css-parser/src/Value/Color.php',
'Sabberworm\\CSS\\Value\\LineName' => $vendorDir . '/sabberworm/php-css-parser/src/Value/LineName.php',
'Sabberworm\\CSS\\Value\\PrimitiveValue' => $vendorDir . '/sabberworm/php-css-parser/src/Value/PrimitiveValue.php',
'Sabberworm\\CSS\\Value\\RuleValueList' => $vendorDir . '/sabberworm/php-css-parser/src/Value/RuleValueList.php',
'Sabberworm\\CSS\\Value\\Size' => $vendorDir . '/sabberworm/php-css-parser/src/Value/Size.php',
'Sabberworm\\CSS\\Value\\URL' => $vendorDir . '/sabberworm/php-css-parser/src/Value/URL.php',
'Sabberworm\\CSS\\Value\\Value' => $vendorDir . '/sabberworm/php-css-parser/src/Value/Value.php',
'Sabberworm\\CSS\\Value\\ValueList' => $vendorDir . '/sabberworm/php-css-parser/src/Value/ValueList.php',
'Svg\\CssLength' => $vendorDir . '/phenx/php-svg-lib/src/Svg/CssLength.php',
'Svg\\DefaultStyle' => $vendorDir . '/phenx/php-svg-lib/src/Svg/DefaultStyle.php',
'Svg\\Document' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Document.php',
'Svg\\Gradient\\Stop' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Gradient/Stop.php',
'Svg\\Style' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Style.php',
'Svg\\Surface\\CPdf' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Surface/CPdf.php',
'Svg\\Surface\\SurfaceCpdf' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Surface/SurfaceCpdf.php',
'Svg\\Surface\\SurfaceInterface' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Surface/SurfaceInterface.php',
'Svg\\Surface\\SurfacePDFLib' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Surface/SurfacePDFLib.php',
'Svg\\Tag\\AbstractTag' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/AbstractTag.php',
'Svg\\Tag\\Anchor' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Anchor.php',
'Svg\\Tag\\Circle' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Circle.php',
'Svg\\Tag\\ClipPath' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/ClipPath.php',
'Svg\\Tag\\Ellipse' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Ellipse.php',
'Svg\\Tag\\Group' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Group.php',
'Svg\\Tag\\Image' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Image.php',
'Svg\\Tag\\Line' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Line.php',
'Svg\\Tag\\LinearGradient' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/LinearGradient.php',
'Svg\\Tag\\Path' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Path.php',
'Svg\\Tag\\Polygon' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Polygon.php',
'Svg\\Tag\\Polyline' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Polyline.php',
'Svg\\Tag\\RadialGradient' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/RadialGradient.php',
'Svg\\Tag\\Rect' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Rect.php',
'Svg\\Tag\\Shape' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Shape.php',
'Svg\\Tag\\Stop' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Stop.php',
'Svg\\Tag\\StyleTag' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/StyleTag.php',
'Svg\\Tag\\Symbol' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Symbol.php',
'Svg\\Tag\\Text' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/Text.php',
'Svg\\Tag\\UseTag' => $vendorDir . '/phenx/php-svg-lib/src/Svg/Tag/UseTag.php',
);

View File

@@ -1,9 +0,0 @@
<?php
// autoload_namespaces.php @generated by Composer
$vendorDir = dirname(__DIR__);
$baseDir = dirname($vendorDir);
return array(
);

Some files were not shown because too many files have changed in this diff Show More