25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 
 

438 satır
15 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. new ReportFilter().init();
  154. initExportButtons();
  155. initPrintButton();
  156. });
  157. // ── ReportFilter ─────────────────────────────────────────────────────────────
  158. class ReportFilter {
  159. constructor() {
  160. this.panel = document.getElementById('report-filter');
  161. this.toggleBtn = document.getElementById('btn-filter-toggle');
  162. this.applyBtn = document.getElementById('btn-filter-apply');
  163. this.hideBtn = document.getElementById('btn-filter-hide');
  164. this.periodSel = document.querySelector('.filter-period-select');
  165. this.customDates = document.querySelector('.filter-custom-dates');
  166. }
  167. init() {
  168. if (!this.panel) return;
  169. this.toggleBtn?.addEventListener('click', () => this.togglePanel());
  170. this.hideBtn?.addEventListener('click', () => this.hidePanel());
  171. this.applyBtn?.addEventListener('click', () => this.applyFilters());
  172. this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
  173. cb.addEventListener('change', () => {
  174. this.syncRowState(cb.closest('.filter-row'), cb.checked);
  175. });
  176. });
  177. this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
  178. el.addEventListener('mousedown', () => this.activateRowByControl(el));
  179. });
  180. this.periodSel?.addEventListener('change', () => {
  181. this.activateRowByControl(this.periodSel);
  182. this.toggleCustomDates(this.periodSel.value === 'custom');
  183. });
  184. this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
  185. btn.addEventListener('click', () => this.addControl(btn));
  186. });
  187. this.panel.addEventListener('click', e => {
  188. const removeBtn = e.target.closest('.filter-row__remove');
  189. if (removeBtn) this.removeControl(removeBtn);
  190. });
  191. this.panel.addEventListener('change', e => {
  192. const sel = e.target.closest('.filter-select');
  193. if (!sel) return;
  194. const container = sel.closest('.filter-row__controls');
  195. if (container) this.refreshGroupSelects(container);
  196. });
  197. this.panel.querySelectorAll('.filter-row').forEach(row => {
  198. const cb = row.querySelector('.filter-row__checkbox');
  199. this.syncRowState(row, cb?.checked ?? false);
  200. });
  201. this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
  202. this.refreshGroupSelects(container);
  203. });
  204. }
  205. togglePanel() {
  206. const isHidden = this.panel.hasAttribute('hidden');
  207. if (isHidden) {
  208. this.panel.removeAttribute('hidden');
  209. this.toggleBtn?.classList.add('report-toolbar__action--active');
  210. } else {
  211. this.hidePanel();
  212. }
  213. }
  214. hidePanel() {
  215. this.panel.setAttribute('hidden', '');
  216. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  217. }
  218. syncRowState(row, active) {
  219. row.classList.toggle('filter-row--inactive', !active);
  220. }
  221. activateRowByControl(el) {
  222. const row = el.closest('.filter-row');
  223. if (!row) return;
  224. const cb = row.querySelector('.filter-row__checkbox');
  225. if (cb && !cb.checked) {
  226. cb.checked = true;
  227. this.syncRowState(row, true);
  228. }
  229. }
  230. toggleCustomDates(show) {
  231. if (!this.customDates) return;
  232. this.customDates.toggleAttribute('hidden', !show);
  233. }
  234. addControl(btn) {
  235. const targetId = btn.dataset.target;
  236. const container = document.getElementById(targetId);
  237. if (!container) return;
  238. const template = container.querySelector('.filter-row__control-group');
  239. if (!template) return;
  240. const clone = template.cloneNode(true);
  241. const clonedSelect = clone.querySelector('.filter-select');
  242. if (clonedSelect) clonedSelect.value = '';
  243. if (!clone.querySelector('.filter-row__remove')) {
  244. const removeBtn = document.createElement('button');
  245. removeBtn.type = 'button';
  246. removeBtn.className = 'filter-row__remove';
  247. removeBtn.textContent = '×';
  248. clone.appendChild(removeBtn);
  249. }
  250. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  251. this.activateRowByControl(clone.querySelector('.filter-select'));
  252. });
  253. container.appendChild(clone);
  254. this.refreshGroupSelects(container);
  255. const row = btn.closest('.filter-row');
  256. const cb = row?.querySelector('.filter-row__checkbox');
  257. if (cb && !cb.checked) {
  258. cb.checked = true;
  259. this.syncRowState(row, true);
  260. }
  261. clonedSelect?.focus();
  262. }
  263. removeControl(removeBtn) {
  264. const group = removeBtn.closest('.filter-row__control-group');
  265. const container = group?.parentElement;
  266. group?.remove();
  267. if (container && !container.querySelector('.filter-row__control-group')) {
  268. const row = container.closest('.filter-row');
  269. const cb = row?.querySelector('.filter-row__checkbox');
  270. if (cb) {
  271. cb.checked = false;
  272. this.syncRowState(row, false);
  273. }
  274. }
  275. if (container) this.refreshGroupSelects(container);
  276. }
  277. refreshGroupSelects(container) {
  278. const selects = [...container.querySelectorAll('.filter-select')];
  279. if (selects.length < 2) return;
  280. const selectedValues = new Set(
  281. selects.map(s => s.value).filter(v => v !== '')
  282. );
  283. selects.forEach(sel => {
  284. const ownValue = sel.value;
  285. sel.querySelectorAll('option').forEach(opt => {
  286. if (!opt.value) return;
  287. opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
  288. });
  289. });
  290. }
  291. applyFilters() {
  292. const params = new URLSearchParams();
  293. params.set('limit', String(window.Report?.limit ?? 50));
  294. this.panel.querySelectorAll('.filter-row').forEach(row => {
  295. const cb = row.querySelector('.filter-row__checkbox');
  296. if (!cb?.checked) return;
  297. const key = row.dataset.filterKey;
  298. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  299. row.querySelectorAll('.filter-select').forEach(sel => {
  300. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  301. });
  302. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  303. params.set(`filter[${key}_neg]`, '1');
  304. }
  305. } else if (key === 'period') {
  306. const val = this.periodSel?.value;
  307. if (!val) return;
  308. params.set('filter[period]', val);
  309. if (val === 'custom' && this.customDates) {
  310. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  311. const fromDay = get('from-day').padStart(2, '0');
  312. const fromMonth = get('from-month').padStart(2, '0');
  313. const fromYear = get('from-year');
  314. const toDay = get('to-day').padStart(2, '0');
  315. const toMonth = get('to-month').padStart(2, '0');
  316. const toYear = get('to-year');
  317. if (fromYear && fromMonth && fromDay) {
  318. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  319. }
  320. if (toYear && toMonth && toDay) {
  321. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  322. }
  323. }
  324. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  325. params.set('filter[period_neg]', '1');
  326. }
  327. } else if (key === 'note') {
  328. const val = row.querySelector('.filter-note-input')?.value?.trim();
  329. if (val) params.set('filter[note]', val);
  330. } else if (key === 'invoiced') {
  331. const checked = row.querySelector('.filter-invoiced-radio:checked');
  332. if (checked) params.set('filter[invoiced]', checked.value);
  333. }
  334. });
  335. window.location.href = `/reports/times?${params}`;
  336. }
  337. }
  338. // ── Export ────────────────────────────────────────────────────────────────────
  339. function initExportButtons() {
  340. ['excel', 'csv', 'pdf'].forEach(format => {
  341. document.getElementById(`btn-export-${format}`)?.addEventListener('click', () => {
  342. const params = new URLSearchParams(window.location.search);
  343. params.delete('limit');
  344. window.location.href = `/reports/export/${format}?${params}`;
  345. });
  346. });
  347. }
  348. function initPrintButton() {
  349. document.getElementById('btn-print')?.addEventListener('click', () => {
  350. window.print();
  351. });
  352. }