Преглед изворни кода

statistics and invoices

master
FlorianEisenmenger пре 1 дан
родитељ
комит
574db4febd
16 измењених фајлова са 1238 додато и 64 уклоњено
  1. +5
    -1
      .claude/settings.local.json
  2. +132
    -16
      httpdocs/assets/scripts/report.js
  3. +238
    -0
      httpdocs/assets/scripts/statistics.js
  4. +1
    -0
      httpdocs/assets/styles/main.scss
  5. +151
    -23
      httpdocs/assets/styles/sections/_report.scss
  6. +142
    -0
      httpdocs/assets/styles/sections/_statistics.scss
  7. +21
    -0
      httpdocs/package-lock.json
  8. +3
    -0
      httpdocs/package.json
  9. +2
    -1
      httpdocs/src/Controller/LexofficeController.php
  10. +168
    -2
      httpdocs/src/Controller/ReportController.php
  11. +137
    -0
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  12. +35
    -11
      httpdocs/src/Service/LexofficeService.php
  13. +100
    -0
      httpdocs/templates/report/statistics.html.twig
  14. +68
    -5
      httpdocs/templates/report/times.html.twig
  15. +34
    -5
      httpdocs/translations/messages.de.yaml
  16. +1
    -0
      httpdocs/webpack.config.js

+ 5
- 1
.claude/settings.local.json Прегледај датотеку

@@ -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 *)"
] ]
} }
} }

+ 132
- 16
httpdocs/assets/scripts/report.js Прегледај датотеку

@@ -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');
} }
}); });
} }

+ 238
- 0
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 = '<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);
}
});
});

+ 1
- 0
httpdocs/assets/styles/main.scss Прегледај датотеку

@@ -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';


+ 151
- 23
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 ──────────────────────────────────────────────────────────── // ─── 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;
}

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

+ 21
- 0
httpdocs/package-lock.json Прегледај датотеку

@@ -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",


+ 3
- 0
httpdocs/package.json Прегледај датотеку

@@ -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"
} }
} }

+ 2
- 1
httpdocs/src/Controller/LexofficeController.php Прегледај датотеку

@@ -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);
} }


+ 168
- 2
httpdocs/src/Controller/ReportController.php Прегледај датотеку

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


+ 137
- 0
httpdocs/src/Repository/Tenant/TimeEntryRepository.php Прегледај датотеку

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


+ 35
- 11
httpdocs/src/Service/LexofficeService.php Прегледај датотеку

@@ -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' => [


+ 100
- 0
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 %}

<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 %}

+ 68
- 5
httpdocs/templates/report/times.html.twig Прегледај датотеку

@@ -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 %}

+ 34
- 5
httpdocs/translations/messages.de.yaml Прегледај датотеку

@@ -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"


+ 1
- 0
httpdocs/webpack.config.js Прегледај датотеку

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


Loading…
Откажи
Сачувај