Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 
 
 
 

579 righe
20 KiB

  1. // assets/scripts/entries.js
  2. import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js';
  3. const LAST_PROJECT_KEY = 'tt_last_project_id';
  4. const LAST_SERVICE_KEY = 'tt_last_service_id';
  5. const LOCK_SVG = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`;
  6. function t(key) {
  7. return window.TT?.i18n?.[key] ?? key;
  8. }
  9. function buildProjectOptions(selectedId = null) {
  10. const groups = {};
  11. (window.TT?.projects ?? []).forEach(p => {
  12. if (!groups[p.clientName]) groups[p.clientName] = [];
  13. groups[p.clientName].push(p);
  14. });
  15. let html = `<option value="">${t('selectPh')}</option>`;
  16. for (const [client, projects] of Object.entries(groups)) {
  17. html += `<optgroup label="${client}">`;
  18. projects.forEach(p => {
  19. const sel = String(p.id) === String(selectedId) ? ' selected' : '';
  20. html += `<option value="${p.id}"${sel}>${p.name}</option>`;
  21. });
  22. html += '</optgroup>';
  23. }
  24. return html;
  25. }
  26. function buildServiceOptions(selectedId = null) {
  27. const billable = (window.TT?.services ?? []).filter(s => s.billable);
  28. const notBillable = (window.TT?.services ?? []).filter(s => !s.billable);
  29. let html = `<option value="">${t('selectPh')}</option>`;
  30. if (billable.length) {
  31. html += `<optgroup label="${t('billable')}">`;
  32. billable.forEach(s => {
  33. const sel = String(s.id) === String(selectedId) ? ' selected' : '';
  34. html += `<option value="${s.id}"${sel}>${s.name}</option>`;
  35. });
  36. html += '</optgroup>';
  37. }
  38. if (notBillable.length) {
  39. html += `<optgroup label="${t('notBillable')}">`;
  40. notBillable.forEach(s => {
  41. const sel = String(s.id) === String(selectedId) ? ' selected' : '';
  42. html += `<option value="${s.id}"${sel}>${s.name}</option>`;
  43. });
  44. html += '</optgroup>';
  45. }
  46. return html;
  47. }
  48. function buildEntryRowHTML(entry, animate = false) {
  49. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  50. const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : '';
  51. const invoiced = !!entry.invoiced;
  52. const actionsHtml = invoiced
  53. ? `<span class="entry-row__badge">${entry.durationFormatted}</span>
  54. <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
  55. : `<span class="entry-row__badge">${entry.durationFormatted}</span>
  56. <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
  57. <svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  58. </button>
  59. <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
  60. <svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
  61. </button>`;
  62. const editFormHtml = invoiced ? '' : `
  63. <div class="entry-row__edit" hidden>
  64. <div class="entry-form__grid entry-form__grid--inline">
  65. <label class="entry-form__label">${t('labelDuration')}</label>
  66. <div class="entry-form__field">
  67. <input type="text" class="input input--sm edit-duration"
  68. value="${entry.durationFormatted}" autocomplete="off" />
  69. <div class="duration-help">
  70. <span class="duration-help__icon">?</span>
  71. <span class="duration-help__hint">${t('durationHint')}</span>
  72. </div>
  73. </div>
  74. <label class="entry-form__label">${t('labelProjectService')}</label>
  75. <div class="entry-form__field entry-form__field--selects">
  76. <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select>
  77. <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select>
  78. </div>
  79. <label class="entry-form__label">${t('labelNote')}</label>
  80. <div class="entry-form__field">
  81. <textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea>
  82. </div>
  83. <div class="entry-form__actions">
  84. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  85. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  86. </div>
  87. </div>
  88. </div>`;
  89. return `
  90. <div class="entry-row${animate ? ' entry-row--new' : ''}${invoiced ? ' entry-row--invoiced' : ''}"
  91. id="entry-${entry.id}"
  92. data-id="${entry.id}"
  93. data-duration="${entry.duration}"
  94. data-project-id="${entry.projectId}"
  95. data-service-id="${entry.serviceId ?? ''}"
  96. data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}"
  97. data-invoiced="${invoiced ? 'true' : 'false'}">
  98. <div class="entry-row__display">
  99. <div class="entry-row__info">
  100. <div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
  101. ${notePart}
  102. </div>
  103. <div class="entry-row__actions">
  104. ${actionsHtml}
  105. </div>
  106. </div>
  107. ${editFormHtml}
  108. </div>`;
  109. }
  110. class EntryManager {
  111. constructor() {
  112. this.list = document.getElementById('entry-list');
  113. this.emptyState = document.getElementById('empty-state');
  114. if (!this.list) return;
  115. const cp = document.getElementById('create-project');
  116. const cs = document.getElementById('create-service');
  117. document.getElementById('create-service')?.addEventListener('change', e => {
  118. saveLastService(e.target.value);
  119. });
  120. document.getElementById('create-project')?.addEventListener('change', e => {
  121. saveLastProject(e.target.value);
  122. });
  123. if (cp) {
  124. const lastProject = getLastProject();
  125. cp.innerHTML = buildProjectOptions(lastProject);
  126. if (lastProject) cp.value = lastProject;
  127. }
  128. if (cs) {
  129. const lastService = getLastService();
  130. cs.innerHTML = buildServiceOptions(lastService);
  131. if (lastService) cs.value = lastService;
  132. }
  133. this.list.querySelectorAll('.entry-row').forEach(row => {
  134. const ep = row.querySelector('.edit-project');
  135. const es = row.querySelector('.edit-service');
  136. if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId);
  137. if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId);
  138. });
  139. this.list.addEventListener('click', e => this.handleListClick(e));
  140. document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());
  141. this.checkAutoEdit();
  142. }
  143. handleListClick(e) {
  144. const row = e.target.closest('.entry-row');
  145. if (!row) return;
  146. const actionEl = e.target.closest('[data-action]');
  147. if (actionEl) {
  148. const action = actionEl.dataset.action;
  149. if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return;
  150. switch (action) {
  151. case 'edit': this.openEdit(row); break;
  152. case 'delete': this.deleteEntry(row); break;
  153. case 'save': this.saveEdit(row); break;
  154. case 'cancel': this.closeEdit(row); break;
  155. }
  156. return;
  157. }
  158. // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen
  159. if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') {
  160. this.openEdit(row);
  161. }
  162. }
  163. async createEntry() {
  164. const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
  165. const projectId = document.getElementById('create-project')?.value;
  166. const serviceId = document.getElementById('create-service')?.value;
  167. const note = document.getElementById('create-note')?.value;
  168. if (!projectId) { alert(t('errorNoProject')); return; }
  169. const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
  170. if (duration === '0:00') {
  171. alert(t('errorZeroDuration'));
  172. return;
  173. }
  174. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  175. const validation = validateDuration(rawMinutes);
  176. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  177. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  178. if (getDailyTotalMinutes() + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }
  179. try {
  180. const res = await fetch('/api/entries', {
  181. method: 'POST',
  182. headers: { 'Content-Type': 'application/json' },
  183. body: JSON.stringify({
  184. date: window.TT.activeDate,
  185. duration,
  186. projectId: parseInt(projectId),
  187. serviceId: serviceId ? parseInt(serviceId) : null,
  188. note: note || null,
  189. }),
  190. });
  191. if (!res.ok) {
  192. const err = await res.json().catch(() => ({}));
  193. console.error('API Fehler:', res.status, err);
  194. alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
  195. return;
  196. }
  197. const data = await res.json();
  198. this.addEntryToDOM(data.entry);
  199. this.updateTotal(data.totalDuration);
  200. this.resetCreateForm();
  201. } catch (err) {
  202. console.error('Netzwerkfehler:', err);
  203. alert(t('errorSave'));
  204. }
  205. }
  206. addEntryToDOM(entry) {
  207. this.hideEmptyState();
  208. let items = document.getElementById('entry-items');
  209. if (!items) {
  210. items = document.createElement('div');
  211. items.className = 'entry-list__items';
  212. items.id = 'entry-items';
  213. this.list.prepend(items);
  214. }
  215. items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
  216. const el = document.getElementById(`entry-${entry.id}`);
  217. requestAnimationFrame(() => requestAnimationFrame(() => {
  218. el?.classList.remove('entry-row--new');
  219. }));
  220. }
  221. resetCreateForm() {
  222. const d = document.getElementById('create-duration');
  223. const p = document.getElementById('create-project');
  224. const s = document.getElementById('create-service');
  225. const n = document.getElementById('create-note');
  226. if (d) d.value = '0:00';
  227. if (n) n.value = '';
  228. if (p) p.value = getLastProject() ?? '';
  229. if (s) s.value = getLastService() ?? '';
  230. }
  231. openEdit(row) {
  232. // Safety-Guard: invoiced-Einträge können nicht geöffnet werden
  233. if (row.dataset.invoiced === 'true') return;
  234. // Kein Edit-Formular vorhanden → nicht öffnen
  235. const editSection = row.querySelector('.entry-row__edit');
  236. if (!editSection) return;
  237. row.querySelector('.entry-row__display').hidden = true;
  238. editSection.hidden = false;
  239. row.querySelector('.edit-duration')?.focus();
  240. }
  241. closeEdit(row) {
  242. row.querySelector('.entry-row__display').hidden = false;
  243. row.querySelector('.entry-row__edit').hidden = true;
  244. }
  245. checkAutoEdit() {
  246. const params = new URLSearchParams(window.location.search);
  247. const editId = params.get('editEntry');
  248. if (!editId) return;
  249. const row = document.getElementById(`entry-${editId}`);
  250. if (row) {
  251. this.openEdit(row);
  252. params.delete('editEntry');
  253. const newUrl = window.location.pathname +
  254. (params.size > 0 ? '?' + params.toString() : '');
  255. history.replaceState(null, '', newUrl);
  256. }
  257. }
  258. async saveEdit(row) {
  259. const id = row.dataset.id;
  260. const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
  261. const projectId = row.querySelector('.edit-project')?.value;
  262. const serviceId = row.querySelector('.edit-service')?.value;
  263. const note = row.querySelector('.edit-note')?.value;
  264. if (!projectId) { alert(t('errorNoProject')); return; }
  265. const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
  266. if (duration === '0:00') {
  267. alert(t('errorZeroDuration'));
  268. return;
  269. }
  270. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  271. const validation = validateDuration(rawMinutes);
  272. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  273. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  274. const currentEntryMinutes = parseInt(row.dataset.duration) || 0;
  275. if (getDailyTotalMinutes() - currentEntryMinutes + rawMinutes > 1440) { alert(t('errorDailyLimitExceeded')); return; }
  276. try {
  277. const res = await fetch(`/api/entries/${id}`, {
  278. method: 'PATCH',
  279. headers: { 'Content-Type': 'application/json' },
  280. body: JSON.stringify({
  281. duration,
  282. projectId: parseInt(projectId),
  283. serviceId: serviceId ? parseInt(serviceId) : null,
  284. note: note || null,
  285. }),
  286. });
  287. if (!res.ok) {
  288. console.error('PATCH fehlgeschlagen:', res.status);
  289. alert(t('errorSave'));
  290. return;
  291. }
  292. const data = await res.json();
  293. this.updateRowDisplay(row, data.entry);
  294. this.updateTotal(data.totalDuration);
  295. this.closeEdit(row);
  296. } catch (err) {
  297. console.error('saveEdit Fehler:', err);
  298. alert(t('errorSave'));
  299. }
  300. }
  301. updateRowDisplay(row, entry) {
  302. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  303. row.querySelector('.entry-row__title').textContent =
  304. `${entry.clientName} / ${entry.projectName}${servicePart}`;
  305. row.querySelector('.entry-row__note')?.remove();
  306. if (entry.note) {
  307. const noteEl = document.createElement('div');
  308. noteEl.className = 'entry-row__note';
  309. noteEl.textContent = entry.note;
  310. row.querySelector('.entry-row__info').appendChild(noteEl);
  311. }
  312. row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
  313. row.dataset.duration = entry.duration;
  314. row.dataset.projectId = entry.projectId;
  315. row.dataset.serviceId = entry.serviceId ?? '';
  316. row.dataset.note = entry.note ?? '';
  317. row.querySelector('.edit-duration').value = entry.durationFormatted;
  318. row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
  319. row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
  320. row.querySelector('.edit-note').value = entry.note ?? '';
  321. }
  322. async deleteEntry(row) {
  323. if (!confirm(t('confirmDelete'))) return;
  324. try {
  325. const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
  326. if (!res.ok) { alert(t('errorDelete')); return; }
  327. const data = await res.json();
  328. row.classList.add('entry-row--removing');
  329. setTimeout(() => {
  330. row.remove();
  331. this.updateTotal(data.totalDuration);
  332. this.checkIfEmpty();
  333. }, 280);
  334. } catch { alert(t('errorDelete')); }
  335. }
  336. async loadEntriesForDate(dateStr) {
  337. window.TT.activeDate = dateStr;
  338. try {
  339. this.list.classList.add('entry-list--fading');
  340. await new Promise(r => setTimeout(r, 180));
  341. const res = await fetch(`/api/entries?date=${dateStr}`);
  342. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  343. const data = await res.json();
  344. this.renderEntries(data.entries, data.totalDuration);
  345. } catch (err) {
  346. console.error(t('errorLoad'), err);
  347. } finally {
  348. this.list.classList.remove('entry-list--fading');
  349. }
  350. }
  351. renderEntries(entries, totalDuration) {
  352. if (!entries.length) {
  353. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  354. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  355. this.emptyState = this.list.querySelector('#empty-state');
  356. return;
  357. }
  358. let html = '<div class="entry-list__items" id="entry-items">';
  359. entries.forEach(e => { html += buildEntryRowHTML(e, false); });
  360. html += `</div><div class="entry-list__footer" id="entry-footer">
  361. <span class="entry-list__total">${totalDuration}</span></div>`;
  362. this.list.innerHTML = html;
  363. this.emptyState = null;
  364. this.list.querySelectorAll('.entry-row').forEach(row => {
  365. row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
  366. row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
  367. });
  368. this.checkAutoEdit();
  369. }
  370. updateTotal(totalDuration) {
  371. let footer = document.getElementById('entry-footer');
  372. if (!footer) {
  373. footer = document.createElement('div');
  374. footer.className = 'entry-list__footer';
  375. footer.id = 'entry-footer';
  376. this.list.appendChild(footer);
  377. }
  378. footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
  379. }
  380. hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
  381. checkIfEmpty() {
  382. const items = document.getElementById('entry-items');
  383. if (items && !items.children.length) {
  384. items.remove();
  385. document.getElementById('entry-footer')?.remove();
  386. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  387. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  388. this.emptyState = this.list.querySelector('#empty-state');
  389. }
  390. }
  391. }
  392. function getDailyTotalMinutes() {
  393. let total = 0;
  394. document.querySelectorAll('#entry-items .entry-row').forEach(row => {
  395. total += parseInt(row.dataset.duration) || 0;
  396. });
  397. return total;
  398. }
  399. function saveLastProject(projectId) {
  400. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  401. }
  402. function getLastProject() {
  403. return localStorage.getItem(LAST_PROJECT_KEY);
  404. }
  405. function saveLastService(serviceId) {
  406. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  407. }
  408. function getLastService() {
  409. return localStorage.getItem(LAST_SERVICE_KEY);
  410. }
  411. // ── Minimal-Modus-Initialisierung ─────────────────────────────────────────────
  412. const NOTE_KEY = 'tt_minimal_note_open';
  413. function initMinimalMode() {
  414. if (document.body.dataset.theme !== 'minimal') return;
  415. initWeekToggle();
  416. initNoteToggle();
  417. initEntriesToggle();
  418. }
  419. function initWeekToggle() {
  420. const btn = document.getElementById('btn-week-toggle');
  421. const collapsible = document.getElementById('tt-header-collapsible');
  422. if (!btn || !collapsible) return;
  423. btn.setAttribute('aria-expanded', 'false');
  424. collapsible.classList.remove('is-open');
  425. let kw = btn.textContent.trim().match(/\d+/)?.[0] ?? '';
  426. btn.textContent = kw ? `KW ${kw} ▾` : '▾';
  427. btn.addEventListener('click', () => {
  428. const open = collapsible.classList.toggle('is-open');
  429. btn.setAttribute('aria-expanded', String(open));
  430. btn.textContent = kw ? `KW ${kw} ${open ? '▴' : '▾'}` : (open ? '▴' : '▾');
  431. });
  432. window._updateWeekToggle = (newKw) => {
  433. kw = String(newKw);
  434. const open = collapsible.classList.contains('is-open');
  435. btn.textContent = `KW ${kw} ${open ? '▴' : '▾'}`;
  436. };
  437. }
  438. function initNoteToggle() {
  439. const btn = document.getElementById('btn-note-toggle');
  440. const label = document.querySelector('.entry-form__label--note');
  441. const field = document.querySelector('.entry-form__field--note');
  442. if (!btn) return;
  443. const open = localStorage.getItem(NOTE_KEY) === '1';
  444. setNoteVisible(open, btn, label, field);
  445. btn.addEventListener('click', () => {
  446. const nowOpen = label?.classList.toggle('is-visible');
  447. field?.classList.toggle('is-visible');
  448. btn.classList.toggle('is-open', !!nowOpen);
  449. btn.textContent = nowOpen ? '× Bemerkung ausblenden' : '+ Bemerkung hinzufügen';
  450. localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0');
  451. });
  452. }
  453. function setNoteVisible(open, btn, label, field) {
  454. if (open) {
  455. label?.classList.add('is-visible');
  456. field?.classList.add('is-visible');
  457. btn.classList.add('is-open');
  458. btn.textContent = '× Bemerkung ausblenden';
  459. } else {
  460. btn.textContent = '+ Bemerkung hinzufügen';
  461. }
  462. }
  463. function initEntriesToggle() {
  464. const summaryBtn = document.getElementById('btn-entries-toggle');
  465. const entryList = document.getElementById('entry-list');
  466. if (!summaryBtn || !entryList) return;
  467. // Immer eingeklappt beim Laden
  468. entryList.classList.add('is-collapsed');
  469. summaryBtn.setAttribute('aria-expanded', 'false');
  470. summaryBtn.addEventListener('click', () => {
  471. const collapsed = entryList.classList.toggle('is-collapsed');
  472. summaryBtn.setAttribute('aria-expanded', String(!collapsed));
  473. });
  474. }
  475. window.entryManager = null;
  476. document.addEventListener('DOMContentLoaded', () => {
  477. initDurationBlurHandler();
  478. initMinimalMode();
  479. window.entryManager = new EntryManager();
  480. });