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

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>