const state = { me: null, csrf: '', settings: null, running: null, entries: [], clients: [], totalCount: 0, totalDuration: 0, filter: { q: '', period: 'all', anchor: new Date(), offset: 0, limit: 120 }, timerTick: null, }; const $ = (s) => document.querySelector(s); const $$ = (s) => [...document.querySelectorAll(s)]; function applyMobileCompactPreference(enabled = true) { document.body.classList.toggle('mobile-compact', !!enabled); const checkbox = $('#setting-mobile-compact'); if (checkbox) checkbox.checked = !!enabled; syncMobileNav(); } function switchMobileView(view) { document.body.classList.toggle('mobile-history', view === 'history'); syncMobileNav(); } function syncMobileNav() { const history = document.body.classList.contains('mobile-history'); const track = $('#mobile-nav-track'), overview = $('#mobile-nav-history'); if (track) { track.classList.toggle('active', !history); if (!history) track.setAttribute('aria-current','page'); else track.removeAttribute('aria-current'); } if (overview) { overview.classList.toggle('active', history); if (history) overview.setAttribute('aria-current','page'); else overview.removeAttribute('aria-current'); } } async function api(url, options = {}) { const headers = { ...(options.headers || {}) }; if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json'; if (state.csrf && options.method && !['GET','HEAD'].includes(options.method.toUpperCase())) headers['X-CSRF-Token'] = state.csrf; const res = await fetch(url, { ...options, headers }); if (res.status === 401) { location.assign('/login'); throw new Error('Nicht angemeldet.'); } const ct = res.headers.get('content-type') || ''; const body = res.status === 204 ? null : (ct.includes('application/json') ? await res.json().catch(() => ({})) : await res.text()); if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`); return body; } function toast(msg) { const el = $('#toast'); el.textContent = msg; el.hidden = false; clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2600); } function showError(sel, err) { const el = $(sel); el.textContent = err?.message || String(err); el.hidden = false; } function hideError(sel) { $(sel).hidden = true; } function pad(n) { return String(n).padStart(2, '0'); } function duration(ms, withSeconds = false) { ms = Math.max(0, ms || 0); const sec = Math.floor(ms / 1000), h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60; return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${h}:${pad(m)}`; } function rounded(ms) { const mins = Math.max(1, Number(state.settings?.rounding_minutes || 1)); if (mins <= 1) return Math.max(0, ms); const step = mins * 60000; return state.settings?.round_up ? Math.ceil(ms / step) * step : Math.round(ms / step) * step; } function clock(ms) { const d = new Date(ms); return new Intl.DateTimeFormat(state.settings?.language === 'en' ? 'en' : 'de-DE', { hour: '2-digit', minute: '2-digit', hour12: state.settings?.time_format === '12' }).format(d); } function localDateTimeValue(ms) { const d = new Date(ms); const off = d.getTimezoneOffset(); return new Date(d.getTime() - off * 60000).toISOString().slice(0,16); } function msFromLocalValue(v) { return v ? new Date(v).getTime() : null; } function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; } function dayTitle(ms) { const d = new Date(ms), now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime(); if (target === today) return 'Heute'; const yd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); yd.setDate(yd.getDate()-1); if (target === yd.getTime()) return 'Gestern'; return new Intl.DateTimeFormat('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' }).format(d); } function initials(s) { return (s || '?').trim().split(/\s+/).slice(0,2).map(x=>x[0]?.toUpperCase()||'').join('') || '?'; } function escapeText(s) { const x=document.createElement('span'); x.textContent=s??''; return x.innerHTML; } function rangeForFilter() { const { period, anchor } = state.filter; if (period === 'all') return { from:0, to:0, label:'Alle Zeiten' }; let from, to; const d = new Date(anchor); d.setHours(0,0,0,0); if (period === 'day') { from = d; to = new Date(d); to.setDate(to.getDate()+1); } if (period === 'week') { const wd = (d.getDay()+6)%7; d.setDate(d.getDate()-wd); from = new Date(d); to = new Date(d); to.setDate(to.getDate()+7); } if (period === 'month') { from = new Date(d.getFullYear(),d.getMonth(),1); to = new Date(d.getFullYear(),d.getMonth()+1,1); } if (period === 'year') { from = new Date(d.getFullYear(),0,1); to = new Date(d.getFullYear()+1,0,1); } let label=''; if (period === 'day') label = new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(from); if (period === 'week') label = `${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit'}).format(from)} – ${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(new Date(to.getTime()-1))}`; if (period === 'month') label = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(from); if (period === 'year') label = String(from.getFullYear()); return { from: from.getTime(), to: to.getTime(), label }; } function stepPeriod(delta) { const d = new Date(state.filter.anchor), p = state.filter.period; if (p === 'day') d.setDate(d.getDate()+delta); if (p === 'week') d.setDate(d.getDate()+7*delta); if (p === 'month') d.setMonth(d.getMonth()+delta); if (p === 'year') d.setFullYear(d.getFullYear()+delta); state.filter.anchor = d; state.filter.offset = 0; refreshEntries(); } function queryString(includePaging = true) { const r = rangeForFilter(); const p = new URLSearchParams(); if (state.filter.q) p.set('q', state.filter.q); if (r.from) p.set('from', r.from); if (r.to) p.set('to', r.to); if (includePaging) { p.set('limit', state.filter.limit); p.set('offset', state.filter.offset); } return p.toString(); } async function init() { try { const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token; const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]); state.settings = settings; state.running = running.entry; state.clients = clients.clients || []; applyMobileCompactPreference(state.settings.mobile_compact !== false); const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') { state.settings.timezone = tz; try { state.settings = await api('/api/settings', { method:'PUT', body:JSON.stringify(state.settings) }); } catch (_) {} } renderIdentity(); renderClients(); renderRunning(); fillSettings(); await Promise.all([refreshEntries(), refreshTotals()]); startTicker(); } catch (e) { toast(e.message); } } function renderIdentity() { $('#user-label').textContent = state.me.display_name || state.me.username; $('#avatar-text').textContent = initials(state.me.display_name || state.me.username); } function renderClients() { $('#client-list').innerHTML = state.clients.map(x => ``).join(''); } function renderRunning() { const e = state.running; $('#running-hint').hidden = !e; $('#edit-running').hidden = !e; $('#timer-button').textContent = e ? '■ Stop' : '▶ Start'; $('#timer-display').classList.toggle('running', !!e); $('#client-input').disabled = !!e; if (e) { $('#client-input').value=e.client||''; $('#activity-input').value=e.activity||''; } updateTicker(); } function startTicker() { clearInterval(state.timerTick); state.timerTick = setInterval(updateTicker, 1000); updateTicker(); } function updateTicker() { const e=state.running, elapsed=e ? Date.now()-e.start_ms : 0; $('#timer-display').textContent=duration(elapsed,true); const warn=!!(e && state.settings?.long_run_reminder && elapsed>8*3600000); $('#long-running-warning').hidden=!warn; } async function timerToggle() { try { if (!state.running) { const x = await api('/api/entries/start', { method:'POST', body:JSON.stringify({ client:$('#client-input').value, activity:$('#activity-input').value, start_ms:Date.now() }) }); state.running=x; renderRunning(); toast('Timer gestartet.'); } else { await saveRunningActivity(); await api(`/api/entries/${state.running.id}/stop`, { method:'POST', body:JSON.stringify({ end_ms:Date.now() }) }); state.running=null; $('#client-input').disabled=false; $('#client-input').value=''; $('#activity-input').value=''; renderRunning(); toast('Timer gestoppt.'); await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]); } } catch(e) { toast(e.message); } } async function saveRunningActivity() { if (!state.running) return; const activity=$('#activity-input').value; if (activity===state.running.activity) return; const x=await api(`/api/entries/${state.running.id}`,{method:'PUT',body:JSON.stringify({client:state.running.client,activity,start_ms:state.running.start_ms,end_ms:null})}); state.running=x; } async function refreshClients(){const x=await api('/api/clients');state.clients=x.clients||[];renderClients();} async function refreshTotals() { const now=new Date(); const today=new Date(now.getFullYear(),now.getMonth(),now.getDate()); const week=new Date(today); week.setDate(week.getDate()-((week.getDay()+6)%7)); try { const [t,w]=await Promise.all([ api(`/api/entries?from=${today.getTime()}&to=${new Date(today.getFullYear(),today.getMonth(),today.getDate()+1).getTime()}&limit=1&offset=0`), api(`/api/entries?from=${week.getTime()}&limit=1&offset=0`) ]); $('#today-total').textContent=duration(t.total_duration_ms); $('#week-total').textContent=duration(w.total_duration_ms); $('#header-week').textContent=duration(w.total_duration_ms); $('#week-badge').hidden=!state.settings.show_week_total; } catch(e){ console.warn(e); } } async function refreshEntries(reset=true) { if(reset){state.filter.offset=0;state.entries=[];} const range=rangeForFilter(); $('#period-label').textContent=range.label; try { const p=await api('/api/entries?'+queryString(true)); state.totalCount=p.total_count;state.totalDuration=p.total_duration_ms; state.entries=reset?p.entries:[...state.entries,...p.entries]; renderEntries(); } catch(e){toast(e.message);} } function renderEntries() { $('#result-count').textContent=state.totalCount; $('#result-total').textContent=duration(state.totalDuration); $('#empty-state').hidden=state.totalCount!==0; $('#load-more').hidden=state.entries.length>=state.totalCount; const groups=[]; let current=null; for(const e of state.entries){const k=dayKey(e.start_ms);if(!current||current.key!==k){current={key:k,start:e.start_ms,items:[]};groups.push(current)}current.items.push(e)} $('#entries').innerHTML=groups.map(g=>{ const dayTotal=g.items.reduce((n,e)=>n+rounded((e.end_ms||e.start_ms)-e.start_ms),0); return `
${escapeText(dayTitle(g.start))}${duration(dayTotal)}
${g.items.map(entryHTML).join('')}
`; }).join(''); $$('.entry-row').forEach(el=>el.addEventListener('click',()=>openEntry(el.dataset.id))); $$('.day-heading').forEach(el=>el.style.position=state.settings.sticky_days?'sticky':'static'); } function entryHTML(e){return ``} function openEntry(id=null, running=false) { hideError('#entry-error'); let e = null; if (running) e=state.running; else if(id) e=state.entries.find(x=>x.id===id); $('#entry-id').value=e?.id||''; $('#edit-client').value=e?.client||''; $('#edit-activity').value=e?.activity||''; const now=Date.now(); $('#edit-start').value=localDateTimeValue(e?.start_ms||now); $('#edit-end').value=e?.end_ms?localDateTimeValue(e.end_ms):(running?'':localDateTimeValue(now+3600000)); $('#entry-dialog-title').textContent=e?'Eintrag bearbeiten':'Zeit nachtragen'; $('#delete-entry').hidden=!e; updateEditDuration(); $('#entry-dialog').showModal(); } function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e=msFromLocalValue($('#edit-end').value);$('#edit-duration').textContent=s&&e&&e>=s?duration(rounded(e-s)):'–'} async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end`
${escapeText(u.display_name||u.username)}${escapeText(u.username)}
${u.role==='admin'?'Admin':'Benutzer'}
`).join('');$$('.user-password').forEach(b=>b.addEventListener('click',()=>openPassword(b.closest('.user-row').dataset.userId)));$$('.user-toggle').forEach(b=>b.addEventListener('click',()=>toggleUser(b.closest('.user-row').dataset.userId,b.textContent==='Aktivieren')))}catch(e){showError('#settings-error',e)}} function openPassword(id){$('#password-user-id').value=id;$('#reset-password').value='';hideError('#password-error');$('#password-dialog').showModal()} async function toggleUser(id,active){try{await api(`/api/admin/users/${id}`,{method:'PATCH',body:JSON.stringify({active})});await loadUsers();toast(active?'Benutzer aktiviert.':'Benutzer deaktiviert.')}catch(e){showError('#settings-error',e)}} async function createUser(ev){ev.preventDefault();hideError('#user-error');const body={username:$('#new-username').value,displayName:$('#new-display-name').value,password:$('#new-password').value,role:$('#new-role').value};try{await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)});$('#user-dialog').close();ev.currentTarget.reset();await loadUsers();toast('Benutzer angelegt.')}catch(e){showError('#user-error',e)}} async function resetPassword(ev){ev.preventDefault();hideError('#password-error');try{await api(`/api/admin/users/${$('#password-user-id').value}/password`,{method:'POST',body:JSON.stringify({password:$('#reset-password').value})});$('#password-dialog').close();toast('Passwort zurückgesetzt.')}catch(e){showError('#password-error',e)}} function dateInputValue(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; } function localDayStartMS(value) { if (!value) return 0; const [y,m,d]=value.split('-').map(Number); return new Date(y,m-1,d,0,0,0,0).getTime(); } function exportParams(includePresentation=true) { const p=new URLSearchParams(); const scope=$('input[name="export-scope"]:checked')?.value||'view'; if(scope==='view') { const r=rangeForFilter(); if(state.filter.q) p.set('q',state.filter.q); if(r.from) p.set('from',r.from); if(r.to) p.set('to',r.to); } else { const from=localDayStartMS($('#export-from').value); const toStart=localDayStartMS($('#export-to').value); if(from) p.set('from',from); if(toStart) { const end=new Date(toStart); end.setDate(end.getDate()+1); p.set('to',end.getTime()); } } if(includePresentation) { if($('#export-sort').value==='asc') p.set('sort','asc'); if($('#export-compact').checked) p.set('compact','1'); } return p; } let exportPreviewSeq=0; async function updateExportPreview() { const seq=++exportPreviewSeq; const scope=$('input[name="export-scope"]:checked')?.value||'view'; $('#export-custom').hidden=scope!=='custom'; if(scope==='view') { $('#export-preview').textContent=`${state.totalCount} Einträge · ${duration(state.totalDuration)}`; return; } const from=localDayStartMS($('#export-from').value), to=localDayStartMS($('#export-to').value); if(!from||!to||to(e.client||'').trim()).filter(Boolean))]; if(!client&&visible.length===1) client=visible[0]; if(!client&&state.filter.q){const exact=state.clients.find(x=>x.toLowerCase()===state.filter.q.toLowerCase());if(exact)client=exact;} $('#service-client').value=client; if(!$('#service-date').value) $('#service-date').value=dateInputValue(new Date()); $('#export-dialog').close(); $('#service-report-dialog').showModal(); updateServicePreview(); } async function downloadServiceReport(ev){ ev.preventDefault();hideError('#service-error'); const submit=$('#service-report-form button[type="submit"]');submit.disabled=true; const body={...serviceFilterPayload(),client:$('#service-client').value.trim(),contact:$('#service-contact').value.trim(),location:$('#service-location').value.trim(),order_number:$('#service-order').value.trim(),subject:$('#service-subject').value.trim(),notes:$('#service-notes').value.trim(),place:$('#service-place').value.trim(),report_date:$('#service-date').value}; try{ const res=await fetch('/api/service-report.pdf',{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':state.csrf},body:JSON.stringify(body)}); if(res.status===401){location.assign('/login');return;} if(!res.ok){const ct=res.headers.get('content-type')||'';const x=ct.includes('application/json')?await res.json().catch(()=>({})):await res.text();throw new Error(x?.error?.message||x||`HTTP ${res.status}`);} const blob=await res.blob(),url=URL.createObjectURL(blob),a=document.createElement('a'); a.href=url;a.download='service-bericht.pdf';document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000); $('#service-report-dialog').close();toast('Service-Bericht erstellt.'); }catch(e){showError('#service-error',e);}finally{submit.disabled=false;} } async function logout() { if(!confirm('Abmelden?')) return; try { await api('/api/logout',{method:'POST',body:'{}'}); } finally { location.assign('/login'); } } function wireEvents(){ $('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().catch(e=>toast(e.message))); $('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry()); $('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration); $('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword); $('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $('#export-service').addEventListener('click',openServiceReport); $('#service-report-form').addEventListener('submit',downloadServiceReport); let serviceClientTimer; $('#service-client').addEventListener('input',()=>{clearTimeout(serviceClientTimer);serviceClientTimer=setTimeout(updateServicePreview,220)}); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview); $('#account-menu').addEventListener('click',logout); $('#mobile-logout').addEventListener('click',logout); let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)}); $$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)})); $('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1)); $('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)}); $$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close())); $$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientXr.right||e.clientYr.bottom)d.close()})); $('#mobile-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-track').addEventListener('click',()=>switchMobileView('track')); $('#mobile-nav-history').addEventListener('click',()=>switchMobileView('history')); $('#mobile-nav-settings').addEventListener('click',openSettings); } wireEvents(); init();