| @@ -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. | |||
| ### 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 | |||
| 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/{contactId}` | GET | Einzelnen Kontakt abrufen | | |||
| | `/api/lexoffice/invoices` | POST | Rechnungsentwurf in Lexware Office anlegen| | |||
| | `/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 | |||
| - 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) { | |||
| 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 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-service">${buildServiceOptions(entry.serviceId)}</select> | |||
| </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> | |||
| <div class="entry-form__field"> | |||
| <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-project-id="${entry.projectId}" | |||
| data-service-id="${entry.serviceId ?? ''}" | |||
| data-label="${esc(entry.label ?? '')}" | |||
| data-note="${esc(entry.note ?? '')}" | |||
| data-invoiced="${invoiced ? 'true' : 'false'}"> | |||
| <div class="entry-row__display"> | |||
| <div class="entry-row__info"> | |||
| <div class="entry-row__title">${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}</div> | |||
| ${labelPart} | |||
| ${notePart} | |||
| </div> | |||
| <div class="entry-row__actions"> | |||
| @@ -209,6 +222,7 @@ class EntryManager { | |||
| const durationRaw = document.getElementById('create-duration')?.value ?? '0:00'; | |||
| const projectId = document.getElementById('create-project')?.value; | |||
| const serviceId = document.getElementById('create-service')?.value; | |||
| const label = document.getElementById('create-label')?.value; | |||
| const note = document.getElementById('create-note')?.value; | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| @@ -228,6 +242,7 @@ class EntryManager { | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| label: label || null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| @@ -242,6 +257,7 @@ class EntryManager { | |||
| this.addEntryToDOM(data.entry); | |||
| this.updateTotal(data.totalDuration); | |||
| this.resetCreateForm(); | |||
| if (projectId) loadProjectLabels(parseInt(projectId, 10), document.getElementById('create-label-chips')); | |||
| } catch { | |||
| alert(t('errorSave')); | |||
| } finally { | |||
| @@ -270,8 +286,10 @@ class EntryManager { | |||
| const d = document.getElementById('create-duration'); | |||
| const p = document.getElementById('create-project'); | |||
| const s = document.getElementById('create-service'); | |||
| const l = document.getElementById('create-label'); | |||
| const n = document.getElementById('create-note'); | |||
| if (d) d.value = '0:00'; | |||
| if (l) l.value = ''; | |||
| if (n) n.value = ''; | |||
| if (p) p.value = getLastProject() ?? ''; | |||
| if (s) s.value = getLastService() ?? ''; | |||
| @@ -314,6 +332,7 @@ class EntryManager { | |||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| const serviceId = row.querySelector('.edit-service')?.value; | |||
| const label = row.querySelector('.edit-label')?.value; | |||
| const note = row.querySelector('.edit-note')?.value; | |||
| if (!projectId) { alert(t('errorNoProject')); return; } | |||
| @@ -337,6 +356,7 @@ class EntryManager { | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| label: label || null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| @@ -362,6 +382,16 @@ class EntryManager { | |||
| row.querySelector('.entry-row__title').textContent = | |||
| `${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(); | |||
| if (entry.note) { | |||
| const noteEl = document.createElement('div'); | |||
| @@ -374,11 +404,14 @@ class EntryManager { | |||
| row.dataset.duration = entry.duration; | |||
| row.dataset.projectId = entry.projectId; | |||
| row.dataset.serviceId = entry.serviceId ?? ''; | |||
| row.dataset.label = entry.label ?? ''; | |||
| 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); | |||
| const editLabel = row.querySelector('.edit-label'); | |||
| if (editLabel) editLabel.value = entry.label ?? ''; | |||
| 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; | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| initDurationBlurHandler(); | |||
| initMinimalMode(); | |||
| window.entryManager = new EntryManager(); | |||
| initCreateLabels(); | |||
| initEditLabels(); | |||
| }); | |||
| @@ -91,6 +91,7 @@ async function saveEdit(row) { | |||
| const id = row.dataset.entryId; | |||
| const projectId = row.querySelector('.edit-project')?.value; | |||
| const label = row.querySelector('.edit-label')?.value; | |||
| const serviceId = row.querySelector('.edit-service')?.value; | |||
| const note = row.querySelector('.edit-note')?.value ?? ''; | |||
| @@ -109,6 +110,7 @@ async function saveEdit(row) { | |||
| duration: dur.formatted, | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| label: label || null, | |||
| note: note || null, | |||
| }), | |||
| }); | |||
| @@ -197,6 +199,52 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| 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 ───────────────────────────────────────────────────────────── | |||
| 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)); | |||
| }); | |||
| initAllFilterLabelAutocompletes(); | |||
| this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => { | |||
| group.addEventListener('click', () => this.activateRowByControl(group)); | |||
| }); | |||
| @@ -308,6 +358,15 @@ class ReportFilter { | |||
| const clonedSelect = clone.querySelector('.filter-select'); | |||
| 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')) { | |||
| const removeBtn = document.createElement('button'); | |||
| removeBtn.type = 'button'; | |||
| @@ -410,6 +469,15 @@ class ReportFilter { | |||
| 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') { | |||
| const val = row.querySelector('.filter-note-input')?.value?.trim(); | |||
| if (val) params.set('filter[note]', val); | |||
| @@ -39,14 +39,14 @@ class StopwatchManager { | |||
| const desktopCtx = this.buildContext( | |||
| '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' | |||
| ); | |||
| if (desktopCtx) this.contexts.push(desktopCtx); | |||
| const hamburgerCtx = this.buildContext( | |||
| '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' | |||
| ); | |||
| if (hamburgerCtx) this.contexts.push(hamburgerCtx); | |||
| @@ -66,7 +66,7 @@ class StopwatchManager { | |||
| 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); | |||
| if (!toggle) return null; | |||
| @@ -75,6 +75,7 @@ class StopwatchManager { | |||
| popover: document.getElementById(popoverId), | |||
| display: document.getElementById(displayId), | |||
| startBtn: document.getElementById(startBtnId), | |||
| labelField: document.getElementById(labelId), | |||
| noteField: document.getElementById(noteId), | |||
| headerTime: document.getElementById(timeId), | |||
| projectSelect: null, | |||
| @@ -251,6 +252,7 @@ class StopwatchManager { | |||
| body: JSON.stringify({ | |||
| projectId: parseInt(projectId, 10), | |||
| serviceId: serviceId ? parseInt(serviceId, 10) : null, | |||
| label: ctx.labelField?.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 { | |||
| display: flex; | |||
| @@ -95,6 +95,16 @@ | |||
| @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 { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| @@ -150,6 +160,7 @@ | |||
| // ─── Abgerechneter Eintrag ──────────────────────────────────────────────── | |||
| .entry-row--invoiced { | |||
| .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__badge { color: $color-text-muted; background: rgba($color-card, 0.6); } | |||
| } | |||
| @@ -169,6 +169,7 @@ | |||
| 130px // Projekt | |||
| 120px // Leistung | |||
| 140px // Benutzer | |||
| 120px // Label | |||
| 1fr // Bemerkung | |||
| 80px // Stunden | |||
| 100px // Umsatz | |||
| @@ -204,7 +205,7 @@ | |||
| } | |||
| &--invoiced .report-table__cell { | |||
| &--date, &--client, &--project, &--service, | |||
| &--date, &--client, &--project, &--service, &--label, | |||
| &--user, &--note, &--duration, &--revenue { | |||
| color: $color-text-light; | |||
| } | |||
| @@ -277,6 +278,16 @@ | |||
| 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 { | |||
| padding: $space-10 $space-5; | |||
| text-align: center; | |||
| @@ -578,7 +589,8 @@ button.report-toolbar__action { | |||
| gap: $space-2; | |||
| .filter-select, | |||
| .filter-note-input { | |||
| .filter-note-input, | |||
| .label-input-wrap { | |||
| width: 300px; | |||
| 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 | |||
| { | |||
| 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( | |||
| private readonly EntityManagerInterface $tenantEm, | |||
| @@ -273,6 +273,12 @@ class ReportController extends AbstractController | |||
| $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'] ?? ''); | |||
| if ($note !== '') { | |||
| $filters['note'] = $note; | |||
| @@ -131,6 +131,7 @@ class TimeTrackingController extends AbstractController | |||
| $entry->setService($service); | |||
| $entry->setDate($date); | |||
| $entry->setDuration($newDuration); | |||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $this->tenantEm->persist($entry); | |||
| @@ -181,6 +182,7 @@ class TimeTrackingController extends AbstractController | |||
| $entry->setProject($project); | |||
| $entry->setService($service); | |||
| $entry->setDuration($newDuration); | |||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $this->tenantEm->flush(); | |||
| @@ -223,6 +225,25 @@ class TimeTrackingController extends AbstractController | |||
| 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 ───────────────────────────────────────────────────────────── | |||
| #[Route('/api/timer/status', name: 'api_timer_status', methods: ['GET'])] | |||
| @@ -294,6 +315,7 @@ class TimeTrackingController extends AbstractController | |||
| $entry->setService($service); | |||
| $entry->setDate(new \DateTimeImmutable($data['date'] ?? 'today', $tz)); | |||
| $entry->setDuration(0); | |||
| $entry->setLabel(!empty($data['label']) ? $data['label'] : null); | |||
| $entry->setNote(!empty($data['note']) ? $data['note'] : null); | |||
| $entry->setTimerStartedAt($now); | |||
| @@ -8,6 +8,7 @@ use Doctrine\ORM\Mapping as ORM; | |||
| #[ORM\Entity(repositoryClass: TimeEntryRepository::class)] | |||
| #[ORM\Table(name: 'time_entry')] | |||
| #[ORM\Index(columns: ['label'], name: 'idx_time_entry_label')] | |||
| #[ORM\HasLifecycleCallbacks] | |||
| class TimeEntry | |||
| { | |||
| @@ -34,6 +35,9 @@ class TimeEntry | |||
| #[ORM\JoinColumn(nullable: true)] | |||
| private ?Service $service = null; | |||
| #[ORM\Column(length: 255, nullable: true)] | |||
| private ?string $label = null; | |||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | |||
| private ?string $note = null; | |||
| @@ -84,6 +88,9 @@ class TimeEntry | |||
| public function getService(): ?Service { return $this->service; } | |||
| 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 setNote(?string $note): static { $this->note = $note; return $this; } | |||
| @@ -109,6 +116,7 @@ class TimeEntry | |||
| 'serviceId' => $this->service?->getId(), | |||
| 'serviceName' => $this->service?->getName(), | |||
| 'serviceBillable' => $this->service?->isBillable(), | |||
| 'label' => $this->label, | |||
| 'note' => $this->note, | |||
| 'invoiced' => $this->invoiced, | |||
| 'timerStartedAt' => $this->timerStartedAt?->format('c'), | |||
| @@ -157,6 +157,11 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| ->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'])) { | |||
| $qb->andWhere('t.note LIKE :note') | |||
| ->setParameter('note', '%' . $filters['note'] . '%'); | |||
| @@ -175,6 +180,7 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| 'project' => 'p.name', | |||
| 'service' => 's.name', | |||
| 'user' => 't.userId', | |||
| 'label' => 't.label', | |||
| 'note' => 't.note', | |||
| 'duration' => 't.duration', | |||
| ]; | |||
| @@ -253,6 +259,44 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| 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 | |||
| { | |||
| $result = $this->buildFilteredQuery($filters) | |||
| @@ -20,6 +20,8 @@ | |||
| <div class="stopwatch-popover__form"> | |||
| <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> | |||
| <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" | |||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | |||
| <div class="stopwatch-popover__actions"> | |||
| @@ -84,6 +86,8 @@ | |||
| <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-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" | |||
| placeholder="{{ 'app.entry.placeholder_note'|trans }}"></textarea> | |||
| <div class="stopwatch-popover__actions"> | |||
| @@ -15,6 +15,8 @@ | |||
| {% 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 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 hasInvoiced = fr.invoiced is defined and fr.invoiced is not empty %} | |||
| {% set isCustom = hasPeriod and frPeriod == 'custom' %} | |||
| @@ -275,6 +277,38 @@ | |||
| </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 ────────────────────────────────────────────────── #} | |||
| <div class="filter-row{% if not hasNote %} filter-row--inactive{% endif %}" | |||
| data-filter-key="note"> | |||
| @@ -173,6 +173,7 @@ | |||
| {{ _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('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('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }} | |||
| {{ _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-service-id="{{ entry.service ? entry.service.id : '' }}" | |||
| data-duration="{{ entry.duration }}" | |||
| data-label="{{ entry.label|default('')|e('html_attr') }}" | |||
| data-note="{{ entry.note|default('')|e('html_attr') }}" | |||
| data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | |||
| @@ -216,6 +218,10 @@ | |||
| {{ userMap[entry.userId] ?? 'app.report.user_fallback'|trans({'%id%': entry.userId}) }} | |||
| </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"> | |||
| {{ entry.note }} | |||
| </div> | |||
| @@ -272,6 +278,13 @@ | |||
| <select class="select edit-service"></select> | |||
| </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> | |||
| <div class="report-row__edit-field"> | |||
| <textarea class="textarea edit-note" rows="2">{{ entry.note|default('') }}</textarea> | |||
| @@ -5,6 +5,7 @@ | |||
| data-duration="{{ entry.duration }}" | |||
| data-project-id="{{ entry.project.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-invoiced="{{ entry.invoiced ? 'true' : 'false' }}"> | |||
| @@ -14,6 +15,9 @@ | |||
| {{ entry.project.client.name }} / {{ entry.project.name }} | |||
| {% if entry.service %} / {{ entry.service.name }}{% endif %} | |||
| </div> | |||
| {% if entry.label %} | |||
| <span class="entry-row__label">{{ entry.label }}</span> | |||
| {% endif %} | |||
| {% if entry.note %} | |||
| <div class="entry-row__note">{{ entry.note }}</div> | |||
| {% endif %} | |||
| @@ -67,6 +71,17 @@ | |||
| </select> | |||
| </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> | |||
| <div class="entry-form__field"> | |||
| <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 }}, | |||
| labelDuration: {{ 'app.entry.label_duration'|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 }}, | |||
| confirmDelete: {{ 'app.entry.confirm_delete'|trans|json_encode|raw }}, | |||
| errorNoProject: {{ 'app.entry.error_no_project'|trans|json_encode|raw }}, | |||
| @@ -126,6 +128,16 @@ window.TT = { | |||
| </select> | |||
| </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> | |||
| <div class="entry-form__field entry-form__field--note"> | |||
| <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_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?" | |||
| label_label: "Label" | |||
| placeholder_label: "Label eingeben…" | |||
| note_show: "+ Bemerkung hinzufügen" | |||
| note_hide: "× Bemerkung ausblenden" | |||
| invoiced_title: "Abgerechnet – Bearbeiten nicht möglich" | |||
| @@ -206,6 +208,7 @@ app: | |||
| filter_service: "Leistung" | |||
| filter_user: "Benutzer" | |||
| filter_period: "Zeitraum" | |||
| filter_label: "Label" | |||
| filter_note: "Bemerkung" | |||
| filter_invoiced: "Abgeschlossen?" | |||
| period_today: "Heute" | |||
| @@ -313,7 +316,6 @@ app: | |||
| 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." | |||
| error_invalid_email: "Bitte eine gültige E-Mail-Adresse eingeben." | |||
| reset_password: | |||
| page_title: "Neues Passwort – spawntree" | |||
| btn_submit: "Passwort speichern & anmelden" | |||