// 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 = ``;
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 => ``
).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 ────────────────────────────────────────────────────────
function initLexofficeInvoiceButton() {
const btn = document.getElementById('btn-lexoffice-invoice');
if (!btn) return;
const originalTitle = btn.title;
btn.addEventListener('click', async () => {
if (btn.disabled) return;
const contactId = btn.dataset.contactId;
const clientName = btn.dataset.clientName;
const dateFrom = btn.dataset.dateFrom;
const dateTo = btn.dataset.dateTo;
btn.disabled = true;
btn.title = t('invoiceCreating');
try {
const res = await fetch('/api/lexoffice/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contactId, dateFrom, dateTo }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? t('invoiceError'));
return;
}
const data = await res.json();
const invoiceId = data.id;
const msg = t('invoiceSuccess').replace('%client%', clientName);
const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`);
if (openInLexoffice && invoiceId) {
window.open(`https://app.lexware.de/permalink/invoices/edit/${invoiceId}`, '_blank');
}
} catch {
alert(t('invoiceError'));
} finally {
btn.disabled = false;
btn.title = originalTitle;
}
});
}