You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

555 regels
20 KiB

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