| @@ -16,7 +16,11 @@ | |||
| "Bash(echo \"exit: $?\")", | |||
| "WebSearch", | |||
| "WebFetch(domain:developers.lexoffice.io)", | |||
| "WebFetch(domain:developers.lexware.io)" | |||
| "WebFetch(domain:developers.lexware.io)", | |||
| "Skill(run)", | |||
| "Bash(ddev describe *)", | |||
| "Bash(curl *)", | |||
| "Bash(ddev mysql *)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -523,30 +523,135 @@ function initPrintButton() { | |||
| }); | |||
| } | |||
| // ── Lexoffice Invoice ──────────────────────────────────────────────────────── | |||
| // ── Lexoffice Invoice Modal ────────────────────────────────────────────────── | |||
| function initLexofficeInvoiceButton() { | |||
| const btn = document.getElementById('btn-lexoffice-invoice'); | |||
| if (!btn) return; | |||
| const originalTitle = btn.title; | |||
| const modal = document.getElementById('invoice-modal'); | |||
| const closeBtn = document.getElementById('invoice-modal-close'); | |||
| const createBtn = document.getElementById('invoice-modal-create'); | |||
| const preview = document.getElementById('invoice-preview'); | |||
| if (!modal || !preview) return; | |||
| const contactId = btn.dataset.contactId; | |||
| const clientName = btn.dataset.clientName; | |||
| const dateFrom = btn.dataset.dateFrom; | |||
| const dateTo = btn.dataset.dateTo; | |||
| let currentItems = []; | |||
| function getFilterParams() { | |||
| const params = new URLSearchParams(window.location.search); | |||
| params.delete('limit'); | |||
| params.delete('sort'); | |||
| params.delete('dir'); | |||
| return params; | |||
| } | |||
| function formatNumber(n) { | |||
| return n.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); | |||
| } | |||
| function renderPreview(items) { | |||
| currentItems = items; | |||
| createBtn.disabled = items.length === 0; | |||
| btn.addEventListener('click', async () => { | |||
| if (btn.disabled) return; | |||
| if (items.length === 0) { | |||
| preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceNoItems'))}</div>`; | |||
| return; | |||
| } | |||
| let totalRevenue = 0; | |||
| const rows = items.map(item => { | |||
| const lineTotal = item.hours * item.rate; | |||
| totalRevenue += lineTotal; | |||
| const descHtml = item.description | |||
| ? `<div class="invoice-preview__desc">${esc(item.description)}</div>` | |||
| : ''; | |||
| return `<div class="invoice-preview__row"> | |||
| <div class="invoice-preview__cell invoice-preview__cell--name">${esc(item.name)}${descHtml}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.hours)}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceUnitHour'))}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(item.rate)} €</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${formatNumber(lineTotal)} €</div> | |||
| </div>`; | |||
| }).join(''); | |||
| preview.innerHTML = ` | |||
| <div class="invoice-preview__table"> | |||
| <div class="invoice-preview__head"> | |||
| <div class="invoice-preview__cell invoice-preview__cell--name">${esc(t('invoiceColName'))}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColHours'))}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--unit">${esc(t('invoiceColUnit'))}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColRate'))}</div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num">${esc(t('invoiceColTotal'))}</div> | |||
| </div> | |||
| ${rows} | |||
| <div class="invoice-preview__foot"> | |||
| <div class="invoice-preview__cell invoice-preview__cell--name"></div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num"></div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--unit"></div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num"></div> | |||
| <div class="invoice-preview__cell invoice-preview__cell--num invoice-preview__cell--total">${formatNumber(totalRevenue)} €</div> | |||
| </div> | |||
| </div>`; | |||
| } | |||
| const contactId = btn.dataset.contactId; | |||
| const clientName = btn.dataset.clientName; | |||
| const dateFrom = btn.dataset.dateFrom; | |||
| const dateTo = btn.dataset.dateTo; | |||
| async function loadPreview(groupBy) { | |||
| preview.innerHTML = `<div class="invoice-modal__loading">${esc(t('invoiceLoading'))}</div>`; | |||
| createBtn.disabled = true; | |||
| btn.disabled = true; | |||
| btn.title = t('invoiceCreating'); | |||
| const params = getFilterParams(); | |||
| params.set('groupBy', groupBy); | |||
| try { | |||
| const res = await fetch(`/api/lexoffice/invoice-preview?${params}`); | |||
| if (!res.ok) throw new Error(); | |||
| const items = await res.json(); | |||
| renderPreview(items); | |||
| } catch { | |||
| preview.innerHTML = `<div class="invoice-modal__empty">${esc(t('invoiceError'))}</div>`; | |||
| } | |||
| } | |||
| function openModal() { | |||
| modal.hidden = false; | |||
| const checked = modal.querySelector('input[name="invoice-group"]:checked'); | |||
| loadPreview(checked?.value ?? 'service'); | |||
| } | |||
| function closeModal() { | |||
| modal.hidden = true; | |||
| } | |||
| btn.addEventListener('click', openModal); | |||
| closeBtn.addEventListener('click', closeModal); | |||
| modal.addEventListener('click', e => { | |||
| if (e.target === modal) closeModal(); | |||
| }); | |||
| modal.querySelectorAll('input[name="invoice-group"]').forEach(radio => { | |||
| radio.addEventListener('change', () => loadPreview(radio.value)); | |||
| }); | |||
| createBtn.addEventListener('click', async () => { | |||
| if (createBtn.disabled || currentItems.length === 0) return; | |||
| createBtn.disabled = true; | |||
| createBtn.textContent = t('invoiceCreating'); | |||
| const lineItems = currentItems.map(item => { | |||
| const li = { name: item.name, quantity: item.hours, unitPrice: item.rate }; | |||
| if (item.description) li.description = item.description; | |||
| return li; | |||
| }); | |||
| try { | |||
| const res = await fetch('/api/lexoffice/invoices', { | |||
| method: 'POST', | |||
| headers: { 'Content-Type': 'application/json' }, | |||
| body: JSON.stringify({ contactId, dateFrom, dateTo }), | |||
| body: JSON.stringify({ contactId, dateFrom, dateTo, lineItems }), | |||
| }); | |||
| if (!res.ok) { | |||
| @@ -556,19 +661,30 @@ function initLexofficeInvoiceButton() { | |||
| } | |||
| const data = await res.json(); | |||
| const invoiceId = data.id; | |||
| const markCb = document.getElementById('invoice-modal-mark-invoiced'); | |||
| if (markCb?.checked) { | |||
| const filterParams = getFilterParams(); | |||
| await fetch(`/api/entries/mark-invoiced?${filterParams}`, { method: 'POST' }).catch(() => {}); | |||
| } | |||
| closeModal(); | |||
| const msg = t('invoiceSuccess').replace('%client%', clientName); | |||
| const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`); | |||
| if (openInLexoffice && invoiceId) { | |||
| window.open(`https://app.lexware.de/permalink/invoices/edit/${invoiceId}`, '_blank'); | |||
| if (openInLexoffice && data.id) { | |||
| window.open(`https://app.lexware.de/permalink/invoices/edit/${data.id}`, '_blank'); | |||
| } | |||
| if (markCb?.checked) { | |||
| window.location.reload(); | |||
| } | |||
| } catch { | |||
| alert(t('invoiceError')); | |||
| } finally { | |||
| btn.disabled = false; | |||
| btn.title = originalTitle; | |||
| createBtn.disabled = false; | |||
| createBtn.textContent = t('invoiceBtnCreate'); | |||
| } | |||
| }); | |||
| } | |||
| @@ -0,0 +1,238 @@ | |||
| import '../styles/main.scss'; | |||
| import { | |||
| Chart, | |||
| BarController, | |||
| BarElement, | |||
| CategoryScale, | |||
| LinearScale, | |||
| Tooltip, | |||
| } from 'chart.js'; | |||
| import { createTranslator } from './utils.js'; | |||
| Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip); | |||
| const t = createTranslator('Statistics'); | |||
| const months = window.Statistics.monthsShort; | |||
| const weekdays = window.Statistics.weekdaysShort; | |||
| let chart = null; | |||
| let cachedData = null; | |||
| function formatLabel(key, groupBy) { | |||
| if (groupBy === 'month') { | |||
| const [year, month] = key.split('-'); | |||
| return months[parseInt(month, 10) - 1] + ' ' + year.slice(2); | |||
| } | |||
| if (groupBy === 'week') { | |||
| const week = key.split('-')[1]; | |||
| return t('weekShort') + ' ' + parseInt(week, 10); | |||
| } | |||
| const d = new Date(key + 'T00:00:00'); | |||
| const dayOfWeek = weekdays[(d.getDay() + 6) % 7]; | |||
| const day = d.getDate(); | |||
| const mon = months[d.getMonth()]; | |||
| return dayOfWeek + ' ' + day + '. ' + mon; | |||
| } | |||
| function formatHours(value) { | |||
| const h = Math.floor(value); | |||
| const m = Math.round((value - h) * 60); | |||
| return h + ':' + String(m).padStart(2, '0'); | |||
| } | |||
| function formatCurrency(value) { | |||
| return value.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' €'; | |||
| } | |||
| function getWeekNumber(dateStr) { | |||
| const d = new Date(dateStr + 'T00:00:00'); | |||
| const dayNum = d.getDay() || 7; | |||
| d.setDate(d.getDate() + 4 - dayNum); | |||
| const yearStart = new Date(d.getFullYear(), 0, 1); | |||
| return Math.ceil((((d - yearStart) / 86400000) + 1) / 7); | |||
| } | |||
| function getBrandColor() { | |||
| return getComputedStyle(document.documentElement) | |||
| .getPropertyValue('--color-primary').trim() || '#4a90d9'; | |||
| } | |||
| function renderChart(data, metric) { | |||
| const canvas = document.getElementById('stats-chart'); | |||
| if (!canvas) return; | |||
| if (chart) { | |||
| chart.destroy(); | |||
| chart = null; | |||
| } | |||
| const isRevenue = metric === 'revenue'; | |||
| const brandColor = getBrandColor(); | |||
| const greyColor = 'rgba(170, 184, 198, 0.55)'; | |||
| const labels = data.labels.map(l => formatLabel(l, data.groupBy)); | |||
| const dataset1 = isRevenue ? data.billableRevenue : data.billable; | |||
| const dataset2 = isRevenue ? data.nonBillableRevenue : data.nonBillable; | |||
| let weekBands = []; | |||
| if (data.groupBy === 'day') { | |||
| let currentWeek = null; | |||
| let bandStart = null; | |||
| let bandIdx = 0; | |||
| data.labels.forEach((key, i) => { | |||
| const wk = getWeekNumber(key); | |||
| if (wk !== currentWeek) { | |||
| if (currentWeek !== null && bandIdx % 2 === 1) { | |||
| weekBands.push([bandStart, i - 1]); | |||
| } | |||
| currentWeek = wk; | |||
| bandStart = i; | |||
| bandIdx++; | |||
| } | |||
| }); | |||
| if (bandIdx % 2 === 1) { | |||
| weekBands.push([bandStart, data.labels.length - 1]); | |||
| } | |||
| } | |||
| chart = new Chart(canvas, { | |||
| type: 'bar', | |||
| data: { | |||
| labels, | |||
| datasets: [ | |||
| { | |||
| label: t('billable'), | |||
| data: dataset1, | |||
| backgroundColor: brandColor, | |||
| borderRadius: 3, | |||
| borderSkipped: false, | |||
| }, | |||
| { | |||
| label: t('nonBillable'), | |||
| data: dataset2, | |||
| backgroundColor: greyColor, | |||
| borderRadius: 3, | |||
| borderSkipped: false, | |||
| }, | |||
| ], | |||
| }, | |||
| options: { | |||
| responsive: true, | |||
| maintainAspectRatio: false, | |||
| animation: { | |||
| duration: 600, | |||
| easing: 'easeOutQuart', | |||
| }, | |||
| interaction: { | |||
| mode: 'index', | |||
| intersect: false, | |||
| }, | |||
| plugins: { | |||
| legend: { display: false }, | |||
| tooltip: { | |||
| backgroundColor: '#1a2a3a', | |||
| titleFont: { weight: '600' }, | |||
| bodyFont: { size: 13 }, | |||
| padding: 10, | |||
| cornerRadius: 6, | |||
| callbacks: { | |||
| label(ctx) { | |||
| if (isRevenue) { | |||
| return ctx.dataset.label + ': ' + formatCurrency(ctx.parsed.y); | |||
| } | |||
| return ctx.dataset.label + ': ' + formatHours(ctx.parsed.y) + ' ' + t('hours'); | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| scales: { | |||
| x: { | |||
| grid: { display: false }, | |||
| ticks: { | |||
| font: { size: 11 }, | |||
| maxRotation: data.groupBy === 'day' ? 45 : 0, | |||
| autoSkip: true, | |||
| autoSkipPadding: 8, | |||
| }, | |||
| }, | |||
| y: { | |||
| beginAtZero: true, | |||
| grid: { color: 'rgba(0,0,0,0.06)' }, | |||
| ticks: { | |||
| font: { size: 11 }, | |||
| callback(value) { | |||
| if (isRevenue) return formatCurrency(value); | |||
| return formatHours(value); | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| plugins: data.groupBy === 'day' ? [{ | |||
| id: 'weekBands', | |||
| beforeDraw(chartInstance) { | |||
| const { ctx, chartArea, scales } = chartInstance; | |||
| if (!chartArea || weekBands.length === 0) return; | |||
| ctx.save(); | |||
| ctx.fillStyle = 'rgba(0, 0, 0, 0.03)'; | |||
| weekBands.forEach(([start, end]) => { | |||
| const x1 = scales.x.getPixelForValue(start) - (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2; | |||
| const x2 = scales.x.getPixelForValue(end) + (scales.x.getPixelForValue(1) - scales.x.getPixelForValue(0)) / 2; | |||
| ctx.fillRect(x1, chartArea.top, x2 - x1, chartArea.bottom - chartArea.top); | |||
| }); | |||
| ctx.restore(); | |||
| }, | |||
| }] : [], | |||
| }); | |||
| } | |||
| async function loadAndRender(range, metric, userId) { | |||
| const wrap = document.getElementById('stats-chart-wrap'); | |||
| if (!wrap) return; | |||
| let url = '/api/statistics?range=' + encodeURIComponent(range); | |||
| if (userId) { | |||
| url += '&userId=' + encodeURIComponent(userId); | |||
| } | |||
| try { | |||
| const res = await fetch(url); | |||
| if (!res.ok) throw new Error(res.statusText); | |||
| cachedData = await res.json(); | |||
| renderChart(cachedData, metric); | |||
| } catch { | |||
| wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | |||
| } | |||
| } | |||
| function getSelectedUserId() { | |||
| const el = document.getElementById('stats-user-select'); | |||
| return el ? el.value : ''; | |||
| } | |||
| document.addEventListener('DOMContentLoaded', () => { | |||
| const rangeSelect = document.getElementById('stats-range-select'); | |||
| const metricSelect = document.getElementById('stats-metric-select'); | |||
| const userSelect = document.getElementById('stats-user-select'); | |||
| if (!rangeSelect || !metricSelect) return; | |||
| loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); | |||
| rangeSelect.addEventListener('change', () => { | |||
| loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); | |||
| }); | |||
| if (userSelect) { | |||
| userSelect.addEventListener('change', () => { | |||
| loadAndRender(rangeSelect.value, metricSelect.value, getSelectedUserId()); | |||
| }); | |||
| } | |||
| metricSelect.addEventListener('change', () => { | |||
| if (cachedData) { | |||
| renderChart(cachedData, metricSelect.value); | |||
| } | |||
| }); | |||
| }); | |||
| @@ -24,6 +24,7 @@ | |||
| @use 'sections/timetracking'; | |||
| @use 'sections/home'; | |||
| @use 'sections/report'; | |||
| @use 'sections/statistics'; | |||
| // ─── Themes ─────────────────────────────────────────────────────────────────── | |||
| @use 'themes/minimal'; | |||
| @@ -34,29 +34,6 @@ | |||
| } | |||
| } | |||
| // ─── 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: var(--header-text); | |||
| background: var(--header-overlay); | |||
| border: 1px solid var(--header-overlay); | |||
| 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; | |||
| @@ -745,3 +722,154 @@ button.report-toolbar__action { | |||
| font-weight: $font-weight-medium; | |||
| } | |||
| } | |||
| // ─── Invoice Modal ─────────────────────────────────────────────────────────── | |||
| .invoice-modal { | |||
| max-width: 620px; | |||
| max-height: 90vh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| } | |||
| .invoice-modal .modal-card__body { | |||
| overflow-y: auto; | |||
| min-height: 0; | |||
| } | |||
| .invoice-modal__group { | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: $space-2; | |||
| } | |||
| .invoice-modal__group-label { | |||
| font-size: $font-size-sm; | |||
| font-weight: $font-weight-medium; | |||
| color: $color-text-dark; | |||
| } | |||
| .invoice-modal__radios { | |||
| display: flex; | |||
| gap: $space-5; | |||
| flex-wrap: wrap; | |||
| } | |||
| .invoice-modal__radio { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: $space-2; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-base; | |||
| cursor: pointer; | |||
| user-select: none; | |||
| input[type='radio'] { | |||
| accent-color: var(--color-primary); | |||
| width: 15px; | |||
| height: 15px; | |||
| cursor: pointer; | |||
| } | |||
| } | |||
| .invoice-modal__invoiced-check { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: $space-2; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-base; | |||
| cursor: pointer; | |||
| user-select: none; | |||
| margin-right: auto; | |||
| input[type='checkbox'] { | |||
| accent-color: var(--color-primary); | |||
| width: 15px; | |||
| height: 15px; | |||
| cursor: pointer; | |||
| } | |||
| } | |||
| .invoice-modal__preview { | |||
| min-height: 80px; | |||
| } | |||
| .invoice-modal__loading, | |||
| .invoice-modal__empty { | |||
| padding: $space-6; | |||
| text-align: center; | |||
| color: $color-text-muted; | |||
| font-size: $font-size-sm; | |||
| } | |||
| // ─── Invoice Preview Table ─────────────────────────────────────────────────── | |||
| .invoice-preview__table { | |||
| border: 1px solid $color-border; | |||
| border-radius: $radius-md; | |||
| overflow: hidden; | |||
| } | |||
| .invoice-preview__head, | |||
| .invoice-preview__row, | |||
| .invoice-preview__foot { | |||
| display: grid; | |||
| grid-template-columns: 1fr 80px 70px 100px 100px; | |||
| align-items: center; | |||
| padding: $space-2 $space-4; | |||
| gap: $space-2; | |||
| } | |||
| .invoice-preview__head { | |||
| background: rgba($color-border, 0.3); | |||
| border-bottom: 1px solid $color-border; | |||
| .invoice-preview__cell { | |||
| font-size: $font-size-xs; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-muted; | |||
| text-transform: uppercase; | |||
| letter-spacing: 0.03em; | |||
| } | |||
| } | |||
| .invoice-preview__row { | |||
| border-bottom: 1px solid rgba($color-border, 0.6); | |||
| &:last-child { border-bottom: none; } | |||
| } | |||
| .invoice-preview__foot { | |||
| border-top: 1px solid $color-border; | |||
| background: rgba($color-border, 0.15); | |||
| } | |||
| .invoice-preview__cell { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-base; | |||
| &--name { | |||
| @include text-truncate; | |||
| } | |||
| &--num { | |||
| text-align: right; | |||
| font-variant-numeric: tabular-nums; | |||
| white-space: nowrap; | |||
| } | |||
| &--unit { | |||
| color: $color-text-muted; | |||
| } | |||
| &--total { | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| } | |||
| } | |||
| .invoice-preview__desc { | |||
| margin-top: $space-1; | |||
| font-size: $font-size-xs; | |||
| color: $color-text-muted; | |||
| white-space: pre-line; | |||
| line-height: 1.5; | |||
| } | |||
| @@ -0,0 +1,142 @@ | |||
| @use '../atoms/variables' as *; | |||
| @use '../atoms/mixins' as *; | |||
| // ─── Statistiken-Bubble (Report-Header) ───────────────────────────────────── | |||
| .report-stats-bubble { | |||
| 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: var(--header-text-muted); | |||
| background: var(--header-overlay); | |||
| border: 1px solid var(--header-overlay); | |||
| border-radius: $radius-pill; | |||
| backdrop-filter: blur(6px); | |||
| -webkit-backdrop-filter: blur(6px); | |||
| white-space: nowrap; | |||
| text-decoration: none; | |||
| transition: background $transition-fast, color $transition-fast; | |||
| cursor: pointer; | |||
| &:hover { | |||
| color: var(--header-text); | |||
| background: rgba(255, 255, 255, 0.28); | |||
| } | |||
| &--active { | |||
| color: var(--header-text); | |||
| background: rgba(255, 255, 255, 0.28); | |||
| border-color: rgba(255, 255, 255, 0.35); | |||
| } | |||
| &__icon { | |||
| width: 16px; | |||
| height: 16px; | |||
| flex-shrink: 0; | |||
| } | |||
| } | |||
| // ─── Toolbar ──────────────────────────────────────────────────────────────── | |||
| .statistics-toolbar { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: space-between; | |||
| padding: $space-4 $space-5; | |||
| border-bottom: 1px solid $color-border; | |||
| @include tablet { | |||
| flex-wrap: wrap; | |||
| gap: $space-3; | |||
| } | |||
| } | |||
| .statistics-toolbar__title { | |||
| font-size: $font-size-md; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| } | |||
| .statistics-toolbar__controls { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-3; | |||
| } | |||
| .statistics-range-select { | |||
| appearance: none; | |||
| background: $color-input-bg url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%237a8a9a'/%3E%3C/svg%3E") no-repeat right 12px center; | |||
| border: 1px solid $color-input-border; | |||
| border-radius: $radius-md; | |||
| padding: $space-2 $space-8 $space-2 $space-3; | |||
| font-size: $font-size-sm; | |||
| font-family: $font-family-base; | |||
| color: $color-text-base; | |||
| cursor: pointer; | |||
| transition: border-color $transition-fast; | |||
| &:hover { | |||
| border-color: var(--color-primary); | |||
| } | |||
| &:focus { | |||
| outline: none; | |||
| border-color: var(--color-primary); | |||
| box-shadow: $shadow-focus; | |||
| } | |||
| } | |||
| // ─── Chart ────────────────────────────────────────────────────────────────── | |||
| .statistics-chart-wrap { | |||
| position: relative; | |||
| height: 400px; | |||
| padding: $space-6 $space-5; | |||
| @include tablet { | |||
| height: 300px; | |||
| padding: $space-4 $space-3; | |||
| } | |||
| } | |||
| .statistics-error { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| height: 100%; | |||
| color: $color-text-muted; | |||
| font-size: $font-size-sm; | |||
| } | |||
| // ─── Legende ──────────────────────────────────────────────────────────────── | |||
| .statistics-legend { | |||
| display: flex; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: $space-6; | |||
| padding: $space-3 $space-5 $space-5; | |||
| } | |||
| .statistics-legend__item { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: $space-2; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| &::before { | |||
| content: ''; | |||
| display: inline-block; | |||
| width: 12px; | |||
| height: 12px; | |||
| border-radius: $radius-sm; | |||
| } | |||
| &--billable::before { | |||
| background: var(--color-primary); | |||
| } | |||
| &--non-billable::before { | |||
| background: $color-text-light; | |||
| } | |||
| } | |||
| @@ -5,6 +5,9 @@ | |||
| "packages": { | |||
| "": { | |||
| "license": "UNLICENSED", | |||
| "dependencies": { | |||
| "chart.js": "^4.5.1" | |||
| }, | |||
| "devDependencies": { | |||
| "@babel/core": "^7.17.0", | |||
| "@babel/preset-env": "^7.16.0", | |||
| @@ -1692,6 +1695,12 @@ | |||
| "webpack": "^5.0.0" | |||
| } | |||
| }, | |||
| "node_modules/@kurkle/color": { | |||
| "version": "0.3.4", | |||
| "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", | |||
| "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", | |||
| "license": "MIT" | |||
| }, | |||
| "node_modules/@parcel/watcher": { | |||
| "version": "2.5.6", | |||
| "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", | |||
| @@ -2800,6 +2809,18 @@ | |||
| "node": ">=8" | |||
| } | |||
| }, | |||
| "node_modules/chart.js": { | |||
| "version": "4.5.1", | |||
| "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", | |||
| "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", | |||
| "license": "MIT", | |||
| "dependencies": { | |||
| "@kurkle/color": "^0.3.0" | |||
| }, | |||
| "engines": { | |||
| "pnpm": ">=8" | |||
| } | |||
| }, | |||
| "node_modules/chokidar": { | |||
| "version": "4.0.3", | |||
| "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", | |||
| @@ -17,5 +17,8 @@ | |||
| "dev": "encore dev", | |||
| "watch": "encore dev --watch", | |||
| "build": "encore production --progress" | |||
| }, | |||
| "dependencies": { | |||
| "chart.js": "^4.5.1" | |||
| } | |||
| } | |||
| @@ -84,13 +84,14 @@ class LexofficeController extends AbstractController | |||
| $contactId = $data['contactId'] ?? ''; | |||
| $dateFrom = $data['dateFrom'] ?? null; | |||
| $dateTo = $data['dateTo'] ?? null; | |||
| $lineItems = $data['lineItems'] ?? null; | |||
| if ($contactId === '') { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400); | |||
| } | |||
| try { | |||
| $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo); | |||
| $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo, $lineItems); | |||
| } catch (\Throwable) { | |||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); | |||
| } | |||
| @@ -106,13 +106,15 @@ class ReportController extends AbstractController | |||
| $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters); | |||
| // Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt | |||
| // und es nicht-abgerechnete Einträge gibt | |||
| $lexofficeInvoice = null; | |||
| if (!$isTracker && $account?->hasLexofficeApiKey()) { | |||
| $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($filters); | |||
| $invoiceFilters = array_merge($filters, ['invoiced' => false]); | |||
| $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($invoiceFilters); | |||
| if (count($distinctClientIds) === 1) { | |||
| $client = $this->clientRepo->find($distinctClientIds[0]); | |||
| if ($client?->isLexofficeClient()) { | |||
| $dateRange = $this->timeEntryRepo->findDateRangeFiltered($filters); | |||
| $dateRange = $this->timeEntryRepo->findDateRangeFiltered($invoiceFilters); | |||
| $lexofficeInvoice = [ | |||
| 'clientId' => $client->getId(), | |||
| 'clientName' => $client->getName(), | |||
| @@ -151,6 +153,63 @@ class ReportController extends AbstractController | |||
| ]); | |||
| } | |||
| // ── Statistiken ───────────────────────────────────────────────────────── | |||
| #[Route('/reports/statistics', name: 'report_statistics')] | |||
| public function statistics(): Response | |||
| { | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| $account = $this->tenantContext->getAccount(); | |||
| $isTracker = $this->roleHelper->isTracker(); | |||
| $userList = []; | |||
| if (!$isTracker) { | |||
| $accountUsers = $this->accountUserRepo->findBy(['account' => $account]); | |||
| foreach ($accountUsers as $au) { | |||
| if (!$au->isArchived()) { | |||
| $userList[] = [ | |||
| 'id' => $au->getUser()->getId(), | |||
| 'name' => $au->getUser()->getFullName(), | |||
| ]; | |||
| } | |||
| } | |||
| } | |||
| return $this->render('report/statistics.html.twig', [ | |||
| 'accountName' => $account?->getName() ?? '', | |||
| 'currentUserId' => $currentUser->getId(), | |||
| 'isTracker' => $isTracker, | |||
| 'userList' => $userList, | |||
| ]); | |||
| } | |||
| #[Route('/api/statistics', name: 'api_statistics', methods: ['GET'])] | |||
| public function statisticsData(Request $request): JsonResponse | |||
| { | |||
| $range = $request->query->get('range', '12months'); | |||
| if (!in_array($range, ['12months', '6months', '4weeks'], true)) { | |||
| $range = '12months'; | |||
| } | |||
| /** @var User $currentUser */ | |||
| $currentUser = $this->security->getUser(); | |||
| $userId = null; | |||
| if ($this->roleHelper->isTracker()) { | |||
| $userId = $currentUser->getId(); | |||
| } else { | |||
| $requestedUserId = $request->query->get('userId'); | |||
| if ($requestedUserId !== null && $requestedUserId !== '') { | |||
| $userId = (int) $requestedUserId; | |||
| } | |||
| } | |||
| $data = $this->timeEntryRepo->getStatisticsData($range, $userId); | |||
| return $this->json($data); | |||
| } | |||
| // ── Excel-Export ───────────────────────────────────────────────────────── | |||
| #[Route('/reports/export/excel', name: 'report_export_excel')] | |||
| @@ -362,6 +421,88 @@ class ReportController extends AbstractController | |||
| return [$from, $to]; | |||
| } | |||
| // ── API: Rechnungs-Vorschau (gruppierte Zeiten) ──────────────────────────── | |||
| #[Route('/api/lexoffice/invoice-preview', name: 'api_lexoffice_invoice_preview', methods: ['GET'])] | |||
| public function invoicePreview(Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $groupBy = $request->query->get('groupBy', 'service'); | |||
| if (!in_array($groupBy, ['service', 'project', 'label'], true)) { | |||
| $groupBy = 'service'; | |||
| } | |||
| $filters = $this->resolveFilters($request); | |||
| $filters['invoiced'] = false; | |||
| $rows = $this->timeEntryRepo->getGroupedForInvoice($filters, $groupBy); | |||
| $grouped = []; | |||
| foreach ($rows as $row) { | |||
| $key = $row['itemName'] ?? ''; | |||
| $totalMinutes = (int) $row['totalMinutes']; | |||
| $totalRevenue = (float) ($row['totalRevenue'] ?? 0); | |||
| $hours = $totalMinutes / 60; | |||
| $rate = $hours > 0 ? round($totalRevenue / $hours, 2) : 0.0; | |||
| $subName = match ($groupBy) { | |||
| 'service' => $row['subName'] ?? '', | |||
| 'project' => $row['subName'] ?? '', | |||
| 'label' => trim(($row['projectName'] ?? '') . (($row['serviceName'] ?? '') !== '' && ($row['serviceName'] ?? '') !== null ? ' / ' . $row['serviceName'] : '')), | |||
| default => '', | |||
| }; | |||
| $grouped[$key][] = [ | |||
| 'subName' => $subName, | |||
| 'clientName' => $row['clientName'] ?? null, | |||
| 'totalMinutes' => $totalMinutes, | |||
| 'rate' => $rate, | |||
| ]; | |||
| } | |||
| $items = []; | |||
| foreach ($grouped as $groupKey => $subRows) { | |||
| $displayName = $groupKey; | |||
| if ($displayName === '' || $displayName === null) { | |||
| $displayName = match ($groupBy) { | |||
| 'service' => $this->translator->trans('app.report.invoice_no_service'), | |||
| 'label' => $this->translator->trans('app.report.invoice_no_label'), | |||
| default => '–', | |||
| }; | |||
| } | |||
| $byRate = []; | |||
| foreach ($subRows as $sub) { | |||
| $rateKey = number_format($sub['rate'], 2, '.', ''); | |||
| if (!isset($byRate[$rateKey])) { | |||
| $byRate[$rateKey] = ['totalMinutes' => 0, 'rate' => $sub['rate'], 'subNames' => []]; | |||
| } | |||
| $byRate[$rateKey]['totalMinutes'] += $sub['totalMinutes']; | |||
| if ($sub['subName'] !== '' && $sub['subName'] !== null) { | |||
| $byRate[$rateKey]['subNames'][] = $sub['subName']; | |||
| } | |||
| } | |||
| foreach ($byRate as $rateGroup) { | |||
| $uniqueSubs = array_unique($rateGroup['subNames']); | |||
| $description = count($uniqueSubs) > 0 | |||
| ? implode("\n", array_map(fn(string $s) => '- ' . $s, $uniqueSubs)) | |||
| : null; | |||
| $items[] = [ | |||
| 'name' => $displayName, | |||
| 'description' => $description, | |||
| 'hours' => round($rateGroup['totalMinutes'] / 60, 2), | |||
| 'rate' => $rateGroup['rate'], | |||
| ]; | |||
| } | |||
| } | |||
| return $this->json($items); | |||
| } | |||
| // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── | |||
| #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] | |||
| @@ -384,6 +525,31 @@ class ReportController extends AbstractController | |||
| return $this->json(['invoiced' => $entry->isInvoiced()]); | |||
| } | |||
| // ── API: Gefilterte Einträge als abgerechnet markieren ────────────────────── | |||
| #[Route('/api/entries/mark-invoiced', name: 'api_entries_mark_invoiced', methods: ['POST'])] | |||
| public function markFilteredInvoiced(Request $request): JsonResponse | |||
| { | |||
| if ($this->roleHelper->isTracker()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $filters = $this->resolveFilters($request); | |||
| $entries = $this->timeEntryRepo->findAllFiltered($filters); | |||
| $count = 0; | |||
| foreach ($entries as $entry) { | |||
| if (!$entry->isInvoiced()) { | |||
| $entry->setInvoiced(true); | |||
| $count++; | |||
| } | |||
| } | |||
| $this->tenantEm->flush(); | |||
| return $this->json(['marked' => $count]); | |||
| } | |||
| // ── Hilfsfunktion ───────────────────────────────────────────────────────── | |||
| private function formatMinutes(int $minutes): string | |||
| @@ -297,6 +297,143 @@ class TimeEntryRepository extends ServiceEntityRepository | |||
| return array_column($rows, 'label'); | |||
| } | |||
| public function getGroupedForInvoice(array $filters, string $groupBy): array | |||
| { | |||
| $qb = $this->buildFilteredQuery($filters); | |||
| $rateExpr = 'COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate)'; | |||
| switch ($groupBy) { | |||
| case 'service': | |||
| $qb->select("s.name AS itemName, p.name AS subName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") | |||
| ->groupBy('s.id, s.name, p.id, p.name') | |||
| ->orderBy('s.name', 'ASC') | |||
| ->addOrderBy('p.name', 'ASC'); | |||
| break; | |||
| case 'project': | |||
| $qb->select("p.name AS itemName, c.name AS clientName, s.name AS subName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") | |||
| ->groupBy('p.id, p.name, c.name, s.id, s.name') | |||
| ->orderBy('p.name', 'ASC') | |||
| ->addOrderBy('s.name', 'ASC'); | |||
| break; | |||
| case 'label': | |||
| $qb->select("t.label AS itemName, p.name AS projectName, s.name AS serviceName, SUM(t.duration) AS totalMinutes, SUM($rateExpr * t.duration / 60) AS totalRevenue") | |||
| ->groupBy('t.label, p.id, p.name, s.id, s.name') | |||
| ->orderBy('t.label', 'ASC') | |||
| ->addOrderBy('p.name', 'ASC'); | |||
| break; | |||
| default: | |||
| return []; | |||
| } | |||
| return $qb->getQuery()->getScalarResult(); | |||
| } | |||
| public function getStatisticsData(string $range, ?int $userId = null): array | |||
| { | |||
| $now = new \DateTimeImmutable('today'); | |||
| [$dateFrom, $groupBy] = match ($range) { | |||
| '6months' => [$now->modify('-6 months +1 day'), 'week'], | |||
| '4weeks' => [$now->modify('-27 days'), 'day'], | |||
| default => [$now->modify('-12 months +1 day'), 'month'], | |||
| }; | |||
| $qb = $this->createQueryBuilder('t') | |||
| ->select('t.date, t.duration, s.billable, p.hourlyRate AS projectRate, c.hourlyRate AS clientRate, s.hourlyRate AS serviceRate') | |||
| ->join('t.project', 'p') | |||
| ->join('p.client', 'c') | |||
| ->leftJoin('t.service', 's') | |||
| ->where('t.date >= :dateFrom') | |||
| ->andWhere('t.date <= :dateTo') | |||
| ->setParameter('dateFrom', $dateFrom->format('Y-m-d')) | |||
| ->setParameter('dateTo', $now->format('Y-m-d')) | |||
| ->orderBy('t.date', 'ASC'); | |||
| if ($userId !== null) { | |||
| $qb->andWhere('t.userId = :userId') | |||
| ->setParameter('userId', $userId); | |||
| } | |||
| $rows = $qb->getQuery()->getResult(); | |||
| $buckets = []; | |||
| foreach ($rows as $row) { | |||
| /** @var \DateTimeImmutable $date */ | |||
| $date = $row['date']; | |||
| $minutes = (int) $row['duration']; | |||
| $billable = $row['billable'] ?? true; | |||
| $rate = (float) ($row['projectRate'] ?? $row['clientRate'] ?? $row['serviceRate'] ?? 0); | |||
| $revenue = $rate * $minutes / 60; | |||
| $key = match ($groupBy) { | |||
| 'week' => $date->format('o-W'), | |||
| 'day' => $date->format('Y-m-d'), | |||
| default => $date->format('Y-m'), | |||
| }; | |||
| if (!isset($buckets[$key])) { | |||
| $buckets[$key] = ['billable' => 0, 'nonBillable' => 0, 'billableRevenue' => 0.0, 'nonBillableRevenue' => 0.0]; | |||
| } | |||
| if ($billable) { | |||
| $buckets[$key]['billable'] += $minutes; | |||
| $buckets[$key]['billableRevenue'] += $revenue; | |||
| } else { | |||
| $buckets[$key]['nonBillable'] += $minutes; | |||
| $buckets[$key]['nonBillableRevenue'] += $revenue; | |||
| } | |||
| } | |||
| $allKeys = $this->generatePeriodKeys($dateFrom, $now, $groupBy); | |||
| $labels = []; | |||
| $billableArr = []; | |||
| $nonBillArr = []; | |||
| $billableRevArr = []; | |||
| $nonBillRevArr = []; | |||
| foreach ($allKeys as $key) { | |||
| $labels[] = $key; | |||
| $billableArr[] = round(($buckets[$key]['billable'] ?? 0) / 60, 2); | |||
| $nonBillArr[] = round(($buckets[$key]['nonBillable'] ?? 0) / 60, 2); | |||
| $billableRevArr[] = round($buckets[$key]['billableRevenue'] ?? 0, 2); | |||
| $nonBillRevArr[] = round($buckets[$key]['nonBillableRevenue'] ?? 0, 2); | |||
| } | |||
| return [ | |||
| 'labels' => $labels, | |||
| 'billable' => $billableArr, | |||
| 'nonBillable' => $nonBillArr, | |||
| 'billableRevenue' => $billableRevArr, | |||
| 'nonBillableRevenue' => $nonBillRevArr, | |||
| 'groupBy' => $groupBy, | |||
| ]; | |||
| } | |||
| private function generatePeriodKeys(\DateTimeImmutable $from, \DateTimeImmutable $to, string $groupBy): array | |||
| { | |||
| $keys = []; | |||
| $cursor = $from; | |||
| while ($cursor <= $to) { | |||
| $key = match ($groupBy) { | |||
| 'week' => $cursor->format('o-W'), | |||
| 'day' => $cursor->format('Y-m-d'), | |||
| default => $cursor->format('Y-m'), | |||
| }; | |||
| if (!in_array($key, $keys, true)) { | |||
| $keys[] = $key; | |||
| } | |||
| $cursor = match ($groupBy) { | |||
| 'week' => $cursor->modify('+7 days'), | |||
| 'day' => $cursor->modify('+1 day'), | |||
| default => $cursor->modify('first day of next month'), | |||
| }; | |||
| } | |||
| return $keys; | |||
| } | |||
| public function sumRevenueFiltered(array $filters): float | |||
| { | |||
| $result = $this->buildFilteredQuery($filters) | |||
| @@ -62,7 +62,10 @@ class LexofficeService | |||
| return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null; | |||
| } | |||
| public function createInvoiceDraft(string $apiKey, string $contactId, ?string $dateFrom = null, ?string $dateTo = null): array | |||
| /** | |||
| * @param array<array{name: string, quantity: float, unitPrice: float}>|null $lineItems | |||
| */ | |||
| public function createInvoiceDraft(string $apiKey, string $contactId, ?string $dateFrom = null, ?string $dateTo = null, ?array $lineItems = null): array | |||
| { | |||
| $now = (new \DateTimeImmutable())->format('Y-m-d\T00:00:00.000P'); | |||
| $shippingStart = $dateFrom | |||
| @@ -72,22 +75,43 @@ class LexofficeService | |||
| ? (new \DateTimeImmutable($dateTo))->format('Y-m-d\T00:00:00.000P') | |||
| : $shippingStart; | |||
| $body = [ | |||
| 'voucherDate' => $now, | |||
| 'address' => ['contactId' => $contactId], | |||
| 'lineItems' => [ | |||
| [ | |||
| $apiLineItems = []; | |||
| if ($lineItems !== null && $lineItems !== []) { | |||
| foreach ($lineItems as $item) { | |||
| $lineItem = [ | |||
| 'type' => 'custom', | |||
| 'name' => 'Leistung', | |||
| 'quantity' => 1, | |||
| 'unitName' => 'Stk', | |||
| 'name' => $item['name'] ?? 'Leistung', | |||
| 'quantity' => round((float) ($item['quantity'] ?? 0), 2), | |||
| 'unitName' => 'Stunde', | |||
| 'unitPrice' => [ | |||
| 'currency' => 'EUR', | |||
| 'netAmount' => 0, | |||
| 'netAmount' => round((float) ($item['unitPrice'] ?? 0), 2), | |||
| 'taxRatePercentage' => 19, | |||
| ], | |||
| ]; | |||
| if (!empty($item['description'])) { | |||
| $lineItem['description'] = $item['description']; | |||
| } | |||
| $apiLineItems[] = $lineItem; | |||
| } | |||
| } else { | |||
| $apiLineItems[] = [ | |||
| 'type' => 'custom', | |||
| 'name' => 'Leistung', | |||
| 'quantity' => 1, | |||
| 'unitName' => 'Stk', | |||
| 'unitPrice' => [ | |||
| 'currency' => 'EUR', | |||
| 'netAmount' => 0, | |||
| 'taxRatePercentage' => 19, | |||
| ], | |||
| ], | |||
| ]; | |||
| } | |||
| $body = [ | |||
| 'voucherDate' => $now, | |||
| 'address' => ['contactId' => $contactId], | |||
| 'lineItems' => $apiLineItems, | |||
| 'totalPrice' => ['currency' => 'EUR'], | |||
| 'taxConditions' => ['taxType' => 'net'], | |||
| 'shippingConditions' => [ | |||
| @@ -0,0 +1,100 @@ | |||
| {# templates/report/statistics.html.twig #} | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %}{{ 'app.report.statistics_page_title'|trans }}{% endblock %} | |||
| {% block javascripts %} | |||
| {{ parent() }} | |||
| {{ encore_entry_script_tags('statistics') }} | |||
| {% endblock %} | |||
| {% block body %} | |||
| <script> | |||
| window.Statistics = { | |||
| monthsShort: {{ deMonthsShort()|json_encode|raw }}, | |||
| weekdaysShort: {{ deWeekdaysShort()|json_encode|raw }}, | |||
| i18n: { | |||
| billable: {{ 'app.service.billable'|trans|json_encode|raw }}, | |||
| nonBillable: {{ 'app.service.not_billable'|trans|json_encode|raw }}, | |||
| hours: {{ 'app.statistics.hours'|trans|json_encode|raw }}, | |||
| revenue: {{ 'app.statistics.revenue'|trans|json_encode|raw }}, | |||
| loading: {{ 'app.statistics.loading'|trans|json_encode|raw }}, | |||
| errorLoad: {{ 'app.statistics.error_load'|trans|json_encode|raw }}, | |||
| weekShort: {{ 'app.date.week_short'|trans|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| <div class="report-page"> | |||
| <div class="report-header"> | |||
| <h1 class="report-header__title">{{ 'app.report.statistics_page_title'|trans }}</h1> | |||
| <div class="report-header__right"> | |||
| <a href="{{ path('report_statistics') }}" class="report-stats-bubble report-stats-bubble--active"> | |||
| <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="report-stats-bubble__icon"> | |||
| <rect x="2" y="10" width="4" height="8" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| <rect x="8" y="6" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| <rect x="14" y="2" width="4" height="16" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| </svg> | |||
| {{ 'app.report.tab_statistics'|trans }} | |||
| </a> | |||
| <nav class="account-tabs"> | |||
| <a href="{{ path('report_times') }}" | |||
| class="account-tab"> | |||
| {{ '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"> | |||
| <div class="statistics-toolbar"> | |||
| <h2 class="statistics-toolbar__title">{{ 'app.statistics.chart_title'|trans }}</h2> | |||
| <div class="statistics-toolbar__controls"> | |||
| {% if not isTracker and userList|length > 1 %} | |||
| <select class="statistics-range-select" id="stats-user-select"> | |||
| <option value="">{{ accountName }}</option> | |||
| {% for user in userList %} | |||
| <option value="{{ user.id }}">{{ user.name }}</option> | |||
| {% endfor %} | |||
| </select> | |||
| {% endif %} | |||
| <select class="statistics-range-select" id="stats-metric-select"> | |||
| <option value="hours" selected>{{ 'app.statistics.hours'|trans }}</option> | |||
| <option value="revenue">{{ 'app.statistics.revenue'|trans }}</option> | |||
| </select> | |||
| <select class="statistics-range-select" id="stats-range-select"> | |||
| <option value="12months" selected>{{ 'app.statistics.range_12months'|trans }}</option> | |||
| <option value="6months">{{ 'app.statistics.range_6months'|trans }}</option> | |||
| <option value="4weeks">{{ 'app.statistics.range_4weeks'|trans }}</option> | |||
| </select> | |||
| </div> | |||
| </div> | |||
| <div class="statistics-chart-wrap" id="stats-chart-wrap"> | |||
| <canvas id="stats-chart"></canvas> | |||
| </div> | |||
| <div class="statistics-legend"> | |||
| <span class="statistics-legend__item statistics-legend__item--billable"> | |||
| {{ 'app.service.billable'|trans }} | |||
| </span> | |||
| <span class="statistics-legend__item statistics-legend__item--non-billable"> | |||
| {{ 'app.service.not_billable'|trans }} | |||
| </span> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {% endblock %} | |||
| @@ -62,6 +62,20 @@ | |||
| invoiceSuccess: {{ 'app.report.invoice_success'|trans|json_encode|raw }}, | |||
| invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }}, | |||
| invoiceOpen: {{ 'app.report.invoice_open'|trans|json_encode|raw }}, | |||
| invoiceModalTitle: {{ 'app.report.invoice_modal_title'|trans|json_encode|raw }}, | |||
| invoiceGroupLabel: {{ 'app.report.invoice_group_label'|trans|json_encode|raw }}, | |||
| invoiceGroupService: {{ 'app.report.invoice_group_service'|trans|json_encode|raw }}, | |||
| invoiceGroupProject: {{ 'app.report.invoice_group_project'|trans|json_encode|raw }}, | |||
| invoiceGroupByLabel: {{ 'app.report.invoice_group_by_label'|trans|json_encode|raw }}, | |||
| invoiceColName: {{ 'app.report.invoice_col_name'|trans|json_encode|raw }}, | |||
| invoiceColHours: {{ 'app.report.invoice_col_hours'|trans|json_encode|raw }}, | |||
| invoiceColUnit: {{ 'app.report.invoice_col_unit'|trans|json_encode|raw }}, | |||
| invoiceColRate: {{ 'app.report.invoice_col_rate'|trans|json_encode|raw }}, | |||
| invoiceColTotal: {{ 'app.report.invoice_col_total'|trans|json_encode|raw }}, | |||
| invoiceUnitHour: {{ 'app.report.invoice_unit_hour'|trans|json_encode|raw }}, | |||
| invoiceBtnCreate: {{ 'app.report.invoice_btn_create'|trans|json_encode|raw }}, | |||
| invoiceLoading: {{ 'app.report.invoice_loading'|trans|json_encode|raw }}, | |||
| invoiceNoItems: {{ 'app.report.invoice_no_items'|trans|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -72,12 +86,14 @@ | |||
| <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"/> | |||
| <a href="{{ path('report_statistics') }}" class="report-stats-bubble"> | |||
| <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg" class="report-stats-bubble__icon"> | |||
| <rect x="2" y="10" width="4" height="8" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| <rect x="8" y="6" width="4" height="12" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| <rect x="14" y="2" width="4" height="16" rx="1" stroke="currentColor" stroke-width="1.3"/> | |||
| </svg> | |||
| {{ accountName }} | |||
| </span> | |||
| {{ 'app.report.tab_statistics'|trans }} | |||
| </a> | |||
| <nav class="account-tabs"> | |||
| <a href="{{ path('report_times') }}" | |||
| @@ -333,4 +349,51 @@ | |||
| </div>{# /.report-page #} | |||
| {% if lexofficeInvoice %} | |||
| <div class="modal-overlay" id="invoice-modal" hidden> | |||
| <div class="modal-card invoice-modal"> | |||
| <div class="modal-card__header"> | |||
| <h2 class="modal-card__title">{{ 'app.report.invoice_modal_title'|trans }}</h2> | |||
| <button type="button" class="modal-card__close" id="invoice-modal-close"> | |||
| <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> | |||
| <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/> | |||
| </svg> | |||
| </button> | |||
| </div> | |||
| <div class="modal-card__body"> | |||
| <div class="invoice-modal__group"> | |||
| <span class="invoice-modal__group-label">{{ 'app.report.invoice_group_label'|trans }}</span> | |||
| <div class="invoice-modal__radios"> | |||
| <label class="invoice-modal__radio"> | |||
| <input type="radio" name="invoice-group" value="service" checked> | |||
| <span>{{ 'app.report.invoice_group_service'|trans }}</span> | |||
| </label> | |||
| <label class="invoice-modal__radio"> | |||
| <input type="radio" name="invoice-group" value="project"> | |||
| <span>{{ 'app.report.invoice_group_project'|trans }}</span> | |||
| </label> | |||
| <label class="invoice-modal__radio"> | |||
| <input type="radio" name="invoice-group" value="label"> | |||
| <span>{{ 'app.report.invoice_group_by_label'|trans }}</span> | |||
| </label> | |||
| </div> | |||
| </div> | |||
| <div class="invoice-modal__preview" id="invoice-preview"> | |||
| <div class="invoice-modal__loading">{{ 'app.report.invoice_loading'|trans }}</div> | |||
| </div> | |||
| </div> | |||
| <div class="modal-card__footer"> | |||
| <label class="invoice-modal__invoiced-check"> | |||
| <input type="checkbox" id="invoice-modal-mark-invoiced" checked> | |||
| <span>{{ 'app.report.invoice_mark_invoiced'|trans }}</span> | |||
| </label> | |||
| <button type="button" class="btn btn-primary" id="invoice-modal-create" disabled> | |||
| {{ 'app.report.invoice_btn_create'|trans }} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| </div> | |||
| {% endif %} | |||
| {% endblock %} | |||
| @@ -157,16 +157,35 @@ app: | |||
| heading: "Reports: Zeiten" | |||
| tab_times: "Zeiten" | |||
| tab_projects: "Projekte" | |||
| tab_statistics: "Statistiken" | |||
| statistics_page_title: "Reports: Statistiken" | |||
| toolbar_filter: "Filtern/Gruppieren" | |||
| export_excel: "Als Excel exportieren" | |||
| export_csv: "Als CSV exportieren" | |||
| export_pdf: "Als PDF exportieren" | |||
| print: "Drucken" | |||
| create_invoice: "Rechnungsentwurf in Lexware Office erstellen" | |||
| invoice_creating: "Rechnungsentwurf wird erstellt…" | |||
| invoice_success: 'Rechnungsentwurf für „%client%" wurde in Lexware Office erstellt.' | |||
| invoice_error: "Fehler beim Erstellen des Rechnungsentwurfs." | |||
| invoice_open: "In Lexware Office öffnen" | |||
| create_invoice: "Rechnungsentwurf in Lexware Office erstellen" | |||
| invoice_creating: "Rechnungsentwurf wird erstellt…" | |||
| invoice_success: 'Rechnungsentwurf für „%client%" wurde in Lexware Office erstellt.' | |||
| invoice_error: "Fehler beim Erstellen des Rechnungsentwurfs." | |||
| invoice_open: "In Lexware Office öffnen" | |||
| invoice_modal_title: "Rechnungsentwurf erstellen" | |||
| invoice_group_label: "Positionen gruppieren nach" | |||
| invoice_group_service: "Nach Leistung" | |||
| invoice_group_project: "Nach Projekt" | |||
| invoice_group_by_label: "Nach Label" | |||
| invoice_col_name: "Bezeichnung" | |||
| invoice_col_hours: "Menge" | |||
| invoice_col_unit: "Einheit" | |||
| invoice_col_rate: "VK (Netto)" | |||
| invoice_col_total: "Gesamt" | |||
| invoice_unit_hour: "Stunde" | |||
| invoice_btn_create: "Rechnung erstellen" | |||
| invoice_loading: "Vorschau wird geladen…" | |||
| invoice_no_items: "Keine Positionen vorhanden." | |||
| invoice_no_service: "Ohne Leistung" | |||
| invoice_no_label: "Ohne Label" | |||
| invoice_mark_invoiced: "Einträge als abgerechnet markieren" | |||
| export_col_date: "Datum" | |||
| export_col_client: "Kunde" | |||
| export_col_project: "Projekt" | |||
| @@ -436,6 +455,16 @@ app: | |||
| confirm_invalid: "Ungültiger Bestätigungslink." | |||
| confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut." | |||
| statistics: | |||
| chart_title: "Arbeitszeit-Statistik" | |||
| range_12months: "Das letzte Jahr" | |||
| range_6months: "Das letzte halbe Jahr" | |||
| range_4weeks: "Die letzten 4 Wochen" | |||
| hours: "Stunden" | |||
| revenue: "Umsatz" | |||
| loading: "Daten werden geladen…" | |||
| error_load: "Fehler beim Laden der Statistikdaten." | |||
| stopwatch: | |||
| title: "Stoppuhr" | |||
| btn_start: "Stoppuhr starten" | |||
| @@ -26,6 +26,7 @@ Encore | |||
| .addEntry('team', './assets/scripts/team.js') | |||
| .addEntry('account', './assets/scripts/account.js') | |||
| .addEntry('report', './assets/scripts/report.js') | |||
| .addEntry('statistics', './assets/scripts/statistics.js') | |||
| // When enabled, Webpack "splits" your files into smaller pieces for greater optimization. | |||
| .splitEntryChunks() | |||