FlorianEisenmenger 1 тиждень тому
джерело
коміт
1351f07044
7 змінених файлів з 1025 додано та 23 видалено
  1. +228
    -0
      httpdocs/assets/scripts/report.js
  2. +248
    -0
      httpdocs/assets/styles/sections/_report.scss
  3. +170
    -21
      httpdocs/src/Controller/ReportController.php
  4. +87
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  5. +253
    -0
      httpdocs/templates/report/_filter-panel.html.twig
  6. +15
    -2
      httpdocs/templates/report/times.html.twig
  7. +24
    -0
      httpdocs/translations/messages.de.yaml

+ 228
- 0
httpdocs/assets/scripts/report.js Переглянути файл

@@ -197,3 +197,231 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
});

// ── ReportFilter ──────────────────────────────────────────────────────────────

class ReportFilter {
constructor() {
this.panel = document.getElementById('report-filter');
this.toggleBtn = document.getElementById('btn-filter-toggle');
this.applyBtn = document.getElementById('btn-filter-apply');
this.hideBtn = document.getElementById('btn-filter-hide');
this.periodSel = document.querySelector('.filter-period-select');
this.customDates = document.querySelector('.filter-custom-dates');
}

init() {
if (!this.panel) return;

// Toolbar-Toggle
this.toggleBtn?.addEventListener('click', () => this.togglePanel());

// Ausblenden-Button
this.hideBtn?.addEventListener('click', () => this.hidePanel());

// Filtern-Button
this.applyBtn?.addEventListener('click', () => this.applyFilters());

// Checkbox-Änderungen
this.panel.querySelectorAll('.filter-row__checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const row = cb.closest('.filter-row');
this.syncRowState(row, cb.checked);
});
});

// Klick auf ausgegrautem Control → Checkbox aktivieren
this.panel.querySelectorAll('.filter-select, .filter-note-input').forEach(el => {
el.addEventListener('mousedown', () => this.activateRowByControl(el));
});

// Zeitraum-Select → Custom-Felder zeigen/verstecken
this.periodSel?.addEventListener('change', () => {
const row = this.periodSel.closest('.filter-row');
this.activateRowByControl(this.periodSel);
this.toggleCustomDates(this.periodSel.value === 'custom');
});

// Plus-Buttons
this.panel.querySelectorAll('.filter-row__add').forEach(btn => {
btn.addEventListener('click', () => this.addControl(btn));
});

// Remove-Buttons (via Delegation, da sie dynamisch entstehen)
this.panel.addEventListener('click', e => {
const removeBtn = e.target.closest('.filter-row__remove');
if (removeBtn) this.removeControl(removeBtn);
});

// Initialer Zustand
this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
this.syncRowState(row, cb?.checked ?? false);
});
}

// ── Panel toggeln ─────────────────────────────────────────────────────────

togglePanel() {
const isHidden = this.panel.hasAttribute('hidden');
if (isHidden) {
this.panel.removeAttribute('hidden');
this.toggleBtn?.classList.add('report-toolbar__action--active');
} else {
this.hidePanel();
}
}

hidePanel() {
this.panel.setAttribute('hidden', '');
this.toggleBtn?.classList.remove('report-toolbar__action--active');
}

// ── Row-Zustand (aktiv / inaktiv) ─────────────────────────────────────────

syncRowState(row, active) {
row.classList.toggle('filter-row--inactive', !active);
}

activateRowByControl(el) {
const row = el.closest('.filter-row');
if (!row) return;
const cb = row.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
}
}

// ── Zeitraum: Custom-Felder ────────────────────────────────────────────────

toggleCustomDates(show) {
if (!this.customDates) return;
if (show) {
this.customDates.removeAttribute('hidden');
} else {
this.customDates.setAttribute('hidden', '');
}
}

// ── Plus: weiteres Control hinzufügen ─────────────────────────────────────

addControl(btn) {
const targetId = btn.dataset.target;
const filterKey = btn.dataset.filterKey;
const container = document.getElementById(targetId);
if (!container) return;

// Erste Gruppe als Template klonen
const template = container.querySelector('.filter-row__control-group');
if (!template) return;

const clone = template.cloneNode(true);

// Select zurücksetzen
const clonedSelect = clone.querySelector('.filter-select');
if (clonedSelect) clonedSelect.value = '';

// Remove-Button hinzufügen (falls noch keiner da)
if (!clone.querySelector('.filter-row__remove')) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'filter-row__remove';
removeBtn.textContent = '×';
clone.appendChild(removeBtn);
}

