| @@ -181,6 +181,14 @@ Controller die Tenant-Entities nutzen brauchen den `tenant_entity_manager` expli | |||||
| Alle drei nutzen die gleichen Filter-Parameter wie die Report-Seite, exportieren ohne Limit. `ReportExportService` bereitet Daten zentral in `prepareData()` auf, formatspezifische Methoden erzeugen die Ausgabe. Tracker sehen nur eigene Einträge. | Alle drei nutzen die gleichen Filter-Parameter wie die Report-Seite, exportieren ohne Limit. `ReportExportService` bereitet Daten zentral in `prepareData()` auf, formatspezifische Methoden erzeugen die Ausgabe. Tracker sehen nur eigene Einträge. | ||||
| ### Sortierung | |||||
| Alle Spalten der Report-Tabelle sind serverseitig sortierbar (Klick auf Spaltenheader togglet ASC/DESC). URL-Parameter: `?sort={column}&dir={ASC|DESC}`. Default: `sort=date&dir=DESC`. | |||||
| Sortierbare Spalten: `date`, `client`, `project`, `service`, `user`, `note`, `duration`, `revenue`. Die Revenue-Sortierung nutzt einen `HIDDEN`-Select-Alias (`COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) * t.duration`), da DQL keine berechneten Ausdrücke direkt im `ORDER BY` unterstützt. Sekundärsort ist immer `t.createdAt DESC`. | |||||
| Twig-Macro `sort_header` im Template rendert die klickbaren Spaltenheader mit Sort-Indikator (▴/▾). JS (`initSortHeaders()`) setzt `sort`/`dir` als URL-Parameter und navigiert. | |||||
| ## Stoppuhr / Timer | ## Stoppuhr / Timer | ||||
| Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen. | Live-Timer zum Tracken von Zeiteinträgen. UI in der Navigation (Desktop + Hamburger-Menü), zusätzlich Play-Button an jeder Entry-Row zum Fortsetzen. | ||||
| @@ -235,9 +243,25 @@ Optionale Verknüpfung von Kunden mit Lexware Office Kontakten. Nur aktiv wenn ` | |||||
| |------------------------------------------|--------|-------------------------------------------| | |------------------------------------------|--------|-------------------------------------------| | ||||
| | `/api/lexoffice/contacts` | GET | Alle Kunden-Kontakte aus Lexware Office | | | `/api/lexoffice/contacts` | GET | Alle Kunden-Kontakte aus Lexware Office | | ||||
| | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | | `/api/lexoffice/contacts/{contactId}` | GET | Einzelnen Kontakt abrufen | | ||||
| | `/api/lexoffice/invoices` | POST | Rechnungsentwurf in Lexware Office anlegen| | |||||
| | `/api/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | | | `/api/clients/{id}/lexoffice-refresh` | PATCH | Kundenname aus Lexware aktualisieren | | ||||
| ### Verhalten | |||||
| ### Rechnungsentwurf aus Report | |||||
| Auf der Report-Seite erscheint ein Invoice-Icon (vor den Export-Buttons, durch Separator getrennt), wenn: | |||||
| 1. Die gefilterten Einträge genau **einen** Kunden enthalten | |||||
| 2. Dieser Kunde eine `lexofficeContactId` hat | |||||
| 3. Der User kein Tracker ist und ein API-Key hinterlegt ist | |||||
| Beim Klick wird via `POST /api/lexoffice/invoices` ein Rechnungsentwurf erstellt: | |||||
| - Kunde vorausgewählt (`address.contactId`) | |||||
| - Leistungszeitraum (`shippingType: serviceperiod`) mit Start-/Enddatum aus `MIN(t.date)` / `MAX(t.date)` der gefilterten Einträge | |||||
| - Platzhalter-Lineitem (wird in Lexware Office befüllt) | |||||
| - Nach Erfolg: Confirm-Dialog mit Option, den Entwurf direkt in Lexware Office zu öffnen (`https://app.lexware.de/permalink/invoices/edit/{id}`) | |||||
| Serverseitige Logik: `ReportController` ermittelt via `TimeEntryRepository::findDistinctClientIdsFiltered()` und `findDateRangeFiltered()` die Daten. `LexofficeService::createInvoiceDraft()` ruft `POST /v1/invoices` auf. | |||||
| ### Verhalten (Kontaktverknüpfung) | |||||
| - Beim Verknüpfen wird der Kundenname aus Lexware übernommen, das Name-Feld wird disabled | - Beim Verknüpfen wird der Kundenname aus Lexware übernommen, das Name-Feld wird disabled | ||||
| - Bereits verknüpfte Kontakte werden in der Dropdown-Liste ausgeblendet (Duplikat-Schutz) | - Bereits verknüpfte Kontakte werden in der Dropdown-Liste ausgeblendet (Duplikat-Schutz) | ||||
| @@ -59,6 +59,7 @@ function buildServiceOptions(selectedId = null) { | |||||
| function buildEntryRowHTML(entry, animate = false) { | function buildEntryRowHTML(entry, animate = false) { | ||||
| const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; | const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; | ||||
| const labelPart = entry.label ? `<span class="entry-row__label">${esc(entry.label)}</span>` : ''; | |||||
| const notePart = entry.note ? `<div class="entry-row__note">${esc(entry.note)}</div>` : ''; | const notePart = entry.note ? `<div class="entry-row__note">${esc(entry.note)}</div>` : ''; | ||||
| const invoiced = !!entry.invoiced; | const invoiced = !!entry.invoiced; | ||||
| @@ -93,6 +94,16 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select> | <select class="select edit-project">${buildProjectOptions(entry.projectId)}</select> | ||||
| <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select> | <select class="select edit-service">${buildServiceOptions(entry.serviceId)}</select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">${t('labelLabel')}</label> | |||||
| <div class="entry-form__field entry-form__field--label"> | |||||
| <div class="label-chips edit-label-chips"></div> | |||||
| <div class="label-input-wrap"> | |||||
| <input type="text" class="input input--sm edit-label" | |||||
| value="${esc(entry.label ?? '')}" | |||||
| placeholder="${t('placeholderLabel')}" autocomplete="off" /> | |||||
| <div class="label-autocomplete edit-label-autocomplete" hidden></div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label">${t('labelNote')}</label> | <label class="entry-form__label">${t('labelNote')}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="3">${esc(entry.note ?? '')}</textarea> | <textarea class="textarea edit-note" rows="3">${esc(entry.note ?? '')}</textarea> | ||||
| @@ -111,12 +122,14 @@ function buildEntryRowHTML(entry, animate = false) { | |||||
| data-duration="${entry.duration}" | data-duration="${entry.duration}" | ||||
| data-project-id="${entry.projectId}" | data-project-id="${entry.projectId}" | ||||
| data-service-id="${entry.serviceId ?? ''}" | data-service-id="${entry.serviceId ?? ''}" | ||||
| data-label="${esc(entry.label ?? '')}" | |||||
| data-note="${esc(entry.note ?? '')}" | data-note="${esc(entry.note ?? '')}" | ||||
| data-invoiced="${invoiced ? 'true' : 'false'}"> | data-invoiced="${invoiced ? 'true' : 'false'}"> | ||||
| <div class="entry-row__display"> | <div class="entry-row__display"> | ||||
| <div class="entry-row__info"> | <div class="entry-row__info"> | ||||
| <div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div> | <div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div> | ||||
| ${labelPart} | |||||
| ${notePart} | ${notePart} | ||||
| </div> | </div> | ||||
| <div class="entry-row__actions"> | <div class="entry-row__actions"> | ||||
| @@ -209,6 +222,7 @@ class EntryManager { | |||||
| const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | ||||
| const projectId = document.getElementById('create-project')?.value; | const projectId = document.getElementById('create-project')?.value; | ||||
| const serviceId = document.getElementById('create-service')?.value; | const serviceId = document.getElementById('create-service')?.value; | ||||
| const label = document.getElementById('create-label')?.value; | |||||
| const note = document.getElementById('create-note')?.value; | const note = document.getElementById('create-note')?.value; | ||||
| if (!projectId) { alert(t('errorNoProject')); return; } | if (!projectId) { alert(t('errorNoProject')); return; } | ||||
| @@ -228,6 +242,7 @@ class EntryManager { | |||||
| duration: dur.formatted, | duration: dur.formatted, | ||||
| projectId: parseInt(projectId, 10), | projectId: parseInt(projectId, 10), | ||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | serviceId: serviceId ? parseInt(serviceId, 10) : null, | ||||
| label: label || null, | |||||
| note: note || null, | note: note || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| @@ -242,6 +257,7 @@ class EntryManager { | |||||
| this.addEntryToDOM(data.entry); | this.addEntryToDOM(data.entry); | ||||
| this.updateTotal(data.totalDuration); | this.updateTotal(data.totalDuration); | ||||
| this.resetCreateForm(); | this.resetCreateForm(); | ||||
| if (projectId) loadProjectLabels(parseInt(projectId, 10), document.getElementById('create-label-chips')); | |||||
| } catch { | } catch { | ||||
| alert(t('errorSave')); | alert(t('errorSave')); | ||||
| } finally { | } finally { | ||||
| @@ -270,8 +286,10 @@ class EntryManager { | |||||
| const d = document.getElementById('create-duration'); | const d = document.getElementById('create-duration'); | ||||
| const p = document.getElementById('create-project'); | const p = document.getElementById('create-project'); | ||||
| const s = document.getElementById('create-service'); | const s = document.getElementById('create-service'); | ||||
| const l = document.getElementById('create-label'); | |||||
| const n = document.getElementById('create-note'); | const n = document.getElementById('create-note'); | ||||
| if (d) d.value = '0:00'; | if (d) d.value = '0:00'; | ||||
| if (l) l.value = ''; | |||||
| if (n) n.value = ''; | if (n) n.value = ''; | ||||
| if (p) p.value = getLastProject() ?? ''; | if (p) p.value = getLastProject() ?? ''; | ||||
| if (s) s.value = getLastService() ?? ''; | if (s) s.value = getLastService() ?? ''; | ||||
| @@ -314,6 +332,7 @@ class EntryManager { | |||||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | ||||
| const projectId = row.querySelector('.edit-project')?.value; | const projectId = row.querySelector('.edit-project')?.value; | ||||
| const serviceId = row.querySelector('.edit-service')?.value; | const serviceId = row.querySelector('.edit-service')?.value; | ||||
| const label = row.querySelector('.edit-label')?.value; | |||||
| const note = row.querySelector('.edit-note')?.value; | const note = row.querySelector('.edit-note')?.value; | ||||
| if (!projectId) { alert(t('errorNoProject')); return; } | if (!projectId) { alert(t('errorNoProject')); return; } | ||||
| @@ -337,6 +356,7 @@ class EntryManager { | |||||
| duration: dur.formatted, | duration: dur.formatted, | ||||
| projectId: parseInt(projectId, 10), | projectId: parseInt(projectId, 10), | ||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | serviceId: serviceId ? parseInt(serviceId, 10) : null, | ||||
| label: label || null, | |||||
| note: note || null, | note: note || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| @@ -362,6 +382,16 @@ class EntryManager { | |||||
| row.querySelector('.entry-row__title').textContent = | row.querySelector('.entry-row__title').textContent = | ||||
| `${entry.clientName} / ${entry.projectName}${servicePart}`; | `${entry.clientName} / ${entry.projectName}${servicePart}`; | ||||
| row.querySelector('.entry-row__label')?.remove(); | |||||
| if (entry.label) { | |||||
| const labelEl = document.createElement('span'); | |||||
| labelEl.className = 'entry-row__label'; | |||||
| labelEl.textContent = entry.label; | |||||
| const info = row.querySelector('.entry-row__info'); | |||||
| const noteEl = info.querySelector('.entry-row__note'); | |||||
| info.insertBefore(labelEl, noteEl); | |||||
| } | |||||
| row.querySelector('.entry-row__note')?.remove(); | row.querySelector('.entry-row__note')?.remove(); | ||||
| if (entry.note) { | if (entry.note) { | ||||
| const noteEl = document.createElement('div'); | const noteEl = document.createElement('div'); | ||||
| @@ -374,11 +404,14 @@ class EntryManager { | |||||
| row.dataset.duration = entry.duration; | row.dataset.duration = entry.duration; | ||||
| row.dataset.projectId = entry.projectId; | row.dataset.projectId = entry.projectId; | ||||
| row.dataset.serviceId = entry.serviceId ?? ''; | row.dataset.serviceId = entry.serviceId ?? ''; | ||||
| row.dataset.label = entry.label ?? ''; | |||||
| row.dataset.note = entry.note ?? ''; | row.dataset.note = entry.note ?? ''; | ||||
| row.querySelector('.edit-duration').value = entry.durationFormatted; | row.querySelector('.edit-duration').value = entry.durationFormatted; | ||||
| row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId); | row.querySelector('.edit-project').innerHTML = buildProjectOptions(entry.projectId); | ||||
| row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId); | row.querySelector('.edit-service').innerHTML = buildServiceOptions(entry.serviceId); | ||||
| const editLabel = row.querySelector('.edit-label'); | |||||
| if (editLabel) editLabel.value = entry.label ?? ''; | |||||
| row.querySelector('.edit-note').value = entry.note ?? ''; | row.querySelector('.edit-note').value = entry.note ?? ''; | ||||
| } | } | ||||
| @@ -546,9 +579,117 @@ function initEntriesToggle() { | |||||
| }); | }); | ||||
| } | } | ||||
| // ── Labels ────────────────────────────────────────────────────────────────── | |||||
| async function loadProjectLabels(projectId, chipsContainer) { | |||||
| if (!chipsContainer || !projectId) { | |||||
| if (chipsContainer) chipsContainer.innerHTML = ''; | |||||
| return; | |||||
| } | |||||
| try { | |||||
| const res = await fetch(`/api/labels?projectId=${projectId}`); | |||||
| if (!res.ok) return; | |||||
| const labels = await res.json(); | |||||
| chipsContainer.innerHTML = labels.map( | |||||
| l => `<button type="button" class="label-chip" data-label="${esc(l)}">${esc(l)}</button>` | |||||
| ).join(''); | |||||
| } catch { /* silent */ } | |||||
| } | |||||
| let acDebounce = null; | |||||
| function initLabelAutocomplete(input, dropdown) { | |||||
| if (!input || !dropdown) return; | |||||
| input.addEventListener('input', () => { | |||||
| clearTimeout(acDebounce); | |||||
| const q = input.value.trim(); | |||||
| if (q.length < 1) { dropdown.hidden = true; return; } | |||||
| acDebounce = setTimeout(async () => { | |||||
| try { | |||||
| const res = await fetch(`/api/labels?q=${encodeURIComponent(q)}`); | |||||
| if (!res.ok) return; | |||||
| const labels = await res.json(); | |||||
| if (!labels.length) { dropdown.hidden = true; return; } | |||||
| dropdown.innerHTML = labels.map( | |||||
| l => `<button type="button" class="label-autocomplete__item" data-label="${esc(l)}">${esc(l)}</button>` | |||||
| ).join(''); | |||||
| dropdown.hidden = false; | |||||
| } catch { dropdown.hidden = true; } | |||||
| }, 300); | |||||
| }); | |||||
| dropdown.addEventListener('click', e => { | |||||
| const item = e.target.closest('[data-label]'); | |||||
| if (!item) return; | |||||
| input.value = item.dataset.label; | |||||
| dropdown.hidden = true; | |||||
| }); | |||||
| input.addEventListener('blur', () => { | |||||
| setTimeout(() => { dropdown.hidden = true; }, 200); | |||||
| }); | |||||
| } | |||||
| function initLabelChipClick(container, input) { | |||||
| if (!container || !input) return; | |||||
| container.addEventListener('click', e => { | |||||
| const chip = e.target.closest('[data-label]'); | |||||
| if (!chip) return; | |||||
| input.value = chip.dataset.label; | |||||
| }); | |||||
| } | |||||
| function initCreateLabels() { | |||||
| const cp = document.getElementById('create-project'); | |||||
| const chips = document.getElementById('create-label-chips'); | |||||
| const input = document.getElementById('create-label'); | |||||
| const ac = document.getElementById('create-label-autocomplete'); | |||||
| initLabelChipClick(chips, input); | |||||
| initLabelAutocomplete(input, ac); | |||||
| if (cp) { | |||||
| const loadChips = () => loadProjectLabels(parseInt(cp.value, 10), chips); | |||||
| cp.addEventListener('change', loadChips); | |||||
| if (cp.value) loadChips(); | |||||
| } | |||||
| } | |||||
| function initEditLabels() { | |||||
| document.addEventListener('click', e => { | |||||
| const editBtn = e.target.closest('[data-action="edit"]'); | |||||
| if (!editBtn) return; | |||||
| const row = editBtn.closest('.entry-row'); | |||||
| if (!row) return; | |||||
| setTimeout(() => { | |||||
| const chips = row.querySelector('.edit-label-chips'); | |||||
| const input = row.querySelector('.edit-label'); | |||||
| const ac = row.querySelector('.edit-label-autocomplete'); | |||||
| const projectId = parseInt(row.querySelector('.edit-project')?.value || row.dataset.projectId, 10); | |||||
| if (chips && !chips.dataset.init) { | |||||
| initLabelChipClick(chips, input); | |||||
| initLabelAutocomplete(input, ac); | |||||
| chips.dataset.init = '1'; | |||||
| } | |||||
| loadProjectLabels(projectId, chips); | |||||
| }, 0); | |||||
| }); | |||||
| } | |||||
| window.entryManager = null; | window.entryManager = null; | ||||
| document.addEventListener('DOMContentLoaded', () => { | document.addEventListener('DOMContentLoaded', () => { | ||||
| initDurationBlurHandler(); | initDurationBlurHandler(); | ||||
| initMinimalMode(); | initMinimalMode(); | ||||
| window.entryManager = new EntryManager(); | window.entryManager = new EntryManager(); | ||||
| initCreateLabels(); | |||||
| initEditLabels(); | |||||
| }); | }); | ||||
| @@ -91,6 +91,7 @@ async function saveEdit(row) { | |||||
| const id = row.dataset.entryId; | const id = row.dataset.entryId; | ||||
| const projectId = row.querySelector('.edit-project')?.value; | const projectId = row.querySelector('.edit-project')?.value; | ||||
| const label = row.querySelector('.edit-label')?.value; | |||||
| const serviceId = row.querySelector('.edit-service')?.value; | const serviceId = row.querySelector('.edit-service')?.value; | ||||
| const note = row.querySelector('.edit-note')?.value ?? ''; | const note = row.querySelector('.edit-note')?.value ?? ''; | ||||
| @@ -109,6 +110,7 @@ async function saveEdit(row) { | |||||
| duration: dur.formatted, | duration: dur.formatted, | ||||
| projectId: parseInt(projectId, 10), | projectId: parseInt(projectId, 10), | ||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | serviceId: serviceId ? parseInt(serviceId, 10) : null, | ||||
| label: label || null, | |||||
| note: note || null, | note: note || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| @@ -197,6 +199,52 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| initLexofficeInvoiceButton(); | initLexofficeInvoiceButton(); | ||||
| }); | }); | ||||
| // ── Filter Label Autocomplete ───────────────────────────────────────────────── | |||||
| let filterAcDebounce = null; | |||||
| function initFilterLabelAutocomplete(input, dropdown) { | |||||
| if (!input || !dropdown) return; | |||||
| input.addEventListener('input', () => { | |||||
| clearTimeout(filterAcDebounce); | |||||
| const q = input.value.trim(); | |||||
| if (q.length < 1) { dropdown.hidden = true; return; } | |||||
| filterAcDebounce = setTimeout(async () => { | |||||
| try { | |||||
| const res = await fetch(`/api/labels?q=${encodeURIComponent(q)}`); | |||||
| if (!res.ok) return; | |||||
| const labels = await res.json(); | |||||
| if (!labels.length) { dropdown.hidden = true; return; } | |||||
| dropdown.innerHTML = labels.map( | |||||
| l => `<button type="button" class="label-autocomplete__item" data-label="${esc(l)}">${esc(l)}</button>` | |||||
| ).join(''); | |||||
| dropdown.hidden = false; | |||||
| } catch { dropdown.hidden = true; } | |||||
| }, 300); | |||||
| }); | |||||
| dropdown.addEventListener('click', e => { | |||||
| const item = e.target.closest('[data-label]'); | |||||
| if (!item) return; | |||||
| input.value = item.dataset.label; | |||||
| dropdown.hidden = true; | |||||
| }); | |||||
| input.addEventListener('blur', () => { | |||||
| setTimeout(() => { dropdown.hidden = true; }, 200); | |||||
| }); | |||||
| } | |||||
| function initAllFilterLabelAutocompletes() { | |||||
| document.querySelectorAll('.filter-label-input').forEach(input => { | |||||
| const ac = input.closest('.label-input-wrap')?.querySelector('.filter-label-autocomplete'); | |||||
| initFilterLabelAutocomplete(input, ac); | |||||
| }); | |||||
| } | |||||
| // ── ReportFilter ───────────────────────────────────────────────────────────── | // ── ReportFilter ───────────────────────────────────────────────────────────── | ||||
| class ReportFilter { | class ReportFilter { | ||||
| @@ -222,10 +270,12 @@ class ReportFilter { | |||||
| }); | }); | ||||
| }); | }); | ||||
| this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => { | |||||
| this.panel.querySelectorAll('.filter-select, .filter-note-input, .filter-label-input').forEach(el => { | |||||
| el.addEventListener('mousedown', () => this.activateRowByControl(el)); | el.addEventListener('mousedown', () => this.activateRowByControl(el)); | ||||
| }); | }); | ||||
| initAllFilterLabelAutocompletes(); | |||||
| this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => { | this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => { | ||||
| group.addEventListener('click', () => this.activateRowByControl(group)); | group.addEventListener('click', () => this.activateRowByControl(group)); | ||||
| }); | }); | ||||
| @@ -308,6 +358,15 @@ class ReportFilter { | |||||
| const clonedSelect = clone.querySelector('.filter-select'); | const clonedSelect = clone.querySelector('.filter-select'); | ||||
| if (clonedSelect) clonedSelect.value = ''; | if (clonedSelect) clonedSelect.value = ''; | ||||
| const clonedInput = clone.querySelector('.filter-label-input'); | |||||
| if (clonedInput) { | |||||
| clonedInput.value = ''; | |||||
| const clonedAc = clone.querySelector('.filter-label-autocomplete'); | |||||
| if (clonedAc) { clonedAc.innerHTML = ''; clonedAc.hidden = true; } | |||||
| initFilterLabelAutocomplete(clonedInput, clonedAc); | |||||
| clonedInput.addEventListener('mousedown', () => this.activateRowByControl(clonedInput)); | |||||
| } | |||||
| if (!clone.querySelector('.filter-row__remove')) { | if (!clone.querySelector('.filter-row__remove')) { | ||||
| const removeBtn = document.createElement('button'); | const removeBtn = document.createElement('button'); | ||||
| removeBtn.type = 'button'; | removeBtn.type = 'button'; | ||||
| @@ -410,6 +469,15 @@ class ReportFilter { | |||||
| params.set('filter[period_neg]', '1'); | params.set('filter[period_neg]', '1'); | ||||
| } | } | ||||
| } else if (key === 'labels') { | |||||
| row.querySelectorAll('.filter-label-input').forEach(inp => { | |||||
| const val = inp.value.trim(); | |||||
| if (val) params.append('filter[labels][]', val); | |||||
| }); | |||||
| if (row.querySelector('.filter-neg-checkbox')?.checked) { | |||||
| params.set('filter[labels_neg]', '1'); | |||||
| } | |||||
| } else if (key === 'note') { | } else if (key === 'note') { | ||||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | const val = row.querySelector('.filter-note-input')?.value?.trim(); | ||||
| if (val) params.set('filter[note]', val); | if (val) params.set('filter[note]', val); | ||||
| @@ -39,14 +39,14 @@ class StopwatchManager { | |||||
| const desktopCtx = this.buildContext( | const desktopCtx = this.buildContext( | ||||
| 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display', | 'stopwatch-toggle', 'stopwatch-popover', 'stopwatch-display', | ||||
| 'stopwatch-start', 'stopwatch-note', 'stopwatch-header-time', | |||||
| 'stopwatch-start', 'stopwatch-note', 'stopwatch-label', 'stopwatch-header-time', | |||||
| 'stopwatch-project', 'stopwatch-service' | 'stopwatch-project', 'stopwatch-service' | ||||
| ); | ); | ||||
| if (desktopCtx) this.contexts.push(desktopCtx); | if (desktopCtx) this.contexts.push(desktopCtx); | ||||
| const hamburgerCtx = this.buildContext( | const hamburgerCtx = this.buildContext( | ||||
| 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display', | 'hamburger-stopwatch', 'hamburger-stopwatch-popover', 'hamburger-stopwatch-display', | ||||
| 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-stopwatch-time', | |||||
| 'hamburger-sw-start', 'hamburger-sw-note', 'hamburger-sw-label', 'hamburger-stopwatch-time', | |||||
| 'hamburger-sw-project', 'hamburger-sw-service' | 'hamburger-sw-project', 'hamburger-sw-service' | ||||
| ); | ); | ||||
| if (hamburgerCtx) this.contexts.push(hamburgerCtx); | if (hamburgerCtx) this.contexts.push(hamburgerCtx); | ||||
| @@ -66,7 +66,7 @@ class StopwatchManager { | |||||
| this.init(); | this.init(); | ||||
| } | } | ||||
| buildContext(toggleId, popoverId, displayId, startBtnId, noteId, timeId, projectId, serviceId) { | |||||
| buildContext(toggleId, popoverId, displayId, startBtnId, noteId, labelId, timeId, projectId, serviceId) { | |||||
| const toggle = document.getElementById(toggleId); | const toggle = document.getElementById(toggleId); | ||||
| if (!toggle) return null; | if (!toggle) return null; | ||||
| @@ -75,6 +75,7 @@ class StopwatchManager { | |||||
| popover: document.getElementById(popoverId), | popover: document.getElementById(popoverId), | ||||
| display: document.getElementById(displayId), | display: document.getElementById(displayId), | ||||
| startBtn: document.getElementById(startBtnId), | startBtn: document.getElementById(startBtnId), | ||||
| labelField: document.getElementById(labelId), | |||||
| noteField: document.getElementById(noteId), | noteField: document.getElementById(noteId), | ||||
| headerTime: document.getElementById(timeId), | headerTime: document.getElementById(timeId), | ||||
| projectSelect: null, | projectSelect: null, | ||||
| @@ -251,6 +252,7 @@ class StopwatchManager { | |||||
| body: JSON.stringify({ | body: JSON.stringify({ | ||||
| projectId: parseInt(projectId, 10), | projectId: parseInt(projectId, 10), | ||||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | serviceId: serviceId ? parseInt(serviceId, 10) : null, | ||||
| label: ctx.labelField?.value || null, | |||||
| note: ctx.noteField?.value || null, | note: ctx.noteField?.value || null, | ||||
| }), | }), | ||||
| }); | }); | ||||
| @@ -88,6 +88,74 @@ | |||||
| } | } | ||||
| } | } | ||||
| // ─── Label Field ──────────────────────────────────────────────────────────── | |||||
| .entry-form__field--label { | |||||
| flex-direction: column; | |||||
| align-items: stretch; | |||||
| gap: $space-2; | |||||
| } | |||||
| .label-input-wrap { | |||||
| position: relative; | |||||
| } | |||||
| .entry-form__field--label .label-input-wrap { | |||||
| flex: 1; | |||||
| } | |||||
| .label-chips { | |||||
| display: flex; | |||||
| flex-wrap: wrap; | |||||
| gap: $space-1; | |||||
| &:empty { display: none; } | |||||
| } | |||||
| .label-chip { | |||||
| font-size: $font-size-xs; | |||||
| color: var(--color-primary); | |||||
| background: rgba(var(--color-primary-rgb), 0.08); | |||||
| border: 1px solid rgba(var(--color-primary-rgb), 0.2); | |||||
| border-radius: $radius-sm; | |||||
| padding: 1px $space-2; | |||||
| cursor: pointer; | |||||
| transition: background $transition-fast; | |||||
| &:hover { | |||||
| background: rgba(var(--color-primary-rgb), 0.16); | |||||
| } | |||||
| } | |||||
| .label-autocomplete { | |||||
| position: absolute; | |||||
| top: 100%; | |||||
| left: 0; | |||||
| right: 0; | |||||
| z-index: 10; | |||||
| background: $color-card; | |||||
| border: 1px solid $color-border; | |||||
| border-radius: $radius-sm; | |||||
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); | |||||
| max-height: 180px; | |||||
| overflow-y: auto; | |||||
| } | |||||
| .label-autocomplete__item { | |||||
| display: block; | |||||
| width: 100%; | |||||
| text-align: left; | |||||
| padding: $space-2 $space-3; | |||||
| font-size: $font-size-sm; | |||||
| color: $color-text-base; | |||||
| cursor: pointer; | |||||
| transition: background $transition-fast; | |||||
| &:hover { | |||||
| background: rgba(var(--color-primary-rgb), 0.08); | |||||
| color: var(--color-primary); | |||||
| } | |||||
| } | |||||
| // ─── Rate Mode (Radio: Default / Custom) ──────────────────────────────────── | // ─── Rate Mode (Radio: Default / Custom) ──────────────────────────────────── | ||||
| .rate-mode { | .rate-mode { | ||||
| display: flex; | display: flex; | ||||
| @@ -95,6 +95,16 @@ | |||||
| @include text-truncate; | @include text-truncate; | ||||
| } | } | ||||
| .entry-row__label { | |||||
| display: inline-block; | |||||
| font-size: $font-size-xs; | |||||
| color: var(--color-primary); | |||||
| background: rgba(var(--color-primary-rgb), 0.08); | |||||
| padding: 1px $space-2; | |||||
| border-radius: $radius-sm; | |||||
| margin-top: 2px; | |||||
| } | |||||
| .entry-row__note { | .entry-row__note { | ||||
| font-size: $font-size-sm; | font-size: $font-size-sm; | ||||
| color: $color-text-muted; | color: $color-text-muted; | ||||
| @@ -150,6 +160,7 @@ | |||||
| // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── | // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── | ||||
| .entry-row--invoiced { | .entry-row--invoiced { | ||||
| .entry-row__title { color: $color-text-muted; font-weight: $font-weight-regular; } | .entry-row__title { color: $color-text-muted; font-weight: $font-weight-regular; } | ||||
| .entry-row__label { color: $color-text-muted; background: rgba($color-card, 0.6); } | |||||
| .entry-row__note { color: $color-text-light; } | .entry-row__note { color: $color-text-light; } | ||||
| .entry-row__badge { color: $color-text-muted; background: rgba($color-card, 0.6); } | .entry-row__badge { color: $color-text-muted; background: rgba($color-card, 0.6); } | ||||
| } | } | ||||
| @@ -169,6 +169,7 @@ | |||||
| 130px // Projekt | 130px // Projekt | ||||
| 120px // Leistung | 120px // Leistung | ||||
| 140px // Benutzer | 140px // Benutzer | ||||
| 120px // Label | |||||
| 1fr // Bemerkung | 1fr // Bemerkung | ||||
| 80px // Stunden | 80px // Stunden | ||||
| 100px // Umsatz | 100px // Umsatz | ||||
| @@ -204,7 +205,7 @@ | |||||
| } | } | ||||
| &--invoiced .report-table__cell { | &--invoiced .report-table__cell { | ||||
| &--date, &--client, &--project, &--service, | |||||
| &--date, &--client, &--project, &--service, &--label, | |||||
| &--user, &--note, &--duration, &--revenue { | &--user, &--note, &--duration, &--revenue { | ||||
| color: $color-text-light; | color: $color-text-light; | ||||
| } | } | ||||
| @@ -277,6 +278,16 @@ | |||||
| margin-top: 1px; | margin-top: 1px; | ||||
| } | } | ||||
| .report-table__label { | |||||
| display: inline-block; | |||||
| font-size: $font-size-xs; | |||||
| color: var(--color-primary); | |||||
| background: rgba(var(--color-primary-rgb), 0.08); | |||||
| padding: 1px $space-2; | |||||
| border-radius: $radius-sm; | |||||
| margin-right: $space-1; | |||||
| } | |||||
| .report-table__empty { | .report-table__empty { | ||||
| padding: $space-10 $space-5; | padding: $space-10 $space-5; | ||||
| text-align: center; | text-align: center; | ||||
| @@ -578,7 +589,8 @@ button.report-toolbar__action { | |||||
| gap: $space-2; | gap: $space-2; | ||||
| .filter-select, | .filter-select, | ||||
| .filter-note-input { | |||||
| .filter-note-input, | |||||
| .label-input-wrap { | |||||
| width: 300px; | width: 300px; | ||||
| max-width: 100%; | max-width: 100%; | ||||
| @@ -0,0 +1,28 @@ | |||||
| <?php | |||||
| declare(strict_types=1); | |||||
| namespace DoctrineMigrations; | |||||
| use Doctrine\DBAL\Schema\Schema; | |||||
| use Doctrine\Migrations\AbstractMigration; | |||||
| final class Version20260618120000 extends AbstractMigration | |||||
| { | |||||
| public function getDescription(): string | |||||
| { | |||||
| return 'Add label column to time_entry'; | |||||
| } | |||||
| public function up(Schema $schema): void | |||||
| { | |||||
| $this->addSql('ALTER TABLE `time_entry` ADD label VARCHAR(255) DEFAULT NULL'); | |||||
| $this->addSql('CREATE INDEX idx_time_entry_label ON `time_entry` (label)'); | |||||
| } | |||||
| public function down(Schema $schema): void | |||||
| { | |||||
| $this->addSql('DROP INDEX idx_time_entry_label ON `time_entry`'); | |||||
| $this->addSql('ALTER TABLE `time_entry` DROP COLUMN label'); | |||||
| } | |||||
| } | |||||
| @@ -25,7 +25,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ReportController extends AbstractController | class ReportController extends AbstractController | ||||
| { | { | ||||
| private const VALID_LIMITS = [50, 100, 250, 500]; | private const VALID_LIMITS = [50, 100, 250, 500]; | ||||
| private const VALID_SORT_COLUMNS = ['date', 'client', 'project', 'service', 'user', 'note', 'duration', 'revenue']; | |||||
| private const VALID_SORT_COLUMNS = ['date', 'client', 'project', 'service', 'label', 'user', 'note', 'duration', 'revenue']; | |||||
| public function __construct( | public function __construct( | ||||
| private readonly EntityManagerInterface $tenantEm, | private readonly EntityManagerInterface $tenantEm, | ||||
| @@ -273,6 +273,12 @@ class ReportController extends AbstractController | |||||
| $filters['dateTo'] = $dateTo; | $filters['dateTo'] = $dateTo; | ||||
| } | } | ||||
| $labels = array_values(array_filter(array_map('trim', (array) ($f['labels'] ?? [])))); | |||||
| if ($labels) { | |||||
| $filters['labels'] = $labels; | |||||
| } | |||||
| if (!empty($f['labels_neg'])) $filters['labelsNeg'] = true; | |||||
| $note = trim($f['note'] ?? ''); | $note = trim($f['note'] ?? ''); | ||||
| if ($note !== '') { | if ($note !== '') { | ||||
| $filters['note'] = $note; | $filters['note'] = $note; | ||||
| @@ -131,6 +131,7 @@ class TimeTrackingController extends AbstractController | |||||
| $entry->setService($service); | $entry->setService($service); | ||||
| $entry->setDate($date); | $entry->setDate($date); | ||||
| $entry->setDuration($newDuration); | $entry->setDuration($newDuration); | ||||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | $entry->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->tenantEm->persist($entry); | $this->tenantEm->persist($entry); | ||||
| @@ -181,6 +182,7 @@ class TimeTrackingController extends AbstractController | |||||
| $entry->setProject($project); | $entry->setProject($project); | ||||
| $entry->setService($service); | $entry->setService($service); | ||||
| $entry->setDuration($newDuration); | $entry->setDuration($newDuration); | ||||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | $entry->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $this->tenantEm->flush(); | $this->tenantEm->flush(); | ||||
| @@ -223,6 +225,25 @@ class TimeTrackingController extends AbstractController | |||||
| return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | return $this->json(['totalDuration' => $this->formatMinutes($totalMin)]); | ||||
| } | } | ||||
| // ── Labels-API ──────────────────────────────────────────────────────────── | |||||
| #[Route('/api/labels', name: 'api_labels', methods: ['GET'])] | |||||
| public function apiLabels(Request $request): JsonResponse | |||||
| { | |||||
| $projectId = $request->query->getInt('projectId'); | |||||
| $query = trim($request->query->get('q', '')); | |||||
| if ($projectId > 0) { | |||||
| return $this->json($this->timeEntryRepo->findTopLabelsByProject($projectId)); | |||||
| } | |||||
| if ($query !== '') { | |||||
| return $this->json($this->timeEntryRepo->searchLabels($query)); | |||||
| } | |||||
| return $this->json([]); | |||||
| } | |||||
| // ── Timer-API ───────────────────────────────────────────────────────────── | // ── Timer-API ───────────────────────────────────────────────────────────── | ||||
| #[Route('/api/timer/status', name: 'api_timer_status', methods: ['GET'])] | #[Route('/api/timer/status', name: 'api_timer_status', methods: ['GET'])] | ||||
| @@ -294,6 +315,7 @@ class TimeTrackingController extends AbstractController | |||||
| $entry->setService($service); | $entry->setService($service); | ||||
| $entry->setDate(new \DateTimeImmutable($data['date'] ?? 'today', $tz)); | $entry->setDate(new \DateTimeImmutable($data['date'] ?? 'today', $tz)); | ||||
| $entry->setDuration(0); | $entry->setDuration(0); | ||||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | $entry->setNote(!empty($data['note']) ? $data['note'] : null); | ||||
| $entry->setTimerStartedAt($now); | $entry->setTimerStartedAt($now); | ||||
| @@ -8,6 +8,7 @@ use Doctrine\ORM\Mapping as ORM; | |||||
| #[ORM\Entity(repositoryClass: TimeEntryRepository::class)] | #[ORM\Entity(repositoryClass: TimeEntryRepository::class)] | ||||
| #[ORM\Table(name: 'time_entry')] | #[ORM\Table(name: 'time_entry')] | ||||
| #[ORM\Index(columns: ['label'], name: 'idx_time_entry_label')] | |||||
| #[ORM\HasLifecycleCallbacks] | #[ORM\HasLifecycleCallbacks] | ||||
| class TimeEntry | class TimeEntry | ||||
| { | { | ||||
| @@ -34,6 +35,9 @@ class TimeEntry | |||||
| #[ORM\JoinColumn(nullable: true)] | #[ORM\JoinColumn(nullable: true)] | ||||
| private ?Service $service = null; | private ?Service $service = null; | ||||
| #[ORM\Column(length: 255, nullable: true)] | |||||
| private ?string $label = null; | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| @@ -84,6 +88,9 @@ class TimeEntry | |||||
| public function getService(): ?Service { return $this->service; } | public function getService(): ?Service { return $this->service; } | ||||
| public function setService(?Service $service): static { $this->service = $service; return $this; } | public function setService(?Service $service): static { $this->service = $service; return $this; } | ||||
| public function getLabel(): ?string { return $this->label; } | |||||
| public function setLabel(?string $label): static { $this->label = $label; return $this; } | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | public function setNote(?string $note): static { $this->note = $note; return $this; } | ||||
| @@ -109,6 +116,7 @@ class TimeEntry | |||||
| 'serviceId' => $this->service?->getId(), | 'serviceId' => $this->service?->getId(), | ||||
| 'serviceName' => $this->service?->getName(), | 'serviceName' => $this->service?->getName(), | ||||
| 'serviceBillable' => $this->service?->isBillable(), | 'serviceBillable' => $this->service?->isBillable(), | ||||
| 'label' => $this->label, | |||||
| 'note' => $this->note, | 'note' => $this->note, | ||||
| 'invoiced' => $this->invoiced, | 'invoiced' => $this->invoiced, | ||||
| 'timerStartedAt' => $this->timerStartedAt?->format('c'), | 'timerStartedAt' => $this->timerStartedAt?->format('c'), | ||||
| @@ -157,6 +157,11 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| ->setParameter('dateTo', $dateTo->format('Y-m-d')); | ->setParameter('dateTo', $dateTo->format('Y-m-d')); | ||||
| } | } | ||||
| if (!empty($filters['labels'])) { | |||||
| $op = !empty($filters['labelsNeg']) ? 'NOT IN' : 'IN'; | |||||
| $qb->andWhere("t.label $op (:labels)") | |||||
| ->setParameter('labels', $filters['labels']); | |||||
| } | |||||
| if (!empty($filters['note'])) { | if (!empty($filters['note'])) { | ||||
| $qb->andWhere('t.note LIKE :note') | $qb->andWhere('t.note LIKE :note') | ||||
| ->setParameter('note', '%' . $filters['note'] . '%'); | ->setParameter('note', '%' . $filters['note'] . '%'); | ||||
| @@ -175,6 +180,7 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| 'project' => 'p.name', | 'project' => 'p.name', | ||||
| 'service' => 's.name', | 'service' => 's.name', | ||||
| 'user' => 't.userId', | 'user' => 't.userId', | ||||
| 'label' => 't.label', | |||||
| 'note' => 't.note', | 'note' => 't.note', | ||||
| 'duration' => 't.duration', | 'duration' => 't.duration', | ||||
| ]; | ]; | ||||
| @@ -253,6 +259,44 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return ['min' => $row['minDate'] ?? null, 'max' => $row['maxDate'] ?? null]; | return ['min' => $row['minDate'] ?? null, 'max' => $row['maxDate'] ?? null]; | ||||
| } | } | ||||
| /** | |||||
| * @return string[] | |||||
| */ | |||||
| public function findTopLabelsByProject(int $projectId, int $limit = 5): array | |||||
| { | |||||
| $rows = $this->createQueryBuilder('t') | |||||
| ->select('t.label, COUNT(t.id) AS useCount') | |||||
| ->where('t.project = :projectId') | |||||
| ->andWhere('t.label IS NOT NULL') | |||||
| ->andWhere("t.label != ''") | |||||
| ->groupBy('t.label') | |||||
| ->orderBy('useCount', 'DESC') | |||||
| ->setParameter('projectId', $projectId) | |||||
| ->setMaxResults($limit) | |||||
| ->getQuery() | |||||
| ->getScalarResult(); | |||||
| return array_column($rows, 'label'); | |||||
| } | |||||
| /** | |||||
| * @return string[] | |||||
| */ | |||||
| public function searchLabels(string $query, int $limit = 10): array | |||||
| { | |||||
| $rows = $this->createQueryBuilder('t') | |||||
| ->select('DISTINCT t.label') | |||||
| ->where('t.label LIKE :q') | |||||
| ->andWhere('t.label IS NOT NULL') | |||||
| ->setParameter('q', '%' . $query . '%') | |||||
| ->orderBy('t.label', 'ASC') | |||||
| ->setMaxResults($limit) | |||||
| ->getQuery() | |||||
| ->getScalarResult(); | |||||
| return array_column($rows, 'label'); | |||||
| } | |||||
| public function sumRevenueFiltered(array $filters): float | public function sumRevenueFiltered(array $filters): float | ||||
| { | { | ||||
| $result = $this->buildFilteredQuery($filters) | $result = $this->buildFilteredQuery($filters) | ||||
| @@ -20,6 +20,8 @@ | |||||
| <div class="stopwatch-popover__form"> | <div class="stopwatch-popover__form"> | ||||
| <div id="stopwatch-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | <div id="stopwatch-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | ||||
| <div id="stopwatch-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | <div id="stopwatch-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | ||||
| <input type="text" id="stopwatch-label" class="input input--sm" | |||||
| placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" /> | |||||
| <textarea id="stopwatch-note" class="textarea" rows="2" | <textarea id="stopwatch-note" class="textarea" rows="2" | ||||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | ||||
| <div class="stopwatch-popover__actions"> | <div class="stopwatch-popover__actions"> | ||||
| @@ -84,6 +86,8 @@ | |||||
| <div class="stopwatch-popover__form"> | <div class="stopwatch-popover__form"> | ||||
| <div id="hamburger-sw-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | <div id="hamburger-sw-project" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_project'|trans }}"></div> | ||||
| <div id="hamburger-sw-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | <div id="hamburger-sw-service" class="searchable-select" data-placeholder="{{ 'app.stopwatch.select_service'|trans }}"></div> | ||||
| <input type="text" id="hamburger-sw-label" class="input input--sm" | |||||
| placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" /> | |||||
| <textarea id="hamburger-sw-note" class="textarea" rows="2" | <textarea id="hamburger-sw-note" class="textarea" rows="2" | ||||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | ||||
| <div class="stopwatch-popover__actions"> | <div class="stopwatch-popover__actions"> | ||||
| @@ -15,6 +15,8 @@ | |||||
| {% set hasServices = fr.services is defined and fr.services is not empty %} | {% set hasServices = fr.services is defined and fr.services is not empty %} | ||||
| {% set hasUsers = fr.users is defined and fr.users is not empty %} | {% set hasUsers = fr.users is defined and fr.users is not empty %} | ||||
| {% set hasPeriod = fr.period is defined and fr.period is not empty %} | {% set hasPeriod = fr.period is defined and fr.period is not empty %} | ||||
| {% set hasLabels = fr.labels is defined and fr.labels is not empty %} | |||||
| {% set labelsNeg = fr.labels_neg is defined and fr.labels_neg is not empty %} | |||||
| {% set hasNote = fr.note is defined and fr.note is not empty %} | {% set hasNote = fr.note is defined and fr.note is not empty %} | ||||
| {% set hasInvoiced = fr.invoiced is defined and fr.invoiced is not empty %} | {% set hasInvoiced = fr.invoiced is defined and fr.invoiced is not empty %} | ||||
| {% set isCustom = hasPeriod and frPeriod == 'custom' %} | {% set isCustom = hasPeriod and frPeriod == 'custom' %} | ||||
| @@ -275,6 +277,38 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| {# ── Label ────────────────────────────────────────────────────── #} | |||||
| <div class="filter-row{% if not hasLabels %} filter-row--inactive{% endif %}" | |||||
| data-filter-key="labels"> | |||||
| <label class="filter-row__label"> | |||||
| <input type="checkbox" class="filter-row__checkbox"{% if hasLabels %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_label'|trans }}</span> | |||||
| </label> | |||||
| <div class="filter-row__body"> | |||||
| <div class="filter-row__controls" id="filter-controls-labels"> | |||||
| {% set selLabels = hasLabels ? fr.labels : [''] %} | |||||
| {% for i, val in selLabels %} | |||||
| <div class="filter-row__control-group"> | |||||
| <div class="label-input-wrap"> | |||||
| <input type="text" class="input filter-label-input" value="{{ val }}" placeholder="..." autocomplete="off"> | |||||
| <div class="label-autocomplete filter-label-autocomplete" hidden></div> | |||||
| </div> | |||||
| {% if i > 0 %} | |||||
| <button type="button" class="filter-row__remove">×</button> | |||||
| {% endif %} | |||||
| </div> | |||||
| {% endfor %} | |||||
| </div> | |||||
| <div class="filter-row__meta"> | |||||
| <button type="button" class="filter-row__add" data-target="filter-controls-labels" data-filter-key="labels">+</button> | |||||
| <label class="filter-neg"> | |||||
| <input type="checkbox" class="filter-neg-checkbox"{% if labelsNeg %} checked{% endif %}> | |||||
| <span>{{ 'app.report.filter_neg'|trans }}</span> | |||||
| </label> | |||||
| </div> | |||||
| </div> | |||||
| </div> | |||||
| {# ── Bemerkung ────────────────────────────────────────────────── #} | {# ── Bemerkung ────────────────────────────────────────────────── #} | ||||
| <div class="filter-row{% if not hasNote %} filter-row--inactive{% endif %}" | <div class="filter-row{% if not hasNote %} filter-row--inactive{% endif %}" | ||||
| data-filter-key="note"> | data-filter-key="note"> | ||||
| @@ -173,6 +173,7 @@ | |||||
| {{ _self.sort_header('project', 'app.report.col_project'|trans, sort, dir, '') }} | {{ _self.sort_header('project', 'app.report.col_project'|trans, sort, dir, '') }} | ||||
| {{ _self.sort_header('service', 'app.report.col_service'|trans, sort, dir, '') }} | {{ _self.sort_header('service', 'app.report.col_service'|trans, sort, dir, '') }} | ||||
| {{ _self.sort_header('user', 'app.report.col_user'|trans, sort, dir, '') }} | {{ _self.sort_header('user', 'app.report.col_user'|trans, sort, dir, '') }} | ||||
| {{ _self.sort_header('label', 'app.entry.label_label'|trans, sort, dir, '') }} | |||||
| {{ _self.sort_header('note', 'app.report.col_note'|trans, sort, dir, '') }} | {{ _self.sort_header('note', 'app.report.col_note'|trans, sort, dir, '') }} | ||||
| {{ _self.sort_header('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }} | {{ _self.sort_header('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }} | ||||
| {{ _self.sort_header('revenue', 'app.report.col_revenue'|trans, sort, dir, totalRevenue|number_format(2, ',', '.') ~ ' €') }} | {{ _self.sort_header('revenue', 'app.report.col_revenue'|trans, sort, dir, totalRevenue|number_format(2, ',', '.') ~ ' €') }} | ||||
| @@ -193,6 +194,7 @@ | |||||
| data-project-id="{{ entry.project.id }}" | data-project-id="{{ entry.project.id }}" | ||||
| data-service-id="{{ entry.service ? entry.service.id : '' }}" | data-service-id="{{ entry.service ? entry.service.id : '' }}" | ||||
| data-duration="{{ entry.duration }}" | data-duration="{{ entry.duration }}" | ||||
| data-label="{{ entry.label|default('')|e('html_attr') }}" | |||||
| data-note="{{ entry.note|default('')|e('html_attr') }}" | data-note="{{ entry.note|default('')|e('html_attr') }}" | ||||
| data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | ||||
| @@ -216,6 +218,10 @@ | |||||
| {{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }} | {{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }} | ||||
| </div> | </div> | ||||
| <div class="report-table__cell report-table__cell--label"> | |||||
| {% if entry.label %}<span class="report-table__label">{{ entry.label }}</span>{% endif %} | |||||
| </div> | |||||
| <div class="report-table__cell report-table__cell--note"> | <div class="report-table__cell report-table__cell--note"> | ||||
| {{ entry.note }} | {{ entry.note }} | ||||
| </div> | </div> | ||||
| @@ -272,6 +278,13 @@ | |||||
| <select class="select edit-service"></select> | <select class="select edit-service"></select> | ||||
| </div> | </div> | ||||
| <label class="report-row__edit-label">{{ 'app.entry.label_label'|trans }}</label> | |||||
| <div class="report-row__edit-field"> | |||||
| <input type="text" class="input input--sm edit-label" | |||||
| value="{{ entry.label|default('') }}" | |||||
| placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" /> | |||||
| </div> | |||||
| <label class="report-row__edit-label">{{ 'app.entry.label_note'|trans }}</label> | <label class="report-row__edit-label">{{ 'app.entry.label_note'|trans }}</label> | ||||
| <div class="report-row__edit-field"> | <div class="report-row__edit-field"> | ||||
| <textarea class="textarea edit-note" rows="2">{{ entry.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="2">{{ entry.note|default('') }}</textarea> | ||||
| @@ -5,6 +5,7 @@ | |||||
| data-duration="{{ entry.duration }}" | data-duration="{{ entry.duration }}" | ||||
| data-project-id="{{ entry.project.id }}" | data-project-id="{{ entry.project.id }}" | ||||
| data-service-id="{{ entry.service ? entry.service.id : '' }}" | data-service-id="{{ entry.service ? entry.service.id : '' }}" | ||||
| data-label="{{ entry.label|default('')|e('html_attr') }}" | |||||
| data-note="{{ entry.note|default('')|e('html_attr') }}" | data-note="{{ entry.note|default('')|e('html_attr') }}" | ||||
| data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | ||||
| @@ -14,6 +15,9 @@ | |||||
| {{ entry.project.client.name }} / {{ entry.project.name }} | {{ entry.project.client.name }} / {{ entry.project.name }} | ||||
| {% if entry.service %} / {{ entry.service.name }}{% endif %} | {% if entry.service %} / {{ entry.service.name }}{% endif %} | ||||
| </div> | </div> | ||||
| {% if entry.label %} | |||||
| <span class="entry-row__label">{{ entry.label }}</span> | |||||
| {% endif %} | |||||
| {% if entry.note %} | {% if entry.note %} | ||||
| <div class="entry-row__note">{{ entry.note }}</div> | <div class="entry-row__note">{{ entry.note }}</div> | ||||
| {% endif %} | {% endif %} | ||||
| @@ -67,6 +71,17 @@ | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.entry.label_label'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--label"> | |||||
| <div class="label-chips edit-label-chips"></div> | |||||
| <div class="label-input-wrap"> | |||||
| <input type="text" class="input input--sm edit-label" | |||||
| value="{{ entry.label|default('') }}" | |||||
| placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" /> | |||||
| <div class="label-autocomplete edit-label-autocomplete" hidden></div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label">{{ 'app.entry.label_note'|trans }}</label> | <label class="entry-form__label">{{ 'app.entry.label_note'|trans }}</label> | ||||
| <div class="entry-form__field"> | <div class="entry-form__field"> | ||||
| <textarea class="textarea edit-note" rows="3">{{ entry.note|default('') }}</textarea> | <textarea class="textarea edit-note" rows="3">{{ entry.note|default('') }}</textarea> | ||||
| @@ -52,6 +52,8 @@ window.TT = { | |||||
| btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | btnDelete: {{ 'app.entry.btn_delete'|trans|json_encode|raw }}, | ||||
| labelDuration: {{ 'app.entry.label_duration'|trans|json_encode|raw }}, | labelDuration: {{ 'app.entry.label_duration'|trans|json_encode|raw }}, | ||||
| labelProjectService: {{ 'app.entry.label_project_service'|trans|json_encode|raw }}, | labelProjectService: {{ 'app.entry.label_project_service'|trans|json_encode|raw }}, | ||||
| labelLabel: {{ 'app.entry.label_label'|trans|json_encode|raw }}, | |||||
| placeholderLabel: {{ 'app.entry.placeholder_label'|trans|json_encode|raw }}, | |||||
| labelNote: {{ 'app.entry.label_note'|trans|json_encode|raw }}, | labelNote: {{ 'app.entry.label_note'|trans|json_encode|raw }}, | ||||
| confirmDelete: {{ 'app.entry.confirm_delete'|trans|json_encode|raw }}, | confirmDelete: {{ 'app.entry.confirm_delete'|trans|json_encode|raw }}, | ||||
| errorNoProject: {{ 'app.entry.error_no_project'|trans|json_encode|raw }}, | errorNoProject: {{ 'app.entry.error_no_project'|trans|json_encode|raw }}, | ||||
| @@ -126,6 +128,16 @@ window.TT = { | |||||
| </select> | </select> | ||||
| </div> | </div> | ||||
| <label class="entry-form__label">{{ 'app.entry.label_label'|trans }}</label> | |||||
| <div class="entry-form__field entry-form__field--label"> | |||||
| <div class="label-chips" id="create-label-chips"></div> | |||||
| <div class="label-input-wrap"> | |||||
| <input type="text" id="create-label" class="input input--sm" | |||||
| placeholder="{{ 'app.entry.placeholder_label'|trans }}" autocomplete="off" /> | |||||
| <div class="label-autocomplete" id="create-label-autocomplete" hidden></div> | |||||
| </div> | |||||
| </div> | |||||
| <label class="entry-form__label entry-form__label--note">{{ 'app.entry.label_note'|trans }}</label> | <label class="entry-form__label entry-form__label--note">{{ 'app.entry.label_note'|trans }}</label> | ||||
| <div class="entry-form__field entry-form__field--note"> | <div class="entry-form__field entry-form__field--note"> | ||||
| <textarea id="create-note" class="textarea" rows="3" | <textarea id="create-note" class="textarea" rows="3" | ||||
| @@ -89,6 +89,8 @@ app: | |||||
| error_duration_too_long: "Eine Dauer von mehr als 24 Stunden ist nicht möglich." | error_duration_too_long: "Eine Dauer von mehr als 24 Stunden ist nicht möglich." | ||||
| error_daily_limit_exceeded: "Du kannst nicht mehr als 24 Stunden pro Tag loggen." | error_daily_limit_exceeded: "Du kannst nicht mehr als 24 Stunden pro Tag loggen." | ||||
| warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | warn_duration_long: "Die Dauer ist länger als 8 Stunden. Wirklich speichern?" | ||||
| label_label: "Label" | |||||
| placeholder_label: "Label eingeben…" | |||||
| note_show: "+ Bemerkung hinzufügen" | note_show: "+ Bemerkung hinzufügen" | ||||
| note_hide: "× Bemerkung ausblenden" | note_hide: "× Bemerkung ausblenden" | ||||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | ||||
| @@ -206,6 +208,7 @@ app: | |||||
| filter_service: "Leistung" | filter_service: "Leistung" | ||||
| filter_user: "Benutzer" | filter_user: "Benutzer" | ||||
| filter_period: "Zeitraum" | filter_period: "Zeitraum" | ||||
| filter_label: "Label" | |||||
| filter_note: "Bemerkung" | filter_note: "Bemerkung" | ||||
| filter_invoiced: "Abgeschlossen?" | filter_invoiced: "Abgeschlossen?" | ||||
| period_today: "Heute" | period_today: "Heute" | ||||
| @@ -313,7 +316,6 @@ app: | |||||
| link_back: "Zurück zur Anmeldung" | link_back: "Zurück zur Anmeldung" | ||||
| sent_info: "Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen Link geschickt. Prüfe auch deinen Spam-Ordner." | sent_info: "Falls ein Konto mit dieser E-Mail existiert, haben wir dir einen Link geschickt. Prüfe auch deinen Spam-Ordner." | ||||
| error_invalid_email: "Bitte eine gültige E-Mail-Adresse eingeben." | error_invalid_email: "Bitte eine gültige E-Mail-Adresse eingeben." | ||||
| reset_password: | reset_password: | ||||
| page_title: "Neues Passwort – spawntree" | page_title: "Neues Passwort – spawntree" | ||||
| btn_submit: "Passwort speichern & anmelden" | btn_submit: "Passwort speichern & anmelden" | ||||