You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

507 lines
17 KiB

  1. // assets/scripts/report.js
  2. import { parseAndValidate, initDurationBlurHandler } from './duration.js';
  3. import { esc, createTranslator } from './utils.js';
  4. const t = createTranslator('Report');
  5. // ── Hilfsfunktionen ──────────────────────────────────────────────────────────
  6. function populateProjectSelect(select, selectedId) {
  7. const projects = window.Report?.projects ?? [];
  8. select.innerHTML = '';
  9. projects.forEach(p => {
  10. const opt = document.createElement('option');
  11. opt.value = p.id;
  12. opt.textContent = `${p.clientName} / ${p.name}`;
  13. if (p.id === selectedId) opt.selected = true;
  14. select.appendChild(opt);
  15. });
  16. }
  17. function populateServiceSelect(select, selectedId) {
  18. const services = window.Report?.services ?? [];
  19. const billable = services.filter(s => s.billable);
  20. const notBillable = services.filter(s => !s.billable);
  21. select.innerHTML = `<option value="">${t('selectPh')}</option>`;
  22. function addGroup(label, list) {
  23. if (!list.length) return;
  24. const group = document.createElement('optgroup');
  25. group.label = label;
  26. list.forEach(s => {
  27. const opt = document.createElement('option');
  28. opt.value = s.id;
  29. opt.textContent = s.name;
  30. if (s.id === selectedId) opt.selected = true;
  31. group.appendChild(opt);
  32. });
  33. select.appendChild(group);
  34. }
  35. addGroup(t('billable'), billable);
  36. addGroup(t('notBillable'), notBillable);
  37. }
  38. // ── Edit öffnen ──────────────────────────────────────────────────────────────
  39. function openEdit(row) {
  40. document.querySelectorAll('.report-table__row--editing').forEach(r => {
  41. if (r !== row) closeEdit(r);
  42. });
  43. const editForm = row.querySelector('.report-row__edit');
  44. if (!editForm) return;
  45. const oldProjectSel = row.querySelector('.edit-project');
  46. const oldServiceSel = row.querySelector('.edit-service');
  47. const projectSel = oldProjectSel.cloneNode(false);
  48. const serviceSel = oldServiceSel.cloneNode(false);
  49. oldProjectSel.replaceWith(projectSel);
  50. oldServiceSel.replaceWith(serviceSel);
  51. const projectId = parseInt(row.dataset.projectId, 10) || null;
  52. const serviceId = parseInt(row.dataset.serviceId, 10) || null;
  53. populateProjectSelect(projectSel, projectId);
  54. populateServiceSelect(serviceSel, serviceId);
  55. projectSel.addEventListener('change', () => {
  56. populateServiceSelect(row.querySelector('.edit-service'), null);
  57. });
  58. editForm.hidden = false;
  59. row.classList.add('report-table__row--editing');
  60. row.querySelector('.edit-duration')?.focus();
  61. }
  62. function closeEdit(row) {
  63. const editForm = row.querySelector('.report-row__edit');
  64. if (!editForm) return;
  65. editForm.hidden = true;
  66. row.classList.remove('report-table__row--editing');
  67. }
  68. // ── Speichern ────────────────────────────────────────────────────────────────
  69. async function saveEdit(row) {
  70. const saveBtn = row.querySelector('[data-action="save"]');
  71. if (saveBtn?.disabled) return;
  72. const id = row.dataset.entryId;
  73. const projectId = row.querySelector('.edit-project')?.value;
  74. const serviceId = row.querySelector('.edit-service')?.value;
  75. const note = row.querySelector('.edit-note')?.value ?? '';
  76. if (!projectId) { alert(t('errorNoProject')); return; }
  77. const dur = parseAndValidate(row.querySelector('.edit-duration')?.value ?? '0:00');
  78. if (dur.error) { alert(t(dur.error)); return; }
  79. if (dur.warn && !confirm(t(dur.warn))) return;
  80. if (saveBtn) saveBtn.disabled = true;
  81. try {
  82. const res = await fetch(`/api/entries/${id}`, {
  83. method: 'PATCH',
  84. headers: { 'Content-Type': 'application/json' },
  85. body: JSON.stringify({
  86. duration: dur.formatted,
  87. projectId: parseInt(projectId, 10),
  88. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  89. note: note || null,
  90. }),
  91. });
  92. if (!res.ok) {
  93. const err = await res.json().catch(() => ({}));
  94. alert(err.error ?? t('errorSave'));
  95. return;
  96. }
  97. window.location.reload();
  98. } catch {
  99. alert(t('errorSave'));
  100. } finally {
  101. if (saveBtn) saveBtn.disabled = false;
  102. }
  103. }
  104. // ── Löschen ──────────────────────────────────────────────────────────────────
  105. async function deleteEntry(row) {
  106. if (!confirm(t('confirmDelete'))) return;
  107. try {
  108. const res = await fetch(`/api/entries/${row.dataset.entryId}`, { method: 'DELETE' });
  109. if (!res.ok) { alert(t('errorDelete')); return; }
  110. window.location.reload();
  111. } catch {
  112. alert(t('errorDelete'));
  113. }
  114. }
  115. // ── Abgerechnet toggeln ──────────────────────────────────────────────────────
  116. async function toggleInvoiced(row) {
  117. const id = row.dataset.entryId;
  118. const btn = row.querySelector('[data-action="toggle-invoiced"]');
  119. try {
  120. const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
  121. if (!res.ok) return;
  122. const data = await res.json();
  123. const invoiced = data.invoiced;
  124. row.dataset.invoiced = invoiced ? 'true' : 'false';
  125. row.classList.toggle('report-table__row--invoiced', invoiced);
  126. if (btn) {
  127. btn.classList.toggle('report-lock--invoiced', invoiced);
  128. btn.title = invoiced ? t('btnUnlock') : t('btnLock');
  129. }
  130. } catch (err) {
  131. console.error('toggleInvoiced error:', err);
  132. }
  133. }
  134. // ── Event-Delegation ─────────────────────────────────────────────────────────
  135. document.addEventListener('DOMContentLoaded', () => {
  136. initDurationBlurHandler();
  137. const table = document.querySelector('.report-table');
  138. if (table) {
  139. table.addEventListener('click', e => {
  140. const btn = e.target.closest('[data-action]');
  141. if (!btn) return;
  142. const row = btn.closest('.report-table__row');
  143. if (!row) return;
  144. switch (btn.dataset.action) {
  145. case 'edit': openEdit(row); break;
  146. case 'cancel': closeEdit(row); break;
  147. case 'save': saveEdit(row); break;
  148. case 'delete': deleteEntry(row); break;
  149. case 'toggle-invoiced': toggleInvoiced(row); break;
  150. }
  151. });
  152. }
  153. initSortHeaders();
  154. new ReportFilter().init();
  155. initExportButtons();
  156. initPrintButton();
  157. initLexofficeInvoiceButton();
  158. });
  159. // ── ReportFilter ─────────────────────────────────────────────────────────────
  160. class ReportFilter {
  161. constructor() {
  162. this.panel = document.getElementById('report-filter');
  163. this.toggleBtn = document.getElementById('btn-filter-toggle');
  164. this.applyBtn = document.getElementById('btn-filter-apply');
  165. this.hideBtn = document.getElementById('btn-filter-hide');
  166. this.periodSel = document.querySelector('.filter-period-select');
  167. this.customDates = document.querySelector('.filter-custom-dates');
  168. }
  169. init() {
  170. if (!this.panel) return;
  171. this.toggleBtn?.addEventListener('click', () => this.togglePanel());
  172. this.hideBtn?.addEventListener('click', () => this.hidePanel());
  173. this.applyBtn?.addEventListener('click', () => this.applyFilters());
  174. this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
  175. cb.addEventListener('change', () => {
  176. this.syncRowState(cb.closest('.filter-row'), cb.checked);
  177. });
  178. });
  179. this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
  180. el.addEventListener('mousedown', () => this.activateRowByControl(el));
  181. });
  182. this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => {
  183. group.addEventListener('click', () => this.activateRowByControl(group));
  184. });
  185. this.periodSel?.addEventListener('change', () => {
  186. this.activateRowByControl(this.periodSel);
  187. this.toggleCustomDates(this.periodSel.value === 'custom');
  188. });
  189. this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
  190. btn.addEventListener('click', () => this.addControl(btn));
  191. });
  192. this.panel.addEventListener('click', e => {
  193. const removeBtn = e.target.closest('.filter-row__remove');
  194. if (removeBtn) this.removeControl(removeBtn);
  195. });
  196. this.panel.addEventListener('change', e => {
  197. const sel = e.target.closest('.filter-select');
  198. if (!sel) return;
  199. const container = sel.closest('.filter-row__controls');
  200. if (container) this.refreshGroupSelects(container);
  201. });
  202. this.panel.querySelectorAll('.filter-row').forEach(row => {
  203. const cb = row.querySelector('.filter-row__checkbox');
  204. this.syncRowState(row, cb?.checked ?? false);
  205. });
  206. this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
  207. this.refreshGroupSelects(container);
  208. });
  209. }
  210. togglePanel() {
  211. const isHidden = this.panel.hasAttribute('hidden');
  212. if (isHidden) {
  213. this.panel.removeAttribute('hidden');
  214. this.toggleBtn?.classList.add('report-toolbar__action--active');
  215. } else {
  216. this.hidePanel();
  217. }
  218. }
  219. hidePanel() {
  220. this.panel.setAttribute('hidden', '');
  221. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  222. }
  223. syncRowState(row, active) {
  224. row.classList.toggle('filter-row--inactive', !active);
  225. }
  226. activateRowByControl(el) {
  227. const row = el.closest('.filter-row');
  228. if (!row) return;
  229. const cb = row.querySelector('.filter-row__checkbox');
  230. if (cb && !cb.checked) {
  231. cb.checked = true;
  232. this.syncRowState(row, true);
  233. }
  234. }
  235. toggleCustomDates(show) {
  236. if (!this.customDates) return;
  237. this.customDates.toggleAttribute('hidden', !show);
  238. }
  239. addControl(btn) {
  240. const targetId = btn.dataset.target;
  241. const container = document.getElementById(targetId);
  242. if (!container) return;
  243. const template = container.querySelector('.filter-row__control-group');
  244. if (!template) return;
  245. const clone = template.cloneNode(true);
  246. const clonedSelect = clone.querySelector('.filter-select');
  247. if (clonedSelect) clonedSelect.value = '';
  248. if (!clone.querySelector('.filter-row__remove')) {
  249. const removeBtn = document.createElement('button');
  250. removeBtn.type = 'button';
  251. removeBtn.className = 'filter-row__remove';
  252. removeBtn.textContent = '×';
  253. clone.appendChild(removeBtn);
  254. }
  255. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  256. this.activateRowByControl(clone.querySelector('.filter-select'));
  257. });
  258. container.appendChild(clone);
  259. this.refreshGroupSelects(container);
  260. const row = btn.closest('.filter-row');
  261. const cb = row?.querySelector('.filter-row__checkbox');
  262. if (cb && !cb.checked) {
  263. cb.checked = true;
  264. this.syncRowState(row, true);
  265. }
  266. clonedSelect?.focus();
  267. }
  268. removeControl(removeBtn) {
  269. const group = removeBtn.closest('.filter-row__control-group');
  270. const container = group?.parentElement;
  271. group?.remove();
  272. if (container && !container.querySelector('.filter-row__control-group')) {
  273. const row = container.closest('.filter-row');
  274. const cb = row?.querySelector('.filter-row__checkbox');
  275. if (cb) {
  276. cb.checked = false;
  277. this.syncRowState(row, false);
  278. }
  279. }
  280. if (container) this.refreshGroupSelects(container);
  281. }
  282. refreshGroupSelects(container) {
  283. const selects = [...container.querySelectorAll('.filter-select')];
  284. if (selects.length < 2) return;
  285. const selectedValues = new Set(
  286. selects.map(s => s.value).filter(v => v !== '')
  287. );
  288. selects.forEach(sel => {
  289. const ownValue = sel.value;
  290. sel.querySelectorAll('option').forEach(opt => {
  291. if (!opt.value) return;
  292. opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
  293. });
  294. });
  295. }
  296. applyFilters() {
  297. const params = new URLSearchParams();
  298. params.set('limit', String(window.Report?.limit ?? 50));
  299. this.panel.querySelectorAll('.filter-row').forEach(row => {
  300. const cb = row.querySelector('.filter-row__checkbox');
  301. if (!cb?.checked) return;
  302. const key = row.dataset.filterKey;
  303. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  304. row.querySelectorAll('.filter-select').forEach(sel => {
  305. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  306. });
  307. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  308. params.set(`filter[${key}_neg]`, '1');
  309. }
  310. } else if (key === 'period') {
  311. const val = this.periodSel?.value;
  312. if (!val) return;
  313. params.set('filter[period]', val);
  314. if (val === 'custom' && this.customDates) {
  315. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  316. const fromDay = get('from-day').padStart(2, '0');
  317. const fromMonth = get('from-month').padStart(2, '0');
  318. const fromYear = get('from-year');
  319. const toDay = get('to-day').padStart(2, '0');
  320. const toMonth = get('to-month').padStart(2, '0');
  321. const toYear = get('to-year');
  322. if (fromYear && fromMonth && fromDay) {
  323. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  324. }
  325. if (toYear && toMonth && toDay) {
  326. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  327. }
  328. }
  329. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  330. params.set('filter[period_neg]', '1');
  331. }
  332. } else if (key === 'note') {
  333. const val = row.querySelector('.filter-note-input')?.value?.trim();
  334. if (val) params.set('filter[note]', val);
  335. } else if (key === 'invoiced') {
  336. const checked = row.querySelector('.filter-invoiced-radio:checked');
  337. if (checked) params.set('filter[invoiced]', checked.value);
  338. }
  339. });
  340. window.location.href = `/reports/times?${params}`;
  341. }
  342. }
  343. // ── Sortierung ───────────────────────────────────────────────────────────────
  344. function initSortHeaders() {
  345. document.querySelectorAll('.report-table__cell--sortable').forEach(cell => {
  346. cell.addEventListener('click', () => {
  347. const params = new URLSearchParams(window.location.search);
  348. params.set('sort', cell.dataset.sort);
  349. params.set('dir', cell.dataset.dir);
  350. window.location.href = `/reports/times?${params}`;
  351. });
  352. });
  353. }
  354. // ── Export ────────────────────────────────────────────────────────────────────
  355. function initExportButtons() {
  356. ['excel', 'csv', 'pdf'].forEach(format => {
  357. document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
  358. const params = new URLSearchParams(window.location.search);
  359. params.delete('limit');
  360. window.location.href = `/reports/export/${format}?${params}`;
  361. });
  362. });
  363. }
  364. function initPrintButton() {
  365. document.getElementById('btn-print')?.addEventListener('click', () => {
  366. window.print();
  367. });
  368. }
  369. // ── Lexoffice Invoice ────────────────────────────────────────────────────────
  370. function initLexofficeInvoiceButton() {
  371. const btn = document.getElementById('btn-lexoffice-invoice');
  372. if (!btn) return;
  373. const originalTitle = btn.title;
  374. btn.addEventListener('click', async () => {
  375. if (btn.disabled) return;
  376. const contactId = btn.dataset.contactId;
  377. const clientName = btn.dataset.clientName;
  378. const dateFrom = btn.dataset.dateFrom;
  379. const dateTo = btn.dataset.dateTo;
  380. btn.disabled = true;
  381. btn.title = t('invoiceCreating');
  382. try {
  383. const res = await fetch('/api/lexoffice/invoices', {
  384. method: 'POST',
  385. headers: { 'Content-Type': 'application/json' },
  386. body: JSON.stringify({ contactId, dateFrom, dateTo }),
  387. });
  388. if (!res.ok) {
  389. const err = await res.json().catch(() => ({}));
  390. alert(err.error ?? t('invoiceError'));
  391. return;
  392. }
  393. const data = await res.json();
  394. const invoiceId = data.id;
  395. const msg = t('invoiceSuccess').replace('%client%', clientName);
  396. const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`);
  397. if (openInLexoffice && invoiceId) {
  398. window.open(`https://app.lexware.de/permalink/invoices/edit/${invoiceId}`, '_blank');
  399. }
  400. } catch {
  401. alert(t('invoiceError'));
  402. } finally {
  403. btn.disabled = false;
  404. btn.title = originalTitle;
  405. }
  406. });
  407. }