| @@ -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!" | |||||
| @@ -4,6 +4,8 @@ import { parseDuration, roundToQuarter, formatMinutes, initDurationBlurHandler, | |||||
| const LAST_PROJECT_KEY = 'tt_last_project_id'; | const LAST_PROJECT_KEY = 'tt_last_project_id'; | ||||
| const LAST_SERVICE_KEY = 'tt_last_service_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) { | function t(key) { | ||||
| return window.TT?.i18n?.[key] ?? key; | return window.TT?.i18n?.[key] ?? key; | ||||
| } | } | ||||
| @@ -57,32 +59,20 @@ function buildServiceOptions(selectedId = null) { | |||||
| function buildEntryRowHTML(entry, animate = false) { | function buildEntryRowHTML(entry, animate = false) { | ||||
| const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; | const servicePart = entry.serviceName ? ` / ${entry.serviceName}` : ''; | ||||
| const notePart = entry.note ? `<div class="entry-row__note">${entry.note}</div>` : ''; | 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, '"')}"> | |||||
| <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-row__edit" hidden> | ||||
| <div class="entry-form__grid entry-form__grid--inline"> | <div class="entry-form__grid entry-form__grid--inline"> | ||||
| <label class="entry-form__label">${t('labelDuration')}</label> | <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> | <button type="button" class="btn btn-secondary" data-action="cancel">${t('btnCancel')}</button> | ||||
| </div> | </div> | ||||
| </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, '"')}" | |||||
| 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> | </div> | ||||
| ${editFormHtml} | |||||
| </div>`; | </div>`; | ||||
| } | } | ||||
| @@ -150,20 +161,30 @@ class EntryManager { | |||||
| this.list.addEventListener('click', e => this.handleListClick(e)); | this.list.addEventListener('click', e => this.handleListClick(e)); | ||||
| document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); | document.getElementById('btn-create')?.addEventListener('click', () => this.createEntry()); | ||||
| this.checkAutoEdit(); | |||||
| } | } | ||||
| handleListClick(e) { | 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; | 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) { | 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__display').hidden = true; | ||||
| row.querySelector('.entry-row__edit').hidden = false; | |||||
| editSection.hidden = false; | |||||
| row.querySelector('.edit-duration')?.focus(); | row.querySelector('.edit-duration')?.focus(); | ||||
| } | } | ||||
| @@ -259,6 +286,20 @@ class EntryManager { | |||||
| row.querySelector('.entry-row__edit').hidden = true; | 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) { | async saveEdit(row) { | ||||
| const id = row.dataset.id; | const id = row.dataset.id; | ||||
| const durationRaw = row.querySelector('.edit-duration')?.value ?? '0:00'; | 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-project').innerHTML = buildProjectOptions(row.dataset.projectId); | ||||
| row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId); | row.querySelector('.edit-service').innerHTML = buildServiceOptions(row.dataset.serviceId); | ||||
| }); | }); | ||||
| this.checkAutoEdit(); | |||||
| } | } | ||||
| updateTotal(totalDuration) { | updateTotal(totalDuration) { | ||||
| @@ -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); | |||||
| } | |||||
| }); | |||||
| }); | |||||
| @@ -154,6 +154,33 @@ | |||||
| @media (hover: none) { opacity: 1; } | @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 ───────────────────────────────────────────────────── | // ─── Bearbeiten-Modus ───────────────────────────────────────────────────── | ||||
| .entry-row__edit { | .entry-row__edit { | ||||
| padding: $space-4 $space-8; | padding: $space-4 $space-8; | ||||
| @@ -21,6 +21,7 @@ | |||||
| // ─── Sections ───────────────────────────────────────────────────────────────── | // ─── Sections ───────────────────────────────────────────────────────────────── | ||||
| @use 'sections/timetracking'; | @use 'sections/timetracking'; | ||||
| @use 'sections/home'; | @use 'sections/home'; | ||||
| @use 'sections/report'; | |||||
| // ─── Reset / Base ───────────────────────────────────────────────────────────── | // ─── Reset / Base ───────────────────────────────────────────────────────────── | ||||
| *, | *, | ||||
| @@ -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 | |||||
| } | |||||
| @@ -31,11 +31,20 @@ services: | |||||
| arguments: | arguments: | ||||
| $em: '@doctrine.orm.tenant_entity_manager' | $em: '@doctrine.orm.tenant_entity_manager' | ||||
| App\Controller\ReportController: | |||||
| arguments: | |||||
| $tenantEm: '@doctrine.orm.tenant_entity_manager' | |||||
| App\Command\SeedCommand: | App\Command\SeedCommand: | ||||
| arguments: | arguments: | ||||
| $centralEm: '@doctrine.orm.central_entity_manager' | $centralEm: '@doctrine.orm.central_entity_manager' | ||||
| $tenantEm: '@doctrine.orm.tenant_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.domain in Subscriber injizieren ─────────────────────────────────── | ||||
| App\EventSubscriber\TenantRequestSubscriber: | App\EventSubscriber\TenantRequestSubscriber: | ||||
| arguments: | arguments: | ||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -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); | |||||
| } | |||||
| } | |||||
| @@ -37,6 +37,9 @@ class TimeEntry | |||||
| #[ORM\Column(type: Types::TEXT, nullable: true)] | #[ORM\Column(type: Types::TEXT, nullable: true)] | ||||
| private ?string $note = null; | private ?string $note = null; | ||||
| #[ORM\Column] | |||||
| private bool $invoiced = false; | |||||
| #[ORM\Column] | #[ORM\Column] | ||||
| private \DateTimeImmutable $createdAt; | private \DateTimeImmutable $createdAt; | ||||
| @@ -81,6 +84,9 @@ class TimeEntry | |||||
| public function getNote(): ?string { return $this->note; } | public function getNote(): ?string { return $this->note; } | ||||
| public function setNote(?string $note): static { $this->note = $note; return $this; } | 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 getCreatedAt(): \DateTimeImmutable { return $this->createdAt; } | ||||
| public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } | public function getUpdatedAt(): \DateTimeImmutable { return $this->updatedAt; } | ||||
| @@ -97,6 +103,7 @@ class TimeEntry | |||||
| 'serviceName' => $this->service?->getName(), | 'serviceName' => $this->service?->getName(), | ||||
| 'serviceBillable' => $this->service?->isBillable(), | 'serviceBillable' => $this->service?->isBillable(), | ||||
| 'note' => $this->note, | 'note' => $this->note, | ||||
| 'invoiced' => $this->invoiced, | |||||
| ]; | ]; | ||||
| } | } | ||||
| } | } | ||||
| @@ -46,6 +46,58 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (int) $result; | 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 | public function countByProject(Project $project): int | ||||
| { | { | ||||
| return (int) $this->createQueryBuilder('t') | return (int) $this->createQueryBuilder('t') | ||||
| @@ -86,4 +138,4 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| ->getQuery() | ->getQuery() | ||||
| ->getSingleScalarResult(); | ->getSingleScalarResult(); | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -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> | |||||
| @@ -7,7 +7,10 @@ | |||||
| class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | class="main-nav__item{% if currentRoute starts with 'timetracking' %} main-nav__item--active{% endif %}"> | ||||
| {{ 'app.nav.time_tracking'|trans }} | {{ 'app.nav.time_tracking'|trans }} | ||||
| </a> | </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> | ||||
| <div class="main-nav__right"> | <div class="main-nav__right"> | ||||
| {% if isCurrentUserMemberOrAdmin() %} | {% if isCurrentUserMemberOrAdmin() %} | ||||
| @@ -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 %} | |||||
| @@ -1,11 +1,12 @@ | |||||
| {# templates/timetracking/_entry_row.html.twig #} | {# 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 }}" | id="entry-{{ entry.id }}" | ||||
| data-id="{{ entry.id }}" | data-id="{{ entry.id }}" | ||||
| data-duration="{{ entry.duration }}" | data-duration="{{ entry.duration }}" | ||||
| data-project-id="{{ entry.project.id }}" | data-project-id="{{ entry.project.id }}" | ||||
| data-service-id="{{ entry.service ? entry.service.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__display"> | ||||
| <div class="entry-row__info"> | <div class="entry-row__info"> | ||||
| @@ -19,56 +20,64 @@ | |||||
| </div> | </div> | ||||
| <div class="entry-row__actions"> | <div class="entry-row__actions"> | ||||
| <span class="entry-row__badge">{{ entry.durationFormatted }}</span> | <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> | </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> | ||||
| </div> | |||||
| {% endif %} | |||||
| </div> | </div> | ||||
| @@ -74,6 +74,7 @@ window.TT = { | |||||
| errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }}, | errorZeroDuration: {{ 'app.entry.error_zero_duration'|trans|json_encode|raw }}, | ||||
| errorDurationTooLong: {{ 'app.entry.error_duration_too_long'|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 }}, | warnDurationLong: {{ 'app.entry.warn_duration_long'|trans|json_encode|raw }}, | ||||
| invoicedTitle: {{ 'app.entry.invoiced_title'|trans|json_encode|raw }}, | |||||
| }, | }, | ||||
| }; | }; | ||||
| </script> | </script> | ||||
| @@ -63,6 +63,28 @@ app: | |||||
| tab_archived: "Archiviert" | tab_archived: "Archiviert" | ||||
| btn_restore: "Wiederherstellen" | 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: | login: | ||||
| page_title: "Anmelden – spawntree" | page_title: "Anmelden – spawntree" | ||||
| label_email: "E-Mail" | label_email: "E-Mail" | ||||
| @@ -25,6 +25,7 @@ Encore | |||||
| .addEntry('registration', './assets/scripts/registration.js') | .addEntry('registration', './assets/scripts/registration.js') | ||||
| .addEntry('team', './assets/scripts/team.js') | .addEntry('team', './assets/scripts/team.js') | ||||
| .addEntry('account', './assets/scripts/account.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. | // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. | ||||
| .splitEntryChunks() | .splitEntryChunks() | ||||