Problem: Frontend JS bundle used credentials:'same-origin' which dropped cookies on cross-domain requests. Login worked (200) but the subsequent /auth/me check returned 401, leaving the user stuck on the login screen. Fix: - AuthController now sets a dtp_jwt HttpOnly cookie on login/refresh - Cookie uses Domain=.fahrschultermin.de (shared between frontend and api subdomain), Secure, SameSite=Lax, Max-Age=8h - JwtMiddleware reads JWT from Authorization header OR cookie - Added AuthController::me() endpoint (was missing, caused 500) - Logout endpoint clears the cookie - Frontend index.php patches fetch() to use credentials:'include' for all /api/v1/* calls
542 lines
19 KiB
HTML
Executable File
542 lines
19 KiB
HTML
Executable File
<!DOCTYPE html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Arbeitszeitbericht – DriveTime Planner</title>
|
||
<style>
|
||
:root {
|
||
--bg: #f3efe6;
|
||
--panel: rgba(255, 252, 246, .94);
|
||
--ink: #111217;
|
||
--muted: #67625a;
|
||
--line: rgba(17, 18, 23, .08);
|
||
--accent: #a74826;
|
||
--accent-deep: #6d2410;
|
||
--teal: #0e5d80;
|
||
--success: #2c7a4b;
|
||
--danger: #a83333;
|
||
--surface-soft: #efe6d7;
|
||
--surface-time: #f7f2ea;
|
||
--input-bg: rgba(255, 255, 255, .88);
|
||
--ghost-bg: rgba(255, 255, 255, .7);
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
font-family: 'Iowan Old Style', Palatino Linotype, Book Antiqua, serif;
|
||
background: var(--bg);
|
||
color: var(--ink);
|
||
min-height: 100vh;
|
||
}
|
||
.topbar {
|
||
background: var(--panel);
|
||
border-bottom: 1px solid var(--line);
|
||
padding: 12px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
backdrop-filter: blur(8px);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.topbar-brand {
|
||
font-size: 1.1rem;
|
||
font-weight: bold;
|
||
color: var(--accent);
|
||
letter-spacing: .02em;
|
||
}
|
||
.topbar-user {
|
||
margin-left: auto;
|
||
color: var(--muted);
|
||
font-size: .9rem;
|
||
}
|
||
.container {
|
||
max-width: 1100px;
|
||
margin: 0 auto;
|
||
padding: 32px 24px;
|
||
}
|
||
h1 {
|
||
font-size: 1.5rem;
|
||
margin-bottom: 24px;
|
||
color: var(--ink);
|
||
}
|
||
.filter-bar {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 20px 24px;
|
||
margin-bottom: 28px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
align-items: flex-end;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.field { display: flex; flex-direction: column; gap: 6px; }
|
||
.field label {
|
||
font-size: .8rem;
|
||
color: var(--muted);
|
||
font-weight: 500;
|
||
letter-spacing: .03em;
|
||
}
|
||
.field select, .field input {
|
||
background: var(--input-bg);
|
||
border: 1px solid var(--line);
|
||
border-radius: 6px;
|
||
padding: 8px 12px;
|
||
font-size: .95rem;
|
||
color: var(--ink);
|
||
font-family: inherit;
|
||
min-width: 160px;
|
||
}
|
||
.field select:focus, .field input:focus {
|
||
outline: none;
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 3px rgba(167, 72, 38, .15);
|
||
}
|
||
button.btn {
|
||
background: var(--accent);
|
||
color: #fff;
|
||
border: none;
|
||
border-radius: 6px;
|
||
padding: 9px 20px;
|
||
font-size: .95rem;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
transition: background .15s;
|
||
white-space: nowrap;
|
||
}
|
||
button.btn:hover { background: var(--accent-deep); }
|
||
button.btn-secondary {
|
||
background: var(--surface-soft);
|
||
color: var(--ink);
|
||
border: 1px solid var(--line);
|
||
}
|
||
button.btn-secondary:hover { background: #e5dcc8; }
|
||
.actions { display: flex; gap: 10px; align-items: flex-end; }
|
||
|
||
/* Table */
|
||
.timesheet-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
backdrop-filter: blur(8px);
|
||
font-size: .9rem;
|
||
}
|
||
.timesheet-table th {
|
||
background: var(--surface-soft);
|
||
padding: 10px 14px;
|
||
text-align: left;
|
||
font-size: .78rem;
|
||
letter-spacing: .04em;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.timesheet-table td {
|
||
padding: 9px 14px;
|
||
border-bottom: 1px solid var(--line);
|
||
vertical-align: middle;
|
||
}
|
||
.timesheet-table tr:last-child td { border-bottom: none; }
|
||
.timesheet-table tr.day-total td {
|
||
background: var(--surface-soft);
|
||
font-weight: 600;
|
||
color: var(--teal);
|
||
font-size: .85rem;
|
||
}
|
||
.timesheet-table tr.separator td {
|
||
background: var(--ghost-bg);
|
||
height: 6px;
|
||
padding: 0;
|
||
}
|
||
.timesheet-table tr.appointment-row:hover td {
|
||
background: rgba(167, 72, 38, .04);
|
||
}
|
||
.ue { font-variant-numeric: tabular-nums; text-align: right; }
|
||
.minutes { font-variant-numeric: tabular-nums; text-align: right; }
|
||
.time { font-variant-numeric: tabular-nums; font-size: .85rem; color: var(--muted); }
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 60px 20px;
|
||
color: var(--muted);
|
||
}
|
||
.loading {
|
||
text-align: center;
|
||
padding: 40px;
|
||
color: var(--muted);
|
||
}
|
||
.error-msg {
|
||
background: #fef2f2;
|
||
border: 1px solid #fecaca;
|
||
color: var(--danger);
|
||
border-radius: 6px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
/* Summary section */
|
||
.summary-section {
|
||
margin-top: 28px;
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 24px;
|
||
}
|
||
.summary-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
padding: 20px 24px;
|
||
backdrop-filter: blur(8px);
|
||
}
|
||
.summary-card h3 {
|
||
font-size: .8rem;
|
||
letter-spacing: .04em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
margin-bottom: 14px;
|
||
}
|
||
.summary-card table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||
.summary-card table th {
|
||
text-align: left;
|
||
font-size: .78rem;
|
||
letter-spacing: .04em;
|
||
text-transform: uppercase;
|
||
color: var(--muted);
|
||
padding: 4px 8px 8px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.summary-card table td {
|
||
padding: 7px 8px;
|
||
border-bottom: 1px solid var(--line);
|
||
}
|
||
.summary-card table tr:last-child td { border-bottom: none; }
|
||
.total-row td { font-weight: 700; color: var(--teal); }
|
||
.grand-total td { font-weight: 800; font-size: 1.05rem; color: var(--accent); border-top: 2px solid var(--line); }
|
||
|
||
/* Print styles */
|
||
@media print {
|
||
.topbar, .filter-bar, button, .actions { display: none !important; }
|
||
body { background: #fff; }
|
||
.container { padding: 0; max-width: 100%; }
|
||
.timesheet-table { border: 1px solid #000; page-break-inside: avoid; }
|
||
.timesheet-table th { background: #eee !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
.timesheet-table tr.day-total td { background: #e8f4f8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
.timesheet-table tr.separator td { background: #ddd !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||
.summary-card { border: 1px solid #000; page-break-inside: avoid; }
|
||
.summary-card h3 { color: #000 !important; }
|
||
@page { size: A4 portrait; margin: 15mm; }
|
||
}
|
||
|
||
.info-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 16px;
|
||
font-size: .85rem;
|
||
color: var(--muted);
|
||
}
|
||
.info-bar strong { color: var(--ink); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="topbar">
|
||
<span class="topbar-brand">DriveTime Planner</span>
|
||
<span class="topbar-brand">› Arbeitszeitbericht</span>
|
||
<span class="topbar-user" id="userName"></span>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<h1>Arbeitszeitbericht</h1>
|
||
|
||
<div class="filter-bar">
|
||
<div class="field">
|
||
<label for="instructor">Fahrlehrer</label>
|
||
<select id="instructor">
|
||
<option value="">– bitte wählen –</option>
|
||
</select>
|
||
</div>
|
||
<div class="field">
|
||
<label for="fromDate">Von</label>
|
||
<input type="date" id="fromDate">
|
||
</div>
|
||
<div class="field">
|
||
<label for="toDate">Bis</label>
|
||
<input type="date" id="toDate">
|
||
</div>
|
||
<div class="actions">
|
||
<button class="btn" onclick="loadReport()">Anzeigen</button>
|
||
<button class="btn btn-secondary" onclick="window.print()">Drucken</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="errorBox" class="error-msg" style="display:none"></div>
|
||
|
||
<div id="reportContent" style="display:none">
|
||
<div class="info-bar">
|
||
<span id="reportInfo"></span>
|
||
<span id="reportDate"></span>
|
||
</div>
|
||
<table class="timesheet-table" id="timesheetTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Datum</th>
|
||
<th>Typ</th>
|
||
<th>von</th>
|
||
<th>bis</th>
|
||
<th class="ue">Min</th>
|
||
<th class="ue">UE</th>
|
||
<th class="ue">TagesGesMin</th>
|
||
<th class="ue">TagesGesUE</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tbody"></tbody>
|
||
</table>
|
||
|
||
<div class="summary-section" id="summarySection">
|
||
<div class="summary-card">
|
||
<h3>Zusammenfassung nach Typ</h3>
|
||
<table id="typeSummaryTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Typ</th>
|
||
<th class="ue">Termine</th>
|
||
<th class="ue">Min gesamt</th>
|
||
<th class="ue">UE gesamt</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="typeSummaryBody"></tbody>
|
||
</table>
|
||
</div>
|
||
<div class="summary-card">
|
||
<h3>Gesamtsumme</h3>
|
||
<table id="grandTotalTable">
|
||
<tbody id="grandTotalBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="loading" class="loading" style="display:none">Lade Daten…</div>
|
||
<div id="emptyState" class="empty-state" style="display:none">
|
||
<p>Keine Termine im gewählten Zeitraum.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const API = 'https://api.fahrschultermin.de';
|
||
|
||
// ── Auth ────────────────────────────────────────────────────────────────────────
|
||
async function ensureAuth() {
|
||
const r = await fetch(API + '/api/v1/auth/me', { credentials: 'include' });
|
||
if (!r.ok) { window.location.href = '/'; return null; }
|
||
return r.json();
|
||
}
|
||
|
||
async function login(email, password) {
|
||
const r = await fetch(API + '/api/v1/auth/login', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email, password })
|
||
});
|
||
if (!r.ok) throw new Error('Login failed');
|
||
return r.json();
|
||
}
|
||
|
||
// ── Instructors ────────────────────────────────────────────────────────────────
|
||
async function loadInstructors() {
|
||
const r = await fetch(API + '/api/v1/instructors', { credentials: 'include' });
|
||
if (!r.ok) return [];
|
||
const data = await r.json();
|
||
return data.data || [];
|
||
}
|
||
|
||
// ── Report ─────────────────────────────────────────────────────────────────────
|
||
async function loadReport() {
|
||
const instructorId = document.getElementById('instructor').value;
|
||
const from = document.getElementById('fromDate').value;
|
||
const to = document.getElementById('toDate').value;
|
||
|
||
const errorBox = document.getElementById('errorBox');
|
||
errorBox.style.display = 'none';
|
||
|
||
if (!instructorId) { showError('Bitte Fahrlehrer auswählen.'); return; }
|
||
if (!from || !to) { showError('Bitte Von- und Bis-Datum angeben.'); return; }
|
||
if (from > to) { showError('Das Von-Datum muss vor dem Bis-Datum liegen.'); return; }
|
||
|
||
document.getElementById('loading').style.display = 'block';
|
||
document.getElementById('reportContent').style.display = 'none';
|
||
document.getElementById('emptyState').style.display = 'none';
|
||
|
||
try {
|
||
const url = `${API}/api/v1/reports/work-timesheet?instructor_id=${encodeURIComponent(instructorId)}&from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
|
||
const r = await fetch(url, { credentials: 'include' });
|
||
if (!r.ok) {
|
||
const err = await r.json();
|
||
throw new Error(err.message || 'Fehler beim Laden');
|
||
}
|
||
const json = await r.json();
|
||
renderReport(json.data);
|
||
} catch (e) {
|
||
showError(e.message);
|
||
} finally {
|
||
document.getElementById('loading').style.display = 'none';
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
const box = document.getElementById('errorBox');
|
||
box.textContent = msg;
|
||
box.style.display = 'block';
|
||
}
|
||
|
||
function fmtDate(d) {
|
||
// d is YYYY-MM-DD
|
||
const [y, m, day] = d.split('-');
|
||
return `${day}.${m}.${y}`;
|
||
}
|
||
|
||
function fmtMin(n) { return String(n); }
|
||
function fmtUe(n) { return String(n).replace('.', ','); }
|
||
|
||
function renderReport(data) {
|
||
document.getElementById('reportContent').style.display = 'block';
|
||
document.getElementById('emptyState').style.display = 'none';
|
||
|
||
const instructor = data.instructor;
|
||
document.getElementById('reportInfo').innerHTML =
|
||
`<strong>${instructor.first_name} ${instructor.last_name}</strong> · ${fmtDate(data.period.from)} – ${fmtDate(data.period.to)}`;
|
||
document.getElementById('reportDate').textContent = new Date().toLocaleDateString('de-DE');
|
||
|
||
const tbody = document.getElementById('tbody');
|
||
tbody.innerHTML = '';
|
||
|
||
if (!data.days || data.days.length === 0) {
|
||
document.getElementById('reportContent').style.display = 'none';
|
||
document.getElementById('emptyState').style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
data.days.forEach((day, di) => {
|
||
const isLastDay = di === data.days.length - 1;
|
||
|
||
day.appointments.forEach((apt, ai) => {
|
||
const isLastAptOfDay = ai === day.appointments.length - 1;
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'appointment-row';
|
||
|
||
tr.innerHTML = `
|
||
<td>${fmtDate(day.date)}</td>
|
||
<td>${escHtml(apt.type)}</td>
|
||
<td class="time">${apt.start_at}</td>
|
||
<td class="time">${apt.end_at}</td>
|
||
<td class="minutes">${fmtMin(apt.duration_minutes)}</td>
|
||
<td class="ue">${fmtUe(apt.ue)}</td>
|
||
<td class="ue">${isLastAptOfDay ? fmtMin(day.day_total_minutes) : ''}</td>
|
||
<td class="ue">${isLastAptOfDay ? fmtUe(day.day_total_ue) : ''}</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
// Day total row
|
||
const dayTotalTr = document.createElement('tr');
|
||
dayTotalTr.className = 'day-total';
|
||
dayTotalTr.innerHTML = `
|
||
<td colspan="4">TagesSumme</td>
|
||
<td class="ue">${fmtMin(day.day_total_minutes)}</td>
|
||
<td class="ue">${fmtUe(day.day_total_ue)}</td>
|
||
<td class="ue"></td>
|
||
<td class="ue"></td>
|
||
`;
|
||
tbody.appendChild(dayTotalTr);
|
||
|
||
// Separator (except after last day)
|
||
if (!isLastDay) {
|
||
const sep = document.createElement('tr');
|
||
sep.className = 'separator';
|
||
sep.innerHTML = '<td colspan="8"></td>';
|
||
tbody.appendChild(sep);
|
||
}
|
||
});
|
||
|
||
// Type summary
|
||
const tsBody = document.getElementById('typeSummaryBody');
|
||
tsBody.innerHTML = '';
|
||
(data.type_summary || []).forEach(row => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td>${escHtml(row.type)}</td>
|
||
<td class="ue">${row.count}</td>
|
||
<td class="ue">${fmtMin(row.total_minutes)}</td>
|
||
<td class="ue">${fmtUe(row.total_ue)}</td>
|
||
`;
|
||
tsBody.appendChild(tr);
|
||
});
|
||
|
||
// Grand total
|
||
const gtBody = document.getElementById('grandTotalBody');
|
||
gtBody.innerHTML = `
|
||
<tr class="grand-total">
|
||
<td>Gesamt</td>
|
||
<td class="ue">${fmtMin(data.summary.total_minutes)} min</td>
|
||
<td class="ue">${fmtUe(data.summary.total_ue)}</td>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function escHtml(s) {
|
||
if (s === null || s === undefined) return '';
|
||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
// ── Init ───────────────────────────────────────────────────────────────────────
|
||
async function init() {
|
||
let user;
|
||
try {
|
||
user = await ensureAuth();
|
||
} catch {
|
||
user = null;
|
||
}
|
||
|
||
if (!user || !user.user) {
|
||
// Try demo login silently
|
||
try {
|
||
await login('admin@nordring.test', 'secret123');
|
||
user = await ensureAuth();
|
||
} catch {
|
||
document.body.innerHTML = '<div class="container" style="margin-top:40px;text-align:center;color:var(--muted);">Bitte zuerst in der Hauptanwendung einloggen.</div>';
|
||
return;
|
||
}
|
||
}
|
||
|
||
document.getElementById('userName').textContent = user.user.first_name + ' ' + user.user.last_name;
|
||
|
||
// Load instructors
|
||
const instructors = await loadInstructors();
|
||
const sel = document.getElementById('instructor');
|
||
instructors.forEach(i => {
|
||
const opt = document.createElement('option');
|
||
opt.value = i.id;
|
||
opt.textContent = `${i.first_name} ${i.last_name}`;
|
||
sel.appendChild(opt);
|
||
});
|
||
|
||
// Default date range: current month
|
||
const now = new Date();
|
||
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().split('T')[0];
|
||
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().split('T')[0];
|
||
document.getElementById('fromDate').value = firstDay;
|
||
document.getElementById('toDate').value = lastDay;
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|