// assets/scripts/entries.js import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js'; const LAST_PROJECT_KEY = 'tt_last_project_id'; const LAST_SERVICE_KEY = 'tt_last_service_id'; const LOCK_SVG = ``; function t(key) { return window.TT?.i18n?.[key] ?? key; } 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 = ``; if (billable.length) { html += ``; billable.forEach(s => { const sel = String(s.id) === String(selectedId) ? ' selected' : ''; html += ``; }); html += ''; } if (notBillable.length) { html += ``; notBillable.forEach(s => { const sel = String(s.id) === String(selectedId) ? ' selected' : ''; html += ``; }); html += ''; } return html; } function buildEntryRowHTML(entry, animate = false) { const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; const notePart = entry.note ? `
${entry.note}
` : ''; const invoiced = !!entry.invoiced; const actionsHtml = invoiced ? `${entry.durationFormatted} ${LOCK_SVG}` : `${entry.durationFormatted} `; const editFormHtml = invoiced ? '' : ` `; return `
${entry.clientName} / ${entry.projectName}${servicePart}
${notePart}
${actionsHtml}
${editFormHtml}
`; } 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'); document.getElementById('create-service')?.addEventListener('change', e => { saveLastService(e.target.value); }); document.getElementById('create-project')?.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; } // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { this.openEdit(row); } } async createEntry() { 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 duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); if (duration === '0:00') { alert(t('errorZeroDuration')); return; } const rawMinutes = roundToQuarter(parseDuration(durationRaw)); const validation = validateDuration(rawMinutes); if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } try { const res = await fetch('/api/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ date: window.TT.activeDate, duration, projectId: parseInt(projectId), serviceId: serviceId ? parseInt(serviceId) : null, note: note || null, }), }); if (!res.ok) { const err = await res.json().catch(() => ({})); console.error('API Fehler:', res.status, err); 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 (err) { console.error('Netzwerkfehler:', err); alert(t('errorSave')); } } 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}`); requestAnimationFrame(() => requestAnimationFrame(() => { el?.classList.remove('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) { // Safety-Guard: invoiced-Einträge können nicht geöffnet werden if (row.dataset.invoiced === 'true') return; // Kein Edit-Formular vorhanden → nicht öffnen 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 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 duration = formatMinutes(roundToQuarter(parseDuration(durationRaw))); if (duration === '0:00') { alert(t('errorZeroDuration')); return; } const rawMinutes = roundToQuarter(parseDuration(durationRaw)); const validation = validateDuration(rawMinutes); if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; const currentEntryMinutes = parseInt(row.dataset.duration) || 0; if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; } try { const res = await fetch(`/api/entries/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration, projectId: parseInt(projectId), serviceId: serviceId ? parseInt(serviceId) : null, note: note || null, }), }); if (!res.ok) { console.error('PATCH fehlgeschlagen:', res.status); alert(t('errorSave')); return; } const data = await res.json(); this.updateRowDisplay(row, data.entry); this.updateTotal(data.totalDuration); this.closeEdit(row); } catch (err) { console.error('saveEdit Fehler:', err); alert(t('errorSave')); } } 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(); row.classList.add('entry-row--removing'); setTimeout(() => { row.remove(); this.updateTotal(data.totalDuration); this.checkIfEmpty(); }, 280); } 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, 180)); 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 += `
${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 = `${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'); } } } function getDailyTotalMinutes() { let total = 0; document.querySelectorAll('#entry-items .entry-row').forEach(row => { total += parseInt(row.dataset.duration) || 0; }); return total; } function saveLastProject(projectId) { if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId); } function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); } function saveLastService(serviceId) { if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId); } function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); } // ── Minimal-Modus-Initialisierung ───────────────────────────────────────────── const NOTE_KEY = 'tt_minimal_note_open'; 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 ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen'; 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 = '× Bemerkung ausblenden'; } else { btn.textContent = '+ Bemerkung hinzufügen'; } } function initEntriesToggle() { const summaryBtn = document.getElementById('btn-entries-toggle'); const entryList = document.getElementById('entry-list'); if (!summaryBtn || !entryList) return; // Immer eingeklappt beim Laden 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(); });