// assets/scripts/report.js import { parseDuration, roundToQuarter, formatMinutes, validateDuration, initDurationBlurHandler, } from './duration.js'; // ── Hilfsfunktionen ─────────────────────────────────────────────────────────── function t(key) { return window.Report?.i18n?.[key] ?? key; } function populateProjectSelect(select, selectedId) { const projects = window.Report?.projects ?? []; select.innerHTML = ''; projects.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = `${p.clientName} / ${p.name}`; if (p.id === selectedId) opt.selected = true; select.appendChild(opt); }); } function populateServiceSelect(select, selectedId) { const services = window.Report?.services ?? []; const billable = services.filter(s => s.billable); const notBillable = services.filter(s => !s.billable); select.innerHTML = ``; function addGroup(label, list) { if (!list.length) return; const group = document.createElement('optgroup'); group.label = label; list.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; if (s.id === selectedId) opt.selected = true; group.appendChild(opt); }); select.appendChild(group); } addGroup(t('billable'), billable); addGroup(t('notBillable'), notBillable); } // ── Edit öffnen ─────────────────────────────────────────────────────────────── function openEdit(row) { document.querySelectorAll('.report-table__row--editing').forEach(r => { if (r !== row) closeEdit(r); }); const editForm = row.querySelector('.report-row__edit'); if (!editForm) return; // Selects klonen um akkumulierte Listener zu vermeiden const oldProjectSel = row.querySelector('.edit-project'); const oldServiceSel = row.querySelector('.edit-service'); const projectSel = oldProjectSel.cloneNode(false); const serviceSel = oldServiceSel.cloneNode(false); oldProjectSel.replaceWith(projectSel); oldServiceSel.replaceWith(serviceSel); const projectId = parseInt(row.dataset.projectId) || null; const serviceId = parseInt(row.dataset.serviceId) || null; populateProjectSelect(projectSel, projectId); populateServiceSelect(serviceSel, serviceId); projectSel.addEventListener('change', () => { populateServiceSelect(row.querySelector('.edit-service'), null); }); editForm.hidden = false; row.classList.add('report-table__row--editing'); row.querySelector('.edit-duration')?.focus(); } function closeEdit(row) { const editForm = row.querySelector('.report-row__edit'); if (!editForm) return; editForm.hidden = true; row.classList.remove('report-table__row--editing'); } // ── Speichern ───────────────────────────────────────────────────────────────── async function saveEdit(row) { const id = row.dataset.entryId; 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 rawMinutes = roundToQuarter(parseDuration(durationRaw)); if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; } const validation = validateDuration(rawMinutes); if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; } if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return; try { const res = await fetch(`/api/entries/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration: formatMinutes(rawMinutes), projectId: parseInt(projectId), serviceId: serviceId ? parseInt(serviceId) : null, note: note || null, }), }); if (!res.ok) { alert(t('errorSave')); return; } window.location.reload(); } catch { alert(t('errorSave')); } } // ── Löschen ─────────────────────────────────────────────────────────────────── async function deleteEntry(row) { if (!confirm(t('confirmDelete'))) return; const id = row.dataset.entryId; try { const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' }); if (!res.ok) { alert(t('errorDelete')); return; } window.location.reload(); } catch { alert(t('errorDelete')); } } // ── Abgerechnet toggeln ─────────────────────────────────────────────────────── async function toggleInvoiced(row) { const id = row.dataset.entryId; const btn = row.querySelector('[data-action="toggle-invoiced"]'); try { const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); if (!res.ok) return; const data = await res.json(); const invoiced = data.invoiced; row.dataset.invoiced = invoiced ? 'true' : 'false'; row.classList.toggle('report-table__row--invoiced', invoiced); if (btn) { btn.classList.toggle('report-lock--invoiced', invoiced); btn.title = invoiced ? t('btnUnlock') : t('btnLock'); } } catch (err) { console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); } } // ── Event-Delegation ────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { initDurationBlurHandler(); const table = document.querySelector('.report-table'); if (!table) return; table.addEventListener('click', e => { const btn = e.target.closest('[data-action]'); if (!btn) return; const row = btn.closest('.report-table__row'); if (!row) return; switch (btn.dataset.action) { case 'edit': openEdit(row); break; case 'cancel': closeEdit(row); break; case 'save': saveEdit(row); break; case 'delete': deleteEntry(row); break; case 'toggle-invoiced': toggleInvoiced(row); break; } }); }); // ── ReportFilter ────────────────────────────────────────────────────────────── class ReportFilter { constructor() { this.panel = document.getElementById('report-filter'); this.toggleBtn = document.getElementById('btn-filter-toggle'); this.applyBtn = document.getElementById('btn-filter-apply'); this.hideBtn = document.getElementById('btn-filter-hide'); this.periodSel = document.querySelector('.filter-period-select'); this.customDates = document.querySelector('.filter-custom-dates'); } init() { if (!this.panel) return; // Toolbar-Toggle this.toggleBtn?.addEventListener('click', () => this.togglePanel()); // Ausblenden-Button this.hideBtn?.addEventListener('click', () => this.hidePanel()); // Filtern-Button this.applyBtn?.addEventListener('click', () => this.applyFilters()); // Checkbox-Änderungen this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => { cb.addEventListener('change', () => { const row = cb.closest('.filter-row'); this.syncRowState(row, cb.checked); }); }); // Klick auf ausgegrautem Control → Checkbox aktivieren this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { el.addEventListener('mousedown', () => this.activateRowByControl(el)); }); // Zeitraum-Select → Custom-Felder zeigen/verstecken this.periodSel?.addEventListener('change', () => { const row = this.periodSel.closest('.filter-row'); this.activateRowByControl(this.periodSel); this.toggleCustomDates(this.periodSel.value === 'custom'); }); // Plus-Buttons this.panel.querySelectorAll('.filter-row__add').forEach(btn => { btn.addEventListener('click', () => this.addControl(btn)); }); // Remove-Buttons (via Delegation, da sie dynamisch entstehen) this.panel.addEventListener('click', e => { const removeBtn = e.target.closest('.filter-row__remove'); if (removeBtn) this.removeControl(removeBtn); }); // Select-Änderung → Optionen in der Gruppe aktualisieren this.panel.addEventListener('change', e => { const sel = e.target.closest('.filter-select'); if (!sel) return; const container = sel.closest('.filter-row__controls'); if (container) this.refreshGroupSelects(container); }); // Initialer Zustand this.panel.querySelectorAll('.filter-row').forEach(row => { const cb = row.querySelector('.filter-row__checkbox'); this.syncRowState(row, cb?.checked ?? false); }); // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern) this.panel.querySelectorAll('.filter-row__controls').forEach(container => { this.refreshGroupSelects(container); }); } // ── Panel toggeln ───────────────────────────────────────────────────────── togglePanel() { const isHidden = this.panel.hasAttribute('hidden'); if (isHidden) { this.panel.removeAttribute('hidden'); this.toggleBtn?.classList.add('report-toolbar__action--active'); } else { this.hidePanel(); } } hidePanel() { this.panel.setAttribute('hidden', ''); this.toggleBtn?.classList.remove('report-toolbar__action--active'); } // ── Row-Zustand (aktiv / inaktiv) ───────────────────────────────────────── syncRowState(row, active) { row.classList.toggle('filter-row--inactive', !active); } activateRowByControl(el) { const row = el.closest('.filter-row'); if (!row) return; const cb = row.querySelector('.filter-row__checkbox'); if (cb && !cb.checked) { cb.checked = true; this.syncRowState(row, true); } } // ── Zeitraum: Custom-Felder ──────────────────────────────────────────────── toggleCustomDates(show) { if (!this.customDates) return; if (show) { this.customDates.removeAttribute('hidden'); } else { this.customDates.setAttribute('hidden', ''); } } // ── Plus: weiteres Control hinzufügen ───────────────────────────────────── addControl(btn) { const targetId = btn.dataset.target; const filterKey = btn.dataset.filterKey; const container = document.getElementById(targetId); if (!container) return; // Erste Gruppe als Template klonen const template = container.querySelector('.filter-row__control-group'); if (!template) return; const clone = template.cloneNode(true); // Select zurücksetzen const clonedSelect = clone.querySelector('.filter-select'); if (clonedSelect) clonedSelect.value = ''; // Remove-Button hinzufügen (falls noch keiner da) if (!clone.querySelector('.filter-row__remove')) { const removeBtn = document.createElement('button'); removeBtn.type = 'button'; removeBtn.className = 'filter-row__remove'; removeBtn.textContent = '×'; clone.appendChild(removeBtn); } // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row clone.querySelector('.filter-select')?.addEventListener('mousedown', () => { this.activateRowByControl(clone.querySelector('.filter-select')); }); container.appendChild(clone); // Optionen deduplizieren this.refreshGroupSelects(container); // Row aktivieren const row = btn.closest('.filter-row'); const cb = row?.querySelector('.filter-row__checkbox'); if (cb && !cb.checked) { cb.checked = true; this.syncRowState(row, true); } clonedSelect?.focus(); } // ── Minus: Control entfernen ────────────────────────────────────────────── removeControl(removeBtn) { const group = removeBtn.closest('.filter-row__control-group'); const container = group?.parentElement; group?.remove(); // Wenn keine Controls mehr übrig → Checkbox deaktivieren if (container && !container.querySelector('.filter-row__control-group')) { const row = container.closest('.filter-row'); const cb = row?.querySelector('.filter-row__checkbox'); if (cb) { cb.checked = false; this.syncRowState(row, false); } } // Verbleibende Selects aktualisieren if (container) this.refreshGroupSelects(container); } // ── Optionen in Mehrfach-Selects deduplizieren ──────────────────────────── refreshGroupSelects(container) { const selects = [...container.querySelectorAll('.filter-select')]; if (selects.length < 2) return; // Alle gewählten Values sammeln const selectedValues = new Set( selects.map(s => s.value).filter(v => v !== '') ); selects.forEach(sel => { const ownValue = sel.value; sel.querySelectorAll('option').forEach(opt => { if (!opt.value) return; // "..." immer sichtbar lassen // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; }); }); } // ── Filter anwenden → URL bauen und navigieren ──────────────────────────── applyFilters() { const params = new URLSearchParams(); params.set('limit', String(window.Report?.limit ?? 50)); this.panel.querySelectorAll('.filter-row').forEach(row => { const cb = row.querySelector('.filter-row__checkbox'); if (!cb?.checked) return; const key = row.dataset.filterKey; if (['clients', 'projects', 'services', 'users'].includes(key)) { row.querySelectorAll('.filter-select').forEach(sel => { if (sel.value) params.append(`filter[${key}][]`, sel.value); }); } else if (key === 'period') { const val = this.periodSel?.value; if (!val) return; params.set('filter[period]', val); if (val === 'custom' && this.customDates) { const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? ''; const fromDay = get('from-day').padStart(2, '0'); const fromMonth = get('from-month').padStart(2, '0'); const fromYear = get('from-year'); const toDay = get('to-day').padStart(2, '0'); const toMonth = get('to-month').padStart(2, '0'); const toYear = get('to-year'); if (fromYear && fromMonth && fromDay) { params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`); } if (toYear && toMonth && toDay) { params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`); } } } else if (key === 'note') { const val = row.querySelector('.filter-note-input')?.value?.trim(); if (val) params.set('filter[note]', val); } else if (key === 'invoiced') { const checked = row.querySelector('.filter-invoiced-radio:checked'); if (checked) params.set('filter[invoiced]', checked.value); } }); window.location.href = `/reports/times?${params}`; } } // ── Init ────────────────────────────────────────────────────────────────────── document.addEventListener('DOMContentLoaded', () => { new ReportFilter().init(); });