// assets/scripts/entries.js import { parseAndValidate, initDurationBlurHandler } from './duration.js'; import { esc, createTranslator, ANIMATION_MS, FADE_MS, MINUTES_PER_DAY, removeWithAnimation, animateIn } from './utils.js'; const LAST_PROJECT_KEY = 'tt_last_project_id'; const LAST_SERVICE_KEY = 'tt_last_service_id'; const NOTE_KEY = 'tt_minimal_note_open'; const LOCK_SVG = ``; const t = createTranslator('TT'); // ── Select-Builder ─────────────────────────────────────────────────────────── function buildProjectOptions(selectedId = null) { const groups = {}; (window.TT?.projects ?? []).forEach(p => { if (!groups[p.clientName]) groups[p.clientName] = []; groups[p.clientName].push(p); }); let html = ``; for (const [client, projects] of Object.entries(groups)) { html += ``; projects.forEach(p => { const sel = String(p.id) === String(selectedId) ? ' selected' : ''; html += ``; }); html += ''; } return html; } function buildServiceOptions(selectedId = null) { const billable = (window.TT?.services ?? []).filter(s => s.billable); const notBillable = (window.TT?.services ?? []).filter(s => !s.billable); let html = ``; const addGroup = (label, list) => { if (!list.length) return; html += ``; list.forEach(s => { const sel = String(s.id) === String(selectedId) ? ' selected' : ''; html += ``; }); html += ''; }; addGroup(t('billable'), billable); addGroup(t('notBillable'), notBillable); return html; } // ── Row HTML ───────────────────────────────────────────────────────────────── function buildEntryRowHTML(entry, animate = false) { const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; const notePart = entry.note ? `
${esc(entry.note)}
` : ''; const invoiced = !!entry.invoiced; const actionsHtml = invoiced ? `${esc(entry.durationFormatted)} ${LOCK_SVG}` : `${esc(entry.durationFormatted)} `; const editFormHtml = invoiced ? '' : ` `; return `
${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}
${notePart}
${actionsHtml}
${editFormHtml}
`; } // ── Helpers ────────────────────────────────────────────────────────────────── function getDailyTotalMinutes() { let total = 0; document.querySelectorAll('#entry-items .entry-row').forEach(row => { total += parseInt(row.dataset.duration, 10) || 0; }); return total; } function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); } function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); } function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); } function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); } // ── EntryManager ───────────────────────────────────────────────────────────── class EntryManager { constructor() { this.list = document.getElementById('entry-list'); this.emptyState = document.getElementById('empty-state'); if (!this.list) return; const cp = document.getElementById('create-project'); const cs = document.getElementById('create-service'); cs?.addEventListener('change', e => saveLastService(e.target.value)); cp?.addEventListener('change', e => saveLastProject(e.target.value)); if (cp) { const lastProject = getLastProject(); cp.innerHTML = buildProjectOptions(lastProject); if (lastProject) cp.value = lastProject; } if (cs) { const lastService = getLastService(); cs.innerHTML = buildServiceOptions(lastService); if (lastService) cs.value = lastService; } this.list.querySelectorAll('.entry-row').forEach(row => { const ep = row.querySelector('.edit-project'); const es = row.querySelector('.edit-service'); if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId); if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId); }); this.list.addEventListener('click', e => this.handleListClick(e)); document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); this.checkAutoEdit(); } handleListClick(e) { const row = e.target.closest('.entry-row'); if (!row) return; const actionEl = e.target.closest('[data-action]'); if (actionEl) { const action = actionEl.dataset.action; if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return; switch (action) { case 'edit': this.openEdit(row); break; case 'delete': this.deleteEntry(row); break; case 'save': this.saveEdit(row); break; case 'cancel': this.closeEdit(row); break; } return; } if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { this.openEdit(row); } } async createEntry() { const btn = document.getElementById('btn-create'); const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; const projectId = document.getElementById('create-project')?.value; const serviceId = document.getElementById('create-service')?.value; const note = document.getElementById('create-note')?.value; if (!projectId) { alert(t('errorNoProject')); return; } const dur = parseAndValidate(durationRaw); if (dur.error) { alert(t(dur.error)); return; } if (dur.warn && !confirm(t(dur.warn))) return; if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; } if (btn) btn.disabled = true; try { const res = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: window.TT.activeDate, duration: dur.formatted, projectId: parseInt(projectId, 10), serviceId: serviceId ? parseInt(serviceId, 10) : null, note: note || null, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); alert(t('errorSave') + (err.error ? `\n${err.error}` : '')); return; } const data = await res.json(); this.addEntryToDOM(data.entry); this.updateTotal(data.totalDuration); this.resetCreateForm(); } catch { alert(t('errorSave')); } finally { if (btn) btn.disabled = false; } } addEntryToDOM(entry) { this.hideEmptyState(); let items = document.getElementById('entry-items'); if (!items) { items = document.createElement('div'); items.className = 'entry-list__items'; items.id = 'entry-items'; this.list.prepend(items); } items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true)); const el = document.getElementById(`entry-${entry.id}`); if (el) animateIn(el, 'entry-row--new'); } resetCreateForm() { const d = document.getElementById('create-duration'); const p = document.getElementById('create-project'); const s = document.getElementById('create-service'); const n = document.getElementById('create-note'); if (d) d.value = '0:00'; if (n) n.value = ''; if (p) p.value = getLastProject() ?? ''; if (s) s.value = getLastService() ?? ''; } openEdit(row) { if (row.dataset.invoiced === 'true') return; const editSection = row.querySelector('.entry-row__edit'); if (!editSection) return; row.querySelector('.entry-row__display').hidden = true; editSection.hidden = false; row.querySelector('.edit-duration')?.focus(); } closeEdit(row) { row.querySelector('.entry-row__display').hidden = false; row.querySelector('.entry-row__edit').hidden = true; } checkAutoEdit() { const params = new URLSearchParams(window.location.search); const editId = params.get('editEntry'); if (!editId) return; const row = document.getElementById(`entry-${editId}`); if (row) { this.openEdit(row); params.delete('editEntry'); const newUrl = window.location.pathname + (params.size > 0 ? '?' + params.toString() : ''); history.replaceState(null, '', newUrl); } } async saveEdit(row) { const saveBtn = row.querySelector('[data-action="save"]'); if (saveBtn?.disabled) return; const id = row.dataset.id; const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; const projectId = row.querySelector('.edit-project')?.value; const serviceId = row.querySelector('.edit-service')?.value; const note = row.querySelector('.edit-note')?.value; if (!projectId) { alert(t('errorNoProject')); return; } const dur = parseAndValidate(durationRaw); if (dur.error) { alert(t(dur.error)); return; } if (dur.warn && !confirm(t(dur.warn))) return; const currentMinutes = parseInt(row.dataset.duration, 10) || 0; if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; } if (saveBtn) saveBtn.disabled = true; try { const res = await fetch(`/api/entries/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration: dur.formatted, projectId: parseInt(projectId, 10), serviceId: serviceId ? parseInt(serviceId, 10) : null, note: note || null, }), }); if (!res.ok) { alert(t('errorSave')); return; } const data = await res.json(); this.updateRowDisplay(row, data.entry); this.updateTotal(data.totalDuration); this.closeEdit(row); } catch { alert(t('errorSave')); } finally { if (saveBtn) saveBtn.disabled = false; } } updateRowDisplay(row, entry) { const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; row.querySelector('.entry-row__title').textContent = `${entry.clientName} / ${entry.projectName}${servicePart}`; row.querySelector('.entry-row__note')?.remove(); if (entry.note) { const noteEl = document.createElement('div'); noteEl.className = 'entry-row__note'; noteEl.textContent = entry.note; row.querySelector('.entry-row__info').appendChild(noteEl); } row.querySelector('.entry-row__badge').textContent = entry.durationFormatted; row.dataset.duration = entry.duration; row.dataset.projectId = entry.projectId; row.dataset.serviceId = entry.serviceId ?? ''; row.dataset.note = entry.note ?? ''; row.querySelector('.edit-duration').value = entry.durationFormatted; row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId); row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId); row.querySelector('.edit-note').value = entry.note ?? ''; } async deleteEntry(row) { if (!confirm(t('confirmDelete'))) return; try { const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' }); if (!res.ok) { alert(t('errorDelete')); return; } const data = await res.json(); removeWithAnimation(row, 'entry-row--removing'); setTimeout(() => { this.updateTotal(data.totalDuration); this.checkIfEmpty(); }, ANIMATION_MS); } catch { alert(t('errorDelete')); } } async loadEntriesForDate(dateStr) { window.TT.activeDate = dateStr; try { this.list.classList.add('entry-list--fading'); await new Promise(r => setTimeout(r, FADE_MS)); const res = await fetch(`/api/entries?date=${dateStr}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); this.renderEntries(data.entries, data.totalDuration); } catch (err) { console.error(t('errorLoad'), err); } finally { this.list.classList.remove('entry-list--fading'); } } renderEntries(entries, totalDuration) { if (!entries.length) { this.list.innerHTML = `

${t('noEntries')}

`; this.emptyState = this.list.querySelector('#empty-state'); return; } let html = '
'; entries.forEach(e => { html += buildEntryRowHTML(e, false); }); html += `
${esc(totalDuration)}
`; this.list.innerHTML = html; this.emptyState = null; this.list.querySelectorAll('.entry-row').forEach(row => { row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId); row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId); }); this.checkAutoEdit(); } updateTotal(totalDuration) { let footer = document.getElementById('entry-footer'); if (!footer) { footer = document.createElement('div'); footer.className = 'entry-list__footer'; footer.id = 'entry-footer'; this.list.appendChild(footer); } footer.innerHTML = `${esc(totalDuration)}`; } hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; } checkIfEmpty() { const items = document.getElementById('entry-items'); if (items && !items.children.length) { items.remove(); document.getElementById('entry-footer')?.remove(); this.list.innerHTML = `

${t('noEntries')}

`; this.emptyState = this.list.querySelector('#empty-state'); } } } // ── Minimal-Modus ──────────────────────────────────────────────────────────── function initMinimalMode() { if (document.body.dataset.theme !== 'minimal') return; initWeekToggle(); initNoteToggle(); initEntriesToggle(); } function initWeekToggle() { const btn = document.getElementById('btn-week-toggle'); const collapsible = document.getElementById('tt-header-collapsible'); if (!btn || !collapsible) return; btn.setAttribute('aria-expanded', 'false'); collapsible.classList.remove('is-open'); let kw = btn.textContent.trim().match(/\d+/)?.[0] ?? ''; btn.textContent = kw ? `KW ${kw} ▾` : '▾'; btn.addEventListener('click', () => { const open = collapsible.classList.toggle('is-open'); btn.setAttribute('aria-expanded', String(open)); btn.textContent = kw ? `KW ${kw} ${open ? '▴' : '▾'}` : (open ? '▴' : '▾'); }); window._updateWeekToggle = (newKw) => { kw = String(newKw); const open = collapsible.classList.contains('is-open'); btn.textContent = `KW ${kw} ${open ? '▴' : '▾'}`; }; } function initNoteToggle() { const btn = document.getElementById('btn-note-toggle'); const label = document.querySelector('.entry-form__label--note'); const field = document.querySelector('.entry-form__field--note'); if (!btn) return; const open = localStorage.getItem(NOTE_KEY) === '1'; setNoteVisible(open, btn, label, field); btn.addEventListener('click', () => { const nowOpen = label?.classList.toggle('is-visible'); field?.classList.toggle('is-visible'); btn.classList.toggle('is-open', !!nowOpen); btn.textContent = nowOpen ? t('noteHide') : t('noteShow'); localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0'); }); } function setNoteVisible(open, btn, label, field) { if (open) { label?.classList.add('is-visible'); field?.classList.add('is-visible'); btn.classList.add('is-open'); btn.textContent = t('noteHide'); } else { btn.textContent = t('noteShow'); } } function initEntriesToggle() { const summaryBtn = document.getElementById('btn-entries-toggle'); const entryList = document.getElementById('entry-list'); if (!summaryBtn || !entryList) return; entryList.classList.add('is-collapsed'); summaryBtn.setAttribute('aria-expanded', 'false'); summaryBtn.addEventListener('click', () => { const collapsed = entryList.classList.toggle('is-collapsed'); summaryBtn.setAttribute('aria-expanded', String(!collapsed)); }); } window.entryManager = null; document.addEventListener('DOMContentLoaded', () => { initDurationBlurHandler(); initMinimalMode(); window.entryManager = new EntryManager(); });