// Neu: Klick auf den geklonten Select aktiviert ebenfalls die Row
clone.querySelector('.filter-select')?.addEventListener('mousedown', () => {
this.activateRowByControl(clone.querySelector('.filter-select'));
});

container.appendChild(clone);

// Row aktivieren
const row = btn.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb && !cb.checked) {
cb.checked = true;
this.syncRowState(row, true);
}

clonedSelect?.focus();
}

// ── Minus: Control entfernen ──────────────────────────────────────────────

removeControl(removeBtn) {
const group = removeBtn.closest('.filter-row__control-group');
const container = group?.parentElement;
group?.remove();

// Wenn keine Controls mehr übrig → Checkbox deaktivieren
if (container && !container.querySelector('.filter-row__control-group')) {
const row = container.closest('.filter-row');
const cb = row?.querySelector('.filter-row__checkbox');
if (cb) {
cb.checked = false;
this.syncRowState(row, false);
}
}
}

// ── Filter anwenden → URL bauen und navigieren ────────────────────────────

applyFilters() {
const params = new URLSearchParams();
params.set('limit', String(window.Report?.limit ?? 50));

this.panel.querySelectorAll('.filter-row').forEach(row => {
const cb = row.querySelector('.filter-row__checkbox');
if (!cb?.checked) return;

const key = row.dataset.filterKey;

if (['clients', 'projects', 'services', 'users'].includes(key)) {
row.querySelectorAll('.filter-select').forEach(sel => {
if (sel.value) params.append(`filter[${key}][]`, sel.value);
});

} else if (key === 'period') {
const val = this.periodSel?.value;
if (!val) return;
params.set('filter[period]', val);

if (val === 'custom' && this.customDates) {
const get = field => this.customDates.querySelector(`[data-date-field="${field}"]`)?.value ?? '';
const fromDay = get('from-day').padStart(2, '0');
const fromMonth = get('from-month').padStart(2, '0');
const fromYear = get('from-year');
const toDay = get('to-day').padStart(2, '0');
const toMonth = get('to-month').padStart(2, '0');
const toYear = get('to-year');

if (fromYear && fromMonth && fromDay) {
params.set('filter[date_from]', `${fromYear}-${fromMonth}-${fromDay}`);
}
if (toYear && toMonth && toDay) {
params.set('filter[date_to]', `${toYear}-${toMonth}-${toDay}`);
}
}

} else if (key === 'note') {
const val = row.querySelector('.filter-note-input')?.value?.trim();
if (val) params.set('filter[note]', val);

} else if (key === 'invoiced') {
const checked = row.querySelector('.filter-invoiced-radio:checked');
if (checked) params.set('filter[invoiced]', checked.value);
}
});

window.location.href = `/reports/times?${params}`;
}
}

// ── Init ──────────────────────────────────────────────────────────────────────

document.addEventListener('DOMContentLoaded', () => {
new ReportFilter().init();
});

+ 248
- 0
httpdocs/assets/styles/sections/_report.scss Переглянути файл

@@ -403,3 +403,251 @@
.report-pagination__lock-spacer {
// Platzhalter für die Aktions-Spalte – hält die Ausrichtung
}

// ─── Toolbar-Button (klickbar) ────────────────────────────────────────────────
button.report-toolbar__action {
background: none;
border: none;
cursor: pointer;
font-family: $font-family-base;
padding: $space-1 $space-3;
margin: -$space-1 -$space-3;
border-radius: $radius-pill;
transition: background $transition-fast, color $transition-fast;

&--active {
background: $color-text-dark;
color: $color-white;

svg path,
svg circle {
stroke: $color-white;
}
}
}

// ─── Filter-Panel ─────────────────────────────────────────────────────────────
.report-filter {
background: $color-card;
border-bottom: 1px solid $color-border;
padding: $space-5 $space-5 0;
}

.report-filter__body {
display: flex;
gap: $space-10;
}

.report-filter__col {
flex: 1;
min-width: 0;
}

.report-filter__heading {
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: $color-text-muted;
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: $space-3;
}

// ─── Filter-Row ───────────────────────────────────────────────────────────────
.filter-row {
display: grid;
grid-template-columns: 160px 1fr 28px;
align-items: flex-start;
gap: $space-3;
padding: $space-2 0;
border-bottom: 1px solid rgba($color-border, 0.6);

&:last-child {
border-bottom: none;
}

// Ausgegraut wenn inaktiv – aber klickbar!
&--inactive {
.filter-select,
.filter-note-input,
.filter-period-select {
opacity: 0.5;
color: $color-text-muted;
}

.filter-row__add {
opacity: 0.4;
}

.filter-radio {
opacity: 0.5;
}
}
}

