您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 
 

696 行
26 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 labelPart = entry.label ? `<span class="entry-row__label">${esc(entry.label)}</span>` : '';
  49. const notePart = entry.note ? `<div class="entry-row__note">${esc(entry.note)}</div>` : '';
  50. const invoiced = !!entry.invoiced;
  51. const actionsHtml = invoiced
  52. ? `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
  53. <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
  54. : `<span class="entry-row__badge">${esc(entry.durationFormatted)}</span>
  55. <button class="entry-row__btn entry-row__btn--stopwatch" title="${t('btnTimerToggle')}" data-action="timer-toggle">
  56. ${STOPWATCH_SVG}
  57. </button>
  58. <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
  59. <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>
  60. </button>
  61. <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
  62. <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>
  63. </button>`;
  64. const editFormHtml = invoiced ? '' : `
  65. <div class="entry-row__edit" hidden>
  66. <div class="entry-form__grid entry-form__grid--inline">
  67. <label class="entry-form__label">${t('labelDuration')}</label>
  68. <div class="entry-form__field">
  69. <input type="text" class="input input--sm edit-duration"
  70. value="${esc(entry.durationFormatted)}" autocomplete="off" />
  71. <div class="duration-help">
  72. <span class="duration-help__icon">?</span>
  73. <span class="duration-help__hint">${t('durationHint')}</span>
  74. </div>
  75. </div>
  76. <label class="entry-form__label">${t('labelProjectService')}</label>
  77. <div class="entry-form__field entry-form__field--selects">
  78. <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select>
  79. <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select>
  80. </div>
  81. <label class="entry-form__label">${t('labelLabel')}</label>
  82. <div class="entry-form__field entry-form__field--label">
  83. <div class="label-chips edit-label-chips"></div>
  84. <div class="label-input-wrap">
  85. <input type="text" class="input input--sm edit-label"
  86. value="${esc(entry.label ?? '')}"
  87. placeholder="${t('placeholderLabel')}" autocomplete="off" />
  88. <div class="label-autocomplete edit-label-autocomplete" hidden></div>
  89. </div>
  90. </div>
  91. <label class="entry-form__label">${t('labelNote')}</label>
  92. <div class="entry-form__field">
  93. <textarea class="textarea edit-note" rows="3">${esc(entry.note ?? '')}</textarea>
  94. </div>
  95. <div class="entry-form__actions">
  96. <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
  97. <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
  98. </div>
  99. </div>
  100. </div>`;
  101. return `
  102. <div class="entry-row${animate ? ' entry-row--new' : ''}${invoiced ? ' entry-row--invoiced' : ''}"
  103. id="entry-${entry.id}"
  104. data-id="${entry.id}"
  105. data-duration="${entry.duration}"
  106. data-project-id="${entry.projectId}"
  107. data-service-id="${entry.serviceId ?? ''}"
  108. data-label="${esc(entry.label ?? '')}"
  109. data-note="${esc(entry.note ?? '')}"
  110. data-invoiced="${invoiced ? 'true' : 'false'}">
  111. <div class="entry-row__display">
  112. <div class="entry-row__info">
  113. <div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div>
  114. ${labelPart}
  115. ${notePart}
  116. </div>
  117. <div class="entry-row__actions">
  118. ${actionsHtml}
  119. </div>
  120. </div>
  121. ${editFormHtml}
  122. </div>`;
  123. }
  124. // ── Helpers ──────────────────────────────────────────────────────────────────
  125. function getDailyTotalMinutes() {
  126. let total = 0;
  127. document.querySelectorAll('#entry-items .entry-row').forEach(row => {
  128. total += parseInt(row.dataset.duration, 10) || 0;
  129. });
  130. return total;
  131. }
  132. function saveLastProject(id) { if (id) localStorage.setItem(LAST_PROJECT_KEY, id); }
  133. function getLastProject() { return localStorage.getItem(LAST_PROJECT_KEY); }
  134. function saveLastService(id) { if (id) localStorage.setItem(LAST_SERVICE_KEY, id); }
  135. function getLastService() { return localStorage.getItem(LAST_SERVICE_KEY); }
  136. // ── EntryManager ─────────────────────────────────────────────────────────────
  137. class EntryManager {
  138. constructor() {
  139. this.list = document.getElementById('entry-list');
  140. this.emptyState = document.getElementById('empty-state');
  141. if (!this.list) return;
  142. const cp = document.getElementById('create-project');
  143. const cs = document.getElementById('create-service');
  144. cs?.addEventListener('change', e => saveLastService(e.target.value));
  145. cp?.addEventListener('change', e => saveLastProject(e.target.value));
  146. if (cp) {
  147. const lastProject = getLastProject();
  148. cp.innerHTML = buildProjectOptions(lastProject);
  149. if (lastProject) cp.value = lastProject;
  150. }
  151. if (cs) {
  152. const lastService = getLastService();
  153. cs.innerHTML = buildServiceOptions(lastService);
  154. if (lastService) cs.value = lastService;
  155. }
  156. this.list.querySelectorAll('.entry-row').forEach(row => {
  157. const ep = row.querySelector('.edit-project');
  158. const es = row.querySelector('.edit-service');
  159. if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId);
  160. if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId);
  161. });
  162. this.list.addEventListener('click', e => this.handleListClick(e));
  163. document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());
  164. this.checkAutoEdit();
  165. }
  166. handleListClick(e) {
  167. const row = e.target.closest('.entry-row');
  168. if (!row) return;
  169. const actionEl = e.target.closest('[data-action]');
  170. if (actionEl) {
  171. const action = actionEl.dataset.action;
  172. if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return;
  173. switch (action) {
  174. case 'edit': this.openEdit(row); break;
  175. case 'delete': this.deleteEntry(row); break;
  176. case 'save': this.saveEdit(row); break;
  177. case 'cancel': this.closeEdit(row); break;
  178. case 'timer-toggle': window.stopwatchManager?.resumeEntry(parseInt(row.dataset.id, 10)); break;
  179. }
  180. return;
  181. }
  182. if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') {
  183. this.openEdit(row);
  184. }
  185. }
  186. async createEntry() {
  187. const btn = document.getElementById('btn-create');
  188. const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
  189. const projectId = document.getElementById('create-project')?.value;
  190. const serviceId = document.getElementById('create-service')?.value;
  191. const label = document.getElementById('create-label')?.value;
  192. const note = document.getElementById('create-note')?.value;
  193. if (!projectId) { alert(t('errorNoProject')); return; }
  194. const dur = parseAndValidate(durationRaw);
  195. if (dur.error) { alert(t(dur.error)); return; }
  196. if (dur.warn && !confirm(t(dur.warn))) return;
  197. if (getDailyTotalMinutes() + dur.minutes > MINUTES_PER_DAY) { alert(t('errorDailyLimitExceeded')); return; }
  198. if (btn) btn.disabled = true;
  199. try {
  200. const res = await fetch('/api/entries', {
  201. method: 'POST',
  202. headers: { 'Content-Type': 'application/json' },
  203. body: JSON.stringify({
  204. date: window.TT.activeDate,
  205. duration: dur.formatted,
  206. projectId: parseInt(projectId, 10),
  207. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  208. label: label || null,
  209. note: note || null,
  210. }),
  211. });
  212. if (!res.ok) {
  213. const err = await res.json().catch(() => ({}));
  214. alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
  215. return;
  216. }
  217. const data = await res.json();
  218. this.addEntryToDOM(data.entry);
  219. this.updateTotal(data.totalDuration);
  220. this.resetCreateForm();
  221. if (projectId) loadProjectLabels(parseInt(projectId, 10), document.getElementById('create-label-chips'));
  222. } catch {
  223. alert(t('errorSave'));
  224. } finally {
  225. if (btn) btn.disabled = false;
  226. }
  227. }
  228. addEntryToDOM(entry) {
  229. this.hideEmptyState();
  230. let items = document.getElementById('entry-items');
  231. if (!items) {
  232. items = document.createElement('div');
  233. items.className = 'entry-list__items';
  234. items.id = 'entry-items';
  235. this.list.prepend(items);
  236. }
  237. items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
  238. const el = document.getElementById(`entry-${entry.id}`);
  239. if (el) animateIn(el, 'entry-row--new');
  240. }
  241. resetCreateForm() {
  242. const d = document.getElementById('create-duration');
  243. const p = document.getElementById('create-project');
  244. const s = document.getElementById('create-service');
  245. const l = document.getElementById('create-label');
  246. const n = document.getElementById('create-note');
  247. if (d) d.value = '0:00';
  248. if (l) l.value = '';
  249. if (n) n.value = '';
  250. if (p) p.value = getLastProject() ?? '';
  251. if (s) s.value = getLastService() ?? '';
  252. }
  253. openEdit(row) {
  254. if (row.dataset.invoiced === 'true') return;
  255. const editSection = row.querySelector('.entry-row__edit');
  256. if (!editSection) return;
  257. row.querySelector('.entry-row__display').hidden = true;
  258. editSection.hidden = false;
  259. row.querySelector('.edit-duration')?.focus();
  260. }
  261. closeEdit(row) {
  262. row.querySelector('.entry-row__display').hidden = false;
  263. row.querySelector('.entry-row__edit').hidden = true;
  264. }
  265. checkAutoEdit() {
  266. const params = new URLSearchParams(window.location.search);
  267. const editId = params.get('editEntry');
  268. if (!editId) return;
  269. const row = document.getElementById(`entry-${editId}`);
  270. if (row) {
  271. this.openEdit(row);
  272. params.delete('editEntry');
  273. const newUrl = window.location.pathname +
  274. (params.size > 0 ? '?' + params.toString() : '');
  275. history.replaceState(null, '', newUrl);
  276. }
  277. }
  278. async saveEdit(row) {
  279. const saveBtn = row.querySelector('[data-action="save"]');
  280. if (saveBtn?.disabled) return;
  281. const id = row.dataset.id;
  282. const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
  283. const projectId = row.querySelector('.edit-project')?.value;
  284. const serviceId = row.querySelector('.edit-service')?.value;
  285. const label = row.querySelector('.edit-label')?.value;
  286. const note = row.querySelector('.edit-note')?.value;
  287. if (!projectId) { alert(t('errorNoProject')); return; }
  288. const dur = parseAndValidate(durationRaw);
  289. if (dur.error) { alert(t(dur.error)); return; }
  290. if (dur.warn && !confirm(t(dur.warn))) return;
  291. const currentMinutes = parseInt(row.dataset.duration, 10) || 0;
  292. if (getDailyTotalMinutes() - currentMinutes + dur.minutes > MINUTES_PER_DAY) {
  293. alert(t('errorDailyLimitExceeded'));
  294. return;
  295. }
  296. if (saveBtn) saveBtn.disabled = true;
  297. try {
  298. const res = await fetch(`/api/entries/${id}`, {
  299. method: 'PATCH',
  300. headers: { 'Content-Type': 'application/json' },
  301. body: JSON.stringify({
  302. duration: dur.formatted,
  303. projectId: parseInt(projectId, 10),
  304. serviceId: serviceId ? parseInt(serviceId, 10) : null,
  305. label: label || null,
  306. note: note || null,
  307. }),
  308. });
  309. if (!res.ok) {
  310. alert(t('errorSave'));
  311. return;
  312. }
  313. const data = await res.json();
  314. this.updateRowDisplay(row, data.entry);
  315. this.updateTotal(data.totalDuration);
  316. this.closeEdit(row);
  317. } catch {
  318. alert(t('errorSave'));
  319. } finally {
  320. if (saveBtn) saveBtn.disabled = false;
  321. }
  322. }
  323. updateRowDisplay(row, entry) {
  324. const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
  325. row.querySelector('.entry-row__title').textContent =
  326. `${entry.clientName} / ${entry.projectName}${servicePart}`;
  327. row.querySelector('.entry-row__label')?.remove();
  328. if (entry.label) {
  329. const labelEl = document.createElement('span');
  330. labelEl.className = 'entry-row__label';
  331. labelEl.textContent = entry.label;
  332. const info = row.querySelector('.entry-row__info');
  333. const noteEl = info.querySelector('.entry-row__note');
  334. info.insertBefore(labelEl, noteEl);
  335. }
  336. row.querySelector('.entry-row__note')?.remove();
  337. if (entry.note) {
  338. const noteEl = document.createElement('div');
  339. noteEl.className = 'entry-row__note';
  340. noteEl.textContent = entry.note;
  341. row.querySelector('.entry-row__info').appendChild(noteEl);
  342. }
  343. row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
  344. row.dataset.duration = entry.duration;
  345. row.dataset.projectId = entry.projectId;
  346. row.dataset.serviceId = entry.serviceId ?? '';
  347. row.dataset.label = entry.label ?? '';
  348. row.dataset.note = entry.note ?? '';
  349. row.querySelector('.edit-duration').value = entry.durationFormatted;
  350. row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
  351. row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
  352. const editLabel = row.querySelector('.edit-label');
  353. if (editLabel) editLabel.value = entry.label ?? '';
  354. row.querySelector('.edit-note').value = entry.note ?? '';
  355. }
  356. async deleteEntry(row) {
  357. if (!confirm(t('confirmDelete'))) return;
  358. try {
  359. const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
  360. if (!res.ok) { alert(t('errorDelete')); return; }
  361. const data = await res.json();
  362. removeWithAnimation(row, 'entry-row--removing');
  363. setTimeout(() => {
  364. this.updateTotal(data.totalDuration);
  365. this.checkIfEmpty();
  366. }, ANIMATION_MS);
  367. } catch {
  368. alert(t('errorDelete'));
  369. }
  370. }
  371. async loadEntriesForDate(dateStr) {
  372. window.TT.activeDate = dateStr;
  373. try {
  374. this.list.classList.add('entry-list--fading');
  375. await new Promise(r => setTimeout(r, FADE_MS));
  376. const res = await fetch(`/api/entries?date=${dateStr}`);
  377. if (!res.ok) throw new Error(`HTTP ${res.status}`);
  378. const data = await res.json();
  379. this.renderEntries(data.entries, data.totalDuration);
  380. } catch (err) {
  381. console.error(t('errorLoad'), err);
  382. } finally {
  383. this.list.classList.remove('entry-list--fading');
  384. }
  385. }
  386. renderEntries(entries, totalDuration) {
  387. if (!entries.length) {
  388. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  389. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  390. this.emptyState = this.list.querySelector('#empty-state');
  391. return;
  392. }
  393. let html = '<div class="entry-list__items" id="entry-items">';
  394. entries.forEach(e => { html += buildEntryRowHTML(e, false); });
  395. html += `</div><div class="entry-list__footer" id="entry-footer">
  396. <span class="entry-list__total">${esc(totalDuration)}</span></div>`;
  397. this.list.innerHTML = html;
  398. this.emptyState = null;
  399. this.list.querySelectorAll('.entry-row').forEach(row => {
  400. row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
  401. row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
  402. });
  403. this.checkAutoEdit();
  404. window.stopwatchManager?.markActiveEntryRow();
  405. }
  406. updateTotal(totalDuration) {
  407. let footer = document.getElementById('entry-footer');
  408. if (!footer) {
  409. footer = document.createElement('div');
  410. footer.className = 'entry-list__footer';
  411. footer.id = 'entry-footer';
  412. this.list.appendChild(footer);
  413. }
  414. footer.innerHTML = `<span class="entry-list__total">${esc(totalDuration)}</span>`;
  415. }
  416. hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
  417. checkIfEmpty() {
  418. const items = document.getElementById('entry-items');
  419. if (items && !items.children.length) {
  420. items.remove();
  421. document.getElementById('entry-footer')?.remove();
  422. this.list.innerHTML = `<div class="empty-state" id="empty-state">
  423. <p class="empty-state__title">${t('noEntries')}</p></div>`;
  424. this.emptyState = this.list.querySelector('#empty-state');
  425. }
  426. }
  427. }
  428. // ── Minimal-Modus ────────────────────────────────────────────────────────────
  429. function initMinimalMode() {
  430. if (document.body.dataset.theme !== 'minimal') return;
  431. initWeekToggle();
  432. initNoteToggle();
  433. initEntriesToggle();
  434. }
  435. function initWeekToggle() {
  436. const btn = document.getElementById('btn-week-toggle');
  437. const collapsible = document.getElementById('tt-header-collapsible');
  438. if (!btn || !collapsible) return;
  439. btn.setAttribute('aria-expanded', 'false');
  440. collapsible.classList.remove('is-open');
  441. let kw = btn.textContent.trim().match(/\d+/)?.[0] ?? '';
  442. btn.textContent = kw ? `KW ${kw} ▾` : '▾';
  443. btn.addEventListener('click', () => {
  444. const open = collapsible.classList.toggle('is-open');
  445. btn.setAttribute('aria-expanded', String(open));
  446. btn.textContent = kw ? `KW ${kw} ${open ? '▴' : '▾'}` : (open ? '▴' : '▾');
  447. });
  448. window._updateWeekToggle = (newKw) => {
  449. kw = String(newKw);
  450. const open = collapsible.classList.contains('is-open');
  451. btn.textContent = `KW ${kw} ${open ? '▴' : '▾'}`;
  452. };
  453. }
  454. function initNoteToggle() {
  455. const btn = document.getElementById('btn-note-toggle');
  456. const label = document.querySelector('.entry-form__label--note');
  457. const field = document.querySelector('.entry-form__field--note');
  458. if (!btn) return;
  459. const open = localStorage.getItem(NOTE_KEY) === '1';
  460. setNoteVisible(open, btn, label, field);
  461. btn.addEventListener('click', () => {
  462. const nowOpen = label?.classList.toggle('is-visible');
  463. field?.classList.toggle('is-visible');
  464. btn.classList.toggle('is-open', !!nowOpen);
  465. btn.textContent = nowOpen ? t('noteHide') : t('noteShow');
  466. localStorage.setItem(NOTE_KEY, nowOpen ? '1' : '0');
  467. });
  468. }
  469. function setNoteVisible(open, btn, label, field) {
  470. if (open) {
  471. label?.classList.add('is-visible');
  472. field?.classList.add('is-visible');
  473. btn.classList.add('is-open');
  474. btn.textContent = t('noteHide');
  475. } else {
  476. btn.textContent = t('noteShow');
  477. }
  478. }
  479. function initEntriesToggle() {
  480. const summaryBtn = document.getElementById('btn-entries-toggle');
  481. const entryList = document.getElementById('entry-list');
  482. if (!summaryBtn || !entryList) return;
  483. entryList.classList.add('is-collapsed');
  484. summaryBtn.setAttribute('aria-expanded', 'false');
  485. summaryBtn.addEventListener('click', () => {
  486. const collapsed = entryList.classList.toggle('is-collapsed');
  487. summaryBtn.setAttribute('aria-expanded', String(!collapsed));
  488. });
  489. }
  490. // ── Labels ──────────────────────────────────────────────────────────────────
  491. async function loadProjectLabels(projectId, chipsContainer) {
  492. if (!chipsContainer || !projectId) {
  493. if (chipsContainer) chipsContainer.innerHTML = '';
  494. return;
  495. }
  496. try {
  497. const res = await fetch(`/api/labels?projectId=${projectId}`);
  498. if (!res.ok) return;
  499. const labels = await res.json();
  500. chipsContainer.innerHTML = labels.map(
  501. l => `<button type="button" class="label-chip" data-label="${esc(l)}">${esc(l)}</button>`
  502. ).join('');
  503. } catch { /* silent */ }
  504. }
  505. let acDebounce = null;
  506. function initLabelAutocomplete(input, dropdown) {
  507. if (!input || !dropdown) return;
  508. input.addEventListener('input', () => {
  509. clearTimeout(acDebounce);
  510. const q = input.value.trim();
  511. if (q.length < 1) { dropdown.hidden = true; return; }
  512. acDebounce = setTimeout(async () => {
  513. try {
  514. const res = await fetch(`/api/labels?q=${encodeURIComponent(q)}`);
  515. if (!res.ok) return;
  516. const labels = await res.json();
  517. if (!labels.length) { dropdown.hidden = true; return; }
  518. dropdown.innerHTML = labels.map(
  519. l => `<button type="button" class="label-autocomplete__item" data-label="${esc(l)}">${esc(l)}</button>`
  520. ).join('');
  521. dropdown.hidden = false;
  522. } catch { dropdown.hidden = true; }
  523. }, 300);
  524. });
  525. dropdown.addEventListener('click', e => {
  526. const item = e.target.closest('[data-label]');
  527. if (!item) return;
  528. input.value = item.dataset.label;
  529. dropdown.hidden = true;
  530. });
  531. input.addEventListener('blur', () => {
  532. setTimeout(() => { dropdown.hidden = true; }, 200);
  533. });
  534. }
  535. function initLabelChipClick(container, input) {
  536. if (!container || !input) return;
  537. container.addEventListener('click', e => {
  538. const chip = e.target.closest('[data-label]');
  539. if (!chip) return;
  540. input.value = chip.dataset.label;
  541. });
  542. }
  543. function initCreateLabels() {
  544. const cp = document.getElementById('create-project');
  545. const chips = document.getElementById('create-label-chips');
  546. const input = document.getElementById('create-label');
  547. const ac = document.getElementById('create-label-autocomplete');
  548. initLabelChipClick(chips, input);
  549. initLabelAutocomplete(input, ac);
  550. if (cp) {
  551. const loadChips = () => loadProjectLabels(parseInt(cp.value, 10), chips);
  552. cp.addEventListener('change', loadChips);
  553. if (cp.value) loadChips();
  554. }
  555. }
  556. function initEditLabels() {
  557. document.addEventListener('click', e => {
  558. const editBtn = e.target.closest('[data-action="edit"]');
  559. if (!editBtn) return;
  560. const row = editBtn.closest('.entry-row');
  561. if (!row) return;
  562. setTimeout(() => {
  563. const chips = row.querySelector('.edit-label-chips');
  564. const input = row.querySelector('.edit-label');
  565. const ac = row.querySelector('.edit-label-autocomplete');
  566. const projectId = parseInt(row.querySelector('.edit-project')?.value || row.dataset.projectId, 10);
  567. if (chips && !chips.dataset.init) {
  568. initLabelChipClick(chips, input);
  569. initLabelAutocomplete(input, ac);
  570. chips.dataset.init = '1';
  571. }
  572. loadProjectLabels(projectId, chips);
  573. }, 0);
  574. });
  575. }
  576. window.entryManager = null;
  577. document.addEventListener('DOMContentLoaded', () => {
  578. initDurationBlurHandler();
  579. initMinimalMode();
  580. window.entryManager = new EntryManager();
  581. initCreateLabels();
  582. initEditLabels();
  583. });