|
- // assets/scripts/report.js
-
- import { parseAndValidate, initDurationBlurHandler } from './duration.js';
- import { esc, createTranslator } from './utils.js';
-
- const t = createTranslator('Report');
-
- // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
-
- 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;
-
- 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, 10) || null;
- const serviceId = parseInt(row.dataset.serviceId, 10) || 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 saveBtn = row.querySelector('[data-action="save"]');
- if (saveBtn?.disabled) return;
-
- const id = row.dataset.entryId;
- 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(row.querySelector('.edit-duration')?.value ?? '0:00');
- if (dur.error) { alert(t(dur.error)); return; }
- if (dur.warn && !confirm(t(dur.warn))) 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) {
- const err = await res.json().catch(() => ({}));
- alert(err.error ?? t('errorSave'));
- return;
- }
-
- window.location.reload();
- } catch {
- alert(t('errorSave'));
- } finally {
- if (saveBtn) saveBtn.disabled = false;
- }
- }
-
- // ── Löschen ──────────────────────────────────────────────────────────────────
-
- async function deleteEntry(row) {
- if (!confirm(t('confirmDelete'))) return;
-
- try {
- const res = await fetch(`/api/entries/${row.dataset.entryId}`, { 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('toggleInvoiced error:', err);
- }
- }
-
- // ── Event-Delegation ─────────────────────────────────────────────────────────
-
- document.addEventListener('DOMContentLoaded', () => {
- initDurationBlurHandler();
-
- const table = document.querySelector('.report-table');
- if (table) {
- 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;
- }
- });
- }
-
- new ReportFilter().init();
- initExportButtons();
- initPrintButton();
- });
-
- // ── 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;
-
- this.toggleBtn?.addEventListener('click', () => this.togglePanel());
- this.hideBtn?.addEventListener('click', () => this.hidePanel());
- this.applyBtn?.addEventListener('click', () => this.applyFilters());
-
- this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
- cb.addEventListener('change', () => {
- this.syncRowState(cb.closest('.filter-row'), cb.checked);
- });
- });
-
- this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
- el.addEventListener('mousedown', () => this.activateRowByControl(el));
- });
-
- this.periodSel?.addEventListener('change', () => {
- this.activateRowByControl(this.periodSel);
- this.toggleCustomDates(this.periodSel.value === 'custom');
- });
-
- this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
- btn.addEventListener('click', () => this.addControl(btn));
- });
-
- this.panel.addEventListener('click', e => {
- const removeBtn = e.target.closest('.filter-row__remove');
- if (removeBtn) this.removeControl(removeBtn);
- });
-
- 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);
- });
-
- this.panel.querySelectorAll('.filter-row').forEach(row => {
- const cb = row.querySelector('.filter-row__checkbox');
- this.syncRowState(row, cb?.checked ?? false);
- });
-
- this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
- this.refreshGroupSelects(container);
- });
- }
-
- 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');
- }
-
- 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);
- }
- }
-
- toggleCustomDates(show) {
- if (!this.customDates) return;
- this.customDates.toggleAttribute('hidden', !show);
- }
-
- addControl(btn) {
- const targetId = btn.dataset.target;
- const container = document.getElementById(targetId);
- if (!container) return;
-
- const template = container.querySelector('.filter-row__control-group');
- if (!template) return;
-
- const clone = template.cloneNode(true);
-
- const clonedSelect = clone.querySelector('.filter-select');
- if (clonedSelect) clonedSelect.value = '';
-
- if (!clone.querySelector('.filter-row__remove')) {
- const removeBtn = document.createElement('button');
- removeBtn.type = 'button';
- removeBtn.className = 'filter-row__remove';
- removeBtn.textContent = '×';
- clone.appendChild(removeBtn);
- }
-
- clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
- this.activateRowByControl(clone.querySelector('.filter-select'));
- });
-
- container.appendChild(clone);
- this.refreshGroupSelects(container);
-
- 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();
- }
-
- removeControl(removeBtn) {
- const group = removeBtn.closest('.filter-row__control-group');
- const container = group?.parentElement;
- group?.remove();
-
- 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);
- }
- }
-
- if (container) this.refreshGroupSelects(container);
- }
-
- refreshGroupSelects(container) {
- const selects = [...container.querySelectorAll('.filter-select')];
- if (selects.length < 2) return;
-
- 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;
- opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
- });
- });
- }
-
- 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}`;
- }
- }
-
- // ── Export ────────────────────────────────────────────────────────────────────
-
- function initExportButtons() {
- ['excel', 'csv', 'pdf'].forEach(format => {
- document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
- const params = new URLSearchParams(window.location.search);
- params.delete('limit');
- window.location.href = `/reports/export/${format}?${params}`;
- });
- });
- }
-
- function initPrintButton() {
- document.getElementById('btn-print')?.addEventListener('click', () => {
- window.print();
- });
- }
|