.filter-row__label {
display: flex;
align-items: center;
gap: $space-2;
cursor: pointer;
font-size: $font-size-sm;
color: $color-text-base;
padding-top: 7px; // optisch mit den Selects ausrichten
user-select: none;
}

.filter-row__checkbox {
width: 14px;
height: 14px;
cursor: pointer;
flex-shrink: 0;
accent-color: $color-primary;
}

.filter-row__controls {
display: flex;
flex-direction: column;
gap: $space-2;
min-width: 0;
}

.filter-row__control-group {
display: flex;
align-items: center;
gap: $space-2;

.filter-select,
.filter-note-input {
width: 300px;
max-width: 100%;
}

&--period {
flex-direction: column;
align-items: flex-start;
gap: $space-2;
}

&--radio {
padding-top: 7px;
gap: $space-4;
}
}

// ─── 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;
cursor: pointer;
font-size: $font-size-md;
line-height: 1;
color: $color-text-muted;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: border-color $transition-fast, color $transition-fast;

&:hover {
border-color: $color-primary;
color: $color-primary;
}
}

.filter-row__remove {
width: 20px;
height: 20px;
border: none;
background: none;
cursor: pointer;
font-size: $font-size-md;
line-height: 1;
color: $color-text-light;
display: flex;
align-items: center;
justify-content: center;
border-radius: $radius-sm;
flex-shrink: 0;
transition: color $transition-fast, background $transition-fast;

&:hover {
color: $color-error;
background: rgba($color-error, 0.08);
}
}

// ─── Zeitraum: Custom-Datumsfelder ───────────────────────────────────────────
.filter-custom-dates {
display: flex;
flex-direction: column;
gap: $space-2;
margin-top: $space-2;
}

.filter-date-group {
display: flex;
align-items: center;
gap: $space-2;
}

.filter-date-label {
font-size: $font-size-sm;
color: $color-text-muted;
width: 26px;
flex-shrink: 0;
}

.filter-date-select {
width: auto;
display: inline-block;

&--sm {
padding-right: $space-6;
min-width: 60px;
}

&--month {
padding-right: $space-6;
min-width: 100px;
}
}

// ─── Radio-Filter (Abgeschlossen?) ────────────────────────────────────────────
.filter-radio {
display: inline-flex;
align-items: center;
gap: $space-1;
font-size: $font-size-sm;
color: $color-text-base;
cursor: pointer;
user-select: none;
accent-color: $color-primary;
}

// ─── Filter-Footer ────────────────────────────────────────────────────────────
.report-filter__footer {
display: flex;
align-items: center;
gap: $space-4;
padding: $space-4 0;
}

.filter-footer__link {
background: none;
border: none;
padding: 0;
cursor: pointer;
font-family: $font-family-base;
font-size: $font-size-sm;
color: $color-text-muted;
text-decoration: underline;
text-underline-offset: 2px;
transition: color $transition-fast;

&:hover {
color: $color-text-base;
}
}

+ 170
- 21
httpdocs/src/Controller/ReportController.php Переглянути файл

@@ -3,6 +3,7 @@
namespace App\Controller;

use App\Repository\Central\AccountUserRepository;
use App\Repository\Tenant\ClientRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Repository\Tenant\ProjectRepository;
use App\Repository\Tenant\ServiceRepository;
@@ -30,6 +31,7 @@ class ReportController extends AbstractController
private readonly Security $security,
private readonly ProjectRepository $projectRepo,
private readonly ServiceRepository $serviceRepo,
private readonly ClientRepository $clientRepo,
) {}

#[Route('/reports/times', name: 'report_times')]
@@ -54,35 +56,182 @@ class ReportController extends AbstractController
$userMap[$au->getUser()->getId()] = $au->getUser()->getFullName();
}

// User-Liste für Filter-Dropdown (für Twig/JS)
$userList = [];
foreach ($userMap as $uid => $uname) {
$userList[] = ['id' => $uid, 'name' => $uname];
}

// Filter aus GET-Parametern lesen
$filterRaw = $request->query->all('filter');
$filters = $this->parseFilters($filterRaw);

