25개 이상의 토픽을 선택하실 수 없습니다. Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

485 lines
17 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. try {
  179. const res = await fetch('/api/entries', {
  180. method: 'POST',
  181. headers: { 'Content-Type': 'application/json' },
  182. body: JSON.stringify({
  183. date: window.TT.activeDate,
  184. duration,
  185. projectId: parseInt(projectId),
  186. serviceId: serviceId ? parseInt(serviceId) : null,
  187. note: note || null,
  188. }),
  189. });
  190. if (!res.ok) {
  191. const err = await res.json().catch(() => ({}));
  192. console.error('API Fehler:', res.status, err);
  193. alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
  194. return;
  195. }
  196. const data = await res.json();
  197. this.addEntryToDOM(data.entry);
  198. this.updateTotal(data.totalDuration);
  199. this.resetCreateForm();
  200. } catch (err) {
  201. console.error('Netzwerkfehler:', err);
  202. alert(t('errorSave'));
  203. }
  204. }
  205. addEntryToDOM(entry) {
  206. this.hideEmptyState();
  207. let items = document.getElementById('entry-items');
  208. if (!items) {
  209. items = document.createElement('div');
  210. items.className = 'entry-list__items';
  211. items.id = 'entry-items';
  212. this.list.prepend(items);
  213. }
  214. items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
  215. const el = document.getElementById(`entry-${entry.id}`);
  216. requestAnimationFrame(() => requestAnimationFrame(() => {
  217. el?.classList.remove('entry-row--new');
  218. }));
  219. }
  220. resetCreateForm() {
  221. const d = document.getElementById('create-duration');
  222. const p = document.getElementById('create-project');
  223. const s = document.getElementById('create-service');
  224. const n = document.getElementById('create-note');
  225. if (d) d.value = '0:00';
  226. if (n) n.value = '';
  227. if (p) p.value = getLastProject() ?? '';
  228. if (s) s.value = getLastService() ?? '';
  229. }
  230. openEdit(row) {
  231. // Safety-Guard: invoiced-Einträge können nicht geöffnet werden
  232. if (row.dataset.invoiced === 'true') return;
  233. // Kein Edit-Formular vorhanden → nicht öffnen
  234. const editSection = row.querySelector('.entry-row__edit');
  235. if (!editSection) return;
  236. row.querySelector('.entry-row__display').hidden = true;
  237. editSection.hidden = false;
  238. row.querySelector('.edit-duration')?.focus();
  239. }
  240. closeEdit(row) {
  241. row.querySelector('.entry-row__display').hidden = false;
  242. row.querySelector('.entry-row__edit').hidden = true;
  243. }
  244. checkAutoEdit() {
  245. const params = new URLSearchParams(window.location.search);
  246. const editId = params.get('editEntry');
  247. if (!editId) return;
  248. const row = document.getElementById(`entry-${editId}`);
  249. if (row) {
  250. this.openEdit(row);
  251. params.delete('editEntry');
  252. const newUrl = window.location.pathname +
  253. (params.size > 0 ? '?' + params.toString() : '');
  254. history.replaceState(null, '', newUrl);
  255. }
  256. }
  257. async saveEdit(row) {
  258. const id = row.dataset.id;
  259. const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
  260. const projectId = row.querySelector('.edit-project')?.value;
  261. const serviceId = row.querySelector('.edit-service')?.value;
  262. const note = row.querySelector('.edit-note')?.value;
  263. if (!projectId) { alert(t('errorNoProject')); return; }
  264. const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
  265. if (duration === '0:00') {
  266. alert(t('errorZeroDuration'));
  267. return;
  268. }
  269. const rawMinutes = roundToQuarter(parseDuration(durationRaw));
  270. const validation = validateDuration(rawMinutes);
  271. if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
  272. if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
  273. try {
  274. const res = await fetch(`/api/entries/${id}`, {
  275. method: 'PATCH',
  276. headers: { 'Content-Type': 'application/json' },
  277. body: JSON.stringify({
  278. duration,
  279. projectId: parseInt(projectId),
  280. serviceId: serviceId ? parseInt(serviceId) : null,
  281. note: note || null,
  282. }),
  283. });
  284. if (!res.ok) {
  285. console.error('PATCH fehlgeschlagen:', res.status);
  286. alert(t('errorSave'));
  287. return;
  288. }
  289. const data = await res.json();
  290. this.updateRowDisplay(row, data.entry);
  291. this.updateTotal(data.totalDuration);
  292. this.closeEdit(row);
  293. } catch (err) {
  294. console.error('saveEdit Fehler:', err);
  295. alert(t('errorSave'));
  296. }
  297. }
  298. updateRowDisplay(row, entry) {
  299. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  300. row.querySelector('.entry-row__title').textContent =
  301. `${entry.clientName} / ${entry.projectName}${servicePart}`;
  302. row.querySelector('.entry-row__note')?.remove();
  303. if (entry.note) {
  304. const noteEl = document.createElement('div');
  305. noteEl.className = 'entry-row__note';
  306. noteEl.textContent = entry.note;
  307. row.querySelector('.entry-row__info').appendChild(noteEl);
  308. }
  309. row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
  310. row.dataset.duration = entry.duration;
  311. row.dataset.projectId = entry.projectId;
  312. row.dataset.serviceId = entry.serviceId ?? '';
  313. row.dataset.note = entry.note ?? '';
  314. row.querySelector('.edit-duration').value = entry.durationFormatted;
  315. row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
  316. row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
  317. row.querySelector('.edit-note').value = entry.note ?? '';
  318. }
  319. async deleteEntry(row) {
  320. if (!confirm(t('confirmDelete'))) return;
  321. try {
  322. const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
  323. if (!res.ok) { alert(t('errorDelete')); return; }
  324. const data = await res.json();
  325. row.classList.add('entry-row--removing');
  326. setTimeout(() => {
  327. row.remove();
  328. this.updateTotal(data.totalDuration);
  329. this.checkIfEmpty();
  330. }, 280);
  331. } catch { alert(t('errorDelete')); }
  332. }
  333. async loadEntriesForDate(dateStr) {
  334. window.TT.activeDate = dateStr;
  335. try {
  336. this.list.classList.add('entry-list--fading');
  337. await new Promise(r => setTimeout(r, 180));
  338. const res = await fetch(`/api/entries?date=${dateStr}`);
  339. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  340. const data = await res.json();
  341. this.renderEntries(data.entries, data.totalDuration);
  342. } catch (err) {
  343. console.error(t('errorLoad'), err);
  344. } finally {
  345. this.list.classList.remove('entry-list--fading');
  346. }
  347. }
  348. renderEntries(entries, totalDuration) {
  349. if (!entries.length) {
  350. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  351. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  352. this.emptyState = this.list.querySelector('#empty-state');
  353. return;
  354. }
  355. let html = '<div class="entry-list__items" id="entry-items">';
  356. entries.forEach(e => { html += buildEntryRowHTML(e, false); });
  357. html += `</div><div class="entry-list__footer" id="entry-footer">
  358. <span class="entry-list__total">${totalDuration}</span></div>`;
  359. this.list.innerHTML = html;
  360. this.emptyState = null;
  361. this.list.querySelectorAll('.entry-row').forEach(row => {
  362. row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
  363. row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
  364. });
  365. this.checkAutoEdit();
  366. }
  367. updateTotal(totalDuration) {
  368. let footer = document.getElementById('entry-footer');
  369. if (!footer) {
  370. footer = document.createElement('div');
  371. footer.className = 'entry-list__footer';
  372. footer.id = 'entry-footer';
  373. this.list.appendChild(footer);
  374. }
  375. footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
  376. }
  377. hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
  378. checkIfEmpty() {
  379. const items = document.getElementById('entry-items');
  380. if (items && !items.children.length) {
  381. items.remove();
  382. document.getElementById('entry-footer')?.remove();
  383. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  384. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  385. this.emptyState = this.list.querySelector('#empty-state');
  386. }
  387. }
  388. }
  389. function saveLastProject(projectId) {
  390. if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
  391. }
  392. function getLastProject() {
  393. return localStorage.getItem(LAST_PROJECT_KEY);
  394. }
  395. function saveLastService(serviceId) {
  396. if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
  397. }
  398. function getLastService() {
  399. return localStorage.getItem(LAST_SERVICE_KEY);
  400. }
  401. window.entryManager = null;
  402. document.addEventListener('DOMContentLoaded', () => {
  403. initDurationBlurHandler();
  404. window.entryManager = new EntryManager();
  405. });