initial commit

This commit is contained in:
2026-07-22 01:12:45 +02:00
commit 124f2746ac
357 changed files with 159669 additions and 0 deletions
+435
View File
@@ -0,0 +1,435 @@
<?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 • Termine</title>
<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; }
.badge-status { text-transform: uppercase; letter-spacing: .04em; }
.table-responsive { overflow-x: auto; }
@media (max-width: 576px) {
.hide-xs { display:none !important; }
.kpi-hint { display:none !important; }
}
.mobile-card .meta { font-size: .9rem; }
.mobile-card .title { font-weight: 600; }
.mobile-card .sub { color: #6c757d; }
</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">Termine</h3>
</div>
<div class="d-flex gap-2 flex-wrap">
<button id="btnRefresh" class="btn btn-outline-secondary btn-sm">↻ Refresh</button>
<button id="btnAdd" class="btn btn-primary btn-sm">+ Termin</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-3">
<label class="form-label mb-1">Status</label>
<select class="form-select" id="statusFilter">
<option value="">Alle</option>
<option value="0">Offen</option>
<option value="1">Bereit</option>
<option value="2">Abgeschlossen</option>
<option value="3">Storniert</option>
</select>
</div>
<div class="col-12 col-md-3 d-flex gap-2">
<button id="btnThisMonth" class="btn btn-outline-primary w-100">Dieser Monat</button>
<button id="btnThisYear" class="btn btn-outline-primary w-100">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">Heute</div>
<div class="fs-3 fw-semibold" id="kpiToday"></div>
<div class="text-muted small kpi-hint" id="kpiTodayHint"></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">Diese Woche</div>
<div class="fs-3 fw-semibold" id="kpiWeek"></div>
<div class="text-muted small kpi-hint" id="kpiWeekHint"></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">Dieser Monat</div>
<div class="fs-3 fw-semibold" id="kpiMonth"></div>
<div class="text-muted small kpi-hint" id="kpiMonthHint"></div>
</div>
</div>
</div>
</div>
<!-- Table -->
<div class="card shadow-sm">
<div class="card-body">
<!-- Mobile Cards (nur xs/sm) -->
<div id="mobileCards" class="d-block d-md-none"></div>
<!-- Desktop Table (ab md) -->
<div class="table-responsive d-none d-md-block">
<table id="tblTermine" class="table table-striped table-hover align-middle w-100">
<thead>
<tr>
<th>Datum</th>
<th class="hide-xs">Zeit</th>
<th>Location</th>
<th>Status</th>
<th>Gage</th>
<th class="d-none d-md-table-cell">Beschreibung</th>
<th>Aktion</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="text-muted small mt-2" id="tableInfo"></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>
<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_LIST = '/api/termine_list.php';
let dt;
const toast = new bootstrap.Toast(document.getElementById('toast'), { delay: 3500 });
function showToast(msg){ document.getElementById('toastBody').textContent = msg; toast.show(); }
function escapeHtml(s){
return String(s ?? '')
.replaceAll('&','&amp;').replaceAll('<','&lt;')
.replaceAll('>','&gt;').replaceAll('"','&quot;')
.replaceAll("'","&#039;");
}
function renderMobileCards(items){
const el = document.getElementById('mobileCards');
if (!el) return;
if (!items.length) {
el.innerHTML = `<div class="text-muted">Keine Termine im Zeitraum.</div>`;
return;
}
el.innerHTML = items.map(r => {
const date = fmtDateDEFromTs(r.startzeit);
const time = `${fmtTimeFromTs(r.startzeit)}${fmtTimeFromTs(r.endzeit)}`;
const loc = `${(r.locationtype||'').trim()} ${(r.location||'').trim()}`.trim();
const desc = (r.beschreibung || '').toString().trim();
const descShort = desc.length > 90 ? desc.slice(0, 90) + '…' : desc;
return `
<div class="card shadow-sm mb-2 mobile-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="title">${escapeHtml(loc || '—')}</div>
<div class="text-end">
${statusBadge(r.status)}
<div class="mono mt-1">${eur(r.gage || 0)}</div>
</div>
</div>
<div class="meta sub mt-1">
<span class="mono">${date}</span> · <span class="mono">${time}</span>
</div>
${desc ? `<div class="mt-2">${escapeHtml(descShort)}</div>` : ''}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-outline-primary btn-sm actEdit" data-id="${r.id}">Edit</button>
</div>
</div>
</div>
`;
}).join('');
}
function isoLocal(d){
const y=d.getFullYear(), m=String(d.getMonth()+1).padStart(2,'0'), day=String(d.getDate()).padStart(2,'0');
return `${y}-${m}-${day}`;
}
function thisMonthRange(){
const d=new Date();
return [isoLocal(new Date(d.getFullYear(), d.getMonth(), 1)), isoLocal(new Date(d.getFullYear(), d.getMonth()+1, 0))];
}
function thisYearRange(){
const d=new Date();
return [isoLocal(new Date(d.getFullYear(), 0, 1)), isoLocal(new Date(d.getFullYear(), 11, 31))];
}
function fmtDateDEFromTs(ts){
const d=new Date(Number(ts)*1000);
const dd=String(d.getDate()).padStart(2,'0');
const mm=String(d.getMonth()+1).padStart(2,'0');
const yy=d.getFullYear();
return `${dd}.${mm}.${yy}`;
}
function fmtTimeFromTs(ts){
const d=new Date(Number(ts)*1000);
const hh=String(d.getHours()).padStart(2,'0');
const mm=String(d.getMinutes()).padStart(2,'0');
return `${hh}:${mm}`;
}
function eur(n){
const v = Number(n)||0;
return v.toLocaleString('de-DE', { style:'currency', currency:'EUR', maximumFractionDigits:0 });
}
function statusBadge(s){
s = String(s ?? '').trim();
if (s === '2') return '<span class="badge text-bg-success badge-status">ABGESCHLOSSEN</span>';
if (s === '0') return '<span class="badge text-bg-secondary badge-status">OFFEN</span>';
if (s === '1') return '<span class="badge text-bg-primary badge-status">BEREIT</span>';
if (s === '3') return '<span class="badge text-bg-danger badge-status">STORNIERT</span>';
return '<span class="badge text-bg-warning badge-status">UNBEKANNT</span>';
}
function actionsHtml(row){
return `
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary actEdit" data-id="${row.id}">Edit</button>
</div>
`;
}
function getQS(){
const from = document.getElementById('fromDate').value;
const to = document.getElementById('toDate').value;
const status = document.getElementById('statusFilter').value;
const qs = new URLSearchParams({ from, to });
if (status !== '') qs.set('status', status);
return qs.toString();
}
async function fetchJSON(url){
const res = await fetch(url, { cache:'no-store' });
const text = await res.text();
try {
const json = JSON.parse(text);
if (!json.ok) throw new Error(json.error || 'API error');
return json;
} catch(e){
throw new Error(`API liefert kein JSON (${res.status}). ${text.slice(0,200)}`);
}
}
// ✅ Sort: Zukunft zuerst (nächster Termin oben), dann Vergangenheit
function sortNextFirst(items){
const nowTs = Math.floor(Date.now() / 1000);
return items.slice().sort((a, b) => {
const as = Number(a.startzeit) || 0;
const bs = Number(b.startzeit) || 0;
const aFuture = as >= nowTs;
const bFuture = bs >= nowTs;
if (aFuture && !bFuture) return -1;
if (!aFuture && bFuture) return 1;
// Beide Zukunft -> ASC (nächster zuerst)
if (aFuture && bFuture) return as - bs;
// Beide Vergangenheit -> DESC (letzter vergangener oben)
return bs - as;
});
}
async function refresh(){
try{
const qs = getQS();
const json = await fetchJSON(`${API_LIST}?${qs}`);
// ✅ sortierte items nutzen (für Mobile & Desktop identisch)
const items = sortNextFirst(json.items || []);
const isMobile = window.matchMedia("(max-width: 767.98px)").matches;
// Desktop: DataTable füllen
if (!isMobile) {
dt.clear().rows.add(items).draw(false);
}
// Mobile: Cards füllen
if (isMobile) {
renderMobileCards(items);
}
document.getElementById('tableInfo').textContent =
`Treffer: ${items.length} • Zeitraum: ${json.from} bis ${json.to}`;
// KPI (clientseitig)
const now = new Date();
const todayKey = isoLocal(now);
// week start (Mon)
const day = (now.getDay()+6)%7; // 0=Mon
const weekStart = new Date(now);
weekStart.setDate(now.getDate()-day);
weekStart.setHours(0,0,0,0);
const [mFrom, mTo] = thisMonthRange();
const mFromMs = new Date(mFrom+'T00:00:00').getTime();
const mToMs = new Date(mTo+'T23:59:59').getTime();
let todayCount=0;
let weekCount=0;
let monthCount = 0;
for(const r of items){
const startMs = Number(r.startzeit)*1000;
const dIso = isoLocal(new Date(startMs));
if (dIso === todayKey) todayCount++;
if (startMs >= weekStart.getTime()) weekCount++;
if (startMs >= mFromMs && startMs <= mToMs) monthCount++;
}
document.getElementById('kpiToday').textContent = todayCount;
document.getElementById('kpiTodayHint').textContent = 'Termine heute';
document.getElementById('kpiWeek').textContent = weekCount;
document.getElementById('kpiWeekHint').textContent = 'Termine seit Montag';
document.getElementById('kpiMonth').textContent = monthCount;
document.getElementById('kpiMonthHint').textContent = 'Termine diesen Monat';
} catch(e){
showToast('Fehler: ' + e.message);
}
}
// Init
$(function(){
const [f,t] = thisMonthRange();
document.getElementById('fromDate').value = f;
document.getElementById('toDate').value = t;
dt = $('#tblTermine').DataTable({
pageLength: 25,
// ✅ wichtig: keine Default-Sortierung, wir sortieren im JS für "nächster Termin oben"
order: [],
columns: [
{
data: 'startzeit',
className:'mono',
render: (d, type) => {
const ts = parseInt(d, 10) || 0;
if (type === 'sort' || type === 'type') return ts;
return fmtDateDEFromTs(ts);
}
},
{ data: null, className:'mono hide-xs', render: (row)=> `${fmtTimeFromTs(row.startzeit)}${fmtTimeFromTs(row.endzeit)}` },
{ data: null, render: (row)=> `${(row.locationtype||'').trim()} ${(row.location||'').trim()}`.trim() },
{ data: 'status', render: (d)=>statusBadge(d) },
{ data: 'gage', className:'mono', render: (d)=> eur(d) },
{ data: 'beschreibung', className:'d-none d-md-table-cell', render: (d)=> (d||'').toString().slice(0,80) },
{ data: null, orderable:false, searchable:false, render: (row)=>actionsHtml(row) },
],
});
['fromDate','toDate','statusFilter'].forEach(id => {
document.getElementById(id).addEventListener('change', refresh);
});
document.getElementById('btnThisMonth').addEventListener('click', () => {
const [a,b] = thisMonthRange();
document.getElementById('fromDate').value = a;
document.getElementById('toDate').value = b;
refresh();
});
document.getElementById('btnThisYear').addEventListener('click', () => {
const [a,b] = thisYearRange();
document.getElementById('fromDate').value = a;
document.getElementById('toDate').value = b;
refresh();
});
document.getElementById('btnRefresh').addEventListener('click', refresh);
refresh();
});
// Hooks für View/Edit
document.addEventListener('click', (ev) => {
const e = ev.target.closest('.actEdit');
if (e) {
const id = e.dataset.id;
window.location = `/termin_show.php?id=${id}`;
return;
}
});
document.getElementById('btnAdd').addEventListener('click', () => {
window.location = `/termin_create.php`;
});
</script>
</body>
</html>