Quellcode durchsuchen

label field

master
FlorianEisenmenger vor 1 Tag
Ursprung
Commit
58d9af7b4e
18 geänderte Dateien mit 523 neuen und 9 gelöschten Zeilen
  1. +25
    -1
      CLAUDE.md
  2. +141
    -0
      httpdocs/assets/scripts/entries.js
  3. +69
    -1
      httpdocs/assets/scripts/report.js
  4. +5
    -3
      httpdocs/assets/scripts/stopwatch.js
  5. +68
    -0
      httpdocs/assets/styles/components/_entry-form.scss
  6. +11
    -0
      httpdocs/assets/styles/components/_entry-list.scss
  7. +14
    -2
      httpdocs/assets/styles/sections/_report.scss
  8. +28
    -0
      httpdocs/migrations/tenant/Version20260618120000.php
  9. +7
    -1
      httpdocs/src/Controller/ReportController.php
  10. +22
    -0
      httpdocs/src/Controller/TimeTrackingController.php
  11. +8
    -0
      httpdocs/src/Entity/Tenant/TimeEntry.php
  12. +44
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  13. +4
    -0
      httpdocs/templates/_sections/nav.html.twig
  14. +34
    -0
      httpdocs/templates/report/_filter-panel.html.twig
  15. +13
    -0
      httpdocs/templates/report/times.html.twig
  16. +15
    -0
      httpdocs/templates/timetracking/_entry_row.html.twig
  17. +12
    -0
      httpdocs/templates/timetracking/week.html.twig
  18. +3
    -1
      httpdocs/translations/messages.de.yaml

+ 25
- 1
CLAUDE.md Datei anzeigen

@@ -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)


+ 141
- 0
httpdocs/assets/scripts/entries.js Datei anzeigen

@@ -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();
});

+ 69
- 1
httpdocs/assets/scripts/report.js Datei anzeigen

@@ -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);


+ 5
- 3
httpdocs/assets/scripts/stopwatch.js Datei anzeigen

@@ -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,
}),
});


+ 68
- 0
httpdocs/assets/styles/components/_entry-form.scss Datei anzeigen

@@ -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;


+ 11
- 0
httpdocs/assets/styles/components/_entry-list.scss Datei anzeigen

@@ -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); }
}


+ 14
- 2
httpdocs/assets/styles/sections/_report.scss Datei anzeigen

@@ -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%;



+ 28
- 0
httpdocs/migrations/tenant/Version20260618120000.php Datei anzeigen

@@ -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');
}
}

+ 7
- 1
httpdocs/src/Controller/ReportController.php Datei anzeigen

@@ -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;


+ 22
- 0
httpdocs/src/Controller/TimeTrackingController.php Datei anzeigen

@@ -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
- 0
httpdocs/src/Entity/Tenant/TimeEntry.php Datei anzeigen

@@ -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'),


+ 44
- 0
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Datei anzeigen

@@ -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)


+ 4
- 0
httpdocs/templates/_sections/nav.html.twig Datei anzeigen

@@ -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">


+ 34
- 0
httpdocs/templates/report/_filter-panel.html.twig Datei anzeigen

@@ -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">


+ 13
- 0
httpdocs/templates/report/times.html.twig Datei anzeigen

@@ -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>


+ 15
- 0
httpdocs/templates/timetracking/_entry_row.html.twig Datei anzeigen

@@ -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>


+ 12
- 0
httpdocs/templates/timetracking/week.html.twig Datei anzeigen

@@ -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"


+ 3
- 1
httpdocs/translations/messages.de.yaml Datei anzeigen

@@ -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"


Laden…
Abbrechen
Speichern