// Tracker: immer auf eigenen User beschränken
if ($isTracker) {
$entries = $this->timeEntryRepo->findForReportByUserId($currentUserId, $limit);
$totalCount = $this->timeEntryRepo->countByUserId($currentUserId);
$totalMinutes = $this->timeEntryRepo->sumDurationByUserId($currentUserId);
$totalRevenue = $this->timeEntryRepo->sumRevenueByUserId($currentUserId);
} else {
$entries = $this->timeEntryRepo->findForReport($limit);
$totalCount = $this->timeEntryRepo->countAll();
$totalMinutes = $this->timeEntryRepo->sumDurationAll();
$totalRevenue = $this->timeEntryRepo->sumRevenueAll();
$filters['userIds'] = [$currentUserId];
}

// Ob der Benutzer explizit Filter gesetzt hat (für "Alle anzeigen")
$filterActive = !empty($request->query->all('filter'));

// Custom-Datumsfelder für Twig vorparsen
$filterDateFrom = null;
$filterDateTo = null;
if (($filterRaw['period'] ?? '') === 'custom') {
if (!empty($filterRaw['date_from'])) {
try {
$filterDateFrom = new \DateTimeImmutable($filterRaw['date_from']);
} catch (\Exception) {
}
}
if (!empty($filterRaw['date_to'])) {
try {
$filterDateTo = new \DateTimeImmutable($filterRaw['date_to']);
} catch (\Exception) {
}
}
}

// Queries — immer gefilterte Methoden (leere Filter = kein WHERE)
$entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit);
$totalCount = $this->timeEntryRepo->countFiltered($filters);
$totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters);
$totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters);

return $this->render('report/times.html.twig', [
'entries' => $entries,
'userMap' => $userMap,
'totalCount' => $totalCount,
'totalDuration' => $this->formatMinutes($totalMinutes),
'totalRevenue' => $totalRevenue,
'limit' => $limit,
'validLimits' => self::VALID_LIMITS,
'accountName' => $account?->getName() ?? '',
'currentUserId' => $currentUserId,
'isAdmin' => $isAdmin,
'projects' => $this->projectRepo->findAllWithClient(),
'services' => $this->serviceRepo->findAllOrderedByBillable(),
'entries' => $entries,
'userMap' => $userMap,
'userList' => $userList,
'totalCount' => $totalCount,
'totalDuration' => $this->formatMinutes($totalMinutes),
'totalRevenue' => $totalRevenue,
'limit' => $limit,
'validLimits' => self::VALID_LIMITS,
'accountName' => $account?->getName() ?? '',
'currentUserId' => $currentUserId,
'isAdmin' => $isAdmin,
'isTracker' => $isTracker,
'projects' => $this->projectRepo->findAllWithClient(),
'services' => $this->serviceRepo->findAllOrderedByBillable(),
'clients' => $this->clientRepo->findAllActiveOrderedByName(),
'filterRaw' => $filterRaw,
'filterActive' => $filterActive,
'filterDateFrom' => $filterDateFrom,
'filterDateTo' => $filterDateTo,
'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1,
]);
}

// ── Filter-Parsing ────────────────────────────────────────────────────────

private function parseFilters(array $f): array
{
$filters = [];

$clientIds = array_values(array_filter(array_map('intval', (array) ($f['clients'] ?? []))));
if ($clientIds) {
$filters['clientIds'] = $clientIds;
}

$projectIds = array_values(array_filter(array_map('intval', (array) ($f['projects'] ?? []))));
if ($projectIds) {
$filters['projectIds'] = $projectIds;
}

$serviceIds = array_values(array_filter(array_map('intval', (array) ($f['services'] ?? []))));
if ($serviceIds) {
$filters['serviceIds'] = $serviceIds;
}

$userIds = array_values(array_filter(array_map('intval', (array) ($f['users'] ?? []))));
if ($userIds) {
$filters['userIds'] = $userIds;
}

[$dateFrom, $dateTo] = $this->parsePeriod($f);
if ($dateFrom !== null) {
$filters['dateFrom'] = $dateFrom;
}
if ($dateTo !== null) {
$filters['dateTo'] = $dateTo;
}

$note = trim($f['note'] ?? '');
if ($note !== '') {
$filters['note'] = $note;
}

if (isset($f['invoiced']) && in_array($f['invoiced'], ['yes', 'no'], true)) {
$filters['invoiced'] = $f['invoiced'] === 'yes';
}

return $filters;
}

