diff --git a/CLAUDE.md b/CLAUDE.md index 5a2adc2..15c0692 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index cbe00a3..a9abcb3 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -59,6 +59,7 @@ function buildServiceOptions(selectedId = null) { function buildEntryRowHTML(entry, animate = false) { const servicePart = entry.serviceName ? ` / ${esc(entry.serviceName)}` : ''; + const labelPart = entry.label ? `${esc(entry.label)}` : ''; const notePart = entry.note ? `
${esc(entry.note)}
` : ''; const invoiced = !!entry.invoiced; @@ -93,6 +94,16 @@ function buildEntryRowHTML(entry, animate = false) { + +
+
+
+ + +
+
@@ -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'}">
${esc(entry.clientName)} / ${esc(entry.projectName)}${servicePart}
+ ${labelPart} ${notePart}
@@ -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 => `` + ).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 => `` + ).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(); }); diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 890dc1d..0c74a41 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -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 => `` + ).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); diff --git a/httpdocs/assets/scripts/stopwatch.js b/httpdocs/assets/scripts/stopwatch.js index 5d36dbf..b4170f7 100644 --- a/httpdocs/assets/scripts/stopwatch.js +++ b/httpdocs/assets/scripts/stopwatch.js @@ -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, }), }); diff --git a/httpdocs/assets/styles/components/_entry-form.scss b/httpdocs/assets/styles/components/_entry-form.scss index daf4000..3af38f2 100644 --- a/httpdocs/assets/styles/components/_entry-form.scss +++ b/httpdocs/assets/styles/components/_entry-form.scss @@ -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; diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss index 2ca5a4b..e94568b 100644 --- a/httpdocs/assets/styles/components/_entry-list.scss +++ b/httpdocs/assets/styles/components/_entry-list.scss @@ -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); } } diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index d1b2001..c5b18c1 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -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%; diff --git a/httpdocs/migrations/tenant/Version20260618120000.php b/httpdocs/migrations/tenant/Version20260618120000.php new file mode 100644 index 0000000..502e817 --- /dev/null +++ b/httpdocs/migrations/tenant/Version20260618120000.php @@ -0,0 +1,28 @@ +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'); + } +} \ No newline at end of file diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 8dba4ed..48a0a0c 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -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; diff --git a/httpdocs/src/Controller/TimeTrackingController.php b/httpdocs/src/Controller/TimeTrackingController.php index 023641c..8755c23 100644 --- a/httpdocs/src/Controller/TimeTrackingController.php +++ b/httpdocs/src/Controller/TimeTrackingController.php @@ -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); diff --git a/httpdocs/src/Entity/Tenant/TimeEntry.php b/httpdocs/src/Entity/Tenant/TimeEntry.php index 72fc282..a031344 100644 --- a/httpdocs/src/Entity/Tenant/TimeEntry.php +++ b/httpdocs/src/Entity/Tenant/TimeEntry.php @@ -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'), diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 47d2a0c..369554f 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -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) diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index ba2e9a2..49f34af 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -20,6 +20,8 @@
+
@@ -84,6 +86,8 @@
+
diff --git a/httpdocs/templates/report/_filter-panel.html.twig b/httpdocs/templates/report/_filter-panel.html.twig index 2b67767..96052fe 100644 --- a/httpdocs/templates/report/_filter-panel.html.twig +++ b/httpdocs/templates/report/_filter-panel.html.twig @@ -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 @@
+ {# ── Label ────────────────────────────────────────────────────── #} +
+ +
+
+ {% set selLabels = hasLabels ? fr.labels : [''] %} + {% for i, val in selLabels %} +
+
+ + +
+ {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+
+ + +
+
+
+ {# ── Bemerkung ────────────────────────────────────────────────── #}
diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index df70c66..3aa1048 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -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}) }}
+
+ {% if entry.label %}{{ entry.label }}{% endif %} +
+
{{ entry.note }}
@@ -272,6 +278,13 @@
+ +
+ +
+
diff --git a/httpdocs/templates/timetracking/_entry_row.html.twig b/httpdocs/templates/timetracking/_entry_row.html.twig index 1d14434..1ce12fd 100644 --- a/httpdocs/templates/timetracking/_entry_row.html.twig +++ b/httpdocs/templates/timetracking/_entry_row.html.twig @@ -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 %}
+ {% if entry.label %} + {{ entry.label }} + {% endif %} {% if entry.note %}
{{ entry.note }}
{% endif %} @@ -67,6 +71,17 @@
+ +
+
+
+ + +
+
+
diff --git a/httpdocs/templates/timetracking/week.html.twig b/httpdocs/templates/timetracking/week.html.twig index 6833a70..24872f6 100644 --- a/httpdocs/templates/timetracking/week.html.twig +++ b/httpdocs/templates/timetracking/week.html.twig @@ -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 = {
+ +
+
+
+ + +
+
+