All checks were successful
release-tag / release-image (push) Successful in 2m6s
367 lines
27 KiB
JavaScript
367 lines
27 KiB
JavaScript
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 => `<option value="${escapeText(x)}"></option>`).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 `<section class="day-group"><div class="day-heading"><strong>${escapeText(dayTitle(g.start))}</strong><span>${duration(dayTotal)}</span></div>${g.items.map(entryHTML).join('')}</section>`;
|
||
}).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 `<button class="entry-row" data-id="${e.id}"><span class="entry-client">${escapeText(e.client||'Kein Kunde')}</span><span class="entry-activity">${escapeText(e.activity||'—')}</span><span class="entry-time">${clock(e.start_ms)}–${clock(e.end_ms)}</span><span class="entry-duration">${duration(rounded(e.end_ms-e.start_ms))}</span><span class="entry-chevron">›</span></button>`}
|
||
|
||
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<start){showError('#entry-error',new Error('Bitte einen gültigen Start- und Endzeitpunkt wählen.'));return}const body={client:$('#edit-client').value,activity:$('#edit-activity').value,start_ms:start,end_ms:end};try{if(id)await api(`/api/entries/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/entries',{method:'POST',body:JSON.stringify(body)});$('#entry-dialog').close();if(state.running?.id===id){state.running=null;renderRunning()}await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);toast('Eintrag gespeichert.')}catch(e){showError('#entry-error',e)}}
|
||
async function deleteEntry(){const id=$('#entry-id').value;if(!id||!confirm('Diesen Eintrag wirklich löschen?'))return;try{await api(`/api/entries/${id}`,{method:'DELETE'});if(state.running?.id===id){state.running=null;renderRunning()}$('#entry-dialog').close();await Promise.all([refreshEntries(true),refreshTotals()]);toast('Eintrag gelöscht.')}catch(e){showError('#entry-error',e)}}
|
||
|
||
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-mobile-compact').checked=s.mobile_compact!==false;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
|
||
async function openSettings(){fillSettings();hideError('#settings-error');$('#settings-dialog').showModal();if(state.me.role==='admin')await loadUsers()}
|
||
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,mobile_compact:$('#setting-mobile-compact').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});applyMobileCompactPreference(state.settings.mobile_compact!==false);$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
|
||
|
||
|
||
async function changeOwnPassword(ev){
|
||
ev.preventDefault(); hideError('#account-password-error');
|
||
try {
|
||
await api('/api/account/password',{method:'POST',body:JSON.stringify({current_password:$('#current-password').value,new_password:$('#own-new-password').value})});
|
||
location.assign('/login');
|
||
} catch(e){ showError('#account-password-error',e); }
|
||
}
|
||
|
||
async function loadUsers(){try{const x=await api('/api/admin/users');$('#user-list').innerHTML=x.users.map(u=>`<div class="user-row" data-user-id="${u.id}"><div class="user-meta"><strong>${escapeText(u.display_name||u.username)}</strong><span>${escapeText(u.username)}</span></div><span class="role-badge">${u.role==='admin'?'Admin':'Benutzer'}</span><button type="button" class="btn subtle user-password" ${u.active?'':'disabled'}>Passwort</button><button type="button" class="btn ${u.active?'danger':'subtle'} user-toggle" ${u.id===state.me.id?'disabled':''}>${u.active?'Deaktivieren':'Aktivieren'}</button></div>`).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<from) { $('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.'; return; }
|
||
try {
|
||
const p=exportParams(false); p.set('limit','1'); p.set('offset','0');
|
||
const result=await api('/api/entries?'+p.toString());
|
||
if(seq===exportPreviewSeq) $('#export-preview').textContent=`${result.total_count} Einträge · ${duration(result.total_duration_ms)}`;
|
||
} catch(e) { if(seq===exportPreviewSeq) $('#export-preview').textContent=e.message; }
|
||
}
|
||
function openExport(){
|
||
const now=new Date(), first=new Date(now.getFullYear(),now.getMonth(),1);
|
||
if(!$('#export-from').value) $('#export-from').value=dateInputValue(first);
|
||
if(!$('#export-to').value) $('#export-to').value=dateInputValue(now);
|
||
updateExportPreview(); $('#export-dialog').showModal();
|
||
}
|
||
function downloadExport(type){
|
||
const p=exportParams(true), a=document.createElement('a');
|
||
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
|
||
}
|
||
|
||
function serviceFilterPayload(){
|
||
const p=exportParams(false);
|
||
return {q:p.get('q')||'',from_ms:Number(p.get('from')||0),to_ms:Number(p.get('to')||0)};
|
||
}
|
||
let servicePreviewSeq=0;
|
||
async function updateServicePreview(){
|
||
const seq=++servicePreviewSeq, client=$('#service-client').value.trim();
|
||
if(!client){$('#service-preview').textContent='Bitte einen Kunden auswählen.';return;}
|
||
try{
|
||
const p=exportParams(false);p.set('client',client);p.set('limit','1');p.set('offset','0');
|
||
const result=await api('/api/entries?'+p.toString());
|
||
if(seq===servicePreviewSeq) $('#service-preview').textContent=result.total_count?`${result.total_count} Tätigkeiten · ${duration(result.total_duration_ms)}`:'Keine abgeschlossenen Tätigkeiten für Kunde/Zeitraum.';
|
||
}catch(e){if(seq===servicePreviewSeq) $('#service-preview').textContent=e.message;}
|
||
}
|
||
function openServiceReport(){
|
||
hideError('#service-error');
|
||
if(($('input[name="export-scope"]:checked')?.value||'view')==='custom'){
|
||
const from=localDayStartMS($('#export-from').value),to=localDayStartMS($('#export-to').value);
|
||
if(!from||!to||to<from){$('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.';return;}
|
||
}
|
||
let client=$('#client-input').value.trim();
|
||
const visible=[...new Set(state.entries.map(e=>(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.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.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();
|