소스 검색

lexoffice invoice ground setup

master
FlorianEisenmenger 4 시간 전
부모
커밋
fe08761a56
9개의 변경된 파일300개의 추가작업 그리고 22개의 파일을 삭제
  1. +69
    -0
      httpdocs/assets/scripts/report.js
  2. +15
    -0
      httpdocs/assets/styles/sections/_report.scss
  3. +34
    -0
      httpdocs/src/Controller/LexofficeController.php
  4. +30
    -1
      httpdocs/src/Controller/ReportController.php
  5. +49
    -4
      httpdocs/src/Repository/Tenant/TimeEntryRepository.php
  6. +57
    -0
      httpdocs/src/Service/LexofficeService.php
  7. +5
    -0
      httpdocs/templates/_atoms/icon-invoice.html.twig
  8. +36
    -17
      httpdocs/templates/report/times.html.twig
  9. +5
    -0
      httpdocs/translations/messages.de.yaml

+ 69
- 0
httpdocs/assets/scripts/report.js 파일 보기

@@ -190,9 +190,11 @@ document.addEventListener('DOMContentLoaded', () => {
});
}

initSortHeaders();
new ReportFilter().init();
initExportButtons();
initPrintButton();
initLexofficeInvoiceButton();
});

// ── ReportFilter ─────────────────────────────────────────────────────────────
@@ -224,6 +226,10 @@ class ReportFilter {
el.addEventListener('mousedown', () => this.activateRowByControl(el));
});

this.panel.querySelectorAll('.filter-row__control-group--radio').forEach(group => {
group.addEventListener('click', () => this.activateRowByControl(group));
});

this.periodSel?.addEventListener('change', () => {
this.activateRowByControl(this.periodSel);
this.toggleCustomDates(this.periodSel.value === 'custom');
@@ -418,6 +424,19 @@ class ReportFilter {
}
}

// ── Sortierung ───────────────────────────────────────────────────────────────

function initSortHeaders() {
document.querySelectorAll('.report-table__cell--sortable').forEach(cell => {
cell.addEventListener('click', () => {
const params = new URLSearchParams(window.location.search);
params.set('sort', cell.dataset.sort);
params.set('dir', cell.dataset.dir);
window.location.href = `/reports/times?${params}`;
});
});
}

// ── Export ────────────────────────────────────────────────────────────────────

function initExportButtons() {
@@ -435,3 +454,53 @@ function initPrintButton() {
window.print();
});
}

// ── Lexoffice Invoice ────────────────────────────────────────────────────────

function initLexofficeInvoiceButton() {
const btn = document.getElementById('btn-lexoffice-invoice');
if (!btn) return;

const originalTitle = btn.title;

btn.addEventListener('click', async () => {
if (btn.disabled) return;

const contactId = btn.dataset.contactId;
const clientName = btn.dataset.clientName;
const dateFrom = btn.dataset.dateFrom;
const dateTo = btn.dataset.dateTo;

btn.disabled = true;
btn.title = t('invoiceCreating');

try {
const res = await fetch('/api/lexoffice/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ contactId, dateFrom, dateTo }),
});

if (!res.ok) {
const err = await res.json().catch(() => ({}));
alert(err.error ?? t('invoiceError'));
return;
}

const data = await res.json();
const invoiceId = data.id;

const msg = t('invoiceSuccess').replace('%client%', clientName);
const openInLexoffice = confirm(`${msg}\n\n${t('invoiceOpen')}?`);

if (openInLexoffice && invoiceId) {
window.open(`https://app.lexware.de/permalink/invoices/edit/${invoiceId}`, '_blank');
}
} catch {
alert(t('invoiceError'));
} finally {
btn.disabled = false;
btn.title = originalTitle;
}
});
}

+ 15
- 0
httpdocs/assets/styles/sections/_report.scss 파일 보기

@@ -249,6 +249,21 @@
}
}

.report-table__cell--sortable {
cursor: pointer;
user-select: none;

&:hover .report-table__cell-label { color: var(--color-primary); }
}

.report-table__cell--sorted .report-table__cell-label {
color: var(--color-primary);
}

.report-table__cell-label {
transition: color $transition-fast;
}

.report-table__sort-icon {
margin-left: 2px;
font-size: $font-size-xs;


+ 34
- 0
httpdocs/src/Controller/LexofficeController.php 파일 보기

@@ -9,6 +9,7 @@ use App\Service\LexofficeService;
use App\Service\TenantContext;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Translation\TranslatorInterface;

@@ -66,4 +67,37 @@ class LexofficeController extends AbstractController

return $this->json($contact);
}

