| @@ -16,7 +16,11 @@ | |||||
| "Bash(echo \"exit: $?\")", | "Bash(echo \"exit: $?\")", | ||||
| "WebSearch", | "WebSearch", | ||||
| "WebFetch(domain:developers.lexoffice.io)", | "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() { | function initLexofficeInvoiceButton() { | ||||
| const btn = document.getElementById('btn-lexoffice-invoice'); | const btn = document.getElementById('btn-lexoffice-invoice'); | ||||
| if (!btn) return; | 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 { | try { | ||||
| const res = await fetch('/api/lexoffice/invoices', { | const res = await fetch('/api/lexoffice/invoices', { | ||||
| method: 'POST', | method: 'POST', | ||||
| headers: { 'Content-Type': 'application/json' }, | headers: { 'Content-Type': 'application/json' }, | ||||
| body: JSON.stringify({ contactId, dateFrom, dateTo }), | |||||
| body: JSON.stringify({ contactId, dateFrom, dateTo, lineItems }), | |||||
| }); | }); | ||||
| if (!res.ok) { | if (!res.ok) { | ||||
| @@ -556,19 +661,30 @@ function initLexofficeInvoiceButton() { | |||||
| } | } | ||||
| const data = await res.json(); | 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 msg = t('invoiceSuccess').replace('%client%', clientName); | ||||
| const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`); | 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 { | } catch { | ||||
| alert(t('invoiceError')); | alert(t('invoiceError')); | ||||
| } finally { | } 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/timetracking'; | ||||
| @use 'sections/home'; | @use 'sections/home'; | ||||
| @use 'sections/report'; | @use 'sections/report'; | ||||
| @use 'sections/statistics'; | |||||
| // ─── Themes ─────────────────────────────────────────────────────────────────── | // ─── Themes ─────────────────────────────────────────────────────────────────── | ||||
| @use 'themes/minimal'; | @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 ──────────────────────────────────────────────────────────── | // ─── Disabled Tab ──────────────────────────────────────────────────────────── | ||||
| .account-tab--disabled { | .account-tab--disabled { | ||||
| opacity: 0.45; | opacity: 0.45; | ||||
| @@ -745,3 +722,154 @@ button.report-toolbar__action { | |||||
| font-weight: $font-weight-medium; | 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": { | "packages": { | ||||
| "": { | "": { | ||||
| "license": "UNLICENSED", | "license": "UNLICENSED", | ||||
| "dependencies": { | |||||
| "chart.js": "^4.5.1" | |||||
| }, | |||||
| "devDependencies": { | "devDependencies": { | ||||
| "@babel/core": "^7.17.0", | "@babel/core": "^7.17.0", | ||||
| "@babel/preset-env": "^7.16.0", | "@babel/preset-env": "^7.16.0", | ||||
| @@ -1692,6 +1695,12 @@ | |||||
| "webpack": "^5.0.0" | "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": { | "node_modules/@parcel/watcher": { | ||||
| "version": "2.5.6", | "version": "2.5.6", | ||||
| "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", | "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", | ||||
| @@ -2800,6 +2809,18 @@ | |||||
| "node": ">=8" | "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": { | "node_modules/chokidar": { | ||||
| "version": "4.0.3", | "version": "4.0.3", | ||||
| "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", | "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", | ||||
| @@ -17,5 +17,8 @@ | |||||
| "dev": "encore dev", | "dev": "encore dev", | ||||
| "watch": "encore dev --watch", | "watch": "encore dev --watch", | ||||
| "build": "encore production --progress" | "build": "encore production --progress" | ||||
| }, | |||||
| "dependencies": { | |||||
| "chart.js": "^4.5.1" | |||||
| } | } | ||||
| } | } | ||||
| @@ -84,13 +84,14 @@ class LexofficeController extends AbstractController | |||||
| $contactId = $data['contactId'] ?? ''; | $contactId = $data['contactId'] ?? ''; | ||||
| $dateFrom = $data['dateFrom'] ?? null; | $dateFrom = $data['dateFrom'] ?? null; | ||||
| $dateTo = $data['dateTo'] ?? null; | $dateTo = $data['dateTo'] ?? null; | ||||
| $lineItems = $data['lineItems'] ?? null; | |||||
| if ($contactId === '') { | if ($contactId === '') { | ||||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400); | return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400); | ||||
| } | } | ||||
| try { | try { | ||||
| $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo); | |||||
| $result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo, $lineItems); | |||||
| } catch (\Throwable) { | } catch (\Throwable) { | ||||
| return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502); | 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); | $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters); | ||||
| // Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt | // Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt | ||||
| // und es nicht-abgerechnete Einträge gibt | |||||
| $lexofficeInvoice = null; | $lexofficeInvoice = null; | ||||
| if (!$isTracker && $account?->hasLexofficeApiKey()) { | if (!$isTracker && $account?->hasLexofficeApiKey()) { | ||||
| $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($filters); | |||||
| $invoiceFilters = array_merge($filters, ['invoiced' => false]); | |||||
| $distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($invoiceFilters); | |||||
| if (count($distinctClientIds) === 1) { | if (count($distinctClientIds) === 1) { | ||||
| $client = $this->clientRepo->find($distinctClientIds[0]); | $client = $this->clientRepo->find($distinctClientIds[0]); | ||||
| if ($client?->isLexofficeClient()) { | if ($client?->isLexofficeClient()) { | ||||
| $dateRange = $this->timeEntryRepo->findDateRangeFiltered($filters); | |||||
| $dateRange = $this->timeEntryRepo->findDateRangeFiltered($invoiceFilters); | |||||
| $lexofficeInvoice = [ | $lexofficeInvoice = [ | ||||
| 'clientId' => $client->getId(), | 'clientId' => $client->getId(), | ||||
| 'clientName' => $client->getName(), | '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 ───────────────────────────────────────────────────────── | // ── Excel-Export ───────────────────────────────────────────────────────── | ||||
| #[Route('/reports/export/excel', name: 'report_export_excel')] | #[Route('/reports/export/excel', name: 'report_export_excel')] | ||||
| @@ -362,6 +421,88 @@ class ReportController extends AbstractController | |||||
| return [$from, $to]; | 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 ─────────────────────────────────────── | // ── API: Abgerechnet-Status toggeln ─────────────────────────────────────── | ||||
| #[Route('/api/entries/{id}/invoiced', name: 'api_entry_invoiced_toggle', methods: ['PATCH'])] | #[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()]); | 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 ───────────────────────────────────────────────────────── | // ── Hilfsfunktion ───────────────────────────────────────────────────────── | ||||
| private function formatMinutes(int $minutes): string | private function formatMinutes(int $minutes): string | ||||
| @@ -297,6 +297,143 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return array_column($rows, 'label'); | 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 | public function sumRevenueFiltered(array $filters): float | ||||
| { | { | ||||
| $result = $this->buildFilteredQuery($filters) | $result = $this->buildFilteredQuery($filters) | ||||
| @@ -62,7 +62,10 @@ class LexofficeService | |||||
| return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null; | 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'); | $now = (new \DateTimeImmutable())->format('Y-m-d\T00:00:00.000P'); | ||||
| $shippingStart = $dateFrom | $shippingStart = $dateFrom | ||||
| @@ -72,22 +75,43 @@ class LexofficeService | |||||
| ? (new \DateTimeImmutable($dateTo))->format('Y-m-d\T00:00:00.000P') | ? (new \DateTimeImmutable($dateTo))->format('Y-m-d\T00:00:00.000P') | ||||
| : $shippingStart; | : $shippingStart; | ||||
| $body = [ | |||||
| 'voucherDate' => $now, | |||||
| 'address' => ['contactId' => $contactId], | |||||
| 'lineItems' => [ | |||||
| [ | |||||
| $apiLineItems = []; | |||||
| if ($lineItems !== null && $lineItems !== []) { | |||||
| foreach ($lineItems as $item) { | |||||
| $lineItem = [ | |||||
| 'type' => 'custom', | 'type' => 'custom', | ||||
| 'name' => 'Leistung', | |||||
| 'quantity' => 1, | |||||
| 'unitName' => 'Stk', | |||||
| 'name' => $item['name'] ?? 'Leistung', | |||||
| 'quantity' => round((float) ($item['quantity'] ?? 0), 2), | |||||
| 'unitName' => 'Stunde', | |||||
| 'unitPrice' => [ | 'unitPrice' => [ | ||||
| 'currency' => 'EUR', | 'currency' => 'EUR', | ||||
| 'netAmount' => 0, | |||||
| 'netAmount' => round((float) ($item['unitPrice'] ?? 0), 2), | |||||
| 'taxRatePercentage' => 19, | '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'], | 'totalPrice' => ['currency' => 'EUR'], | ||||
| 'taxConditions' => ['taxType' => 'net'], | 'taxConditions' => ['taxType' => 'net'], | ||||
| 'shippingConditions' => [ | '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 }}, | invoiceSuccess: {{ 'app.report.invoice_success'|trans|json_encode|raw }}, | ||||
| invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }}, | invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }}, | ||||
| invoiceOpen: {{ 'app.report.invoice_open'|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> | </script> | ||||
| @@ -72,12 +86,14 @@ | |||||
| <h1 class="report-header__title">{{ 'app.report.heading'|trans }}</h1> | <h1 class="report-header__title">{{ 'app.report.heading'|trans }}</h1> | ||||
| <div class="report-header__right"> | <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> | </svg> | ||||
| {{ accountName }} | |||||
| </span> | |||||
| {{ 'app.report.tab_statistics'|trans }} | |||||
| </a> | |||||
| <nav class="account-tabs"> | <nav class="account-tabs"> | ||||
| <a href="{{ path('report_times') }}" | <a href="{{ path('report_times') }}" | ||||
| @@ -333,4 +349,51 @@ | |||||
| </div>{# /.report-page #} | </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 %} | {% endblock %} | ||||
| @@ -157,16 +157,35 @@ app: | |||||
| heading: "Reports: Zeiten" | heading: "Reports: Zeiten" | ||||
| tab_times: "Zeiten" | tab_times: "Zeiten" | ||||
| tab_projects: "Projekte" | tab_projects: "Projekte" | ||||
| tab_statistics: "Statistiken" | |||||
| statistics_page_title: "Reports: Statistiken" | |||||
| toolbar_filter: "Filtern/Gruppieren" | toolbar_filter: "Filtern/Gruppieren" | ||||
| export_excel: "Als Excel exportieren" | export_excel: "Als Excel exportieren" | ||||
| export_csv: "Als CSV exportieren" | export_csv: "Als CSV exportieren" | ||||
| export_pdf: "Als PDF exportieren" | export_pdf: "Als PDF exportieren" | ||||
| print: "Drucken" | 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_date: "Datum" | ||||
| export_col_client: "Kunde" | export_col_client: "Kunde" | ||||
| export_col_project: "Projekt" | export_col_project: "Projekt" | ||||
| @@ -436,6 +455,16 @@ app: | |||||
| confirm_invalid: "Ungültiger Bestätigungslink." | confirm_invalid: "Ungültiger Bestätigungslink." | ||||
| confirm_expired: "Dieser Link ist abgelaufen (gültig 24 Stunden). Bitte registriere dich erneut." | 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: | stopwatch: | ||||
| title: "Stoppuhr" | title: "Stoppuhr" | ||||
| btn_start: "Stoppuhr starten" | btn_start: "Stoppuhr starten" | ||||
| @@ -26,6 +26,7 @@ Encore | |||||
| .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') | .addEntry('report', './assets/scripts/report.js') | ||||
| .addEntry('statistics', './assets/scripts/statistics.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() | ||||