Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 
 

468 рядки
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) { alert(t('errorSave')); return; }
  101. window.location.reload();
  102. } catch {
  103. alert(t('errorSave'));
  104. }
  105. }
  106. // ── Löschen ───────────────────────────────────────────────────────────────────
  107. async function deleteEntry(row) {
  108. if (!confirm(t('confirmDelete'))) return;
  109. const id = row.dataset.entryId;
  110. try {
  111. const res = await fetch(`/api/entries/${id}`, { method: 'DELETE' });
  112. if (!res.ok) { alert(t('errorDelete')); return; }
  113. window.location.reload();
  114. } catch {
  115. alert(t('errorDelete'));
  116. }
  117. }
  118. // ── Abgerechnet toggeln ───────────────────────────────────────────────────────
  119. async function toggleInvoiced(row) {
  120. const id = row.dataset.entryId;
  121. const btn = row.querySelector('[data-action="toggle-invoiced"]');
  122. try {
  123. const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' });
  124. if (!res.ok) return;
  125. const data = await res.json();
  126. const invoiced = data.invoiced;
  127. row.dataset.invoiced = invoiced ? 'true' : 'false';
  128. row.classList.toggle('report-table__row--invoiced', invoiced);
  129. if (btn) {
  130. btn.classList.toggle('report-lock--invoiced', invoiced);
  131. btn.title = invoiced ? t('btnUnlock') : t('btnLock');
  132. }
  133. } catch (err) {
  134. console.error('Fehler beim Toggeln des Abrechnungsstatus:', err);
  135. }
  136. }
  137. // ── Event-Delegation ──────────────────────────────────────────────────────────
  138. document.addEventListener('DOMContentLoaded', () => {
  139. initDurationBlurHandler();
  140. const table = document.querySelector('.report-table');
  141. if (!table) return;
  142. table.addEventListener('click', e => {
  143. const btn = e.target.closest('[data-action]');
  144. if (!btn) return;
  145. const row = btn.closest('.report-table__row');
  146. if (!row) return;
  147. switch (btn.dataset.action) {
  148. case 'edit': openEdit(row); break;
  149. case 'cancel': closeEdit(row); break;
  150. case 'save': saveEdit(row); break;
  151. case 'delete': deleteEntry(row); break;
  152. case 'toggle-invoiced': toggleInvoiced(row); break;
  153. }
  154. });
  155. });
  156. // ── ReportFilter ──────────────────────────────────────────────────────────────
  157. class ReportFilter {
  158. constructor() {
  159. this.panel = document.getElementById('report-filter');
  160. this.toggleBtn = document.getElementById('btn-filter-toggle');
  161. this.applyBtn = document.getElementById('btn-filter-apply');
  162. this.hideBtn = document.getElementById('btn-filter-hide');
  163. this.periodSel = document.querySelector('.filter-period-select');
  164. this.customDates = document.querySelector('.filter-custom-dates');
  165. }
  166. init() {
  167. if (!this.panel) return;
  168. // Toolbar-Toggle
  169. this.toggleBtn?.addEventListener('click', () => this.togglePanel());
  170. // Ausblenden-Button
  171. this.hideBtn?.addEventListener('click', () => this.hidePanel());
  172. // Filtern-Button
  173. this.applyBtn?.addEventListener('click', () => this.applyFilters());
  174. // Checkbox-Änderungen
  175. this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
  176. cb.addEventListener('change', () => {
  177. const row = cb.closest('.filter-row');
  178. this.syncRowState(row, cb.checked);
  179. });
  180. });
  181. // Klick auf ausgegrautem Control → Checkbox aktivieren
  182. this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
  183. el.addEventListener('mousedown', () => this.activateRowByControl(el));
  184. });
  185. // Zeitraum-Select → Custom-Felder zeigen/verstecken
  186. this.periodSel?.addEventListener('change', () => {
  187. const row = this.periodSel.closest('.filter-row');
  188. this.activateRowByControl(this.periodSel);
  189. this.toggleCustomDates(this.periodSel.value === 'custom');
  190. });
  191. // Plus-Buttons
  192. this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
  193. btn.addEventListener('click', () => this.addControl(btn));
  194. });
  195. // Remove-Buttons (via Delegation, da sie dynamisch entstehen)
  196. this.panel.addEventListener('click', e => {
  197. const removeBtn = e.target.closest('.filter-row__remove');
  198. if (removeBtn) this.removeControl(removeBtn);
  199. });
  200. // Select-Änderung → Optionen in der Gruppe aktualisieren
  201. this.panel.addEventListener('change', e => {
  202. const sel = e.target.closest('.filter-select');
  203. if (!sel) return;
  204. const container = sel.closest('.filter-row__controls');
  205. if (container) this.refreshGroupSelects(container);
  206. });
  207. // Initialer Zustand
  208. this.panel.querySelectorAll('.filter-row').forEach(row => {
  209. const cb = row.querySelector('.filter-row__checkbox');
  210. this.syncRowState(row, cb?.checked ?? false);
  211. });
  212. // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern)
  213. this.panel.querySelectorAll('.filter-row__controls').forEach(container => {
  214. this.refreshGroupSelects(container);
  215. });
  216. }
  217. // ── Panel toggeln ─────────────────────────────────────────────────────────
  218. togglePanel() {
  219. const isHidden = this.panel.hasAttribute('hidden');
  220. if (isHidden) {
  221. this.panel.removeAttribute('hidden');
  222. this.toggleBtn?.classList.add('report-toolbar__action--active');
  223. } else {
  224. this.hidePanel();
  225. }
  226. }
  227. hidePanel() {
  228. this.panel.setAttribute('hidden', '');
  229. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  230. }
  231. // ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────
  232. syncRowState(row, active) {
  233. row.classList.toggle('filter-row--inactive', !active);
  234. }
  235. activateRowByControl(el) {
  236. const row = el.closest('.filter-row');
  237. if (!row) return;
  238. const cb = row.querySelector('.filter-row__checkbox');
  239. if (cb && !cb.checked) {
  240. cb.checked = true;
  241. this.syncRowState(row, true);
  242. }
  243. }
  244. // ── Zeitraum: Custom-Felder ────────────────────────────────────────────────
  245. toggleCustomDates(show) {
  246. if (!this.customDates) return;
  247. if (show) {
  248. this.customDates.removeAttribute('hidden');
  249. } else {
  250. this.customDates.setAttribute('hidden', '');
  251. }
  252. }
  253. // ── Plus: weiteres Control hinzufügen ─────────────────────────────────────
  254. addControl(btn) {
  255. const targetId = btn.dataset.target;
  256. const filterKey = btn.dataset.filterKey;
  257. const container = document.getElementById(targetId);
  258. if (!container) return;
  259. // Erste Gruppe als Template klonen
  260. const template = container.querySelector('.filter-row__control-group');
  261. if (!template) return;
  262. const clone = template.cloneNode(true);
  263. // Select zurücksetzen
  264. const clonedSelect = clone.querySelector('.filter-select');
  265. if (clonedSelect) clonedSelect.value = '';
  266. // Remove-Button hinzufügen (falls noch keiner da)
  267. if (!clone.querySelector('.filter-row__remove')) {
  268. const removeBtn = document.createElement('button');
  269. removeBtn.type = 'button';
  270. removeBtn.className = 'filter-row__remove';
  271. removeBtn.textContent = '×';
  272. clone.appendChild(removeBtn);
  273. }
  274. // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
  275. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  276. this.activateRowByControl(clone.querySelector('.filter-select'));
  277. });
  278. container.appendChild(clone);
  279. // Optionen deduplizieren
  280. this.refreshGroupSelects(container);
  281. // Row aktivieren
  282. const row = btn.closest('.filter-row');
  283. const cb = row?.querySelector('.filter-row__checkbox');
  284. if (cb && !cb.checked) {
  285. cb.checked = true;
  286. this.syncRowState(row, true);
  287. }
  288. clonedSelect?.focus();
  289. }
  290. // ── Minus: Control entfernen ──────────────────────────────────────────────
  291. removeControl(removeBtn) {
  292. const group = removeBtn.closest('.filter-row__control-group');
  293. const container = group?.parentElement;
  294. group?.remove();
  295. // Wenn keine Controls mehr übrig → Checkbox deaktivieren
  296. if (container && !container.querySelector('.filter-row__control-group')) {
  297. const row = container.closest('.filter-row');
  298. const cb = row?.querySelector('.filter-row__checkbox');
  299. if (cb) {
  300. cb.checked = false;
  301. this.syncRowState(row, false);
  302. }
  303. }
  304. // Verbleibende Selects aktualisieren
  305. if (container) this.refreshGroupSelects(container);
  306. }
  307. // ── Optionen in Mehrfach-Selects deduplizieren ────────────────────────────
  308. refreshGroupSelects(container) {
  309. const selects = [...container.querySelectorAll('.filter-select')];
  310. if (selects.length < 2) return;
  311. // Alle gewählten Values sammeln
  312. const selectedValues = new Set(
  313. selects.map(s => s.value).filter(v => v !== '')
  314. );
  315. selects.forEach(sel => {
  316. const ownValue = sel.value;
  317. sel.querySelectorAll('option').forEach(opt => {
  318. if (!opt.value) return; // "..." immer sichtbar lassen
  319. // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select
  320. opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue;
  321. });
  322. });
  323. }
  324. // ── Filter anwenden → URL bauen und navigieren ────────────────────────────
  325. applyFilters() {
  326. const params = new URLSearchParams();
  327. params.set('limit', String(window.Report?.limit ?? 50));
  328. this.panel.querySelectorAll('.filter-row').forEach(row => {
  329. const cb = row.querySelector('.filter-row__checkbox');
  330. if (!cb?.checked) return;
  331. const key = row.dataset.filterKey;
  332. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  333. row.querySelectorAll('.filter-select').forEach(sel => {
  334. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  335. });
  336. } else if (key === 'period') {
  337. const val = this.periodSel?.value;
  338. if (!val) return;
  339. params.set('filter[period]', val);
  340. if (val === 'custom' && this.customDates) {
  341. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  342. const fromDay = get('from-day').padStart(2, '0');
  343. const fromMonth = get('from-month').padStart(2, '0');
  344. const fromYear = get('from-year');
  345. const toDay = get('to-day').padStart(2, '0');
  346. const toMonth = get('to-month').padStart(2, '0');
  347. const toYear = get('to-year');
  348. if (fromYear && fromMonth && fromDay) {
  349. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  350. }
  351. if (toYear && toMonth && toDay) {
  352. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  353. }
  354. }
  355. } else if (key === 'note') {
  356. const val = row.querySelector('.filter-note-input')?.value?.trim();
  357. if (val) params.set('filter[note]', val);
  358. } else if (key === 'invoiced') {
  359. const checked = row.querySelector('.filter-invoiced-radio:checked');
  360. if (checked) params.set('filter[invoiced]', checked.value);
  361. }
  362. });
  363. window.location.href = `/reports/times?${params}`;
  364. }
  365. }
  366. // ── Init ──────────────────────────────────────────────────────────────────────
  367. document.addEventListener('DOMContentLoaded', () => {
  368. new ReportFilter().init();
  369. });