選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。
 
 
 
 
 

428 行
16 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. // Initialer Zustand
  201. this.panel.querySelectorAll('.filter-row').forEach(row => {
  202. const cb = row.querySelector('.filter-row__checkbox');
  203. this.syncRowState(row, cb?.checked ?? false);
  204. });
  205. }
  206. // ── Panel toggeln ─────────────────────────────────────────────────────────
  207. togglePanel() {
  208. const isHidden = this.panel.hasAttribute('hidden');
  209. if (isHidden) {
  210. this.panel.removeAttribute('hidden');
  211. this.toggleBtn?.classList.add('report-toolbar__action--active');
  212. } else {
  213. this.hidePanel();
  214. }
  215. }
  216. hidePanel() {
  217. this.panel.setAttribute('hidden', '');
  218. this.toggleBtn?.classList.remove('report-toolbar__action--active');
  219. }
  220. // ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────
  221. syncRowState(row, active) {
  222. row.classList.toggle('filter-row--inactive', !active);
  223. }
  224. activateRowByControl(el) {
  225. const row = el.closest('.filter-row');
  226. if (!row) return;
  227. const cb = row.querySelector('.filter-row__checkbox');
  228. if (cb && !cb.checked) {
  229. cb.checked = true;
  230. this.syncRowState(row, true);
  231. }
  232. }
  233. // ── Zeitraum: Custom-Felder ────────────────────────────────────────────────
  234. toggleCustomDates(show) {
  235. if (!this.customDates) return;
  236. if (show) {
  237. this.customDates.removeAttribute('hidden');
  238. } else {
  239. this.customDates.setAttribute('hidden', '');
  240. }
  241. }
  242. // ── Plus: weiteres Control hinzufügen ─────────────────────────────────────
  243. addControl(btn) {
  244. const targetId = btn.dataset.target;
  245. const filterKey = btn.dataset.filterKey;
  246. const container = document.getElementById(targetId);
  247. if (!container) return;
  248. // Erste Gruppe als Template klonen
  249. const template = container.querySelector('.filter-row__control-group');
  250. if (!template) return;
  251. const clone = template.cloneNode(true);
  252. // Select zurücksetzen
  253. const clonedSelect = clone.querySelector('.filter-select');
  254. if (clonedSelect) clonedSelect.value = '';
  255. // Remove-Button hinzufügen (falls noch keiner da)
  256. if (!clone.querySelector('.filter-row__remove')) {
  257. const removeBtn = document.createElement('button');
  258. removeBtn.type = 'button';
  259. removeBtn.className = 'filter-row__remove';
  260. removeBtn.textContent = '×';
  261. clone.appendChild(removeBtn);
  262. }
  263. // Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
  264. clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
  265. this.activateRowByControl(clone.querySelector('.filter-select'));
  266. });
  267. container.appendChild(clone);
  268. // Row aktivieren
  269. const row = btn.closest('.filter-row');
  270. const cb = row?.querySelector('.filter-row__checkbox');
  271. if (cb && !cb.checked) {
  272. cb.checked = true;
  273. this.syncRowState(row, true);
  274. }
  275. clonedSelect?.focus();
  276. }
  277. // ── Minus: Control entfernen ──────────────────────────────────────────────
  278. removeControl(removeBtn) {
  279. const group = removeBtn.closest('.filter-row__control-group');
  280. const container = group?.parentElement;
  281. group?.remove();
  282. // Wenn keine Controls mehr übrig → Checkbox deaktivieren
  283. if (container && !container.querySelector('.filter-row__control-group')) {
  284. const row = container.closest('.filter-row');
  285. const cb = row?.querySelector('.filter-row__checkbox');
  286. if (cb) {
  287. cb.checked = false;
  288. this.syncRowState(row, false);
  289. }
  290. }
  291. }
  292. // ── Filter anwenden → URL bauen und navigieren ────────────────────────────
  293. applyFilters() {
  294. const params = new URLSearchParams();
  295. params.set('limit', String(window.Report?.limit ?? 50));
  296. this.panel.querySelectorAll('.filter-row').forEach(row => {
  297. const cb = row.querySelector('.filter-row__checkbox');
  298. if (!cb?.checked) return;
  299. const key = row.dataset.filterKey;
  300. if (['clients', 'projects', 'services', 'users'].includes(key)) {
  301. row.querySelectorAll('.filter-select').forEach(sel => {
  302. if (sel.value) params.append(`filter[${key}][]`, sel.value);
  303. });
  304. } else if (key === 'period') {
  305. const val = this.periodSel?.value;
  306. if (!val) return;
  307. params.set('filter[period]', val);
  308. if (val === 'custom' && this.customDates) {
  309. const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
  310. const fromDay = get('from-day').padStart(2, '0');
  311. const fromMonth = get('from-month').padStart(2, '0');
  312. const fromYear = get('from-year');
  313. const toDay = get('to-day').padStart(2, '0');
  314. const toMonth = get('to-month').padStart(2, '0');
  315. const toYear = get('to-year');
  316. if (fromYear && fromMonth && fromDay) {
  317. params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
  318. }
  319. if (toYear && toMonth && toDay) {
  320. params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
  321. }
  322. }
  323. } else if (key === 'note') {
  324. const val = row.querySelector('.filter-note-input')?.value?.trim();
  325. if (val) params.set('filter[note]', val);
  326. } else if (key === 'invoiced') {
  327. const checked = row.querySelector('.filter-invoiced-radio:checked');
  328. if (checked) params.set('filter[invoiced]', checked.value);
  329. }
  330. });
  331. window.location.href = `/reports/times?${params}`;
  332. }
  333. }
  334. // ── Init ──────────────────────────────────────────────────────────────────────
  335. document.addEventListener('DOMContentLoaded', () => {
  336. new ReportFilter().init();
  337. });