Added support for Streamdeck Pedal and updated UI to better fit the Packed UI style

This commit is contained in:
2026-02-27 22:47:08 +01:00
committed by erik
parent 5a70f775f1
commit 93faae5cc8
1463 changed files with 306917 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
/**
* 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

@@ -0,0 +1,202 @@
// 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();
}
});
})();

1391
pirp/public/assets/style.css Normal file

File diff suppressed because it is too large Load Diff

192
pirp/public/belegarchiv.php Normal file
View File

@@ -0,0 +1,192 @@
<?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>

139
pirp/public/customers.php Normal file
View File

@@ -0,0 +1,139 @@
<?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>

336
pirp/public/euer.php Normal file
View File

@@ -0,0 +1,336 @@
<?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

@@ -0,0 +1,10 @@
<?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

@@ -0,0 +1,29 @@
<?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;

389
pirp/public/expenses.php Normal file
View File

@@ -0,0 +1,389 @@
<?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>

422
pirp/public/index.php Normal file
View File

@@ -0,0 +1,422 @@
<?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>

224
pirp/public/invoice_new.php Normal file
View File

@@ -0,0 +1,224 @@
<?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

@@ -0,0 +1,85 @@
<?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

@@ -0,0 +1,129 @@
<?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>

248
pirp/public/invoices.php Normal file
View File

@@ -0,0 +1,248 @@
<?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

@@ -0,0 +1,60 @@
<?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;

559
pirp/public/journal.php Normal file
View File

@@ -0,0 +1,559 @@
<?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

@@ -0,0 +1,81 @@
<?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

@@ -0,0 +1,168 @@
<?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

@@ -0,0 +1,311 @@
<?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

@@ -0,0 +1,20 @@
<?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

@@ -0,0 +1,159 @@
<?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

@@ -0,0 +1,5 @@
<?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

@@ -0,0 +1,248 @@
<?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>

42
pirp/public/login.php Normal file
View File

@@ -0,0 +1,42 @@
<?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>

6
pirp/public/logout.php Normal file
View File

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

128
pirp/public/mahnung_new.php Normal file
View File

@@ -0,0 +1,128 @@
<?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

@@ -0,0 +1,45 @@
<?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;

147
pirp/public/recurring.php Normal file
View File

@@ -0,0 +1,147 @@
<?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

@@ -0,0 +1,277 @@
<?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

@@ -0,0 +1,158 @@
<?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

@@ -0,0 +1,81 @@
<?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]);

802
pirp/public/settings.php Normal file
View File

@@ -0,0 +1,802 @@
<?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.

After

Width:  |  Height:  |  Size: 469 KiB