FlorianEisenmenger 1 неделю назад
Родитель
Сommit
f169950336
18 измененных файлов: 949 добавлений и 82 удалений
  1. +13
    -0
      httpdocs/2-update-tenant-db.sh
  2. +79
    -36
      httpdocs/assets/scripts/entries.js
  3. +32
    -0
      httpdocs/assets/scripts/report.js
  4. +27
    -0
      httpdocs/assets/styles/components/_entry-list.scss
  5. +1
    -0
      httpdocs/assets/styles/main.scss
  6. +328
    -0
      httpdocs/assets/styles/sections/_report.scss
  7. +9
    -0
      httpdocs/config/services.yaml
  8. +61
    -0
      httpdocs/src/Command/UpdateTenantSchemaCommand.php
  9. +81
    -0
      httpdocs/src/Controller/ReportController.php
  10. +7
    -0
      httpdocs/src/Entity/Tenant/TimeEntry.php
  11. +53
    -1
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  12. +5
    -0
      httpdocs/templates/_atoms/icon-lock.html.twig
  13. +4
    -1
      httpdocs/templates/_sections/nav.html.twig
  14. +172
    -0
      httpdocs/templates/report/times.html.twig
  15. +53
    -44
      httpdocs/templates/timetracking/_entry_row.html.twig
  16. +1
    -0
      httpdocs/templates/timetracking/week.html.twig
  17. +22
    -0
      httpdocs/translations/messages.de.yaml
  18. +1
    -0
      httpdocs/webpack.config.js

+ 13
- 0
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!"

+ 79
- 36
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 = `<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/><path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></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 ? `<div class="entry-row__note">${entry.note}</div>` : '';

return `
<div class="entry-row${animate ? ' entry-row--new' : ''}"
id="entry-${entry.id}"
data-id="${entry.id}"
data-duration="${entry.duration}"
data-project-id="${entry.projectId}"
data-service-id="${entry.serviceId ?? ''}"
data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}">

<div class="entry-row__display">
<div class="entry-row__info">
<div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
${notePart}
</div>
<div class="entry-row__actions">
<span class="entry-row__badge">${entry.durationFormatted}</span>
<button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
</div>
</div>

const invoiced = !!entry.invoiced;

const actionsHtml = invoiced
? `<span class="entry-row__badge">${entry.durationFormatted}</span>
<span class="entry-row__lock-indicator" title="${t('invoicedTitle')}">${LOCK_SVG}</span>`
: `<span class="entry-row__badge">${entry.durationFormatted}</span>
<button class="entry-row__btn entry-row__btn--edit" title="${t('btnEdit')}" data-action="edit">
<svg viewBox="0 0 16 16" fill="none"><path d="M11 2l3 3L5 14H2v-3L11 2z" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
<button class="entry-row__btn entry-row__btn--delete" title="${t('btnDelete')}" data-action="delete">
<svg viewBox="0 0 16 16" fill="none"><path d="M3 4h10M6 4V2h4v2M5 4l.5 9h5l.5-9" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>`;

const editFormHtml = invoiced ? '' : `
<div class="entry-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">
<label class="entry-form__label">${t('labelDuration')}</label>
@@ -108,7 +98,28 @@ function buildEntryRowHTML(entry, animate = false) {
<button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button>
</div>
</div>
</div>`;

return `
<div class="entry-row${animate ? ' entry-row--new' : ''}${invoiced ? ' entry-row--invoiced' : ''}"
id="entry-${entry.id}"
data-id="${entry.id}"
data-duration="${entry.duration}"
data-project-id="${entry.projectId}"
data-service-id="${entry.serviceId ?? ''}"
data-note="${(entry.note ?? '').replace(/"/g, '&quot;')}"
data-invoiced="${invoiced ? 'true' : 'false'}">

<div class="entry-row__display">
<div class="entry-row__info">
<div class="entry-row__title">${entry.clientName} / ${entry.projectName}${servicePart}</div>
${notePart}
</div>
<div class="entry-row__actions">
${actionsHtml}
</div>
</div>
${editFormHtml}
</div>`;
}

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


+ 32
- 0
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);
}
});
});

+ 27
- 0
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;


+ 1
- 0
httpdocs/assets/styles/main.scss Просмотреть файл

@@ -21,6 +21,7 @@
// ─── Sections ─────────────────────────────────────────────────────────────────
@use 'sections/timetracking';
@use 'sections/home';
@use 'sections/report';

// ─── Reset / Base ─────────────────────────────────────────────────────────────
*,


+ 328
- 0
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
}

+ 9
- 0
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:


+ 61
- 0
httpdocs/src/Command/UpdateTenantSchemaCommand.php Просмотреть файл

@@ -0,0 +1,61 @@
<?php

namespace App\Command;

use App\Repository\Central\AccountRepository;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaTool;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;

