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.
 
 
 
 
 

442 regels
15 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. function t(key) {
  6. return window.TT?.i18n?.[key] ?? key;
  7. }
  8. function buildProjectOptions(selectedId = null) {
  9. const groups = {};
  10. (window.TT?.projects ?? []).forEach(p => {
  11. if (!groups[p.clientName]) groups[p.clientName] = [];
  12. groups[p.clientName].push(p);
  13. });
  14. let html = `<option value="">${t('selectPh')}</option>`;
  15. for (const [client, projects] of Object.entries(groups)) {
  16. html += `<optgroup label="${client}">`;
  17. projects.forEach(p => {
  18. const sel = String(p.id) === String(selectedId) ? ' selected' : '';
  19. html += `<option value="${p.id}"${sel}>${p.name}</option>`;
  20. });
  21. html += '</optgroup>';
  22. }
  23. return html;
  24. }
  25. function buildServiceOptions(selectedId = null) {
  26. const billable = (window.TT?.services ?? []).filter(s => s.billable);
  27. const notBillable = (window.TT?.services ?? []).filter(s => !s.billable);
  28. let html = `<option value="">${t('selectPh')}</option>`;
  29. if (billable.length) {
  30. html += `<optgroup label="${t('billable')}">`;
  31. billable.forEach(s => {
  32. const sel = String(s.id) === String(selectedId) ? ' selected' : '';
  33. html += `<option value="${s.id}"${sel}>${s.name}</option>`;
  34. });
  35. html += '</optgroup>';
  36. }
  37. if (notBillable.length) {
  38. html += `<optgroup label="${t('notBillable')}">`;
  39. notBillable.forEach(s => {
  40. const sel = String(s.id) === String(selectedId) ? ' selected' : '';
  41. html += `<option value="${s.id}"${sel}>${s.name}</option>`;
  42. });
  43. html += '</optgroup>';
  44. }
  45. return html;
  46. }
  47. function buildEntryRowHTML(entry, animate = false) {
  48. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  49. const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : '';
  50. return `
  51. <div class="entry-row${animate ? ' entry-row--new' : ''}"
  52. id="entry-${entry.id}"
  53. data-id="${entry.id}"
  54. data-duration="${entry.duration}"
  55. data-project-id="${entry.projectId}"
  56. data-service-id="${entry.serviceId ?? ''}"
  57. data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}">
  58. <div class="entry-row__display">
  59. <div class="entry-row__info">
  60. <div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
  61. ${notePart}
  62. </div>
  63. <div class="entry-row__actions">
  64. <span class="entry-row__badge">${entry.durationFormatted}</span>
  65. <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
  66. <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>
  67. </button>
  68. <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
  69. <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>
  70. </button>
  71. </div>
  72. </div>
  73. <div class="entry-row__edit" hidden>
  74. <div class="entry-form__grid entry-form__grid--inline">
  75. <label class="entry-form__label">${t('labelDuration')}</label>
  76. <div class="entry-form__field">
  77. <input type="text" class="input input--sm edit-duration"
  78. value="${entry.durationFormatted}" autocomplete="off" />
  79. <div class="duration-help">
  80. <span class="duration-help__icon">?</span>
  81. <span class="duration-help__hint">${t('durationHint')}</span>
  82. </div>
  83. </div>
  84. <label class="entry-form__label">${t('labelProjectService')}</label>
  85. <div class="entry-form__field entry-form__field--selects">
  86. <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select>
  87. <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select>
  88. </div>
  89. <label class="entry-form__label">${t('labelNote')}</label>
  90. <div class="entry-form__field">
  91. <textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea>
  92. </div>
  93. <div class="entry-form__actions">
  94. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  95. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  96. </div>
  97. </div>
  98. </div>
  99. </div>`;
  100. }
  101. class EntryManager {
  102. constructor() {
  103. this.list = document.getElementById('entry-list');
  104. this.emptyState = document.getElementById('empty-state');
  105. if (!this.list) return;
  106. const cp = document.getElementById('create-project');
  107. const cs = document.getElementById('create-service');
  108. document.getElementById('create-service')?.addEventListener('change', e => {
  109. saveLastService(e.target.value);
  110. });
  111. document.getElementById('create-project')?.addEventListener('change', e => {
  112. saveLastProject(e.target.value);
  113. });
  114. if (cp) {
  115. const lastProject = getLastProject();
  116. cp.innerHTML = buildProjectOptions(lastProject);
  117. if (lastProject) cp.value = lastProject;
  118. }
  119. if (cs) {
  120. const lastService = getLastService();
  121. cs.innerHTML = buildServiceOptions(lastService);
  122. if (lastService) cs.value = lastService;
  123. }
  124. this.list.querySelectorAll('.entry-row').forEach(row => {
  125. const ep = row.querySelector('.edit-project');
  126. const es = row.querySelector('.edit-service');
  127. if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId);
  128. if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId);
  129. });
  130. this.list.addEventListener('click', e => this.handleListClick(e));
  131. document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());
  132. }
  133. handleListClick(e) {
  134. const actionEl = e.target.closest('[data-action]');
  135. if (!actionEl) return;
  136. const action = actionEl.dataset.action;
  137. const row = e.target.closest('.entry-row');
  138. if (!row) return;
  139. switch (action) {
  140. case 'edit': this.openEdit(row); break;
  141. case 'delete': this.deleteEntry(row); break;
  142. case 'save': this.saveEdit(row); break;
  143. case 'cancel': this.closeEdit(row); break;
  144. }
  145. }
  146. async createEntry() {
  147. const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
  148. const projectId = document.getElementById('create-project')?.value;
  149. const serviceId = document.getElementById('create-service')?.value;
  150. const note = document.getElementById('create-note')?.value;
  151. if (!projectId) { alert(t('errorNoProject')); return; }
  152. const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
  153. if (duration === '0:00') {
  154. alert(t('errorZeroDuration'));
  155. return;
  156. }
  157. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  158. const validation = validateDuration(rawMinutes);
  159. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  160. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  161. try {
  162. const res = await fetch('/api/entries', {
  163. method: 'POST',
  164. headers: { 'Content-Type': 'application/json' },
  165. body: JSON.stringify({
  166. date: window.TT.activeDate,
  167. duration,
  168. projectId: parseInt(projectId),
  169. serviceId: serviceId ? parseInt(serviceId) : null,
  170. note: note || null,
  171. }),
  172. });
  173. if (!res.ok) {
  174. const err = await res.json().catch(() => ({}));
  175. console.error('API Fehler:', res.status, err);
  176. alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
  177. return;
  178. }
  179. const data = await res.json();
  180. this.addEntryToDOM(data.entry);
  181. this.updateTotal(data.totalDuration);
  182. this.resetCreateForm();
  183. } catch (err) {
  184. console.error('Netzwerkfehler:', err);
  185. alert(t('errorSave'));
  186. }
  187. }
  188. addEntryToDOM(entry) {
  189. this.hideEmptyState();
  190. let items = document.getElementById('entry-items');
  191. if (!items) {
  192. items = document.createElement('div');
  193. items.className = 'entry-list__items';
  194. items.id = 'entry-items';
  195. this.list.prepend(items);
  196. }
  197. items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
  198. const el = document.getElementById(`entry-${entry.id}`);
  199. requestAnimationFrame(() => requestAnimationFrame(() => {
  200. el?.classList.remove('entry-row--new');
  201. }));
  202. }
  203. resetCreateForm() {
  204. const d = document.getElementById('create-duration');
  205. const p = document.getElementById('create-project');
  206. const s = document.getElementById('create-service');
  207. const n = document.getElementById('create-note');
  208. if (d) d.value = '0:00';
  209. if (n) n.value = '';
  210. if (p) p.value = getLastProject() ?? '';
  211. if (s) s.value = getLastService() ?? '';
  212. }
  213. openEdit(row) {
  214. row.querySelector('.entry-row__display').hidden = true;
  215. row.querySelector('.entry-row__edit').hidden = false;
  216. row.querySelector('.edit-duration')?.focus();
  217. }
  218. closeEdit(row) {
  219. row.querySelector('.entry-row__display').hidden = false;
  220. row.querySelector('.entry-row__edit').hidden = true;
  221. }
  222. async saveEdit(row) {
  223. const id = row.dataset.id;
  224. const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
  225. const projectId = row.querySelector('.edit-project')?.value;
  226. const serviceId = row.querySelector('.edit-service')?.value;
  227. const note = row.querySelector('.edit-note')?.value;
  228. if (!projectId) { alert(t('errorNoProject')); return; }
  229. const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
  230. if (duration === '0:00') {
  231. alert(t('errorZeroDuration'));
  232. return;
  233. }
  234. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  235. const validation = validateDuration(rawMinutes);
  236. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  237. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  238. try {
  239. const res = await fetch(`/api/entries/${id}`, {
  240. method: 'PATCH',
  241. headers: { 'Content-Type': 'application/json' },
  242. body: JSON.stringify({
  243. duration,
  244. projectId: parseInt(projectId),
  245. serviceId: serviceId ? parseInt(serviceId) : null,
  246. note: note || null,
  247. }),
  248. });
  249. if (!res.ok) {
  250. console.error('PATCH fehlgeschlagen:', res.status);
  251. alert(t('errorSave'));
  252. return;
  253. }
  254. const data = await res.json();
  255. this.updateRowDisplay(row, data.entry);
  256. this.updateTotal(data.totalDuration);
  257. this.closeEdit(row);
  258. } catch (err) {
  259. console.error('saveEdit Fehler:', err);
  260. alert(t('errorSave'));
  261. }
  262. }
  263. updateRowDisplay(row, entry) {
  264. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  265. row.querySelector('.entry-row__title').textContent =
  266. `${entry.clientName} / ${entry.projectName}${servicePart}`;
  267. row.querySelector('.entry-row__note')?.remove();
  268. if (entry.note) {
  269. const noteEl = document.createElement('div');
  270. noteEl.className = 'entry-row__note';
  271. noteEl.textContent = entry.note;
  272. row.querySelector('.entry-row__info').appendChild(noteEl);
  273. }
  274. row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
  275. row.dataset.duration = entry.duration;
  276. row.dataset.projectId = entry.projectId;
  277. row.dataset.serviceId = entry.serviceId ?? '';
  278. row.dataset.note = entry.note ?? '';
  279. row.querySelector('.edit-duration').value = entry.durationFormatted;
  280. row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
  281. row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
  282. row.querySelector('.edit-note').value = entry.note ?? '';
  283. }
  284. async deleteEntry(row) {
  285. if (!confirm(t('confirmDelete'))) return;
  286. try {
  287. const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
  288. if (!res.ok) { alert(t('errorDelete')); return; }
  289. const data = await res.json();
  290. row.classList.add('entry-row--removing');
  291. setTimeout(() => {
  292. row.remove();
  293. this.updateTotal(data.totalDuration);
  294. this.checkIfEmpty();
  295. }, 280);
  296. } catch { alert(t('errorDelete')); }
  297. }
  298. async loadEntriesForDate(dateStr) {
  299. window.TT.activeDate = dateStr;
  300. try {
  301. this.list.classList.add('entry-list--fading');
  302. await new Promise(r => setTimeout(r, 180));
  303. const res = await fetch(`/api/entries?date=${dateStr}`);
  304. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  305. const data = await res.json();
  306. this.renderEntries(data.entries, data.totalDuration);
  307. } catch (err) {
  308. console.error(t('errorLoad'), err);
  309. } finally {
  310. this.list.classList.remove('entry-list--fading');
  311. }
  312. }
  313. renderEntries(entries, totalDuration) {
  314. if (!entries.length) {
  315. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  316. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  317. this.emptyState = this.list.querySelector('#empty-state');
  318. return;
  319. }
  320. let html = '<div class="entry-list__items" id="entry-items">';
  321. entries.forEach(e => { html += buildEntryRowHTML(e, false); });
  322. html += `</div><div class="entry-list__footer" id="entry-footer">
  323. <span class="entry-list__total">${totalDuration}</span></div>`;
  324. this.list.innerHTML = html;
  325. this.emptyState = null;
  326. this.list.querySelectorAll('.entry-row').forEach(row => {
  327. row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
  328. row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
  329. });
  330. }
  331. updateTotal(totalDuration) {
  332. let footer = document.getElementById('entry-footer');
  333. if (!footer) {
  334. footer = document.createElement('div');
  335. footer.className = 'entry-list__footer';
  336. footer.id = 'entry-footer';
  337. this.list.appendChild(footer);
  338. }
  339. footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
  340. }
  341. hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
  342. checkIfEmpty() {
  343. const items = document.getElementById('entry-items');
  344. if (items && !items.children.length) {
  345. items.remove();
  346. document.getElementById('entry-footer')?.remove();
  347. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  348. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  349. this.emptyState = this.list.querySelector('#empty-state');
  350. }
  351. }
  352. }
  353. function saveLastProject(projectId) {
  354. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  355. }
  356. function getLastProject() {
  357. return localStorage.getItem(LAST_PROJECT_KEY);
  358. }
  359. function saveLastService(serviceId) {
  360. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  361. }
  362. function getLastService() {
  363. return localStorage.getItem(LAST_SERVICE_KEY);
  364. }
  365. window.entryManager = null;
  366. document.addEventListener('DOMContentLoaded', () => {
  367. initDurationBlurHandler();
  368. window.entryManager = new EntryManager();
  369. });