|
- // assets/scripts/entries.js
- import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, validateDuration } from './duration.js';
-
- const LAST_PROJECT_KEY = 'tt_last_project_id';
- const LAST_SERVICE_KEY = 'tt_last_service_id';
-
- 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>`;
-
- function t(key) {
- return window.TT?.i18n?.[key] ?? key;
- }
-
- function buildProjectOptions(selectedId = null) {
- const groups = {};
- (window.TT?.projects ?? []).forEach(p => {
- if (!groups[p.clientName]) groups[p.clientName] = [];
- groups[p.clientName].push(p);
- });
-
- let html = `<option value="">${t('selectPh')}</option>`;
- for (const [client, projects] of Object.entries(groups)) {
- html += `<optgroup label="${client}">`;
- projects.forEach(p => {
- const sel = String(p.id) === String(selectedId) ? ' selected' : '';
- html += `<option value="${p.id}"${sel}>${p.name}</option>`;
- });
- html += '</optgroup>';
- }
- return html;
- }
-
- function buildServiceOptions(selectedId = null) {
- const billable = (window.TT?.services ?? []).filter(s => s.billable);
- const notBillable = (window.TT?.services ?? []).filter(s => !s.billable);
-
- let html = `<option value="">${t('selectPh')}</option>`;
-
- if (billable.length) {
- html += `<optgroup label="${t('billable')}">`;
- billable.forEach(s => {
- const sel = String(s.id) === String(selectedId) ? ' selected' : '';
- html += `<option value="${s.id}"${sel}>${s.name}</option>`;
- });
- html += '</optgroup>';
- }
-
- if (notBillable.length) {
- html += `<optgroup label="${t('notBillable')}">`;
- notBillable.forEach(s => {
- const sel = String(s.id) === String(selectedId) ? ' selected' : '';
- html += `<option value="${s.id}"${sel}>${s.name}</option>`;
- });
- html += '</optgroup>';
- }
-
- return html;
- }
-
- function buildEntryRowHTML(entry, animate = false) {
- const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
- const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : '';
- const invoiced = !!entry.invoiced;
-
- const actionsHtml = invoiced
- ? `<span class="entry-row__badge">${entry.durationFormatted}</span>
- <span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
- : `<span class="entry-row__badge">${entry.durationFormatted}</span>
- <button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
- <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>
- </button>
- <button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
- <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>
- </button>`;
-
- const editFormHtml = invoiced ? '' : `
- <div class="entry-row__edit" hidden>
- <div class="entry-form__grid entry-form__grid--inline">
- <label class="entry-form__label">${t('labelDuration')}</label>
- <div class="entry-form__field">
- <input type="text" class="input input--sm edit-duration"
- value="${entry.durationFormatted}" autocomplete="off" />
- <div class="duration-help">
- <span class="duration-help__icon">?</span>
- <span class="duration-help__hint">${t('durationHint')}</span>
- </div>
- </div>
- <label class="entry-form__label">${t('labelProjectService')}</label>
- <div class="entry-form__field entry-form__field--selects">
- <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select>
- <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select>
- </div>
- <label class="entry-form__label">${t('labelNote')}</label>
- <div class="entry-form__field">
- <textarea class="textarea edit-note" rows="3">${entry.note ?? ''}</textarea>
- </div>
- <div class="entry-form__actions">
- <button type="button" class="btn btn-primary" data-action="save">${t('btnSave')}</button>
- <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
- </div>
- </div>
- </div>`;
-
- return `
- <div class="entry-row${animate ? ' entry-row--new' : ''}${invoiced ? ' entry-row--invoiced' : ''}"
- id="entry-${entry.id}"
- data-id="${entry.id}"
- data-duration="${entry.duration}"
- data-project-id="${entry.projectId}"
- data-service-id="${entry.serviceId ?? ''}"
- data-note="${(entry.note ?? '').replace(/"/g, '"')}"
- data-invoiced="${invoiced ? 'true' : 'false'}">
-
- <div class="entry-row__display">
- <div class="entry-row__info">
- <div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
- ${notePart}
- </div>
- <div class="entry-row__actions">
- ${actionsHtml}
- </div>
- </div>
- ${editFormHtml}
- </div>`;
- }
-
- class EntryManager {
- constructor() {
- this.list = document.getElementById('entry-list');
- this.emptyState = document.getElementById('empty-state');
-
- if (!this.list) return;
-
- const cp = document.getElementById('create-project');
- const cs = document.getElementById('create-service');
-
- document.getElementById('create-service')?.addEventListener('change', e => {
- saveLastService(e.target.value);
- });
-
- document.getElementById('create-project')?.addEventListener('change', e => {
- saveLastProject(e.target.value);
- });
-
- if (cp) {
- const lastProject = getLastProject();
- cp.innerHTML = buildProjectOptions(lastProject);
- if (lastProject) cp.value = lastProject;
- }
- if (cs) {
- const lastService = getLastService();
- cs.innerHTML = buildServiceOptions(lastService);
- if (lastService) cs.value = lastService;
- }
-
- this.list.querySelectorAll('.entry-row').forEach(row => {
- const ep = row.querySelector('.edit-project');
- const es = row.querySelector('.edit-service');
- if (ep) ep.innerHTML = buildProjectOptions(row.dataset.projectId);
- if (es) es.innerHTML = buildServiceOptions(row.dataset.serviceId);
- });
-
- this.list.addEventListener('click', e => this.handleListClick(e));
- document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry());
-
- this.checkAutoEdit();
- }
-
- handleListClick(e) {
- const row = e.target.closest('.entry-row');
- if (!row) return;
-
- const actionEl = e.target.closest('[data-action]');
- if (actionEl) {
- const action = actionEl.dataset.action;
- if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return;
- switch (action) {
- case 'edit': this.openEdit(row); break;
- case 'delete': this.deleteEntry(row); break;
- case 'save': this.saveEdit(row); break;
- case 'cancel': this.closeEdit(row); break;
- }
- return;
- }
-
- // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen
- if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') {
- this.openEdit(row);
- }
- }
-
- async createEntry() {
- const durationRaw = document.getElementById('create-duration')?.value ?? '0:00';
- const projectId = document.getElementById('create-project')?.value;
- const serviceId = document.getElementById('create-service')?.value;
- const note = document.getElementById('create-note')?.value;
-
- if (!projectId) { alert(t('errorNoProject')); return; }
-
- const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
-
- if (duration === '0:00') {
- alert(t('errorZeroDuration'));
- return;
- }
-
- const rawMinutes = roundToQuarter(parseDuration(durationRaw));
- const validation = validateDuration(rawMinutes);
- if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
- if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
-
- try {
- const res = await fetch('/api/entries', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- date: window.TT.activeDate,
- duration,
- projectId: parseInt(projectId),
- serviceId: serviceId ? parseInt(serviceId) : null,
- note: note || null,
- }),
- });
-
- if (!res.ok) {
- const err = await res.json().catch(() => ({}));
- console.error('API Fehler:', res.status, err);
- alert(t('errorSave') + (err.error ? `\n${err.error}` : ''));
- return;
- }
-
- const data = await res.json();
- this.addEntryToDOM(data.entry);
- this.updateTotal(data.totalDuration);
- this.resetCreateForm();
-
- } catch (err) {
- console.error('Netzwerkfehler:', err);
- alert(t('errorSave'));
- }
- }
-
- addEntryToDOM(entry) {
- this.hideEmptyState();
-
- let items = document.getElementById('entry-items');
- if (!items) {
- items = document.createElement('div');
- items.className = 'entry-list__items';
- items.id = 'entry-items';
- this.list.prepend(items);
- }
-
- items.insertAdjacentHTML('beforeend', buildEntryRowHTML(entry, true));
-
- const el = document.getElementById(`entry-${entry.id}`);
- requestAnimationFrame(() => requestAnimationFrame(() => {
- el?.classList.remove('entry-row--new');
- }));
- }
-
- resetCreateForm() {
- const d = document.getElementById('create-duration');
- const p = document.getElementById('create-project');
- const s = document.getElementById('create-service');
- const n = document.getElementById('create-note');
- if (d) d.value = '0:00';
- if (n) n.value = '';
- if (p) p.value = getLastProject() ?? '';
- if (s) s.value = getLastService() ?? '';
- }
-
- openEdit(row) {
- // Safety-Guard: invoiced-Einträge können nicht geöffnet werden
- if (row.dataset.invoiced === 'true') return;
- // Kein Edit-Formular vorhanden → nicht öffnen
- const editSection = row.querySelector('.entry-row__edit');
- if (!editSection) return;
-
- row.querySelector('.entry-row__display').hidden = true;
- editSection.hidden = false;
- row.querySelector('.edit-duration')?.focus();
- }
-
- closeEdit(row) {
- row.querySelector('.entry-row__display').hidden = false;
- row.querySelector('.entry-row__edit').hidden = true;
- }
-
- checkAutoEdit() {
- const params = new URLSearchParams(window.location.search);
- const editId = params.get('editEntry');
- if (!editId) return;
- const row = document.getElementById(`entry-${editId}`);
- if (row) {
- this.openEdit(row);
- params.delete('editEntry');
- const newUrl = window.location.pathname +
- (params.size > 0 ? '?' + params.toString() : '');
- history.replaceState(null, '', newUrl);
- }
- }
-
- async saveEdit(row) {
- const id = row.dataset.id;
- const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00';
- const projectId = row.querySelector('.edit-project')?.value;
- const serviceId = row.querySelector('.edit-service')?.value;
- const note = row.querySelector('.edit-note')?.value;
-
- if (!projectId) { alert(t('errorNoProject')); return; }
-
- const duration = formatMinutes(roundToQuarter(parseDuration(durationRaw)));
-
- if (duration === '0:00') {
- alert(t('errorZeroDuration'));
- return;
- }
-
- const rawMinutes = roundToQuarter(parseDuration(durationRaw));
- const validation = validateDuration(rawMinutes);
- if (validation.status === 'error') { alert(t('errorDurationTooLong')); return; }
- if (validation.status === 'warn' && !confirm(t('warnDurationLong'))) return;
-
- try {
- const res = await fetch(`/api/entries/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- duration,
- projectId: parseInt(projectId),
- serviceId: serviceId ? parseInt(serviceId) : null,
- note: note || null,
- }),
- });
-
- if (!res.ok) {
- console.error('PATCH fehlgeschlagen:', res.status);
- alert(t('errorSave'));
- return;
- }
-
- const data = await res.json();
- this.updateRowDisplay(row, data.entry);
- this.updateTotal(data.totalDuration);
- this.closeEdit(row);
-
- } catch (err) {
- console.error('saveEdit Fehler:', err);
- alert(t('errorSave'));
- }
- }
-
- updateRowDisplay(row, entry) {
- const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : '';
- row.querySelector('.entry-row__title').textContent =
- `${entry.clientName} / ${entry.projectName}${servicePart}`;
-
- row.querySelector('.entry-row__note')?.remove();
- if (entry.note) {
- const noteEl = document.createElement('div');
- noteEl.className = 'entry-row__note';
- noteEl.textContent = entry.note;
- row.querySelector('.entry-row__info').appendChild(noteEl);
- }
-
- row.querySelector('.entry-row__badge').textContent = entry.durationFormatted;
- row.dataset.duration = entry.duration;
- row.dataset.projectId = entry.projectId;
- row.dataset.serviceId = entry.serviceId ?? '';
- row.dataset.note = entry.note ?? '';
-
- row.querySelector('.edit-duration').value = entry.durationFormatted;
- row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId);
- row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId);
- row.querySelector('.edit-note').value = entry.note ?? '';
- }
-
- async deleteEntry(row) {
- if (!confirm(t('confirmDelete'))) return;
-
- try {
- const res = await fetch(`/api/entries/${row.dataset.id}`, { method: 'DELETE' });
- if (!res.ok) { alert(t('errorDelete')); return; }
-
- const data = await res.json();
- row.classList.add('entry-row--removing');
- setTimeout(() => {
- row.remove();
- this.updateTotal(data.totalDuration);
- this.checkIfEmpty();
- }, 280);
-
- } catch { alert(t('errorDelete')); }
- }
-
- async loadEntriesForDate(dateStr) {
- window.TT.activeDate = dateStr;
-
- try {
- this.list.classList.add('entry-list--fading');
- await new Promise(r => setTimeout(r, 180));
-
- const res = await fetch(`/api/entries?date=${dateStr}`);
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
- const data = await res.json();
-
- this.renderEntries(data.entries, data.totalDuration);
- } catch (err) {
- console.error(t('errorLoad'), err);
- } finally {
- this.list.classList.remove('entry-list--fading');
- }
- }
-
- renderEntries(entries, totalDuration) {
- if (!entries.length) {
- this.list.innerHTML = `<div class="empty-state" id="empty-state">
- <p class="empty-state__title">${t('noEntries')}</p></div>`;
- this.emptyState = this.list.querySelector('#empty-state');
- return;
- }
-
- let html = '<div class="entry-list__items" id="entry-items">';
- entries.forEach(e => { html += buildEntryRowHTML(e, false); });
- html += `</div><div class="entry-list__footer" id="entry-footer">
- <span class="entry-list__total">${totalDuration}</span></div>`;
-
- this.list.innerHTML = html;
- this.emptyState = null;
-
- this.list.querySelectorAll('.entry-row').forEach(row => {
- row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId);
- row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId);
- });
-
- this.checkAutoEdit();
- }
-
- updateTotal(totalDuration) {
- let footer = document.getElementById('entry-footer');
- if (!footer) {
- footer = document.createElement('div');
- footer.className = 'entry-list__footer';
- footer.id = 'entry-footer';
- this.list.appendChild(footer);
- }
- footer.innerHTML = `<span class="entry-list__total">${totalDuration}</span>`;
- }
-
- hideEmptyState() { this.emptyState?.remove(); this.emptyState = null; }
-
- checkIfEmpty() {
- const items = document.getElementById('entry-items');
- if (items && !items.children.length) {
- items.remove();
- document.getElementById('entry-footer')?.remove();
- this.list.innerHTML = `<div class="empty-state" id="empty-state">
- <p class="empty-state__title">${t('noEntries')}</p></div>`;
- this.emptyState = this.list.querySelector('#empty-state');
- }
- }
- }
-
- function saveLastProject(projectId) {
- if (projectId) localStorage.setItem(LAST_PROJECT_KEY, projectId);
- }
-
- function getLastProject() {
- return localStorage.getItem(LAST_PROJECT_KEY);
- }
-
- function saveLastService(serviceId) {
- if (serviceId) localStorage.setItem(LAST_SERVICE_KEY, serviceId);
- }
-
- function getLastService() {
- return localStorage.getItem(LAST_SERVICE_KEY);
- }
-
- window.entryManager = null;
- document.addEventListener('DOMContentLoaded', () => {
- initDurationBlurHandler();
- window.entryManager = new EntryManager();
- });
|