@@ -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 => `
${esc(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 => `
${esc(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 => `
${esc(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 @@
+
{{ 'app.entry.label_label'|trans }}
+
+
{{ 'app.entry.label_note'|trans }}
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 = {
+
{{ 'app.entry.label_label'|trans }}
+
+
{{ 'app.entry.label_note'|trans }}