#[Route('/api/lexoffice/invoices', name: 'api_lexoffice_create_invoice', methods: ['POST'])]
public function createInvoice(Request $request): JsonResponse
{
if ($this->roleHelper->isTracker()) {
return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403);
}

$account = $this->tenantContext->getAccount();
if (!$account?->hasLexofficeApiKey()) {
return $this->json(['error' => $this->translator->trans('app.lexoffice.error_no_api_key')], 400);
}

$data = json_decode($request->getContent(), true);
$contactId = $data['contactId'] ?? '';
$dateFrom = $data['dateFrom'] ?? null;
$dateTo = $data['dateTo'] ?? null;

if ($contactId === '') {
return $this->json(['error' => $this->translator->trans('app.lexoffice.error_not_linked')], 400);
}

try {
$result = $this->lexofficeService->createInvoiceDraft($account->getLexofficeApiKey(), $contactId, $dateFrom, $dateTo);
} catch (\Throwable) {
return $this->json(['error' => $this->translator->trans('app.lexoffice.error_api')], 502);
}

return $this->json([
'id' => $result['id'] ?? null,
'resourceUri' => $result['resourceUri'] ?? null,
], 201);
}
}

+ 30
- 1
httpdocs/src/Controller/ReportController.php 파일 보기

@@ -25,6 +25,7 @@ use Symfony\Contracts\Translation\TranslatorInterface;
class ReportController extends AbstractController
{
private const VALID_LIMITS = [50, 100, 250, 500];
private const VALID_SORT_COLUMNS = ['date', 'client', 'project', 'service', 'user', 'note', 'duration', 'revenue'];

public function __construct(
private readonly EntityManagerInterface $tenantEm,
@@ -92,12 +93,37 @@ class ReportController extends AbstractController
}
}

$sort = $request->query->get('sort', 'date');
if (!in_array($sort, self::VALID_SORT_COLUMNS, true)) {
$sort = 'date';
}
$dir = strtoupper($request->query->get('dir', 'DESC')) === 'ASC' ? 'ASC' : 'DESC';

// Queries — immer gefilterte Methoden (leere Filter = kein WHERE)
$entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit);
$entries = $this->timeEntryRepo->findForReportFiltered($filters, $limit, $sort, $dir);
$totalCount = $this->timeEntryRepo->countFiltered($filters);
$totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters);
$totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($filters);

// Lexoffice-Invoice-Button: nur sichtbar wenn genau 1 Kunde mit Lexoffice-Kontakt
$lexofficeInvoice = null;
if (!$isTracker && $account?->hasLexofficeApiKey()) {
$distinctClientIds = $this->timeEntryRepo->findDistinctClientIdsFiltered($filters);
if (count($distinctClientIds) === 1) {
$client = $this->clientRepo->find($distinctClientIds[0]);
if ($client?->isLexofficeClient()) {
$dateRange = $this->timeEntryRepo->findDateRangeFiltered($filters);
$lexofficeInvoice = [
'clientId' => $client->getId(),
'clientName' => $client->getName(),
'contactId' => $client->getLexofficeContactId(),
'dateFrom' => $dateRange['min'],
'dateTo' => $dateRange['max'],
];
}
}
}

return $this->render('report/times.html.twig', [
'entries' => $entries,
'userMap' => $userMap,
@@ -116,9 +142,12 @@ class ReportController extends AbstractController
'clients' => $this->clientRepo->findAllActiveOrderedByName(),
'filterRaw' => $filterRaw,
'filterActive' => $filterActive,
'sort' => $sort,
'dir' => $dir,
'filterDateFrom' => $filterDateFrom,
'filterDateTo' => $filterDateTo,
'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1,
'lexofficeInvoice' => $lexofficeInvoice,
]);
}



+ 49
- 4
httpdocs/src/Repository/Tenant/TimeEntryRepository.php 파일 보기

@@ -169,11 +169,30 @@ class TimeEntryRepository extends ServiceEntityRepository
return $qb;
}

public function findForReportFiltered(array $filters, int $limit): array
private const SORT_COLUMNS = [
'date' => 't.date',
'client' => 'c.name',
'project' => 'p.name',
'service' => 's.name',
'user' => 't.userId',
'note' => 't.note',
'duration' => 't.duration',
];