#[AsCommand(
name: 'app:update-tenant-schema',
description: 'Schema aller Tenant-DBs auf Stand der aktuellen Entities bringen (nur ADD, kein DROP)',
)]
class UpdateTenantSchemaCommand extends Command
{
public function __construct(
private readonly EntityManagerInterface $centralEm,
private readonly EntityManagerInterface $tenantEm,
private readonly AccountRepository $accountRepo,
private readonly TenantContext $tenantContext,
) {
parent::__construct();
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);

$accounts = $this->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: <info>%s</info> (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;
}
}

+ 81
- 0
httpdocs/src/Controller/ReportController.php Просмотреть файл

@@ -0,0 +1,81 @@
<?php

namespace App\Controller;

use App\Repository\Central\AccountUserRepository;
use App\Repository\Tenant\TimeEntryRepository;
use App\Service\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

class ReportController extends AbstractController
{
private const VALID_LIMITS = [50, 100, 250, 500];

public function __construct(
private readonly EntityManagerInterface $tenantEm,
private readonly TimeEntryRepository $timeEntryRepo,
private readonly AccountUserRepository $accountUserRepo,
private readonly TenantContext $tenantContext,
) {}

#[Route('/reports/times', name: 'report_times')]
public function times(Request $request): Response
{
$limit = (int) $request->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);
}
}

+ 7
- 0
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,
];
}
}

+ 53
- 1
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();
}
}
}

+ 5
- 0
httpdocs/templates/_atoms/icon-lock.html.twig Просмотреть файл

@@ -0,0 +1,5 @@
{# templates/_atoms/icon-lock.html.twig #}
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="7.5" width="10" height="7" rx="1.5" stroke="currentColor" stroke-width="1.3"/>
<path d="M5.5 7.5V5.5a2.5 2.5 0 015 0v2" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>
</svg>

+ 4
- 1
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 }}
</a>
<span class="main-nav__item main-nav__item--disabled">{{ 'app.nav.reports'|trans }}</span>
<a href="{{ path('report_times') }}"
class="main-nav__item{% if currentRoute starts with 'report' %} main-nav__item--active{% endif %}">
{{ 'app.nav.reports'|trans }}
</a>
</div>
<div class="main-nav__right">
{% if isCurrentUserMemberOrAdmin() %}


+ 172
- 0
httpdocs/templates/report/times.html.twig Просмотреть файл

