| @@ -190,9 +190,11 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| }); | }); | ||||
| } | } | ||||
| initSortHeaders(); | |||||
| new ReportFilter().init(); | new ReportFilter().init(); | ||||
| initExportButtons(); | initExportButtons(); | ||||
| initPrintButton(); | initPrintButton(); | ||||
| initLexofficeInvoiceButton(); | |||||
| }); | }); | ||||
| // ── ReportFilter ───────────────────────────────────────────────────────────── | // ── ReportFilter ───────────────────────────────────────────────────────────── | ||||
| @@ -224,6 +226,10 @@ class ReportFilter { | |||||
| el.addEventListener('mousedown', () => this.activateRowByControl(el)); | 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.periodSel?.addEventListener('change', () => { | ||||
| this.activateRowByControl(this.periodSel); | this.activateRowByControl(this.periodSel); | ||||
| this.toggleCustomDates(this.periodSel.value === 'custom'); | 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 ──────────────────────────────────────────────────────────────────── | // ── Export ──────────────────────────────────────────────────────────────────── | ||||
| function initExportButtons() { | function initExportButtons() { | ||||
| @@ -435,3 +454,53 @@ function initPrintButton() { | |||||
| window.print(); | 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; | |||||
| } | |||||
| }); | |||||
| } | |||||
| @@ -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 { | .report-table__sort-icon { | ||||
| margin-left: 2px; | margin-left: 2px; | ||||
| font-size: $font-size-xs; | font-size: $font-size-xs; | ||||
| @@ -9,6 +9,7 @@ use App\Service\LexofficeService; | |||||
| use App\Service\TenantContext; | use App\Service\TenantContext; | ||||
| use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; | ||||
| use Symfony\Component\HttpFoundation\JsonResponse; | use Symfony\Component\HttpFoundation\JsonResponse; | ||||
| use Symfony\Component\HttpFoundation\Request; | |||||
| use Symfony\Component\Routing\Attribute\Route; | use Symfony\Component\Routing\Attribute\Route; | ||||
| use Symfony\Contracts\Translation\TranslatorInterface; | use Symfony\Contracts\Translation\TranslatorInterface; | ||||
| @@ -66,4 +67,37 @@ class LexofficeController extends AbstractController | |||||
| return $this->json($contact); | 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); | |||||
| } | |||||
| } | } | ||||
| @@ -25,6 +25,7 @@ use Symfony\Contracts\Translation\TranslatorInterface; | |||||
| class ReportController extends AbstractController | class ReportController extends AbstractController | ||||
| { | { | ||||
| private const VALID_LIMITS = [50, 100, 250, 500]; | private const VALID_LIMITS = [50, 100, 250, 500]; | ||||
| private const VALID_SORT_COLUMNS = ['date', 'client', 'project', 'service', 'user', 'note', 'duration', 'revenue']; | |||||
| public function __construct( | public function __construct( | ||||
| private readonly EntityManagerInterface $tenantEm, | 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) | // 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); | $totalCount = $this->timeEntryRepo->countFiltered($filters); | ||||
| $totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters); | $totalMinutes = $this->timeEntryRepo->sumDurationFiltered($filters); | ||||
| $totalRevenue = $this->timeEntryRepo->sumRevenueFiltered($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', [ | return $this->render('report/times.html.twig', [ | ||||
| 'entries' => $entries, | 'entries' => $entries, | ||||
| 'userMap' => $userMap, | 'userMap' => $userMap, | ||||
| @@ -116,9 +142,12 @@ class ReportController extends AbstractController | |||||
| 'clients' => $this->clientRepo->findAllActiveOrderedByName(), | 'clients' => $this->clientRepo->findAllActiveOrderedByName(), | ||||
| 'filterRaw' => $filterRaw, | 'filterRaw' => $filterRaw, | ||||
| 'filterActive' => $filterActive, | 'filterActive' => $filterActive, | ||||
| 'sort' => $sort, | |||||
| 'dir' => $dir, | |||||
| 'filterDateFrom' => $filterDateFrom, | 'filterDateFrom' => $filterDateFrom, | ||||
| 'filterDateTo' => $filterDateTo, | 'filterDateTo' => $filterDateTo, | ||||
| 'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1, | 'trackingInterval' => $this->tenantContext->getAccount()?->getTrackingInterval() ?? 1, | ||||
| 'lexofficeInvoice' => $lexofficeInvoice, | |||||
| ]); | ]); | ||||
| } | } | ||||
| @@ -169,11 +169,30 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return $qb; | 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') | ->addOrderBy('t.createdAt', 'DESC') | ||||
| ->setMaxResults($limit) | ->setMaxResults($limit) | ||||
| ->getQuery() | ->getQuery() | ||||
| @@ -208,6 +227,32 @@ class TimeEntryRepository extends ServiceEntityRepository | |||||
| return (int) $result; | 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 | public function sumRevenueFiltered(array $filters): float | ||||
| { | { | ||||
| $result = $this->buildFilteredQuery($filters) | $result = $this->buildFilteredQuery($filters) | ||||
| @@ -62,6 +62,63 @@ 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 | |||||
| { | |||||
| $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 | private function requestWithRetry(string $method, string $url, string $apiKey, array $query = []): array | ||||
| { | { | ||||
| $maxRetries = 3; | $maxRetries = 3; | ||||
| @@ -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> | |||||
| @@ -58,6 +58,10 @@ | |||||
| selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }}, | selectPh: {{ 'app.entry.select_placeholder'|trans|json_encode|raw }}, | ||||
| btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }}, | btnLock: {{ 'app.report.btn_lock'|trans|json_encode|raw }}, | ||||
| btnUnlock: {{ 'app.report.btn_unlock'|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> | </script> | ||||
| @@ -104,6 +108,20 @@ | |||||
| </button> | </button> | ||||
| </div> | </div> | ||||
| <div class="report-toolbar__right"> | <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" | <button class="report-toolbar__export" | ||||
| id="btn-export-excel" | id="btn-export-excel" | ||||
| type="button" | type="button" | ||||
| @@ -138,25 +156,26 @@ | |||||
| {% include 'report/_filter-panel.html.twig' %} | {% include 'report/_filter-panel.html.twig' %} | ||||
| {# ── Tabellen-Header ───────────────────────────────────────────────── #} | {# ── 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"> | ||||
| <div class="report-table__head"> | <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 class="report-table__cell report-table__cell--actions"></div> | ||||
| </div> | </div> | ||||
| @@ -160,6 +160,11 @@ app: | |||||
| 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" | |||||
| export_col_date: "Datum" | export_col_date: "Datum" | ||||
| export_col_client: "Kunde" | export_col_client: "Kunde" | ||||
| export_col_project: "Projekt" | export_col_project: "Projekt" | ||||