public function findForReportFiltered(array $filters, int $limit, string $sort = 'date', string $dir = 'DESC'): array
{
return $this->buildFilteredQuery($filters)
->addSelect('p', 'c', 's')
->orderBy('t.date', 'DESC')
$qb = $this->buildFilteredQuery($filters)
->addSelect('p', 'c', 's');

if ($sort === 'revenue') {
$qb->addSelect('(COALESCE(p.hourlyRate, c.hourlyRate, s.hourlyRate) * t.duration) AS HIDDEN revenueSort')
->orderBy('revenueSort', $dir);
} else {
$column = self::SORT_COLUMNS[$sort] ?? self::SORT_COLUMNS['date'];
$qb->orderBy($column, $dir);
}

return $qb
->addOrderBy('t.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
@@ -208,6 +227,32 @@ class TimeEntryRepository extends ServiceEntityRepository
return (int) $result;
}

/**
* @return int[]
*/
public function findDistinctClientIdsFiltered(array $filters): array
{
$rows = $this->buildFilteredQuery($filters)
->select('DISTINCT IDENTITY(p.client) AS clientId')
->getQuery()
->getScalarResult();

return array_map(fn(array $row) => (int) $row['clientId'], $rows);
}

/**
* @return array{min: ?string, max: ?string}
*/
public function findDateRangeFiltered(array $filters): array
{
$row = $this->buildFilteredQuery($filters)
->select('MIN(t.date) AS minDate, MAX(t.date) AS maxDate')
->getQuery()
->getSingleResult();

return ['min' => $row['minDate'] ?? null, 'max' => $row['maxDate'] ?? null];
}

public function sumRevenueFiltered(array $filters): float
{
$result = $this->buildFilteredQuery($filters)


+ 57
- 0
httpdocs/src/Service/LexofficeService.php 파일 보기

@@ -62,6 +62,63 @@ class LexofficeService
return $name !== '' ? ['id' => $contact['id'], 'name' => $name] : null;
}

public function createInvoiceDraft(string $apiKey, string $contactId, ?string $dateFrom = null, ?string $dateTo = null): array
{
$now = (new \DateTimeImmutable())->format('Y-m-d\T00:00:00.000P');
$shippingStart = $dateFrom
? (new \DateTimeImmutable($dateFrom))->format('Y-m-d\T00:00:00.000P')
: $now;
$shippingEnd = $dateTo
? (new \DateTimeImmutable($dateTo))->format('Y-m-d\T00:00:00.000P')
: $shippingStart;

$body = [
'voucherDate' => $now,
'address' => ['contactId' => $contactId],
'lineItems' => [
[
'type' => 'custom',
'name' => 'Leistung',
'quantity' => 1,
'unitName' => 'Stk',
'unitPrice' => [
'currency' => 'EUR',
'netAmount' => 0,
'taxRatePercentage' => 19,
],
],
],
'totalPrice' => ['currency' => 'EUR'],
'taxConditions' => ['taxType' => 'net'],
'shippingConditions' => [
'shippingType' => 'serviceperiod',
'shippingDate' => $shippingStart,
'shippingEndDate' => $shippingEnd,
],
];

$response = $this->httpClient->request('POST', self::BASE_URL . '/invoices', [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'json' => $body,
]);

$statusCode = $response->getStatusCode();

if ($statusCode === 429) {
throw new \RuntimeException('Lexware API rate limit exceeded');
}

if ($statusCode < 200 || $statusCode >= 300) {
throw new \RuntimeException('Lexware API error: ' . $statusCode);
}

return $response->toArray(false);
}

private function requestWithRetry(string $method, string $url, string $apiKey, array $query = []): array
{
$maxRetries = 3;


+ 5
- 0
httpdocs/templates/_atoms/icon-invoice.html.twig 파일 보기

@@ -0,0 +1,5 @@
<svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.5 1.5h5l3 3v9a1 1 0 01-1 1h-7a1 1 0 01-1-1v-11a1 1 0 011-1z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.5 1.5v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.5 7h5M5.5 9h5M5.5 11h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>

+ 36
- 17
httpdocs/templates/report/times.html.twig 파일 보기

@@ -58,6 +58,10 @@
selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }},
btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }},
btnUnlock: {{ 'app.report.btn_unlock'|trans|json_encode|raw }},
invoiceCreating: {{ 'app.report.invoice_creating'|trans|json_encode|raw }},
invoiceSuccess: {{ 'app.report.invoice_success'|trans|json_encode|raw }},
invoiceError: {{ 'app.report.invoice_error'|trans|json_encode|raw }},
invoiceOpen: {{ 'app.report.invoice_open'|trans|json_encode|raw }},
}
};
</script>
@@ -104,6 +108,20 @@
</button>
</div>
<div class="report-toolbar__right">
{% if lexofficeInvoice %}
<button class="report-toolbar__export"
id="btn-lexoffice-invoice"
type="button"
title="{{ 'app.report.create_invoice'|trans }}"
data-contact-id="{{ lexofficeInvoice.contactId }}"
data-client-name="{{ lexofficeInvoice.clientName }}"
data-date-from="{{ lexofficeInvoice.dateFrom }}"
data-date-to="{{ lexofficeInvoice.dateTo }}">
{% include '_atoms/icon-invoice.html.twig' %}
</button>
<span class="report-toolbar__separator"></span>
{% endif %}

