Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 
 

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