|
- // 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 = `<option value="">${t('selectPh')}</option>`;
-
- 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) {
- const err = await res.json().catch(() => ({}));
- alert(err.error ?? 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);
- });
- if (row.querySelector('.filter-neg-checkbox')?.checked) {
- params.set(`filter[${key}_neg]`, '1');
- }
-
- } 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}`);
- }
- }
- if (row.querySelector('.filter-neg-checkbox')?.checked) {
- params.set('filter[period_neg]', '1');
- }
-
- } 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();
- });
|