private function parsePeriod(array $f): array
{
$period = $f['period'] ?? '';
$now = new \DateTimeImmutable();

return match ($period) {
'today' => [
$now->setTime(0, 0, 0),
$now->setTime(23, 59, 59),
],
'yesterday' => [
$now->modify('-1 day')->setTime(0, 0, 0),
$now->modify('-1 day')->setTime(23, 59, 59),
],
'current_week' => [
new \DateTimeImmutable('monday this week'),
new \DateTimeImmutable('sunday this week'),
],
'prev_week' => [
new \DateTimeImmutable('monday last week'),
new \DateTimeImmutable('sunday last week'),
],
'current_month' => [
new \DateTimeImmutable($now->format('Y-m-01')),
new \DateTimeImmutable($now->format('Y-m-t')),
],
'prev_month' => [
new \DateTimeImmutable($now->modify('first day of last month')->format('Y-m-d')),
new \DateTimeImmutable($now->modify('last day of last month')->format('Y-m-d')),
],
'current_year' => [
new \DateTimeImmutable($now->format('Y') . '-01-01'),
new \DateTimeImmutable($now->format('Y') . '-12-31'),
],
'prev_year' => [
new \DateTimeImmutable(($now->format('Y') - 1) . '-01-01'),
new \DateTimeImmutable(($now->format('Y') - 1) . '-12-31'),
],
'custom' => $this->parseCustomDates($f),
default => [null, null],
};
}

private function parseCustomDates(array $f): array
{
$from = null;
$to = null;

if (!empty($f['date_from'])) {
try {
$from = new \DateTimeImmutable($f['date_from']);
} catch (\Exception) {
}
}
if (!empty($f['date_to'])) {
try {
$to = new \DateTimeImmutable($f['date_to']);
} catch (\Exception) {
}
}

return [$from, $to];
}

// ── API: Abgerechnet-Status toggeln ───────────────────────────────────────

#[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])]


+ 87
- 0
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Переглянути файл

@@ -186,4 +186,91 @@ class TimeEntryRepository extends ServiceEntityRepository
->getQuery()
->getSingleScalarResult();
}

// ── Report mit Filtern ────────────────────────────────────────────────────

private function buildFilteredQuery(array $filters): \Doctrine\ORM\QueryBuilder
{
$qb = $this->createQueryBuilder('t')
->join('t.project', 'p')
->join('p.client', 'c')
->leftJoin('t.service', 's');

if (!empty($filters['clientIds'])) {
$qb->andWhere('c.id IN (:clientIds)')
->setParameter('clientIds', $filters['clientIds']);
}
if (!empty($filters['projectIds'])) {
$qb->andWhere('p.id IN (:projectIds)')
->setParameter('projectIds', $filters['projectIds']);
}
if (!empty($filters['serviceIds'])) {
$qb->andWhere('s.id IN (:serviceIds)')
->setParameter('serviceIds', $filters['serviceIds']);
}
if (!empty($filters['userIds'])) {
$qb->andWhere('t.userId IN (: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'));
}
if (!empty($filters['note'])) {
$qb->andWhere('t.note LIKE :note')
->setParameter('note', '%' . $filters['note'] . '%');
}
if (isset($filters['invoiced'])) {
$qb->andWhere('t.invoiced = :invoiced')
->setParameter('invoiced', $filters['invoiced']);
}

return $qb;
}

