|
- // 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 label = row.querySelector('.edit-label')?.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,
- label: label || 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;
- }
- });
- }
-
- initSortHeaders();
- new ReportFilter().init();
- initExportButtons();
- initPrintButton();
- initLexofficeInvoiceButton();
- });
-
- // ── Filter Label Autocomplete ─────────────────────────────────────────────────
-
- let filterAcDebounce = null;
-
- function initFilterLabelAutocomplete(input, dropdown) {
- if (!input || !dropdown) return;
-
- input.addEventListener('input', () => {
- clearTimeout(filterAcDebounce);
- const q = input.value.trim();
- if (q.length < 1) { dropdown.hidden = true; return; }
-
- filterAcDebounce = setTimeout(async () => {
- try {
- const res = await fetch(`/api/labels?q=${encodeURIComponent(q)}`);
- if (!res.ok) return;
- const labels = await res.json();
- if (!labels.length) { dropdown.hidden = true; return; }
-
- dropdown.innerHTML = labels.map(
- l => `<button type="button" class="label-autocomplete__item" data-label="${esc(l)}">${esc(l)}</button>`
- ).join('');
- dropdown.hidden = false;
- } catch { dropdown.hidden = true; }
- }, 300);
- });
-
- dropdown.addEventListener('click', e => {
- const item = e.target.closest('[data-label]');
- if (!item) return;
- input.value = item.dataset.label;
- dropdown.hidden = true;
- });
-
- input.addEventListener('blur', () => {
- setTimeout(() => { dropdown.hidden = true; }, 200);
- });
- }
-
- function initAllFilterLabelAutocompletes() {
- document.querySelectorAll('.filter-label-input').forEach(input => {
- const ac = input.closest('.label-input-wrap')?.querySelector('.filter-label-autocomplete');
- initFilterLabelAutocomplete(input, ac);
- });
- }
-
- // ── 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, .filter-label-input').forEach(el => {
- el.addEventListener('mousedown', () => this.activateRowByControl(el));
- });
-
- initAllFilterLabelAutocompletes();
-
- this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => {
- group.addEventListener('click', () => this.activateRowByControl(group));
- });
-
- 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 = '';
-
- const clonedInput = clone.querySelector('.filter-label-input');
- if (clonedInput) {
- clonedInput.value = '';
- const clonedAc = clone.querySelector('.filter-label-autocomplete');
- if (clonedAc) { clonedAc.innerHTML = ''; clonedAc.hidden = true; }
- initFilterLabelAutocomplete(clonedInput, clonedAc);
- clonedInput.addEventListener('mousedown', () => this.activateRowByControl(clonedInput));
- }
-
- 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 === 'labels') {
- row.querySelectorAll('.filter-label-input').forEach(inp => {
- const val = inp.value.trim();
- if (val) params.append('filter[labels][]', val);
- });
- if (row.querySelector('.filter-neg-checkbox')?.checked) {
- params.set('filter[labels_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}`;
- }
- }
-
- // ── Sortierung ───────────────────────────────────────────────────────────────
-
- function initSortHeaders() {
- document.querySelectorAll('.report-table__cell--sortable').forEach(cell => {
- cell.addEventListener('click', () => {
- const params = new URLSearchParams(window.location.search);
- params.set('sort', cell.dataset.sort);
- params.set('dir', cell.dataset.dir);
- 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();
- });
- }
-
- // ── Lexoffice Invoice Modal ──────────────────────────────────────────────────
-
- function initLexofficeInvoiceButton() {
- const btn = document.getElementById('btn-lexoffice-invoice');
- if (!btn) return;
-
- const modal = document.getElementById('invoice-modal');
- const closeBtn = document.getElementById('invoice-modal-close');
- const createBtn = document.getElementById('invoice-modal-create');
- const preview = document.getElementById('invoice-preview');
- if (!modal || !preview) return;
-
- const contactId = btn.dataset.contactId;
- const clientName = btn.dataset.clientName;
- const dateFrom = btn.dataset.dateFrom;
- const dateTo = btn.dataset.dateTo;
-
- let currentItems = [];
-
- function getFilterParams() {
- const params = new URLSearchParams(window.location.search);
- params.delete('limit');
- params.delete('sort');
- params.delete('dir');
- return params;
- }
-
- function formatNumber(n) {
- return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
- }
-
- function renderPreview(items) {
- currentItems = items;
- createBtn.disabled = items.length === 0;
-
- if (items.length === 0) {
- preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceNoItems'))}</div>`;
- return;
- }
-
- let totalRevenue = 0;
- const rows = items.map(item => {
- const lineTotal = item.hours * item.rate;
- totalRevenue += lineTotal;
- const descHtml = item.description
- ? `<div class="invoice-preview__desc">${esc(item.description)}</div>`
- : '';
- return `<div class="invoice-preview__row">
- <div class="invoice-preview__cell invoice-preview__cell--name">${esc(item.name)}${descHtml}</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.hours)}</div>
- <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceUnitHour'))}</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.rate)} €</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(lineTotal)} €</div>
- </div>`;
- }).join('');
-
- preview.innerHTML = `
- <div class="invoice-preview__table">
- <div class="invoice-preview__head">
- <div class="invoice-preview__cell invoice-preview__cell--name">${esc(t('invoiceColName'))}</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColHours'))}</div>
- <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceColUnit'))}</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColRate'))}</div>
- <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColTotal'))}</div>
- </div>
- ${rows}
- <div class="invoice-preview__foot">
- <div class="invoice-preview__cell invoice-preview__cell--name"></div>
- <div class="invoice-preview__cell invoice-preview__cell--num"></div>
- <div class="invoice-preview__cell invoice-preview__cell--unit"></div>
- <div class="invoice-preview__cell invoice-preview__cell--num"></div>
- <div class="invoice-preview__cell invoice-preview__cell--num invoice-preview__cell--total">${formatNumber(totalRevenue)} €</div>
- </div>
- </div>`;
- }
-
- async function loadPreview(groupBy) {
- preview.innerHTML = `<div class="invoice-modal__loading">${esc(t('invoiceLoading'))}</div>`;
- createBtn.disabled = true;
-
- const params = getFilterParams();
- params.set('groupBy', groupBy);
-
- try {
- const res = await fetch(`/api/lexoffice/invoice-preview?${params}`);
- if (!res.ok) throw new Error();
- const items = await res.json();
- renderPreview(items);
- } catch {
- preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceError'))}</div>`;
- }
- }
-
- function openModal() {
- modal.hidden = false;
- const checked = modal.querySelector('input[name="invoice-group"]:checked');
- loadPreview(checked?.value ?? 'service');
- }
-
- function closeModal() {
- modal.hidden = true;
- }
-
- btn.addEventListener('click', openModal);
- closeBtn.addEventListener('click', closeModal);
- modal.addEventListener('click', e => {
- if (e.target === modal) closeModal();
- });
-
- modal.querySelectorAll('input[name="invoice-group"]').forEach(radio => {
- radio.addEventListener('change', () => loadPreview(radio.value));
- });
-
- createBtn.addEventListener('click', async () => {
- if (createBtn.disabled || currentItems.length === 0) return;
- createBtn.disabled = true;
- createBtn.textContent = t('invoiceCreating');
-
- const lineItems = currentItems.map(item => {
- const li = { name: item.name, quantity: item.hours, unitPrice: item.rate };
- if (item.description) li.description = item.description;
- return li;
- });
-
- try {
- const res = await fetch('/api/lexoffice/invoices', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ contactId, dateFrom, dateTo, lineItems }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- alert(err.error ?? t('invoiceError'));
- return;
- }
-
- const data = await res.json();
-
- const markCb = document.getElementById('invoice-modal-mark-invoiced');
- if (markCb?.checked) {
- const filterParams = getFilterParams();
- await fetch(`/api/entries/mark-invoiced?${filterParams}`, { method: 'POST' }).catch(() => {});
- }
-
- closeModal();
-
- const msg = t('invoiceSuccess').replace('%client%', clientName);
- const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`);
-
- if (openInLexoffice && data.id) {
- window.open(`https://app.lexware.de/permalink/invoices/edit/${data.id}`, '_blank');
- }
-
- if (markCb?.checked) {
- window.location.reload();
- }
- } catch {
- alert(t('invoiceError'));
- } finally {
- createBtn.disabled = false;
- createBtn.textContent = t('invoiceBtnCreate');
- }
- });
- }
|