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.
 
 
 
 
 

478 satır
18 KiB

  1. // assets/scripts/report.js
  2. import {
  3. parseDuration,
  4. roundToQuarter,
  5. formatMinutes,
  6. validateDuration,
  7. initDurationBlurHandler,
  8. } from './duration.js';
  9. // ── Hilfsfunktionen ───────────────────────────────────────────────────────────
  10. function t(key) {
  11. return window.Report?.i18n?.[key] ?? key;
  12. }
  13. function populateProjectSelect(select, selectedId) {
  14. const projects = window.Report?.projects ?? [];
  15. select.innerHTML = '';
  16. projects.forEach(p => {
  17. const opt = document.createElement('option');
  18. opt.value = p.id;
  19. opt.textContent = `${p.clientName} / ${p.name}`;
  20. if (p.id === selectedId) opt.selected = true;
  21. select.appendChild(opt);
  22. });
  23. }
  24. function populateServiceSelect(select, selectedId) {
  25. const services = window.Report?.services ?? [];
  26. const billable = services.filter(s => s.billable);
  27. const notBillable = services.filter(s => !s.billable);
  28. select.innerHTML = `<option value="">${t('selectPh')}</option>`;
  29. function addGroup(label, list) {
  30. if (!list.length) return;
  31. const group = document.createElement('optgroup');
  32. group.label = label;
  33. list.forEach(s => {
  34. const opt = document.createElement('option');
  35. opt.value = s.id;
  36. opt.textContent = s.name;
  37. if (s.id === selectedId) opt.selected = true;
  38. group.appendChild(opt);
  39. });
  40. select.appendChild(group);
  41. }
  42. addGroup(t('billable'), billable);
  43. addGroup(t('notBillable'), notBillable);
  44. }
  45. // ── Edit öffnen ───────────────────────────────────────────────────────────────
  46. function openEdit(row) {
  47. document.querySelectorAll('.report-table__row--editing').forEach(r => {
  48. if (r !== row) closeEdit(r);
  49. });
  50. const editForm = row.querySelector('.report-row__edit');
  51. if (!editForm) return;
  52. // Selects klonen um akkumulierte Listener zu vermeiden
  53. const oldProjectSel = row.querySelector('.edit-project');
  54. const oldServiceSel = row.querySelector('.edit-service');
  55. const projectSel = oldProjectSel.cloneNode(false);
  56. const serviceSel = oldServiceSel.cloneNode(false);
  57. oldProjectSel.replaceWith(projectSel);
  58. oldServiceSel.replaceWith(serviceSel);
  59. const projectId = parseInt(row.dataset.projectId) || null;
  60. const serviceId = parseInt(row.dataset.serviceId) || null;
  61. populateProjectSelect(projectSel, projectId);
  62. populateServiceSelect(serviceSel, serviceId);
  63. projectSel.addEventListener('change', () => {
  64. populateServiceSelect(row.querySelector('.edit-service'), null);
  65. });
  66. editForm.hidden = false;
  67. row.classList.add('report-table__row--editing');
  68. row.querySelector('.edit-duration')?.focus();
  69. }
  70. function closeEdit(row) {
  71. const editForm = row.querySelector('.report-row__edit');
  72. if (!editForm) return;
  73. editForm.hidden = true;
  74. row.classList.remove('report-table__row--editing');
  75. }
  76. // ── Speichern ─────────────────────────────────────────────────────────────────
  77. async function saveEdit(row) {
  78. const id = row.dataset.entryId;
  79. const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
  80. const projectId = row.querySelector('.edit-project')?.value;
  81. const serviceId = row.querySelector('.edit-service')?.value;
  82. const note = row.querySelector('.edit-note')?.value ?? '';
  83. if (!projectId) { alert(t('errorNoProject')); return; }
  84. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  85. if (rawMinutes === 0) { alert(t('errorZeroDuration')); return; }
  86. const validation = validateDuration(rawMinutes);
  87. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  88. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  89. try {
  90. const res = await fetch(`/api/entries/${id}`, {
  91. method: 'PATCH',
  92. headers: { 'Content-Type': 'application/json' },
  93. body: JSON.stringify({
  94. duration: formatMinutes(rawMinutes),
  95. projectId: parseInt(projectId),
  96. serviceId: serviceId ? parseInt(serviceId) : null,
  97. note: note || null,
  98. }),
  99. });
  100. if (!res.ok) {
  101. const err = await res.json().catch(() => ({}));
  102. alert(err.error ?? t('errorSave'));
  103. return;
  104. }
  105. window.location.reload();
  106. } catch {
  107. alert(t('errorSave'));
  108. }
  109. }
  110. // ── Löschen ───────────────────────────────────────────────────────────────────
  111. async function deleteEntry(row) {
  112. if (!confirm(t('confirmDelete'))) return;
  113. const id = row.dataset.entryId;
  114. try {
  115. const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' });
  116. if (!res.ok) { alert(t('errorDelete')); return; }
  117. window.location.reload();
  118. } catch {
  119. alert(t('errorDelete'));
  120. }
  121. }
  122. // ── Abgerechnet toggeln ───────────────────────────────────────────────────────
  123. async function toggleInvoiced(row) {
  124. const id = row.dataset.entryId;
  125. const btn = row.querySelector('[data-action="toggle-invoiced"]');
  126. try {
  127. const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
  128. if (!res.ok) return;
  129. const data = await res.json();
  130. const invoiced = data.invoiced;
  131. row.dataset.invoiced = invoiced ? 'true' : 'false';
  132. row.classList.toggle('report-table__row--invoiced', invoiced);
  133. if (btn) {
  134. btn.classList.toggle('report-lock--invoiced', invoiced);
  135. btn.title = invoiced ? t('btnUnlock') : t('btnLock');
  136. }
  137. } catch (err) {
  138. console.error('Fehler beim Toggeln des Abrechnungsstatus:', err);
  139. }
  140. }
  141. // ── Event-Delegation ──────────────────────────────────────────────────────────
  142. document.addEventListener('DOMContentLoaded', () => {
  143. initDurationBlurHandler();
  144. const table = document.querySelector('.report-table');
  145. if (!table) return;
  146. table.addEventListener('click', e => {
  147. const btn = e.target.closest('[data-action]');
  148. if (!btn) return;
  149. const row = btn.closest('.report-table__row');
  150. if (!row) return;
  151. switch (btn.dataset.action) {
  152. case 'edit': openEdit(row); break;
  153. case 'cancel': closeEdit(row); break;
  154. case 'save': saveEdit(row); break;
  155. case 'delete': deleteEntry(row); break;
  156. case 'toggle-invoiced': toggleInvoiced(row); break;
  157. }
  158. });
  159. });
  160. // ── ReportFilter ──────────────────────────────────────────────────────────────
  161. class ReportFilter {
  162. constructor() {
  163. this.panel = document.getElementById('report-filter');
  164. this.toggleBtn = document.getElementById('btn-filter-toggle');
  165. this.applyBtn = document.getElementById('btn-filter-apply');
  166. this.hideBtn = document.getElementById('btn-filter-hide');
  167. this.periodSel = document.querySelector('.filter-period-select');
  168. this.customDates = document.querySelector('.filter-custom-dates');
  169. }
  170. init() {
  171. if (!this.panel) return;
  172. // Toolbar-Toggle
  173. this.toggleBtn?.addEventListener('click', () => this.togglePanel());
  174. // Ausblenden-Button
  175. this.hideBtn?.addEventListener('click', () => this.hidePanel());
  176. // Filtern-Button
  177. this.applyBtn?.addEventListener('click', () => this.applyFilters());
  178. // Checkbox-Änderungen
  179. this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
  180. cb.addEventListener('change', () => {
  181. const row = cb.closest('.filter-row');
  182. this.syncRowState(row, cb.checked);
  183. });
  184. });
  185. // Klick auf ausgegrautem Control → Checkbox aktivieren
  186. this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
  187. el.addEventListener('mousedown', () => this.activateRowByControl(el));
  188. });
  189. // Zeitraum-Select → Custom-Felder zeigen/verstecken
  190. this.periodSel?.addEventListener('change', () => {
  191. const row = this.periodSel.closest('.filter-row');
  192. this.activateRowByControl(this.periodSel);
  193. this.toggleCustomDates(this.periodSel.value === 'custom');
  194. });
  195. // Plus-Buttons
  196. this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
  197. btn.addEventListener('click', () => this.addControl(btn));
  198. });
  199. // Remove-Buttons (via Delegation, da sie dynamisch entstehen)
  200. this.panel.addEventListener('click', e => {
  201. const removeBtn = e.target.closest('.filter-row__remove');
  202. if (removeBtn) this.removeControl(removeBtn);
  203. });
  204. // Select-Änderung → Optionen in der Gruppe aktualisieren
  205. this.panel.addEventListener('change', e => {
  206. const sel = e.target.closest('.filter-select');
  207. if (!sel) return;
  208. const container = sel.closest('.filter-row__controls');
  209. if (container) this.refreshGroupSelects(container);
  210. });
  211. // Initialer Zustand
  212. this.panel.querySelectorAll('.filter-row').forEach(row => {
  213. const cb = row.querySelector('.filter-row__checkbox');
  214. this.syncRowState(row, cb?.checked ?? false);
  215. });
  216. // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern)
  217. this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
  218. this.refreshGroupSelects(container);
  219. });
  220. }
  221. // ── Panel toggeln ─────────────────────────────────────────────────────────
  222. togglePanel() {
  223. const isHidden = this.panel.hasAttribute('hidden');
  224. if (isHidden) {
  225. this.panel.removeAttribute('hidden');
  226. this.toggleBtn?.classList.add('report-toolbar__action--active');
  227. } else {
  228. this.hidePanel();
  229. }
  230. }
  231. hidePanel() {
  232. this.panel.setAttribute('hidden', '');
  233. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  234. }
  235. // ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────
  236. syncRowState(row, active) {
  237. row.classList.toggle('filter-row--inactive', !active);
  238. }
  239. activateRowByControl(el) {
  240. const row = el.closest('.filter-row');
  241. if (!row) return;
  242. const cb = row.querySelector('.filter-row__checkbox');
  243. if (cb && !cb.checked) {
  244. cb.checked = true;
  245. this.syncRowState(row, true);
  246. }
  247. }
  248. // ── Zeitraum: Custom-Felder ────────────────────────────────────────────────
  249. toggleCustomDates(show) {
  250. if (!this.customDates) return;
  251. if (show) {
  252. this.customDates.removeAttribute('hidden');
  253. } else {
  254. this.customDates.setAttribute('hidden', '');
  255. }
  256. }
  257. // ── Plus: weiteres Control hinzufügen ─────────────────────────────────────
  258. addControl(btn) {
  259. const targetId = btn.dataset.target;
  260. const filterKey = btn.dataset.filterKey;
  261. const container = document.getElementById(targetId);
  262. if (!container) return;
  263. // Erste Gruppe als Template klonen
  264. const template = container.querySelector('.filter-row__control-group');
  265. if (!template) return;
  266. const clone = template.cloneNode(true);
  267. // Select zurücksetzen
  268. const clonedSelect = clone.querySelector('.filter-select');
  269. if (clonedSelect) clonedSelect.value = '';
  270. // Remove-Button hinzufügen (falls noch keiner da)
  271. if (!clone.querySelector('.filter-row__remove')) {
  272. const removeBtn = document.createElement('button');
  273. removeBtn.type = 'button';
  274. removeBtn.className = 'filter-row__remove';
  275. removeBtn.textContent = '×';
  276. clone.appendChild(removeBtn);
  277. }
  278. // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
  279. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  280. this.activateRowByControl(clone.querySelector('.filter-select'));
  281. });
  282. container.appendChild(clone);
  283. // Optionen deduplizieren
  284. this.refreshGroupSelects(container);
  285. // Row aktivieren
  286. const row = btn.closest('.filter-row');
  287. const cb = row?.querySelector('.filter-row__checkbox');
  288. if (cb && !cb.checked) {
  289. cb.checked = true;
  290. this.syncRowState(row, true);
  291. }
  292. clonedSelect?.focus();
  293. }
  294. // ── Minus: Control entfernen ──────────────────────────────────────────────
  295. removeControl(removeBtn) {
  296. const group = removeBtn.closest('.filter-row__control-group');
  297. const container = group?.parentElement;
  298. group?.remove();
  299. // Wenn keine Controls mehr übrig → Checkbox deaktivieren
  300. if (container && !container.querySelector('.filter-row__control-group')) {
  301. const row = container.closest('.filter-row');
  302. const cb = row?.querySelector('.filter-row__checkbox');
  303. if (cb) {
  304. cb.checked = false;
  305. this.syncRowState(row, false);
  306. }
  307. }
  308. // Verbleibende Selects aktualisieren
  309. if (container) this.refreshGroupSelects(container);
  310. }
  311. // ── Optionen in Mehrfach-Selects deduplizieren ────────────────────────────
  312. refreshGroupSelects(container) {
  313. const selects = [...container.querySelectorAll('.filter-select')];
  314. if (selects.length < 2) return;
  315. // Alle gewählten Values sammeln
  316. const selectedValues = new Set(
  317. selects.map(s => s.value).filter(v => v !== '')
  318. );
  319. selects.forEach(sel => {
  320. const ownValue = sel.value;
  321. sel.querySelectorAll('option').forEach(opt => {
  322. if (!opt.value) return; // "..." immer sichtbar lassen
  323. // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select
  324. opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
  325. });
  326. });
  327. }
  328. // ── Filter anwenden → URL bauen und navigieren ────────────────────────────
  329. applyFilters() {
  330. const params = new URLSearchParams();
  331. params.set('limit', String(window.Report?.limit ?? 50));
  332. this.panel.querySelectorAll('.filter-row').forEach(row => {
  333. const cb = row.querySelector('.filter-row__checkbox');
  334. if (!cb?.checked) return;
  335. const key = row.dataset.filterKey;
  336. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  337. row.querySelectorAll('.filter-select').forEach(sel => {
  338. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  339. });
  340. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  341. params.set(`filter[${key}_neg]`, '1');
  342. }
  343. } else if (key === 'period') {
  344. const val = this.periodSel?.value;
  345. if (!val) return;
  346. params.set('filter[period]', val);
  347. if (val === 'custom' && this.customDates) {
  348. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  349. const fromDay = get('from-day').padStart(2, '0');
  350. const fromMonth = get('from-month').padStart(2, '0');
  351. const fromYear = get('from-year');
  352. const toDay = get('to-day').padStart(2, '0');
  353. const toMonth = get('to-month').padStart(2, '0');
  354. const toYear = get('to-year');
  355. if (fromYear && fromMonth && fromDay) {
  356. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  357. }
  358. if (toYear && toMonth && toDay) {
  359. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  360. }
  361. }
  362. if (row.querySelector('.filter-neg-checkbox')?.checked) {
  363. params.set('filter[period_neg]', '1');
  364. }
  365. } else if (key === 'note') {
  366. const val = row.querySelector('.filter-note-input')?.value?.trim();
  367. if (val) params.set('filter[note]', val);
  368. } else if (key === 'invoiced') {
  369. const checked = row.querySelector('.filter-invoiced-radio:checked');
  370. if (checked) params.set('filter[invoiced]', checked.value);
  371. }
  372. });
  373. window.location.href = `/reports/times?${params}`;
  374. }
  375. }
  376. // ── Init ──────────────────────────────────────────────────────────────────────
  377. document.addEventListener('DOMContentLoaded', () => {
  378. new ReportFilter().init();
  379. });