public function findForReportFiltered(array $filters, int $limit): array
{
return $this->buildFilteredQuery($filters)
->addSelect('p', 'c', 's')
->orderBy('t.date', 'DESC')
->addOrderBy('t.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}

public function countFiltered(array $filters): int
{
return (int) $this->buildFilteredQuery($filters)
->select('COUNT(t.id)')
->getQuery()
->getSingleScalarResult();
}

public function sumDurationFiltered(array $filters): int
{
$result = $this->buildFilteredQuery($filters)
->select('SUM(t.duration)')
->getQuery()
->getSingleScalarResult();

return (int) $result;
}

public function sumRevenueFiltered(array $filters): float
{
$result = $this->buildFilteredQuery($filters)
->select('SUM(c.hourlyRate * t.duration / 60)')
->andWhere('c.hourlyRate IS NOT NULL')
->andWhere('(s IS NULL OR s.billable = :billable_rev)')
->setParameter('billable_rev', true)
->getQuery()
->getSingleScalarResult();

return (float) ($result ?? 0.0);
}
}

+ 253
- 0
httpdocs/templates/report/_filter-panel.html.twig Переглянути файл

@@ -0,0 +1,253 @@
{# templates/report/_filter-panel.html.twig #}
{# Variablen: clients, projects, services, userList, filterRaw, filterActive,
filterDateFrom, filterDateTo, isTracker, limit #}

{% 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 hasClients = fr.clients is defined and fr.clients is not empty %}
{% set hasProjects = fr.projects is defined and fr.projects is not empty %}
{% 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 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' %}

{% set fromDay = filterDateFrom ? filterDateFrom|date('j') : dayNow %}
{% set fromMonth = filterDateFrom ? filterDateFrom|date('n') : monthNow %}
{% set fromYear = filterDateFrom ? filterDateFrom|date('Y') : yearNow %}
{% set toDay = filterDateTo ? filterDateTo|date('j') : dayNow %}
{% set toMonth = filterDateTo ? filterDateTo|date('n') : monthNow %}
{% set toYear = filterDateTo ? filterDateTo|date('Y') : yearNow %}

<div class="report-filter" id="report-filter"{% if not filterActive %} hidden{% endif %}>

<div class="report-filter__body">
<div class="report-filter__col">

<h3 class="report-filter__heading">{{ 'app.report.filter_by'|trans }}</h3>

{# ── Kunde ────────────────────────────────────────────────────── #}
<div class="filter-row{% if not hasClients %} filter-row--inactive{% endif %}"
data-filter-key="clients">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasClients %} checked{% endif %}>
<span>{{ 'app.report.filter_client'|trans }}</span>
</label>
<div class="filter-row__controls" id="filter-controls-clients">
{% set selClients = hasClients ? fr.clients : [''] %}
{% for i, val in selClients %}
<div class="filter-row__control-group">
<select class="select filter-select" data-filter-key="clients">
<option value="">...</option>
{% for client in clients %}
<option value="{{ client.id }}"{% if val == client.id %} selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
{% if i > 0 %}
<button type="button" class="filter-row__remove">×</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="filter-row__add" data-target="filter-controls-clients" data-filter-key="clients">+</button>
</div>

{# ── Projekt ──────────────────────────────────────────────────── #}
<div class="filter-row{% if not hasProjects %} filter-row--inactive{% endif %}"
data-filter-key="projects">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasProjects %} checked{% endif %}>
<span>{{ 'app.report.filter_project'|trans }}</span>
</label>
<div class="filter-row__controls" id="filter-controls-projects">
{% set selProjects = hasProjects ? fr.projects : [''] %}
{% for i, val in selProjects %}
<div class="filter-row__control-group">
<select class="select filter-select" data-filter-key="projects">
<option value="">...</option>
{% for project in projects %}
<option value="{{ project.id }}"{% if val == project.id %} selected{% endif %}>{{ project.client.name }} / {{ project.name }}</option>
{% endfor %}
</select>
{% if i > 0 %}
<button type="button" class="filter-row__remove">×</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="filter-row__add" data-target="filter-controls-projects" data-filter-key="projects">+</button>
</div>

{# ── Leistung ─────────────────────────────────────────────────── #}
<div class="filter-row{% if not hasServices %} filter-row--inactive{% endif %}"
data-filter-key="services">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasServices %} checked{% endif %}>
<span>{{ 'app.report.filter_service'|trans }}</span>
</label>
<div class="filter-row__controls" id="filter-controls-services">
{% set selServices = hasServices ? fr.services : [''] %}
{% for i, val in selServices %}
<div class="filter-row__control-group">
<select class="select filter-select" data-filter-key="services">
<option value="">...</option>
{% for service in services %}
<option value="{{ service.id }}"{% if val == service.id %} selected{% endif %}>{{ service.name }}</option>
{% endfor %}
</select>
{% if i > 0 %}
<button type="button" class="filter-row__remove">×</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="filter-row__add" data-target="filter-controls-services" data-filter-key="services">+</button>
</div>

{# ── Benutzer (nur für Admins / Members, nicht für Tracker) ───── #}
{% if not isTracker %}
<div class="filter-row{% if not hasUsers %} filter-row--inactive{% endif %}"
data-filter-key="users">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasUsers %} checked{% endif %}>
<span>{{ 'app.report.filter_user'|trans }}</span>
</label>
<div class="filter-row__controls" id="filter-controls-users">
{% set selUsers = hasUsers ? fr.users : [''] %}
{% for i, val in selUsers %}
<div class="filter-row__control-group">
<select class="select filter-select" data-filter-key="users">
<option value="">...</option>
{% for user in userList %}
<option value="{{ user.id }}"{% if val == user.id %} selected{% endif %}>{{ user.name }}</option>
{% endfor %}
</select>
{% if i > 0 %}
<button type="button" class="filter-row__remove">×</button>
{% endif %}
</div>
{% endfor %}
</div>
<button type="button" class="filter-row__add" data-target="filter-controls-users" data-filter-key="users">+</button>
</div>
{% endif %}

{# ── Zeitraum ─────────────────────────────────────────────────── #}
<div class="filter-row{% if not hasPeriod %} filter-row--inactive{% endif %}"
data-filter-key="period">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasPeriod %} checked{% endif %}>
<span>{{ 'app.report.filter_period'|trans }}</span>
</label>
<div class="filter-row__controls">
<div class="filter-row__control-group filter-row__control-group--period">
<select class="select filter-select filter-period-select" data-filter-key="period">
<option value="">...</option>
<option value="today" {% if frPeriod == 'today' %}selected{% endif %}>{{ 'app.report.period_today'|trans }}</option>
<option value="yesterday" {% if frPeriod == 'yesterday' %}selected{% endif %}>{{ 'app.report.period_yesterday'|trans }}</option>
<option value="current_week" {% if frPeriod == 'current_week' %}selected{% endif %}>{{ 'app.report.period_current_week'|trans }}</option>
<option value="prev_week" {% if frPeriod == 'prev_week' %}selected{% endif %}>{{ 'app.report.period_prev_week'|trans }}</option>
<option value="current_month" {% if frPeriod == 'current_month' %}selected{% endif %}>{{ 'app.report.period_current_month'|trans }}</option>
<option value="prev_month" {% if frPeriod == 'prev_month' %}selected{% endif %}>{{ 'app.report.period_prev_month'|trans }}</option>
<option value="current_year" {% if frPeriod == 'current_year' %}selected{% endif %}>{{ 'app.report.period_current_year'|trans }}</option>
<option value="prev_year" {% if frPeriod == 'prev_year' %}selected{% endif %}>{{ 'app.report.period_prev_year'|trans }}</option>
<option value="custom" {% if frPeriod == 'custom' %}selected{% endif %}>{{ 'app.report.period_custom'|trans }}</option>
</select>

<div class="filter-custom-dates"{% if not isCustom %} hidden{% endif %}>
<div class="filter-date-group">
<span class="filter-date-label">{{ 'app.report.period_from'|trans }}</span>
<select class="select filter-date-select filter-date-select--sm" data-date-field="from-day">
{% for d in 1..31 %}
<option value="{{ d }}"{% if fromDay == d %} selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<select class="select filter-date-select filter-date-select--month" data-date-field="from-month">
{% for i in 0..11 %}
<option value="{{ i + 1 }}"{% if fromMonth == (i + 1) %} selected{% endif %}>{{ months[i] }}</option>
{% endfor %}
</select>
<select class="select filter-date-select filter-date-select--sm" data-date-field="from-year">
{% for y in range(yearNow - 5, yearNow) %}
<option value="{{ y }}"{% if fromYear == y %} selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
<div class="filter-date-group">
<span class="filter-date-label">{{ 'app.report.period_to'|trans }}</span>
<select class="select filter-date-select filter-date-select--sm" data-date-field="to-day">
{% for d in 1..31 %}
<option value="{{ d }}"{% if toDay == d %} selected{% endif %}>{{ d }}</option>
{% endfor %}
</select>
<select class="select filter-date-select filter-date-select--month" data-date-field="to-month">
{% for i in 0..11 %}
<option value="{{ i + 1 }}"{% if toMonth == (i + 1) %} selected{% endif %}>{{ months[i] }}</option>
{% endfor %}
</select>
<select class="select filter-date-select filter-date-select--sm" data-date-field="to-year">
{% for y in range(yearNow - 5, yearNow) %}
<option value="{{ y }}"{% if toYear == y %} selected{% endif %}>{{ y }}</option>
{% endfor %}
</select>
</div>
</div>

</div>
</div>
</div>

{# ── Bemerkung ────────────────────────────────────────────────── #}
<div class="filter-row{% if not hasNote %} filter-row--inactive{% endif %}"
data-filter-key="note">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasNote %} checked{% endif %}>
<span>{{ 'app.report.filter_note'|trans }}</span>
</label>
<div class="filter-row__controls">
<div class="filter-row__control-group">
<input type="text" class="input filter-note-input" value="{{ fr.note ?? '' }}" placeholder="...">
</div>
</div>
</div>

{# ── Abgeschlossen? ───────────────────────────────────────────── #}
<div class="filter-row{% if not hasInvoiced %} filter-row--inactive{% endif %}"
data-filter-key="invoiced">
<label class="filter-row__label">
<input type="checkbox" class="filter-row__checkbox"{% if hasInvoiced %} checked{% endif %}>
<span>{{ 'app.report.filter_invoiced'|trans }}</span>
</label>
<div class="filter-row__controls">
<div class="filter-row__control-group filter-row__control-group--radio">
<label class="filter-radio">
<input type="radio" class="filter-invoiced-radio" value="yes"{% if frInvoiced != 'no' %} checked{% endif %}>
{{ 'app.report.invoiced_yes'|trans }}
</label>
<label class="filter-radio">
<input type="radio" class="filter-invoiced-radio" value="no"{% if frInvoiced == 'no' %} checked{% endif %}>
{{ 'app.report.invoiced_no'|trans }}
</label>
</div>
</div>
</div>

</div>{# /.report-filter__col #}
</div>{# /.report-filter__body #}

<div class="report-filter__footer">
<button type="button" class="btn btn-primary" id="btn-filter-apply">{{ 'app.report.btn_filter'|trans }}</button>
<button type="button" class="filter-footer__link" id="btn-filter-hide">{{ 'app.report.btn_hide'|trans }}</button>
{% if filterActive %}
<a href="{{ path('report_times', {limit: limit}) }}" class="filter-footer__link">{{ 'app.report.btn_show_all'|trans }}</a>
{% endif %}
</div>

</div>{# /.report-filter #}

+ 15
- 2
httpdocs/templates/report/times.html.twig Переглянути файл

@@ -17,6 +17,13 @@
trackingInterval: {{ trackingInterval }},
currentUserId: {{ currentUserId }},
isAdmin: {{ isAdmin ? 'true' : 'false' }},
isTracker: {{ isTracker ? 'true' : 'false' }},
limit: {{ limit }},
clients: [
{% for client in clients %}
{ id: {{ client.id }}, name: {{ client.name|json_encode|raw }} }{% if not loop.last %},{% endif %}
{% endfor %}
],
projects: [
{% for project in projects %}
{
@@ -33,6 +40,7 @@
billable: {{ service.billable ? 'true' : 'false' }} }{% if not loop.last %},{% endif %}
{% endfor %}
],
users: {{ userList|json_encode|raw }},
i18n: {
btnSave: {{ 'app.entry.btn_save'|trans|json_encode|raw }},
btnCancel: {{ 'app.entry.btn_cancel'|trans|json_encode|raw }},
@@ -85,13 +93,15 @@
{# ── Toolbar ──────────────────────────────────────────────────────── #}
<div class="report-toolbar">
<div class="report-toolbar__left">
<span class="report-toolbar__action report-toolbar__action--disabled">
<button class="report-toolbar__action{% if filterActive %} report-toolbar__action--active{% endif %}"
id="btn-filter-toggle"
type="button">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="6.5" cy="6.5" r="4" stroke="currentColor" stroke-width="1.3"/>
<path d="M11 11l2.5 2.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>
{{ 'app.report.toolbar_filter'|trans }}
</span>
</button>
<span class="report-toolbar__action report-toolbar__action--disabled">
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
@@ -101,6 +111,9 @@
</div>
</div>

{# ── Filter-Panel ─────────────────────────────────────────────────── #}
{% include 'report/_filter-panel.html.twig' %}

{# ── Tabellen-Header ───────────────────────────────────────────────── #}
<div class="report-table">
<div class="report-table__head">


+ 24
- 0
httpdocs/translations/messages.de.yaml Переглянути файл

@@ -84,6 +84,30 @@ app:
btn_lock: "Als abgerechnet markieren"
btn_unlock: "Abrechnung aufheben"
invoiced_title: "Abgerechnet – Bearbeiten nicht möglich"
filter_by: "Filtern nach"
btn_filter: "Filtern"
btn_hide: "Ausblenden"
btn_show_all: "Alle anzeigen"
filter_client: "Kunde"
filter_project: "Projekt"
filter_service: "Leistung"
filter_user: "Benutzer"
filter_period: "Zeitraum"
filter_note: "Bemerkung"
filter_invoiced: "Abgeschlossen?"
period_today: "Heute"
period_yesterday: "Gestern"
period_current_week: "Aktuelle Woche"
period_prev_week: "Vorherige Woche"
period_current_month: "Aktueller Monat"
period_prev_month: "Vorheriger Monat"
period_current_year: "Aktuelles Jahr"
period_prev_year: "Vorheriges Jahr"
period_custom: "Von … bis"
period_from: "von"
period_to: "bis"
invoiced_yes: "Ja"
invoiced_no: "Nein"

forgot_password:
page_title: "Passwort vergessen – spawntree"


Завантаження…
Відмінити
Зберегти