initial commit
This commit is contained in:
+506
@@ -0,0 +1,506 @@
|
||||
<?php
|
||||
require_once "config/session.php";
|
||||
|
||||
if (!isset($_SESSION["loggedin"]) || $_SESSION["loggedin"] !== true) {
|
||||
header("location: index.php");
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
include('navbar.php');
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>ANJUMA • Finanzen</title>
|
||||
|
||||
<!-- Bootstrap 5 + DataTables via CDN (sicher, keine 404) -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.10/css/dataTables.bootstrap5.min.css">
|
||||
|
||||
<style>
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
.kpi-card { min-height: 92px; }
|
||||
.dt-nowrap td { white-space: nowrap; }
|
||||
|
||||
@media (max-width: 576px) {
|
||||
#kpiIncomeHint, #kpiExpensesHint { display:none; }
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.dt-nowrap td { white-space: normal !important; }
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-light">
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h3 class="mb-0">Finanzen</h3>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button id="btnRefresh" class="btn btn-outline-secondary btn-sm">↻ Refresh</button>
|
||||
<button id="btnAddExpense" class="btn btn-primary btn-sm">+ Ausgabe</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label mb-1">Von</label>
|
||||
<input type="date" class="form-control" id="fromDate">
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label mb-1">Bis</label>
|
||||
<input type="date" class="form-control" id="toDate">
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex gap-2">
|
||||
<button id="btnThisMonth" class="btn btn-outline-primary">Dieser Monat</button>
|
||||
<button id="btnLastMonth" class="btn btn-outline-primary">Letzter Monat</button>
|
||||
<button id="btnThisYear" class="btn btn-outline-primary">Dieses Jahr</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI -->
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card shadow-sm kpi-card">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Einnahmen</div>
|
||||
<div class="fs-3 fw-semibold" id="kpiIncome">–</div>
|
||||
<div class="text-muted small" id="kpiIncomeHint">–</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card shadow-sm kpi-card">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Ausgaben</div>
|
||||
<div class="fs-3 fw-semibold" id="kpiExpenses">–</div>
|
||||
<div class="text-muted small" id="kpiExpensesHint">–</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<div class="card shadow-sm kpi-card">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small">Netto</div>
|
||||
<div class="fs-3 fw-semibold" id="kpiNet">–</div>
|
||||
<div class="text-muted small">Einnahmen − Ausgaben</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tabIncome" type="button" role="tab">Einnahmen (Termine)</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabExpenses" type="button" role="tab">Ausgaben</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- Income -->
|
||||
<div class="tab-pane fade show active" id="tabIncome" role="tabpanel">
|
||||
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||
<div class="card-body">
|
||||
<table id="tblIncome" class="table table-striped table-hover align-middle dt-nowrap w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Zeit</th>
|
||||
<th>Location</th>
|
||||
<th>Gage</th>
|
||||
<th>Notiz</th>
|
||||
<th>ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="text-muted small mt-2" id="incomeInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expenses -->
|
||||
<div class="tab-pane fade" id="tabExpenses" role="tabpanel">
|
||||
<div class="card shadow-sm border-top-0 rounded-top-0">
|
||||
<div class="card-body">
|
||||
<table id="tblExpenses" class="table table-striped table-hover align-middle dt-nowrap w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum</th>
|
||||
<th>Kategorie</th>
|
||||
<th>Titel</th>
|
||||
<th>Betrag</th>
|
||||
<th>Methode</th>
|
||||
<th>Notiz</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="text-muted small mt-2" id="expensesInfo"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expense Modal -->
|
||||
<div class="modal fade" id="expenseModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<form class="modal-content" id="expenseForm">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="expenseModalTitle">Ausgabe</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="expId" name="id" value="">
|
||||
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label mb-1">Datum</label>
|
||||
<input type="date" class="form-control" id="expDate" name="date" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label mb-1">Betrag (EUR)</label>
|
||||
<input type="number" class="form-control" id="expAmount" name="amount_eur" min="1" step="1" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label mb-1">Kategorie</label>
|
||||
<input type="text" class="form-control" id="expCategory" name="category" placeholder="equipment / fuel_travel / marketing ..." required>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label mb-1">Methode</label>
|
||||
<select class="form-select" id="expMethod" name="method">
|
||||
<option value="bank">Bank</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="paypal">PayPal</option>
|
||||
<option value="card">Karte</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label mb-1">Titel</label>
|
||||
<input type="text" class="form-control" id="expTitle" name="title" required>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label mb-1">Notiz</label>
|
||||
<textarea class="form-control" id="expNotes" name="notes" rows="3"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning mt-3 mb-0 small">
|
||||
Tipp: Kategorien konsistent halten (z.B. <span class="mono">equipment</span>, <span class="mono">fuel_travel</span>, <span class="mono">marketing</span>), dann sind Auswertungen später easy.
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary" id="btnSaveExpense">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirm Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Ausgabe löschen</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Schließen"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="deleteBody">Wirklich löschen?</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button>
|
||||
<button type="button" class="btn btn-danger" id="btnDoDelete">Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1080">
|
||||
<div id="toast" class="toast align-items-center text-bg-dark border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body" id="toastBody">...</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/datatables.net@1.13.10/js/jquery.dataTables.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/datatables.net-bs5@1.13.10/js/dataTables.bootstrap5.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const API_INCOME = '/api/finance_income.php';
|
||||
const API_EXP_LIST = '/api/finance_expenses_list.php';
|
||||
const API_EXP_SAVE = '/api/finance_expenses_save.php';
|
||||
const API_EXP_DEL = '/api/finance_expenses_delete.php';
|
||||
|
||||
let dtIncome, dtExpenses;
|
||||
let deleteId = null;
|
||||
|
||||
const toast = new bootstrap.Toast(document.getElementById('toast'), { delay: 3500 });
|
||||
function showToast(msg) {
|
||||
document.getElementById('toastBody').textContent = msg;
|
||||
toast.show();
|
||||
}
|
||||
|
||||
function eur(n) {
|
||||
const v = Number(n) || 0;
|
||||
return v.toLocaleString('de-DE', { style: 'currency', currency: 'EUR', maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
function fmtDateDE(iso) {
|
||||
if (!iso) return '';
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
|
||||
function todayISO() {
|
||||
const d = new Date();
|
||||
const m = String(d.getMonth()+1).padStart(2,'0');
|
||||
const day = String(d.getDate()).padStart(2,'0');
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function setRange(from, to) {
|
||||
document.getElementById('fromDate').value = from;
|
||||
document.getElementById('toDate').value = to;
|
||||
}
|
||||
|
||||
function isoLocal(dateObj) {
|
||||
const y = dateObj.getFullYear();
|
||||
const m = String(dateObj.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(dateObj.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function thisMonthRange() {
|
||||
const d = new Date();
|
||||
const from = new Date(d.getFullYear(), d.getMonth(), 1);
|
||||
const to = new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
||||
return [isoLocal(from), isoLocal(to)];
|
||||
}
|
||||
|
||||
function lastMonthRange() {
|
||||
const d = new Date();
|
||||
const from = new Date(d.getFullYear(), d.getMonth() - 1, 1);
|
||||
const to = new Date(d.getFullYear(), d.getMonth(), 0);
|
||||
return [isoLocal(from), isoLocal(to)];
|
||||
}
|
||||
|
||||
function thisYearRange() {
|
||||
const d = new Date();
|
||||
const from = new Date(d.getFullYear(), 0, 1);
|
||||
const to = new Date(d.getFullYear(), 11, 31);
|
||||
return [isoLocal(from), isoLocal(to)];
|
||||
}
|
||||
|
||||
|
||||
function getFilterQS() {
|
||||
const from = document.getElementById('fromDate').value;
|
||||
const to = document.getElementById('toDate').value;
|
||||
const qs = new URLSearchParams({ from, to });
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'API error');
|
||||
return json;
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
try {
|
||||
const qs = getFilterQS();
|
||||
|
||||
const [income, expenses] = await Promise.all([
|
||||
fetchJSON(`${API_INCOME}?${qs}`),
|
||||
fetchJSON(`${API_EXP_LIST}?${qs}`),
|
||||
]);
|
||||
|
||||
// KPI
|
||||
const inc = Number(income.sum_eur || 0);
|
||||
const exp = Number(expenses.sum_eur || 0);
|
||||
document.getElementById('kpiIncome').textContent = eur(inc);
|
||||
document.getElementById('kpiExpenses').textContent = eur(exp);
|
||||
document.getElementById('kpiNet').textContent = eur(inc - exp);
|
||||
|
||||
document.getElementById('kpiIncomeHint').textContent = `${income.items.length} abgeschlossene Termine`;
|
||||
document.getElementById('kpiExpensesHint').textContent = `${expenses.items.length} Ausgaben`;
|
||||
|
||||
// Tables
|
||||
dtIncome.clear().rows.add(income.items || []).draw(false);
|
||||
dtExpenses.clear().rows.add(expenses.items || []).draw(false);
|
||||
|
||||
document.getElementById('incomeInfo').textContent =
|
||||
`Zeitraum ${income.from} bis ${income.to} • Summe: ${eur(inc)}`;
|
||||
|
||||
document.getElementById('expensesInfo').textContent =
|
||||
`Zeitraum ${expenses.from} bis ${expenses.to} • Summe: ${eur(exp)}`;
|
||||
|
||||
} catch (e) {
|
||||
showToast('Fehler: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function expenseActions(row) {
|
||||
const encoded = encodeURIComponent(JSON.stringify(row));
|
||||
return `
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<button class="btn btn-outline-secondary btnEditExpense" data-row="${encoded}">Edit</button>
|
||||
<button class="btn btn-outline-danger btnDeleteExpense" data-id="${row.id}" data-title="${encodeURIComponent(row.title)}">Delete</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openExpenseModal(row=null) {
|
||||
document.getElementById('expenseModalTitle').textContent = row ? 'Ausgabe bearbeiten' : 'Neue Ausgabe';
|
||||
document.getElementById('expId').value = row ? row.id : '';
|
||||
document.getElementById('expDate').value = row ? row.date : todayISO();
|
||||
document.getElementById('expCategory').value = row ? row.category : '';
|
||||
document.getElementById('expTitle').value = row ? row.title : '';
|
||||
document.getElementById('expAmount').value = row ? row.amount_eur : '';
|
||||
document.getElementById('expMethod').value = row ? row.method : 'bank';
|
||||
document.getElementById('expNotes').value = row ? (row.notes || '') : '';
|
||||
|
||||
new bootstrap.Modal(document.getElementById('expenseModal')).show();
|
||||
}
|
||||
|
||||
function openDeleteModal(id, title) {
|
||||
deleteId = id;
|
||||
const t = title ? decodeURIComponent(title) : '';
|
||||
document.getElementById('deleteBody').innerHTML =
|
||||
`Ausgabe <b>${t}</b> (ID <span class="mono">${id}</span>) wirklich löschen?`;
|
||||
new bootstrap.Modal(document.getElementById('deleteModal')).show();
|
||||
}
|
||||
|
||||
document.getElementById('btnRefresh').addEventListener('click', refreshAll);
|
||||
|
||||
document.getElementById('btnThisMonth').addEventListener('click', () => {
|
||||
const [f,t] = thisMonthRange(); setRange(f,t); refreshAll();
|
||||
});
|
||||
document.getElementById('btnLastMonth').addEventListener('click', () => {
|
||||
const [f,t] = lastMonthRange(); setRange(f,t); refreshAll();
|
||||
});
|
||||
document.getElementById('btnThisYear').addEventListener('click', () => {
|
||||
const [f,t] = thisYearRange(); setRange(f,t); refreshAll();
|
||||
});
|
||||
|
||||
document.getElementById('btnAddExpense').addEventListener('click', () => openExpenseModal(null));
|
||||
|
||||
document.getElementById('expenseForm').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const fd = new FormData(ev.target);
|
||||
const res = await fetch(API_EXP_SAVE, { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Save failed');
|
||||
|
||||
showToast('Gespeichert ✅');
|
||||
bootstrap.Modal.getInstance(document.getElementById('expenseModal'))?.hide();
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
showToast('Fehler: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btnDoDelete').addEventListener('click', async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('id', deleteId);
|
||||
const res = await fetch(API_EXP_DEL, { method: 'POST', body: fd });
|
||||
const json = await res.json();
|
||||
if (!json.ok) throw new Error(json.error || 'Delete failed');
|
||||
|
||||
showToast('Gelöscht 🗑️');
|
||||
bootstrap.Modal.getInstance(document.getElementById('deleteModal'))?.hide();
|
||||
deleteId = null;
|
||||
await refreshAll();
|
||||
} catch (e) {
|
||||
showToast('Fehler: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Delegated events for table buttons
|
||||
document.addEventListener('click', (ev) => {
|
||||
const editBtn = ev.target.closest('.btnEditExpense');
|
||||
if (editBtn) {
|
||||
const row = JSON.parse(decodeURIComponent(editBtn.dataset.row));
|
||||
openExpenseModal(row);
|
||||
return;
|
||||
}
|
||||
|
||||
const delBtn = ev.target.closest('.btnDeleteExpense');
|
||||
if (delBtn) {
|
||||
openDeleteModal(delBtn.dataset.id, delBtn.dataset.title);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Init
|
||||
$(function() {
|
||||
// Default range: this month
|
||||
const [f,t] = thisMonthRange();
|
||||
setRange(f,t);
|
||||
|
||||
dtIncome = $('#tblIncome').DataTable({
|
||||
pageLength: 10,
|
||||
order: [[0,'desc']],
|
||||
columns: [
|
||||
{ data: 'date', className: 'mono', render: (d)=>fmtDateDE(d) },
|
||||
{ data: 'time', className: 'mono' },
|
||||
{ data: 'location' },
|
||||
{ data: 'gage', className: 'mono', render: (d)=>eur(d) },
|
||||
{ data: 'desc', render: (d)=>(d||'').toString().slice(0,80) },
|
||||
{ data: 'id', className: 'mono' },
|
||||
],
|
||||
columnDefs: [
|
||||
{ targets: [1,4,5], visible: window.matchMedia("(max-width: 576px)").matches ? false : true },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
dtExpenses = $('#tblExpenses').DataTable({
|
||||
pageLength: 10,
|
||||
order: [[0,'desc']],
|
||||
columns: [
|
||||
{ data: 'date', className: 'mono', render: (d)=>fmtDateDE(d) },
|
||||
{ data: 'category', className: 'mono' },
|
||||
{ data: 'title' },
|
||||
{ data: 'amount_eur', className: 'mono', render: (d)=>eur(d) },
|
||||
{ data: 'method', className: 'mono' },
|
||||
{ data: 'notes', render: (d)=>(d||'').toString().slice(0,80) },
|
||||
{ data: null, orderable:false, searchable:false, render:(row)=>expenseActions(row) },
|
||||
],
|
||||
columnDefs: [
|
||||
{ targets: [1,4,5], visible: window.matchMedia("(max-width: 576px)").matches ? false : true },
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
refreshAll();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user