<button class="report-toolbar__export"
id="btn-export-excel"
type="button"
@@ -138,25 +156,26 @@
{% include 'report/_filter-panel.html.twig' %}

{# ── Tabellen-Header ───────────────────────────────────────────────── #}
{% macro sort_header(col, label, sort, dir, extra) %}
{% set active = sort == col %}
{% set nextDir = (active and dir == 'ASC') ? 'DESC' : 'ASC' %}
<div class="report-table__cell report-table__cell--{{ col }} report-table__cell--sortable{% if active %} report-table__cell--sorted{% endif %}"
data-sort="{{ col }}" data-dir="{{ nextDir }}">
<span class="report-table__cell-label">{{ label }}{% if active %}<span class="report-table__sort-icon">{{ dir == 'ASC' ? '▴' : '▾' }}</span>{% endif %}</span>
{% if extra %}<span class="report-table__summary">{{ extra }}</span>{% endif %}
</div>
{% endmacro %}

<div class="report-table">
<div class="report-table__head">
<div class="report-table__cell report-table__cell--date">
{{ 'app.report.col_date'|trans }}
<span class="report-table__sort-icon">▾</span>
</div>
<div class="report-table__cell report-table__cell--client">{{ 'app.report.col_client'|trans }}</div>
<div class="report-table__cell report-table__cell--project">{{ 'app.report.col_project'|trans }}</div>
<div class="report-table__cell report-table__cell--service">{{ 'app.report.col_service'|trans }}</div>
<div class="report-table__cell report-table__cell--user">{{ 'app.report.col_user'|trans }}</div>
<div class="report-table__cell report-table__cell--note">{{ 'app.report.col_note'|trans }}</div>
<div class="report-table__cell report-table__cell--duration">
{{ 'app.report.col_hours'|trans }}
<span class="report-table__summary">{{ totalDuration }}</span>
</div>
<div class="report-table__cell report-table__cell--revenue">
{{ 'app.report.col_revenue'|trans }}
<span class="report-table__summary">{{ totalRevenue|number_format(2, ',', '.') }} €</span>
</div>
{{ _self.sort_header('date', 'app.report.col_date'|trans, sort, dir, '') }}
{{ _self.sort_header('client', 'app.report.col_client'|trans, sort, dir, '') }}
{{ _self.sort_header('project', 'app.report.col_project'|trans, sort, dir, '') }}
{{ _self.sort_header('service', 'app.report.col_service'|trans, sort, dir, '') }}
{{ _self.sort_header('user', 'app.report.col_user'|trans, sort, dir, '') }}
{{ _self.sort_header('note', 'app.report.col_note'|trans, sort, dir, '') }}
{{ _self.sort_header('duration', 'app.report.col_hours'|trans, sort, dir, totalDuration) }}
{{ _self.sort_header('revenue', 'app.report.col_revenue'|trans, sort, dir, totalRevenue|number_format(2, ',', '.') ~ ' €') }}
<div class="report-table__cell report-table__cell--actions"></div>
</div>



+ 5
- 0
httpdocs/translations/messages.de.yaml 파일 보기

@@ -160,6 +160,11 @@ app:
export_csv: "Als CSV exportieren"
export_pdf: "Als PDF exportieren"
print: "Drucken"
create_invoice: "Rechnungsentwurf in Lexware Office erstellen"
invoice_creating: "Rechnungsentwurf wird erstellt…"
invoice_success: 'Rechnungsentwurf für „%client%" wurde in Lexware Office erstellt.'
invoice_error: "Fehler beim Erstellen des Rechnungsentwurfs."
invoice_open: "In Lexware Office öffnen"
export_col_date: "Datum"
export_col_client: "Kunde"
export_col_project: "Projekt"


불러오는 중...
취소
저장