diff --git a/httpdocs/2-update-tenant-db.sh b/httpdocs/2-update-tenant-db.sh new file mode 100644 index 0000000..ed2af7b --- /dev/null +++ b/httpdocs/2-update-tenant-db.sh @@ -0,0 +1,13 @@ +#!/bin/bash +set -e + +echo "⏳ Tenant-Schemas aktualisieren (nur ADD, kein DROP)..." +ddev exec php bin/console app:update-tenant-schema + +echo "⏳ Cache leeren..." +ddev exec php bin/console cache:clear + +echo "⏳ Assets bauen..." +ddev exec npm run build + +echo "✅ Fertig!" diff --git a/httpdocs/assets/scripts/entries.js b/httpdocs/assets/scripts/entries.js index 9b2d13f..194b3d6 100644 --- a/httpdocs/assets/scripts/entries.js +++ b/httpdocs/assets/scripts/entries.js @@ -4,6 +4,8 @@ import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, const LAST_PROJECT_KEY = 'tt_last_project_id'; const LAST_SERVICE_KEY = 'tt_last_service_id'; +const LOCK_SVG = ``; + function t(key) { return window.TT?.i18n?.[key] ?? key; } @@ -57,32 +59,20 @@ function buildServiceOptions(selectedId = null) { function buildEntryRowHTML(entry, animate = false) { const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; const notePart = entry.note ? `
${entry.note}
` : ''; - - return ` -
- -
-
-
${entry.clientName} / ${entry.projectName}${servicePart}
- ${notePart} -
-
- ${entry.durationFormatted} - - -
-
- + const invoiced = !!entry.invoiced; + + const actionsHtml = invoiced + ? `${entry.durationFormatted} + ${LOCK_SVG}` + : `${entry.durationFormatted} + + `; + + const editFormHtml = invoiced ? '' : ` +
`; + + return ` +
+ +
+
+
${entry.clientName} / ${entry.projectName}${servicePart}
+ ${notePart} +
+
+ ${actionsHtml} +
+ ${editFormHtml}
`; } @@ -150,20 +161,30 @@ class EntryManager { this.list.addEventListener('click', e => this.handleListClick(e)); document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); + + this.checkAutoEdit(); } handleListClick(e) { - const actionEl = e.target.closest('[data-action]'); - if (!actionEl) return; - const action = actionEl.dataset.action; - const row = e.target.closest('.entry-row'); + const row = e.target.closest('.entry-row'); if (!row) return; - switch (action) { - case 'edit': this.openEdit(row); break; - case 'delete': this.deleteEntry(row); break; - case 'save': this.saveEdit(row); break; - case 'cancel': this.closeEdit(row); break; + const actionEl = e.target.closest('[data-action]'); + if (actionEl) { + const action = actionEl.dataset.action; + if (row.dataset.invoiced === 'true' && (action === 'edit' || action === 'delete')) return; + switch (action) { + case 'edit': this.openEdit(row); break; + case 'delete': this.deleteEntry(row); break; + case 'save': this.saveEdit(row); break; + case 'cancel': this.closeEdit(row); break; + } + return; + } + + // Klick auf Anzeige-Bereich (kein Button) → Edit öffnen + if (e.target.closest('.entry-row__display') && row.dataset.invoiced !== 'true') { + this.openEdit(row); } } @@ -249,8 +270,14 @@ class EntryManager { } openEdit(row) { + // Safety-Guard: invoiced-Einträge können nicht geöffnet werden + if (row.dataset.invoiced === 'true') return; + // Kein Edit-Formular vorhanden → nicht öffnen + const editSection = row.querySelector('.entry-row__edit'); + if (!editSection) return; + row.querySelector('.entry-row__display').hidden = true; - row.querySelector('.entry-row__edit').hidden = false; + editSection.hidden = false; row.querySelector('.edit-duration')?.focus(); } @@ -259,6 +286,20 @@ class EntryManager { row.querySelector('.entry-row__edit').hidden = true; } + checkAutoEdit() { + const params = new URLSearchParams(window.location.search); + const editId = params.get('editEntry'); + if (!editId) return; + const row = document.getElementById(`entry-${editId}`); + if (row) { + this.openEdit(row); + params.delete('editEntry'); + const newUrl = window.location.pathname + + (params.size > 0 ? '?' + params.toString() : ''); + history.replaceState(null, '', newUrl); + } + } + async saveEdit(row) { const id = row.dataset.id; const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; @@ -391,6 +432,8 @@ class EntryManager { row.querySelector('.edit-project').innerHTML = buildProjectOptions(row.dataset.projectId); row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId); }); + + this.checkAutoEdit(); } updateTotal(totalDuration) { diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js new file mode 100644 index 0000000..031c7b4 --- /dev/null +++ b/httpdocs/assets/scripts/report.js @@ -0,0 +1,32 @@ +// assets/scripts/report.js + +document.addEventListener('DOMContentLoaded', () => { + document.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action="toggle-invoiced"]'); + if (!btn) return; + + const row = btn.closest('[data-entry-id]'); + if (!row) return; + + const id = row.dataset.entryId; + + try { + const res = await fetch(`/api/entries/${id}/invoiced`, { method: 'PATCH' }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + const invoiced = data.invoiced; + + row.dataset.invoiced = invoiced ? 'true' : 'false'; + row.classList.toggle('report-table__row--invoiced', invoiced); + + btn.classList.toggle('report-lock--invoiced', invoiced); + btn.title = invoiced + ? (window.REPORT?.i18n?.btnUnlock ?? '') + : (window.REPORT?.i18n?.btnLock ?? ''); + + } catch (err) { + console.error('Fehler beim Toggeln des Abrechnungsstatus:', err); + } + }); +}); diff --git a/httpdocs/assets/styles/components/_entry-list.scss b/httpdocs/assets/styles/components/_entry-list.scss index b561ff1..422ae5d 100644 --- a/httpdocs/assets/styles/components/_entry-list.scss +++ b/httpdocs/assets/styles/components/_entry-list.scss @@ -154,6 +154,33 @@ @media (hover: none) { opacity: 1; } } +// ─── Abgerechneter Eintrag ──────────────────────────────────────────────── +// Kein opacity auf dem Row – das würde auch das Schloss-Icon aufhellen. +// Stattdessen nur die Text-Elemente selektiv dämpfen. +.entry-row--invoiced { + .entry-row__title { color: $color-text-muted; font-weight: $font-weight-regular; } + .entry-row__note { color: $color-text-light; } + .entry-row__badge { color: $color-text-muted; background: rgba($color-card, 0.6); } +} + +.entry-row__lock-indicator { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; + // Explizit dunkel – auch wenn der Row gedämpft ist + color: $color-text-dark; + + svg { width: 14px; height: 14px; pointer-events: none; } +} + +// Höhere Spezifizität sicherstellen +.entry-row--invoiced .entry-row__lock-indicator { + color: $color-text-dark; +} + // ─── Bearbeiten-Modus ───────────────────────────────────────────────────── .entry-row__edit { padding: $space-4 $space-8; diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index 478f9bf..f3cae61 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -21,6 +21,7 @@ // ─── Sections ───────────────────────────────────────────────────────────────── @use 'sections/timetracking'; @use 'sections/home'; +@use 'sections/report'; // ─── Reset / Base ───────────────────────────────────────────────────────────── *, diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss new file mode 100644 index 0000000..5910cce --- /dev/null +++ b/httpdocs/assets/styles/sections/_report.scss @@ -0,0 +1,328 @@ +@use '../atoms/variables' as *; + +// ─── Page ───────────────────────────────────────────────────────────────────── +.report-page { + min-height: 100vh; + background: $color-bg; + display: flex; + flex-direction: column; +} + +// ─── Header ────────────────────────────────────────────────────────────────── +.report-header { + background: linear-gradient(135deg, $color-header-from 0%, $color-header-to 100%); + padding: $space-4 $space-6; + display: flex; + align-items: center; + justify-content: space-between; + gap: $space-6; + box-shadow: $shadow-header; +} + +.report-header__title { + font-size: $font-size-xl; + font-weight: $font-weight-bold; + color: $color-white; +} + +.report-header__right { + display: flex; + align-items: center; + gap: $space-4; +} + +// ─── Account-Name Anzeige ──────────────────────────────────────────────────── +.report-account-name { + display: inline-flex; + align-items: center; + gap: $space-2; + padding: $space-2 $space-4; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: rgba($color-white, 0.9); + background: rgba($color-white, 0.15); + border: 1px solid rgba($color-white, 0.25); + border-radius: $radius-pill; + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + white-space: nowrap; + + &__icon { + width: 16px; + height: 16px; + flex-shrink: 0; + } +} + +// ─── Disabled Tab ──────────────────────────────────────────────────────────── +.account-tab--disabled { + opacity: 0.45; + pointer-events: none; + cursor: default; +} + +// ─── Content ───────────────────────────────────────────────────────────────── +.report-content { + flex: 1; + max-width: 1200px; + width: 100%; + margin: $space-6 auto; + padding: 0 $space-6; +} + +// ─── Karte ─────────────────────────────────────────────────────────────────── +.report-card { + background: $color-card-white; + border-radius: $radius-lg; + box-shadow: $shadow-card; + overflow: hidden; +} + +// ─── Toolbar ───────────────────────────────────────────────────────────────── +.report-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: $space-3 $space-5; + border-bottom: 1px solid $color-border; +} + +.report-toolbar__left { + display: flex; + align-items: center; + gap: $space-6; +} + +.report-toolbar__action { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $color-primary; + cursor: pointer; + text-decoration: none; + + svg { + width: 14px; + height: 14px; + flex-shrink: 0; + } + + &--disabled { + color: $color-text-muted; + pointer-events: none; + cursor: default; + } +} + +// ─── Tabelle ───────────────────────────────────────────────────────────────── +.report-table { + width: 100%; +} + +.report-table__head, +.report-table__row { + display: grid; + grid-template-columns: + 110px // Datum + 140px // Kunde + 130px // Projekt + 120px // Leistung + 140px // Benutzer + 1fr // Bemerkung + 80px // Stunden + 100px // Umsatz + 36px; // Schloss + align-items: center; + border-bottom: 1px solid $color-border; + padding: 0 $space-5; +} + +.report-table__head { + padding-top: $space-2; + padding-bottom: $space-2; + background: transparent; + + .report-table__cell { + font-size: $font-size-xs; + font-weight: $font-weight-bold; + color: $color-text-base; + text-transform: uppercase; + letter-spacing: 0.03em; + line-height: 1.3; + } +} + +.report-table__row { + padding-top: $space-3; + padding-bottom: $space-3; + transition: background $transition-fast; + + &:hover { + background: rgba($color-primary, 0.035); + } + + &--invoiced { + // Kein opacity – das würde auch das Schloss-Icon abdunkeln/aufhellen. + // Selektiv nur die Text-Zellen dämpfen: + .report-table__cell--date { color: $color-text-light; } + .report-table__cell--client { color: $color-text-light; } + .report-table__cell--project { color: $color-text-light; } + .report-table__cell--service { color: $color-text-light; } + .report-table__cell--user { color: $color-text-light; } + .report-table__cell--note { color: $color-text-light; } + .report-table__cell--duration { color: $color-text-light; } + .report-table__cell--revenue { color: $color-text-light; } + + .report-table__date-link { color: $color-text-light; text-decoration: none; } + } + + &:last-child { + border-bottom: none; + } +} + +.report-table__cell { + font-size: $font-size-base; + color: $color-text-base; + padding-right: $space-3; + line-height: 1.4; + min-width: 0; + + &--duration, + &--revenue { + text-align: right; + padding-right: $space-4; + white-space: nowrap; + font-variant-numeric: tabular-nums; + } + + &--lock { + display: flex; + justify-content: flex-start; + align-items: center; + padding-right: 0; + } + + &--note { + color: $color-text-muted; + font-size: $font-size-sm; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} + +.report-table__sort-icon { + margin-left: 2px; + font-size: $font-size-xs; +} + +.report-table__summary { + display: block; + font-size: $font-size-xs; + font-weight: $font-weight-regular; + color: $color-text-muted; + margin-top: 1px; +} + +.report-table__date-link { + color: $color-primary; + text-decoration: none; + white-space: nowrap; + + &:hover { + text-decoration: underline; + } +} + +.report-table__empty { + padding: $space-10 $space-5; + text-align: center; + color: $color-text-muted; + font-size: $font-size-sm; +} + +// ─── Schloss-Button ────────────────────────────────────────────────────────── +.report-lock { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: none; + cursor: pointer; + color: $color-text-light; + border-radius: $radius-sm; + transition: color $transition-fast, background $transition-fast; + + svg { + width: 14px; + height: 14px; + } + + &:hover { + color: $color-text-muted; + background: rgba($color-text-dark, 0.06); + } + + &--invoiced { + color: $color-text-dark; + + &:hover { + color: $color-text-dark; + background: rgba($color-text-dark, 0.06); + } + } +} + +// ─── Pagination-Footer ──────────────────────────────────────────────────────── +// Gleiche Grid-Spalten wie .report-table__head/.report-table__row: +// 1fr entspricht allen Spalten links der Zahlen (Anzeigen-Text) +// 80px = Stunden, 100px = Umsatz, 36px = Schloss-Platzhalter +.report-pagination { + display: grid; + grid-template-columns: 1fr 80px 100px 36px; + align-items: center; + padding: $space-3 $space-5; + border-top: 1px solid $color-border; + font-size: $font-size-sm; + color: $color-text-muted; +} + +.report-pagination__limits { + display: flex; + align-items: center; + gap: $space-2; + + a { + color: $color-primary; + text-decoration: underline; + cursor: pointer; + + &:hover { + color: $color-primary-dark; + } + } + + strong { + color: $color-text-dark; + font-weight: $font-weight-bold; + } +} + +.report-pagination__duration, +.report-pagination__revenue { + text-align: right; + padding-right: $space-4; + font-weight: $font-weight-medium; + color: $color-text-muted; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.report-pagination__lock-spacer { + // Platzhalter für die Schloss-Spalte – hält die Ausrichtung +} diff --git a/httpdocs/config/services.yaml b/httpdocs/config/services.yaml index a62cb52..ff2569b 100644 --- a/httpdocs/config/services.yaml +++ b/httpdocs/config/services.yaml @@ -31,11 +31,20 @@ services: arguments: $em: '@doctrine.orm.tenant_entity_manager' + App\Controller\ReportController: + arguments: + $tenantEm: '@doctrine.orm.tenant_entity_manager' + App\Command\SeedCommand: arguments: $centralEm: '@doctrine.orm.central_entity_manager' $tenantEm: '@doctrine.orm.tenant_entity_manager' + App\Command\UpdateTenantSchemaCommand: + arguments: + $centralEm: '@doctrine.orm.central_entity_manager' + $tenantEm: '@doctrine.orm.tenant_entity_manager' + # ── app.domain in Subscriber injizieren ─────────────────────────────────── App\EventSubscriber\TenantRequestSubscriber: arguments: diff --git a/httpdocs/src/Command/UpdateTenantSchemaCommand.php b/httpdocs/src/Command/UpdateTenantSchemaCommand.php new file mode 100644 index 0000000..e685ca9 --- /dev/null +++ b/httpdocs/src/Command/UpdateTenantSchemaCommand.php @@ -0,0 +1,61 @@ +accountRepo->findAll(); + + if (empty($accounts)) { + $io->warning('Keine Accounts in der Central-DB gefunden.'); + return Command::SUCCESS; + } + + $metadata = $this->tenantEm->getMetadataFactory()->getAllMetadata(); + $schemaTool = new SchemaTool($this->tenantEm); + + foreach ($accounts as $account) { + $io->text(sprintf('→ Account: %s (db_%s)', $account->getName(), $account->getSlug())); + + $this->tenantContext->setAccount($account); + $this->tenantEm->clear(); + $this->tenantEm->getConnection()->close(); + + // saveMode = true: nur hinzufügen, kein DROP von Tabellen/Spalten + $schemaTool->updateSchema($metadata, true); + + $io->text(' ✓ Schema aktualisiert'); + } + + $io->success(sprintf('%d Tenant-DB(s) aktualisiert.', count($accounts))); + + return Command::SUCCESS; + } +} diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php new file mode 100644 index 0000000..9c2ba4b --- /dev/null +++ b/httpdocs/src/Controller/ReportController.php @@ -0,0 +1,81 @@ +query->get('limit', 50); + if (!in_array($limit, self::VALID_LIMITS, true)) { + $limit = 50; + } + + // User-Map: userId → vollständiger Name + $account = $this->tenantContext->getAccount(); + $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); + $userMap = []; + foreach ($accountUsers as $au) { + $userMap[$au->getUser()->getId()] = $au->getUser()->getFullName(); + } + + $entries = $this->timeEntryRepo->findForReport($limit); + $totalCount = $this->timeEntryRepo->countAll(); + $totalMinutes = $this->timeEntryRepo->sumDurationAll(); + $totalRevenue = $this->timeEntryRepo->sumRevenueAll(); + + 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() ?? '', + ]); + } + + // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── + + #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] + public function toggleInvoiced(int $id): JsonResponse + { + $entry = $this->timeEntryRepo->find($id); + if (!$entry) { + return $this->json(['error' => 'Nicht gefunden'], 404); + } + + $entry->setInvoiced(!$entry->isInvoiced()); + $this->tenantEm->flush(); + + return $this->json(['invoiced' => $entry->isInvoiced()]); + } + + // ── Hilfsfunktion ───────────────────────────────────────────────────────── + + private function formatMinutes(int $minutes): string + { + return sprintf('%d:%02d', intdiv($minutes, 60), $minutes % 60); + } +} diff --git a/httpdocs/src/Entity/Tenant/TimeEntry.php b/httpdocs/src/Entity/Tenant/TimeEntry.php index 980eb48..e5a40a6 100644 --- a/httpdocs/src/Entity/Tenant/TimeEntry.php +++ b/httpdocs/src/Entity/Tenant/TimeEntry.php @@ -37,6 +37,9 @@ class TimeEntry #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $note = null; + #[ORM\Column] + private bool $invoiced = false; + #[ORM\Column] private \DateTimeImmutable $createdAt; @@ -81,6 +84,9 @@ class TimeEntry public function getNote(): ?string { return $this->note; } public function setNote(?string $note): static { $this->note = $note; return $this; } + public function isInvoiced(): bool { return $this->invoiced; } + public function setInvoiced(bool $invoiced): static { $this->invoiced = $invoiced; return $this; } + public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } @@ -97,6 +103,7 @@ class TimeEntry 'serviceName' => $this->service?->getName(), 'serviceBillable' => $this->service?->isBillable(), 'note' => $this->note, + 'invoiced' => $this->invoiced, ]; } } \ No newline at end of file diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 5f53573..24271dd 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -46,6 +46,58 @@ class TimeEntryRepository extends ServiceEntityRepository return (int) $result; } + // ── Report ──────────────────────────────────────────────────────────────── + + public function findForReport(int $limit = 50): array + { + return $this->createQueryBuilder('t') + ->join('t.project', 'p') + ->join('p.client', 'c') + ->leftJoin('t.service', 's') + ->addSelect('p', 'c', 's') + ->orderBy('t.date', 'DESC') + ->addOrderBy('t.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + public function countAll(): int + { + return (int) $this->createQueryBuilder('t') + ->select('COUNT(t.id)') + ->getQuery() + ->getSingleScalarResult(); + } + + public function sumDurationAll(): int + { + $result = $this->createQueryBuilder('t') + ->select('SUM(t.duration)') + ->getQuery() + ->getSingleScalarResult(); + + return (int) $result; + } + + public function sumRevenueAll(): float + { + $result = $this->createQueryBuilder('t') + ->select('SUM(c.hourlyRate * t.duration / 60)') + ->join('t.project', 'p') + ->join('p.client', 'c') + ->leftJoin('t.service', 's') + ->where('c.hourlyRate IS NOT NULL') + ->andWhere('(s IS NULL OR s.billable = :billable)') + ->setParameter('billable', true) + ->getQuery() + ->getSingleScalarResult(); + + return (float) ($result ?? 0.0); + } + + // ── Zähler für abhängige Entitäten ──────────────────────────────────────── + public function countByProject(Project $project): int { return (int) $this->createQueryBuilder('t') @@ -86,4 +138,4 @@ class TimeEntryRepository extends ServiceEntityRepository ->getQuery() ->getSingleScalarResult(); } -} \ No newline at end of file +} diff --git a/httpdocs/templates/_atoms/icon-lock.html.twig b/httpdocs/templates/_atoms/icon-lock.html.twig new file mode 100644 index 0000000..1937c37 --- /dev/null +++ b/httpdocs/templates/_atoms/icon-lock.html.twig @@ -0,0 +1,5 @@ +{# templates/_atoms/icon-lock.html.twig #} + + + + diff --git a/httpdocs/templates/_sections/nav.html.twig b/httpdocs/templates/_sections/nav.html.twig index b996192..2946c7d 100644 --- a/httpdocs/templates/_sections/nav.html.twig +++ b/httpdocs/templates/_sections/nav.html.twig @@ -7,7 +7,10 @@ class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> {{ 'app.nav.time_tracking'|trans }} - {{ 'app.nav.reports'|trans }} + + {{ 'app.nav.reports'|trans }} +