Files
drivetimeplaner/www/fahrschultermin.de/public/zeiterfassung.html
Hermes Agent 5d87e4975c Add HttpOnly cookie auth for cross-domain SPA login
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
2026-06-04 20:33:31 +02:00

542 lines
19 KiB
HTML
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── 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>