diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 5cb9d81..2fd3fca 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -253,11 +253,24 @@ class ReportFilter { if (removeBtn) this.removeControl(removeBtn); }); + // Select-Änderung → Optionen in der Gruppe aktualisieren + this.panel.addEventListener('change', e => { + const sel = e.target.closest('.filter-select'); + if (!sel) return; + const container = sel.closest('.filter-row__controls'); + if (container) this.refreshGroupSelects(container); + }); + // Initialer Zustand this.panel.querySelectorAll('.filter-row').forEach(row => { const cb = row.querySelector('.filter-row__checkbox'); this.syncRowState(row, cb?.checked ?? false); }); + + // Bereits geladene Mehrfach-Selects deduplizieren (nach Seiten-Reload mit Filtern) + this.panel.querySelectorAll('.filter-row__controls').forEach(container => { + this.refreshGroupSelects(container); + }); } // ── Panel toggeln ───────────────────────────────────────────────────────── @@ -338,6 +351,9 @@ class ReportFilter { container.appendChild(clone); + // Optionen deduplizieren + this.refreshGroupSelects(container); + // Row aktivieren const row = btn.closest('.filter-row'); const cb = row?.querySelector('.filter-row__checkbox'); @@ -365,6 +381,30 @@ class ReportFilter { this.syncRowState(row, false); } } + + // Verbleibende Selects aktualisieren + if (container) this.refreshGroupSelects(container); + } + + // ── Optionen in Mehrfach-Selects deduplizieren ──────────────────────────── + + refreshGroupSelects(container) { + const selects = [...container.querySelectorAll('.filter-select')]; + if (selects.length < 2) return; + + // Alle gewählten Values sammeln + const selectedValues = new Set( + selects.map(s => s.value).filter(v => v !== '') + ); + + selects.forEach(sel => { + const ownValue = sel.value; + sel.querySelectorAll('option').forEach(opt => { + if (!opt.value) return; // "..." immer sichtbar lassen + // Verstecken wenn woanders gewählt, aber nicht beim eigenen Select + opt.hidden = selectedValues.has(opt.value) && opt.value !== ownValue; + }); + }); } // ── Filter anwenden → URL bauen und navigieren ──────────────────────────── diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index 7b33be6..2428e8a 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -411,7 +411,7 @@ button.report-toolbar__action { cursor: pointer; font-family: $font-family-base; padding: $space-1 $space-3; - margin: -$space-1 -$space-3; + margin: (-$space-1) (-$space-3); border-radius: $radius-pill; transition: background $transition-fast, color $transition-fast; @@ -441,6 +441,7 @@ button.report-toolbar__action { .report-filter__col { flex: 1; min-width: 0; + max-width: 66%; } .report-filter__heading { @@ -455,7 +456,7 @@ button.report-toolbar__action { // ─── Filter-Row ─────────────────────────────────────────────────────────────── .filter-row { display: grid; - grid-template-columns: 160px 1fr 28px; + grid-template-columns: 160px 1fr; align-items: flex-start; gap: $space-3; padding: $space-2 0; @@ -481,6 +482,11 @@ button.report-toolbar__action { .filter-radio { opacity: 0.5; } + + .filter-neg { + opacity: 0.4; + pointer-events: none; + } } } @@ -503,11 +509,19 @@ button.report-toolbar__action { accent-color: $color-primary; } +// ─── Body: Controls + Meta nebeneinander ──────────────────────────────────── +.filter-row__body { + display: flex; + align-items: flex-start; + gap: $space-3; +} + .filter-row__controls { display: flex; flex-direction: column; gap: $space-2; min-width: 0; + flex: 1; } .filter-row__control-group { @@ -533,11 +547,24 @@ button.report-toolbar__action { } } +// ─── Meta: Plus-Button + Negativfilter ────────────────────────────────────── +.filter-row__meta { + display: flex; + align-items: center; + gap: $space-3; + padding-top: 7px; // vertikal mit Select ausrichten + flex-shrink: 0; + white-space: nowrap; + + &--no-add { + padding-left: calc(22px + #{$space-3}); // Platz für fehlenden Add-Button + } +} + // ─── Plus- und Minus-Button ─────────────────────────────────────────────────── .filter-row__add { width: 22px; height: 22px; - margin-top: 7px; border: 1px solid $color-input-border; background: $color-white; border-radius: $radius-sm; @@ -656,3 +683,28 @@ button.report-toolbar__action { color: $color-text-base; } } + +// ─── Negativfilter-Checkbox ─────────────────────────────────────────────────── +.filter-neg { + display: inline-flex; + align-items: center; + gap: $space-1; + font-size: $font-size-sm; + color: $color-text-muted; + cursor: pointer; + user-select: none; + white-space: nowrap; + + input[type="checkbox"] { + width: 13px; + height: 13px; + cursor: pointer; + accent-color: $color-warning; + flex-shrink: 0; + } + + &:has(input:checked) { + color: $color-warning; + font-weight: $font-weight-medium; + } +} diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index b3934cc..9035973 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -169,6 +169,13 @@ class ReportController extends AbstractController $filters['invoiced'] = $f['invoiced'] === 'yes'; } + // Negativfilter-Flags + if (!empty($f['clients_neg'])) $filters['clientsNeg'] = true; + if (!empty($f['projects_neg'])) $filters['projectsNeg'] = true; + if (!empty($f['services_neg'])) $filters['servicesNeg'] = true; + if (!empty($f['users_neg'])) $filters['usersNeg'] = true; + if (!empty($f['period_neg'])) $filters['periodNeg'] = true; + return $filters; } diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index f8ef6ce..9ec0727 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -197,29 +197,49 @@ class TimeEntryRepository extends ServiceEntityRepository ->leftJoin('t.service', 's'); if (!empty($filters['clientIds'])) { - $qb->andWhere('c.id IN (:clientIds)') + $op = !empty($filters['clientsNeg']) ? 'NOT IN' : 'IN'; + $qb->andWhere("c.id $op (:clientIds)") ->setParameter('clientIds', $filters['clientIds']); } if (!empty($filters['projectIds'])) { - $qb->andWhere('p.id IN (:projectIds)') + $op = !empty($filters['projectsNeg']) ? 'NOT IN' : 'IN'; + $qb->andWhere("p.id $op (:projectIds)") ->setParameter('projectIds', $filters['projectIds']); } if (!empty($filters['serviceIds'])) { - $qb->andWhere('s.id IN (:serviceIds)') + $op = !empty($filters['servicesNeg']) ? 'NOT IN' : 'IN'; + $qb->andWhere("s.id $op (:serviceIds)") ->setParameter('serviceIds', $filters['serviceIds']); } if (!empty($filters['userIds'])) { - $qb->andWhere('t.userId IN (:userIds)') + $op = !empty($filters['usersNeg']) ? 'NOT IN' : 'IN'; + $qb->andWhere("t.userId $op (:userIds)") ->setParameter('userIds', $filters['userIds']); } - if (!empty($filters['dateFrom'])) { - $qb->andWhere('t.date >= :dateFrom') - ->setParameter('dateFrom', $filters['dateFrom']->format('Y-m-d')); - } - if (!empty($filters['dateTo'])) { - $qb->andWhere('t.date <= :dateTo') - ->setParameter('dateTo', $filters['dateTo']->format('Y-m-d')); + + // Zeitraum — positiv: BETWEEN, negativ: NOT BETWEEN (= < OR >) + $dateFrom = $filters['dateFrom'] ?? null; + $dateTo = $filters['dateTo'] ?? null; + $periodNeg = !empty($filters['periodNeg']); + + if ($dateFrom !== null && $dateTo !== null) { + if ($periodNeg) { + $qb->andWhere('t.date < :dateFrom OR t.date > :dateTo'); + } else { + $qb->andWhere('t.date BETWEEN :dateFrom AND :dateTo'); + } + $qb->setParameter('dateFrom', $dateFrom->format('Y-m-d')) + ->setParameter('dateTo', $dateTo->format('Y-m-d')); + } elseif ($dateFrom !== null) { + $op = $periodNeg ? '<' : '>='; + $qb->andWhere("t.date $op :dateFrom") + ->setParameter('dateFrom', $dateFrom->format('Y-m-d')); + } elseif ($dateTo !== null) { + $op = $periodNeg ? '>' : '<='; + $qb->andWhere("t.date $op :dateTo") + ->setParameter('dateTo', $dateTo->format('Y-m-d')); } + if (!empty($filters['note'])) { $qb->andWhere('t.note LIKE :note') ->setParameter('note', '%' . $filters['note'] . '%'); diff --git a/httpdocs/templates/report/_filter-panel.html.twig b/httpdocs/templates/report/_filter-panel.html.twig index ad32f1c..8715f5d 100644 --- a/httpdocs/templates/report/_filter-panel.html.twig +++ b/httpdocs/templates/report/_filter-panel.html.twig @@ -2,13 +2,13 @@ {# Variablen: clients, projects, services, userList, filterRaw, filterActive, filterDateFrom, filterDateTo, isTracker, limit #} -{% set fr = filterRaw %} +{% set fr = filterRaw %} {% set frPeriod = fr.period is defined ? fr.period : '' %} {% set frInvoiced = fr.invoiced is defined ? fr.invoiced : '' %} -{% set months = deMonths() %} -{% set yearNow = 'now'|date('Y') %} -{% set dayNow = 'now'|date('j') %} -{% set monthNow = 'now'|date('n') %} +{% set months = deMonths() %} +{% set yearNow = 'now'|date('Y') %} +{% set dayNow = 'now'|date('j') %} +{% set monthNow = 'now'|date('n') %} {% set hasClients = fr.clients is defined and fr.clients is not empty %} {% set hasProjects = fr.projects is defined and fr.projects is not empty %} @@ -19,6 +19,12 @@ {% set hasInvoiced = fr.invoiced is defined and fr.invoiced is not empty %} {% set isCustom = hasPeriod and frPeriod == 'custom' %} +{% set clientsNeg = fr.clients_neg is defined and fr.clients_neg is not empty %} +{% set projectsNeg = fr.projects_neg is defined and fr.projects_neg is not empty %} +{% set servicesNeg = fr.services_neg is defined and fr.services_neg is not empty %} +{% set usersNeg = fr.users_neg is defined and fr.users_neg is not empty %} +{% set periodNeg = fr.period_neg is defined and fr.period_neg is not empty %} + {% set fromDay = filterDateFrom ? filterDateFrom|date('j') : dayNow %} {% set fromMonth = filterDateFrom ? filterDateFrom|date('n') : monthNow %} {% set fromYear = filterDateFrom ? filterDateFrom|date('Y') : yearNow %} @@ -40,23 +46,31 @@ {{ 'app.report.filter_client'|trans }} -
- {% set selClients = hasClients ? fr.clients : [''] %} - {% for i, val in selClients %} -
- - {% if i > 0 %} - - {% endif %} -
- {% endfor %} +
+
+ {% set selClients = hasClients ? fr.clients : [''] %} + {% for i, val in selClients %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+
+ + +
-
{# ── Projekt ──────────────────────────────────────────────────── #} @@ -66,23 +80,31 @@ {{ 'app.report.filter_project'|trans }} -
- {% set selProjects = hasProjects ? fr.projects : [''] %} - {% for i, val in selProjects %} -
- - {% if i > 0 %} - - {% endif %} -
- {% endfor %} +
+
+ {% set selProjects = hasProjects ? fr.projects : [''] %} + {% for i, val in selProjects %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+
+ + +
-
{# ── Leistung ─────────────────────────────────────────────────── #} @@ -92,65 +114,26 @@ {{ 'app.report.filter_service'|trans }} -
- {% set selServices = hasServices ? fr.services : [''] %} - {% set activeServices = services|filter(s => not s.isArchived()) %} - {% set archivedServices = services|filter(s => s.isArchived()) %} - {% for i, val in selServices %} -
- - {% if i > 0 %} - - {% endif %} -
- {% endfor %} -
- - - - {# ── Benutzer (nur für Admins / Members, nicht für Tracker) ───── #} - {% if not isTracker %} -
- -
- {% set selUsers = hasUsers ? fr.users : [''] %} - {% set activeUsers = userList|filter(u => not u.archived) %} - {% set archivedUsers = userList|filter(u => u.archived) %} - {% for i, val in selUsers %} +
+
+ {% set selServices = hasServices ? fr.services : [''] %} + {% set activeServices = services|filter(s => not s.isArchived()) %} + {% set archivedServices = services|filter(s => s.isArchived()) %} + {% for i, val in selServices %}
- - {% if activeUsers|length > 0 %} + {% if activeServices|length > 0 %} - {% for user in activeUsers %} - + {% for service in activeServices %} + {% endfor %} {% endif %} - {% if archivedUsers|length > 0 %} + {% if archivedServices|length > 0 %} - {% for user in archivedUsers %} - + {% for service in archivedServices %} + {% endfor %} {% endif %} @@ -161,7 +144,62 @@
{% endfor %}
- +
+ + +
+
+
+ + {# ── Benutzer (nur für Admins / Members, nicht für Tracker) ───── #} + {% if not isTracker %} +
+ +
+
+ {% set selUsers = hasUsers ? fr.users : [''] %} + {% set activeUsers = userList|filter(u => not u.archived) %} + {% set archivedUsers = userList|filter(u => u.archived) %} + {% for i, val in selUsers %} +
+ + {% if i > 0 %} + + {% endif %} +
+ {% endfor %} +
+
+ + +
+
{% endif %} @@ -172,60 +210,67 @@ {{ 'app.report.filter_period'|trans }} -
-
- +
+
+
+ -
-
- {{ 'app.report.period_from'|trans }} - - - -
-
- {{ 'app.report.period_to'|trans }} - - - +
+
+ {{ 'app.report.period_from'|trans }} + + + +
+
+ {{ 'app.report.period_to'|trans }} + + + +
- +
+
+
@@ -237,9 +282,11 @@ {{ 'app.report.filter_note'|trans }} -
-
- +
+
+
+ +
@@ -251,16 +298,18 @@ {{ 'app.report.filter_invoiced'|trans }} -
-
- - +
+
+
+ + +
diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index 9878097..387d369 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -102,12 +102,6 @@ {{ 'app.report.toolbar_filter'|trans }} - - - - - {{ 'app.report.toolbar_edit'|trans }} -
diff --git a/httpdocs/translations/messages.de.yaml b/httpdocs/translations/messages.de.yaml index bcf96d5..d406193 100644 --- a/httpdocs/translations/messages.de.yaml +++ b/httpdocs/translations/messages.de.yaml @@ -108,6 +108,7 @@ app: period_to: "bis" invoiced_yes: "Ja" invoiced_no: "Nein" + filter_neg: "Negativfilter" forgot_password: page_title: "Passwort vergessen – spawntree"