@@ -0,0 +1,172 @@
{# templates/report/times.html.twig #}
{% extends 'base.html.twig' %}

{% set monthsShort = deMonthsShort() %}

{% block title %}{{ 'app.report.page_title'|trans }}{% endblock %}

{% block javascripts %}
{{ parent() }}
{{ encore_entry_script_tags('report') }}
{% endblock %}

{% block body %}

<div class="report-page">

<div class="report-header">
<h1 class="report-header__title">{{ 'app.report.heading'|trans }}</h1>

<div class="report-header__right">
<span class="report-account-name">
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="report-account-name__icon">
<path d="M10 11a4 4 0 100-8 4 4 0 000 8zM3 17a7 7 0 0114 0" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/>
</svg>
{{ accountName }}
</span>

<nav class="account-tabs">
<a href="{{ path('report_times') }}"
class="account-tab account-tab--active">
{{ 'app.report.tab_times'|trans }}
</a>
<span class="account-tab account-tab--disabled">
{{ 'app.report.tab_projects'|trans }}
</span>
</nav>
</div>
</div>

<div class="report-content">
<div class="report-card">

{# ── Toolbar ──────────────────────────────────────────────────────── #}
<div class="report-toolbar">
<div class="report-toolbar__left">
<span class="report-toolbar__action report-toolbar__action--disabled">
<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>
<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"/>
</svg>
{{ 'app.report.toolbar_edit'|trans }}
</span>
</div>
</div>

{# ── Tabellen-Header ───────────────────────────────────────────────── #}
<div class="report-table">
<div class="report-table__head">
<div class="report-table__cell report-table__cell--date">
{{ 'app.report.col_date'|trans }}
<span class="report-table__sort-icon">▾</span>
</div>
<div class="report-table__cell report-table__cell--client">{{ 'app.report.col_client'|trans }}</div>
<div class="report-table__cell report-table__cell--project">{{ 'app.report.col_project'|trans }}</div>
<div class="report-table__cell report-table__cell--service">{{ 'app.report.col_service'|trans }}</div>
<div class="report-table__cell report-table__cell--user">{{ 'app.report.col_user'|trans }}</div>
<div class="report-table__cell report-table__cell--note">{{ 'app.report.col_note'|trans }}</div>
<div class="report-table__cell report-table__cell--duration">
{{ 'app.report.col_hours'|trans }}
<span class="report-table__summary">{{ totalDuration }}</span>
</div>
<div class="report-table__cell report-table__cell--revenue">
{{ 'app.report.col_revenue'|trans }}
<span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span>
</div>
<div class="report-table__cell report-table__cell--lock"></div>
</div>

{# ── Einträge ──────────────────────────────────────────────────── #}
{% for entry in entries %}
{% set service = entry.service %}
{% set billable = (service is null or service.billable) %}
{% set hourlyRate = entry.project.client.hourlyRate %}
{% set weekDateStr = entry.date|date('Y-m-d') %}
{% set editUrl = path('timetracking_week_date', {date: weekDateStr}) ~ '?editEntry=' ~ entry.id %}
{% set monthShort = monthsShort[entry.date|date('n') - 1] %}

<div class="report-table__row{% if entry.invoiced %} report-table__row--invoiced{% endif %}"
data-entry-id="{{ entry.id }}"
data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}">

<div class="report-table__cell report-table__cell--date">
<a href="{{ editUrl }}" class="report-table__date-link">
{{ entry.date|date('j') }}. {{ monthShort }} {{ entry.date|date('y') }}
</a>
</div>

<div class="report-table__cell report-table__cell--client">
{{ entry.project.client.name }}
</div>

<div class="report-table__cell report-table__cell--project">
{{ entry.project.name }}
</div>

<div class="report-table__cell report-table__cell--service">
{{ service ? service.name : '' }}
</div>

<div class="report-table__cell report-table__cell--user">
{{ userMap[entry.userId] ?? ('User #' ~ entry.userId) }}
</div>

<div class="report-table__cell report-table__cell--note">
{{ entry.note }}
</div>

<div class="report-table__cell report-table__cell--duration">
{{ entry.durationFormatted }}
</div>

<div class="report-table__cell report-table__cell--revenue">
{% if billable and hourlyRate is not null %}
{{ (hourlyRate * entry.duration / 60)|number_format(2, ',', '.') }} €
{% endif %}
</div>

<div class="report-table__cell report-table__cell--lock">
<button class="report-lock{% if entry.invoiced %} report-lock--invoiced{% endif %}"
data-action="toggle-invoiced"
title="{{ entry.invoiced ? 'app.report.btn_unlock'|trans : 'app.report.btn_lock'|trans }}">
{% include '_atoms/icon-lock.html.twig' %}
</button>
</div>

</div>
{% else %}
<div class="report-table__empty">{{ 'app.report.no_entries'|trans }}</div>
{% endfor %}

{# ── Pagination-Footer ─────────────────────────────────────────── #}
<div class="report-pagination">
<div class="report-pagination__limits">
{{ 'app.report.show'|trans }}
{% for l in validLimits %}
{% if l == limit %}
<strong>{{ l }}</strong>
{% else %}
<a href="{{ path('report_times', {limit: l}) }}">{{ l }}</a>
{% endif %}
{% endfor %}
{{ 'app.report.of_total'|trans({'%count%': totalCount|number_format(0, ',', '.')}) }}
</div>
<span class="report-pagination__duration">{{ totalDuration }}</span>
<span class="report-pagination__revenue">{{ totalRevenue|number_format(2, ',', '.') }} €</span>
<span class="report-pagination__lock-spacer"></span>
</div>

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

</div>{# /.report-card #}
</div>{# /.report-content #}

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

{% endblock %}

+ 53
- 44
httpdocs/templates/timetracking/_entry_row.html.twig Просмотреть файл

@@ -1,11 +1,12 @@
{# templates/timetracking/_entry_row.html.twig #}
<div class="entry-row"
<div class="entry-row{% if entry.invoiced %} entry-row--invoiced{% endif %}"
id="entry-{{ entry.id }}"
data-id="{{ entry.id }}"
data-duration="{{ entry.duration }}"
data-project-id="{{ entry.project.id }}"
data-service-id="{{ entry.service ? entry.service.id : '' }}"
data-note="{{ entry.note|default('')|e('html_attr') }}">
data-note="{{ entry.note|default('')|e('html_attr') }}"
data-invoiced="{{ entry.invoiced ? 'true' : 'false' }}">

<div class="entry-row__display">
<div class="entry-row__info">
@@ -19,56 +20,64 @@
</div>
<div class="entry-row__actions">
<span class="entry-row__badge">{{ entry.durationFormatted }}</span>
<button class="entry-row__btn entry-row__btn--edit"
title="{{ 'app.entry.btn_edit'|trans }}"
data-action="edit">
{% include '_atoms/icon-edit.html.twig' %}
</button>
<button class="entry-row__btn entry-row__btn--delete"
title="{{ 'app.entry.btn_delete'|trans }}"
data-action="delete">
{% include '_atoms/icon-delete.html.twig' %}
</button>
{% if entry.invoiced %}
<span class="entry-row__lock-indicator" title="{{ 'app.entry.invoiced_title'|trans }}">
{% include '_atoms/icon-lock.html.twig' %}
</span>
{% else %}
<button class="entry-row__btn entry-row__btn--edit"
title="{{ 'app.entry.btn_edit'|trans }}"
data-action="edit">
{% include '_atoms/icon-edit.html.twig' %}
</button>
<button class="entry-row__btn entry-row__btn--delete"
title="{{ 'app.entry.btn_delete'|trans }}"
data-action="delete">
{% include '_atoms/icon-delete.html.twig' %}
</button>
{% endif %}
</div>
</div>

<div class="entry-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">
{% if not entry.invoiced %}
<div class="entry-row__edit" hidden>
<div class="entry-form__grid entry-form__grid--inline">

<label class="entry-form__label">{{ 'app.entry.label_duration'|trans }}</label>
<div class="entry-form__field">
<input type="text"
class="input input--sm edit-duration"
value="{{ entry.durationFormatted }}"
autocomplete="off" />
{% include '_atoms/duration-help.html.twig' %}
</div>
<label class="entry-form__label">{{ 'app.entry.label_duration'|trans }}</label>
<div class="entry-form__field">
<input type="text"
class="input input--sm edit-duration"
value="{{ entry.durationFormatted }}"
autocomplete="off" />
{% include '_atoms/duration-help.html.twig' %}
</div>

<label class="entry-form__label">{{ 'app.entry.label_project_service'|trans }}</label>
<div class="entry-form__field entry-form__field--selects">
<select class="select edit-project">
{# Wird per JS befüllt #}
</select>
<select class="select edit-service">
{# Wird per JS befüllt #}
</select>
</div>
<label class="entry-form__label">{{ 'app.entry.label_project_service'|trans }}</label>
<div class="entry-form__field entry-form__field--selects">
<select class="select edit-project">
{# Wird per JS befüllt #}
</select>
<select class="select edit-service">
{# Wird per JS befüllt #}
</select>
</div>

<label class="entry-form__label">{{ 'app.entry.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="3">{{ entry.note|default('') }}</textarea>
</div>
<label class="entry-form__label">{{ 'app.entry.label_note'|trans }}</label>
<div class="entry-form__field">
<textarea class="textarea edit-note" rows="3">{{ entry.note|default('') }}</textarea>
</div>

<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">
{{ 'app.entry.btn_save'|trans }}
</button>
<button type="button" class="btn btn-secondary" data-action="cancel">
{{ 'app.entry.btn_cancel'|trans }}
</button>
</div>
<div class="entry-form__actions">
<button type="button" class="btn btn-primary" data-action="save">
{{ 'app.entry.btn_save'|trans }}
</button>
<button type="button" class="btn btn-secondary" data-action="cancel">
{{ 'app.entry.btn_cancel'|trans }}
</button>
</div>

</div>
</div>
</div>
{% endif %}

</div>

+ 1
- 0
httpdocs/templates/timetracking/week.html.twig Просмотреть файл

@@ -74,6 +74,7 @@ window.TT = {
errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }},
errorDurationTooLong: {{ 'app.entry.error_duration_too_long'|trans|json_encode|raw }},
warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }},
invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }},
},
};
</script>


+ 22
- 0
httpdocs/translations/messages.de.yaml Просмотреть файл

@@ -63,6 +63,28 @@ app:
tab_archived: "Archiviert"
btn_restore: "Wiederherstellen"

report:
page_title: "Reports: Zeiten"
heading: "Reports: Zeiten"
tab_times: "Zeiten"
tab_projects: "Projekte"
toolbar_filter: "Filtern/Gruppieren"
toolbar_edit: "Einträge bearbeiten"
col_date: "Datum"
col_client: "Kunde"
col_project: "Projekt"
col_service: "Leistung"
col_user: "Benutzer"
col_note: "Bemerkung"
col_hours: "Stunden"
col_revenue: "Umsatz"
show: "Anzeigen:"
of_total: "von insgesamt %count% Einträgen"
no_entries: "Keine Einträge vorhanden."
btn_lock: "Als abgerechnet markieren"
btn_unlock: "Abrechnung aufheben"
invoiced_title: "Abgerechnet – Bearbeiten nicht möglich"

login:
page_title: "Anmelden – spawntree"
label_email: "E-Mail"


+ 1
- 0
httpdocs/webpack.config.js Просмотреть файл

@@ -25,6 +25,7 @@ Encore
.addEntry('registration', './assets/scripts/registration.js')
.addEntry('team', './assets/scripts/team.js')
.addEntry('account', './assets/scripts/account.js')
.addEntry('report', './assets/scripts/report.js')

// When enabled, Webpack "splits" your files into smaller pieces for greater optimization.
.splitEntryChunks()


Загрузка…
Отмена
Сохранить