diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b067396..f36f964 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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 *)" ] } } diff --git a/httpdocs/assets/scripts/report.js b/httpdocs/assets/scripts/report.js index 0c74a41..95b7054 100644 --- a/httpdocs/assets/scripts/report.js +++ b/httpdocs/assets/scripts/report.js @@ -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 = `
${esc(t('invoiceNoItems'))}
`; + return; + } + + let totalRevenue = 0; + const rows = items.map(item => { + const lineTotal = item.hours * item.rate; + totalRevenue += lineTotal; + const descHtml = item.description + ? `
${esc(item.description)}
` + : ''; + return `
+
${esc(item.name)}${descHtml}
+
${formatNumber(item.hours)}
+
${esc(t('invoiceUnitHour'))}
+
${formatNumber(item.rate)} €
+
${formatNumber(lineTotal)} €
+
`; + }).join(''); + + preview.innerHTML = ` +
+
+
${esc(t('invoiceColName'))}
+
${esc(t('invoiceColHours'))}
+
${esc(t('invoiceColUnit'))}
+
${esc(t('invoiceColRate'))}
+
${esc(t('invoiceColTotal'))}
+
+ ${rows} +
+
+
+
+
+
${formatNumber(totalRevenue)} €
+
+
`; + } - 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 = `
${esc(t('invoiceLoading'))}
`; + 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 = `
${esc(t('invoiceError'))}
`; + } + } + + 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'); } }); } diff --git a/httpdocs/assets/scripts/statistics.js b/httpdocs/assets/scripts/statistics.js new file mode 100644 index 0000000..0589650 --- /dev/null +++ b/httpdocs/assets/scripts/statistics.js @@ -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 = '

' + t('errorLoad') + '

'; + } +} + +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); + } + }); +}); diff --git a/httpdocs/assets/styles/main.scss b/httpdocs/assets/styles/main.scss index e26f620..9fd7d36 100644 --- a/httpdocs/assets/styles/main.scss +++ b/httpdocs/assets/styles/main.scss @@ -24,6 +24,7 @@ @use 'sections/timetracking'; @use 'sections/home'; @use 'sections/report'; +@use 'sections/statistics'; // ─── Themes ─────────────────────────────────────────────────────────────────── @use 'themes/minimal'; diff --git a/httpdocs/assets/styles/sections/_report.scss b/httpdocs/assets/styles/sections/_report.scss index c5b18c1..36f7655 100644 --- a/httpdocs/assets/styles/sections/_report.scss +++ b/httpdocs/assets/styles/sections/_report.scss @@ -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; +} diff --git a/httpdocs/assets/styles/sections/_statistics.scss b/httpdocs/assets/styles/sections/_statistics.scss new file mode 100644 index 0000000..84dd8a2 --- /dev/null +++ b/httpdocs/assets/styles/sections/_statistics.scss @@ -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; + } +} diff --git a/httpdocs/package-lock.json b/httpdocs/package-lock.json index e54582b..4c92674 100644 --- a/httpdocs/package-lock.json +++ b/httpdocs/package-lock.json @@ -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", diff --git a/httpdocs/package.json b/httpdocs/package.json index bb6f1c7..0bccb04 100644 --- a/httpdocs/package.json +++ b/httpdocs/package.json @@ -17,5 +17,8 @@ "dev": "encore dev", "watch": "encore dev --watch", "build": "encore production --progress" + }, + "dependencies": { + "chart.js": "^4.5.1" } } diff --git a/httpdocs/src/Controller/LexofficeController.php b/httpdocs/src/Controller/LexofficeController.php index d68fdbc..7862336 100644 --- a/httpdocs/src/Controller/LexofficeController.php +++ b/httpdocs/src/Controller/LexofficeController.php @@ -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); } diff --git a/httpdocs/src/Controller/ReportController.php b/httpdocs/src/Controller/ReportController.php index 48a0a0c..1164f15 100644 --- a/httpdocs/src/Controller/ReportController.php +++ b/httpdocs/src/Controller/ReportController.php @@ -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 diff --git a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php index 369554f..1569eeb 100644 --- a/httpdocs/src/Repository/Tenant/TimeEntryRepository.php +++ b/httpdocs/src/Repository/Tenant/TimeEntryRepository.php @@ -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) diff --git a/httpdocs/src/Service/LexofficeService.php b/httpdocs/src/Service/LexofficeService.php index ae854c5..166d76b 100644 --- a/httpdocs/src/Service/LexofficeService.php +++ b/httpdocs/src/Service/LexofficeService.php @@ -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|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' => [ diff --git a/httpdocs/templates/report/statistics.html.twig b/httpdocs/templates/report/statistics.html.twig new file mode 100644 index 0000000..0e1c0b2 --- /dev/null +++ b/httpdocs/templates/report/statistics.html.twig @@ -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 %} + + + +
+ +
+

{{ 'app.report.statistics_page_title'|trans }}

+ + +
+ +
+
+ +
+

{{ 'app.statistics.chart_title'|trans }}

+
+ {% if not isTracker and userList|length > 1 %} + + {% endif %} + + +
+
+ +
+ +
+ +
+ + {{ 'app.service.billable'|trans }} + + + {{ 'app.service.not_billable'|trans }} + +
+ +
+
+ +
+ +{% endblock %} diff --git a/httpdocs/templates/report/times.html.twig b/httpdocs/templates/report/times.html.twig index 3aa1048..3fbbbfa 100644 --- a/httpdocs/templates/report/times.html.twig +++ b/httpdocs/templates/report/times.html.twig @@ -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 }}, } }; @@ -72,12 +86,14 @@

{{ 'app.report.heading'|trans }}

- + {{ 'app.report.tab_statistics'|trans }} +