initial commit
This commit is contained in:
+435
@@ -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('&','&').replaceAll('<','<')
|
||||
.replaceAll('>','>').replaceAll('"','"')
|
||||
.replaceAll("'","'");
|
||||
}
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user