25'ten fazla konu seçemezsiniz Konular bir harf veya rakamla başlamalı, kısa çizgiler ('-') içerebilir ve en fazla 35 karakter uzunluğunda olabilir.
 
 
 
 
 

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