| @@ -20,7 +20,12 @@ | |||
| "Skill(run)", | |||
| "Bash(ddev describe *)", | |||
| "Bash(curl *)", | |||
| "Bash(ddev mysql *)" | |||
| "Bash(ddev mysql *)", | |||
| "Bash(git status *)", | |||
| "Bash(npx playwright *)", | |||
| "Bash(npm init *)", | |||
| "Bash(npm install *)", | |||
| "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 node test.mjs)" | |||
| ] | |||
| } | |||
| } | |||
| @@ -212,4 +212,220 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| } | |||
| }); | |||
| } | |||
| // ── mite-Import ───────────────────────────────────────────────────────────── | |||
| const importFile = document.getElementById('import-file'); | |||
| const importFileInfo = document.getElementById('import-file-info'); | |||
| const importFileName = document.getElementById('import-file-name'); | |||
| const importFileRemove = document.getElementById('import-file-remove'); | |||
| const btnAnalyze = document.getElementById('btn-import-analyze'); | |||
| const btnExecute = document.getElementById('btn-import-execute'); | |||
| const btnReset = document.getElementById('btn-import-reset'); | |||
| const previewCard = document.getElementById('import-preview'); | |||
| const previewContent = document.getElementById('import-preview-content'); | |||
| const resultCard = document.getElementById('import-result'); | |||
| const resultContent = document.getElementById('import-result-content'); | |||
| const dropArea = document.getElementById('import-drop-area'); | |||
| if (importFile) { | |||
| let selectedFile = null; | |||
| function setFile(file) { | |||
| if (!file || !file.name.toLowerCase().endsWith('.xml')) { | |||
| showToast(t('importErrorNoFile'), true); | |||
| return; | |||
| } | |||
| selectedFile = file; | |||
| importFileName.textContent = file.name + ' (' + formatFileSize(file.size) + ')'; | |||
| importFileInfo.hidden = false; | |||
| btnAnalyze.disabled = false; | |||
| previewCard.hidden = true; | |||
| resultCard.hidden = true; | |||
| } | |||
| function clearFile() { | |||
| selectedFile = null; | |||
| importFile.value = ''; | |||
| importFileInfo.hidden = true; | |||
| btnAnalyze.disabled = true; | |||
| previewCard.hidden = true; | |||
| resultCard.hidden = true; | |||
| } | |||
| function formatFileSize(bytes) { | |||
| if (bytes < 1024) return bytes + ' B'; | |||
| if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; | |||
| return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; | |||
| } | |||
| importFile.addEventListener('change', () => { | |||
| if (importFile.files.length > 0) setFile(importFile.files[0]); | |||
| }); | |||
| importFileRemove.addEventListener('click', clearFile); | |||
| // Drag & Drop | |||
| ['dragenter', 'dragover'].forEach(evt => { | |||
| dropArea.addEventListener(evt, (e) => { | |||
| e.preventDefault(); | |||
| dropArea.classList.add('import-upload__area--dragover'); | |||
| }); | |||
| }); | |||
| ['dragleave', 'drop'].forEach(evt => { | |||
| dropArea.addEventListener(evt, () => { | |||
| dropArea.classList.remove('import-upload__area--dragover'); | |||
| }); | |||
| }); | |||
| dropArea.addEventListener('drop', (e) => { | |||
| e.preventDefault(); | |||
| if (e.dataTransfer.files.length > 0) setFile(e.dataTransfer.files[0]); | |||
| }); | |||
| // Analyse | |||
| btnAnalyze.addEventListener('click', async () => { | |||
| if (!selectedFile) return; | |||
| btnAnalyze.disabled = true; | |||
| btnAnalyze.textContent = t('importAnalyzing'); | |||
| const form = new FormData(); | |||
| form.append('file', selectedFile); | |||
| try { | |||
| const res = await fetch('/api/import/mite/preview', { method: 'POST', body: form }); | |||
| const json = await res.json(); | |||
| if (!res.ok) throw new Error(json.error ?? t('errorGeneric')); | |||
| renderPreview(json); | |||
| previewCard.hidden = false; | |||
| resultCard.hidden = true; | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } finally { | |||
| btnAnalyze.textContent = t('importAnalyze'); | |||
| btnAnalyze.disabled = false; | |||
| } | |||
| }); | |||
| // Import ausführen | |||
| btnExecute.addEventListener('click', async () => { | |||
| if (!selectedFile) return; | |||
| if (!confirm(t('importConfirm'))) return; | |||
| btnExecute.disabled = true; | |||
| btnExecute.textContent = t('importExecuting'); | |||
| const form = new FormData(); | |||
| form.append('file', selectedFile); | |||
| const createUserIds = []; | |||
| previewContent.querySelectorAll('input[name^="user-mode-"]:checked').forEach(radio => { | |||
| if (radio.value === 'create') { | |||
| createUserIds.push(parseInt(radio.name.replace('user-mode-', ''), 10)); | |||
| } | |||
| }); | |||
| form.append('createUsers', JSON.stringify(createUserIds)); | |||
| try { | |||
| const res = await fetch('/api/import/mite/execute', { method: 'POST', body: form }); | |||
| const json = await res.json(); | |||
| if (!res.ok) throw new Error(json.error ?? t('errorGeneric')); | |||
| renderResult(json); | |||
| previewCard.hidden = true; | |||
| resultCard.hidden = false; | |||
| showToast(t('importSuccess')); | |||
| } catch (e) { | |||
| showToast(e.message, true); | |||
| } finally { | |||
| btnExecute.textContent = t('importExecute'); | |||
| btnExecute.disabled = false; | |||
| } | |||
| }); | |||
| // Reset | |||
| btnReset.addEventListener('click', () => { | |||
| previewCard.hidden = true; | |||
| resultCard.hidden = true; | |||
| }); | |||
| function renderPreview(data) { | |||
| const rows = [ | |||
| { label: t('importLabelClients'), value: data.customers }, | |||
| { label: t('importLabelProjects'), value: data.projects }, | |||
| { label: t('importLabelServices'), value: data.services }, | |||
| { label: t('importLabelEntries'), value: data.timeEntries }, | |||
| ]; | |||
| let html = '<dl class="import-preview__stats">'; | |||
| for (const row of rows) { | |||
| html += `<dt>${esc(row.label)}</dt><dd>${esc(String(row.value))}</dd>`; | |||
| } | |||
| if (data.dateRange.from && data.dateRange.to) { | |||
| html += `<dt>${esc(t('importLabelDateRange'))}</dt><dd>${esc(formatDate(data.dateRange.from))} – ${esc(formatDate(data.dateRange.to))}</dd>`; | |||
| } | |||
| html += '</dl>'; | |||
| // Benutzer-Zuordnung | |||
| if (data.users.length > 0) { | |||
| html += `<h3 class="import-preview__subtitle">${esc(t('importLabelUsers'))}</h3>`; | |||
| html += '<ul class="import-preview__users">'; | |||
| for (const u of data.users) { | |||
| html += `<li><strong>${esc(u.name)}</strong> <span class="import-preview__email">(${esc(u.email)})</span> — ${u.entryCount} ${esc(t('importLabelEntries'))}`; | |||
| if (u.matched) { | |||
| html += ` <span class="import-badge import-badge--matched">${esc(t('importUserMatched'))} ${esc(u.matchedUserName)}</span>`; | |||
| } else { | |||
| const radioName = `user-mode-${u.miteId}`; | |||
| html += `<div class="import-user-options">`; | |||
| html += `<label class="import-user-option"><input type="radio" name="${radioName}" value="me" checked> ${esc(t('importUserAssignMe'))}</label>`; | |||
| html += `<label class="import-user-option"><input type="radio" name="${radioName}" value="create"> ${esc(t('importUserCreate'))}</label>`; | |||
| html += `</div>`; | |||
| } | |||
| html += `</li>`; | |||
| } | |||
| html += '</ul>'; | |||
| } | |||
| // Warnungen | |||
| if (data.warnings.length > 0) { | |||
| html += `<h3 class="import-preview__subtitle">${esc(t('importLabelWarnings'))}</h3>`; | |||
| html += '<ul class="import-preview__warnings">'; | |||
| for (const w of data.warnings) { | |||
| html += `<li>${esc(w)}</li>`; | |||
| } | |||
| html += '</ul>'; | |||
| } | |||
| previewContent.innerHTML = html; | |||
| } | |||
| function renderResult(data) { | |||
| function statLine(label, total, created) { | |||
| const existing = total - created; | |||
| let detail = `<strong>${created}</strong> ${esc(t('importNewLabel'))}`; | |||
| if (existing > 0) { | |||
| detail += `, ${existing} ${esc(t('importExistingLabel'))}`; | |||
| } | |||
| return `<dt>${esc(label)}</dt><dd>${detail}</dd>`; | |||
| } | |||
| let html = '<dl class="import-preview__stats import-preview__stats--result">'; | |||
| html += statLine(t('importLabelClients'), data.clients, data.clientsCreated); | |||
| html += statLine(t('importLabelProjects'), data.projects, data.projectsCreated); | |||
| html += statLine(t('importLabelServices'), data.services, data.servicesCreated); | |||
| html += `<dt>${esc(t('importResultEntries'))}</dt><dd><strong>${data.timeEntries}</strong></dd>`; | |||
| if (data.usersCreated > 0) { | |||
| html += `<dt>${esc(t('importResultUsers'))}</dt><dd><strong>${data.usersCreated}</strong></dd>`; | |||
| } | |||
| html += '</dl>'; | |||
| resultContent.innerHTML = html; | |||
| } | |||
| function formatDate(dateStr) { | |||
| const d = new Date(dateStr + 'T00:00:00'); | |||
| return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); | |||
| } | |||
| } | |||
| }); | |||
| @@ -298,6 +298,27 @@ function renderDonut(canvasId, legendId, chartKey, items, metric) { | |||
| } | |||
| } | |||
| function updateTotal(data, metric) { | |||
| const el = document.getElementById('stats-total'); | |||
| if (!el || !data) return; | |||
| const billable = metric === 'revenue' ? data.billableRevenue : data.billable; | |||
| const nonBillable = metric === 'revenue' ? data.nonBillableRevenue : data.nonBillable; | |||
| const total = [...billable, ...nonBillable].reduce((s, v) => s + v, 0); | |||
| if (total === 0) { | |||
| el.hidden = true; | |||
| return; | |||
| } | |||
| if (metric === 'revenue') { | |||
| el.textContent = t('totalRevenue').replace('__REVENUE__', formatCurrency(total)); | |||
| } else { | |||
| el.textContent = t('totalHours').replace('__HOURS__', formatHours(total) + ' h'); | |||
| } | |||
| el.hidden = false; | |||
| } | |||
| function renderDonuts(data, metric) { | |||
| if (!data?.distribution) return; | |||
| const d = data.distribution; | |||
| @@ -321,6 +342,7 @@ async function loadAndRender(range, metric, userId) { | |||
| cachedData = await res.json(); | |||
| renderChart(cachedData, metric); | |||
| renderDonuts(cachedData, metric); | |||
| updateTotal(cachedData, metric); | |||
| } catch { | |||
| wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | |||
| } | |||
| @@ -353,6 +375,7 @@ document.addEventListener('DOMContentLoaded', () => { | |||
| if (cachedData) { | |||
| renderChart(cachedData, metricSelect.value); | |||
| renderDonuts(cachedData, metricSelect.value); | |||
| updateTotal(cachedData, metricSelect.value); | |||
| } | |||
| }); | |||
| }); | |||
| @@ -201,6 +201,212 @@ | |||
| } | |||
| } | |||
| // ─── Import-Sektion ────────────────────────────────────────────────────── | |||
| .import-section__title { | |||
| font-size: $font-size-lg; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| margin-bottom: $space-2; | |||
| } | |||
| .import-section__desc { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| line-height: 1.55; | |||
| margin-bottom: $space-6; | |||
| } | |||
| .import-upload__area { | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| gap: $space-2; | |||
| padding: $space-8 $space-6; | |||
| border: 2px dashed $color-input-border; | |||
| border-radius: $radius-md; | |||
| cursor: pointer; | |||
| transition: border-color $transition-fast, background $transition-fast; | |||
| &:hover, | |||
| &--dragover { | |||
| border-color: var(--color-primary, $color-primary); | |||
| background: rgba(var(--color-primary-rgb, 58, 123, 191), 0.04); | |||
| } | |||
| } | |||
| .import-upload__icon { | |||
| color: $color-text-muted; | |||
| } | |||
| .import-upload__text { | |||
| font-size: $font-size-sm; | |||
| font-weight: $font-weight-medium; | |||
| color: $color-text-dark; | |||
| } | |||
| .import-upload__hint { | |||
| font-size: $font-size-xs; | |||
| color: $color-text-muted; | |||
| } | |||
| .import-upload__file-info { | |||
| display: flex; | |||
| align-items: center; | |||
| gap: $space-3; | |||
| margin-top: $space-3; | |||
| padding: $space-3 $space-4; | |||
| background: $color-bg; | |||
| border-radius: $radius-sm; | |||
| font-size: $font-size-sm; | |||
| color: $color-text-dark; | |||
| } | |||
| .import-upload__remove { | |||
| margin-left: auto; | |||
| background: none; | |||
| border: none; | |||
| font-size: $font-size-lg; | |||
| color: $color-text-muted; | |||
| cursor: pointer; | |||
| padding: 0 $space-2; | |||
| line-height: 1; | |||
| &:hover { color: $color-error; } | |||
| } | |||
| .import-actions { | |||
| margin-top: $space-5; | |||
| display: flex; | |||
| gap: $space-4; | |||
| &--preview { | |||
| padding-top: $space-5; | |||
| border-top: 1px solid $color-border; | |||
| } | |||
| } | |||
| // ─── Import-Vorschau ───────────────────────────────────────────────────── | |||
| .import-preview__stats { | |||
| display: grid; | |||
| grid-template-columns: auto 1fr; | |||
| gap: $space-2 $space-6; | |||
| font-size: $font-size-sm; | |||
| dt { | |||
| color: $color-text-muted; | |||
| font-weight: $font-weight-medium; | |||
| } | |||
| dd { | |||
| color: $color-text-dark; | |||
| font-weight: $font-weight-bold; | |||
| margin: 0; | |||
| } | |||
| &--result dd { | |||
| color: $color-text-dark; | |||
| font-weight: $font-weight-regular; | |||
| strong { | |||
| color: var(--color-primary, $color-primary); | |||
| font-weight: $font-weight-bold; | |||
| } | |||
| } | |||
| } | |||
| .import-preview__subtitle { | |||
| font-size: $font-size-sm; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| margin-top: $space-5; | |||
| margin-bottom: $space-2; | |||
| } | |||
| .import-preview__users { | |||
| list-style: none; | |||
| padding: 0; | |||
| margin: 0; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: $space-2; | |||
| li { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-dark; | |||
| line-height: 1.6; | |||
| } | |||
| } | |||
| .import-preview__email { | |||
| color: $color-text-muted; | |||
| } | |||
| .import-user-options { | |||
| display: flex; | |||
| gap: $space-4; | |||
| margin-top: $space-1; | |||
| } | |||
| .import-user-option { | |||
| display: inline-flex; | |||
| align-items: center; | |||
| gap: $space-1; | |||
| font-size: $font-size-xs; | |||
| color: $color-text-muted; | |||
| cursor: pointer; | |||
| input[type="radio"] { | |||
| margin: 0; | |||
| accent-color: var(--color-primary, $color-primary); | |||
| } | |||
| } | |||
| .import-badge { | |||
| display: inline-block; | |||
| font-size: $font-size-xs; | |||
| padding: 1px $space-2; | |||
| border-radius: $radius-pill; | |||
| font-weight: $font-weight-medium; | |||
| &--matched { | |||
| background: rgba(34, 197, 94, 0.12); | |||
| color: #15803d; | |||
| } | |||
| &--fallback { | |||
| background: rgba(234, 179, 8, 0.15); | |||
| color: #a16207; | |||
| } | |||
| } | |||
| .import-preview__warnings { | |||
| list-style: none; | |||
| padding: 0; | |||
| margin: 0; | |||
| display: flex; | |||
| flex-direction: column; | |||
| gap: $space-1; | |||
| li { | |||
| font-size: $font-size-sm; | |||
| color: $color-text-muted; | |||
| padding-left: $space-4; | |||
| position: relative; | |||
| &::before { | |||
| content: '⚠'; | |||
| position: absolute; | |||
| left: 0; | |||
| font-size: $font-size-xs; | |||
| } | |||
| } | |||
| } | |||
| .import-result { | |||
| border-left: 3px solid var(--color-primary, $color-primary); | |||
| } | |||
| // ─── Toast ─────────────────────────────────────────────────────────────────── | |||
| .account-toast { | |||
| position: fixed; | |||
| @@ -87,6 +87,15 @@ | |||
| } | |||
| } | |||
| // ─── Gesamtsumme ─────────────────────────────────────────────────────────── | |||
| .statistics-total { | |||
| text-align: right; | |||
| padding: $space-3 $space-5 0; | |||
| font-size: $font-size-lg; | |||
| font-weight: $font-weight-bold; | |||
| color: $color-text-dark; | |||
| } | |||
| // ─── Chart ────────────────────────────────────────────────────────────────── | |||
| .statistics-chart-wrap { | |||
| position: relative; | |||
| @@ -69,5 +69,13 @@ services: | |||
| tags: | |||
| - { name: doctrine.middleware, connection: tenant } | |||
| App\Service\MiteImportService: | |||
| arguments: | |||
| $centralEm: '@doctrine.orm.central_entity_manager' | |||
| App\Controller\ImportController: | |||
| arguments: | |||
| $tenantEm: '@doctrine.orm.tenant_entity_manager' | |||
| App\Controller\InviteController: | |||
| arguments: ~ | |||
| @@ -37,7 +37,7 @@ class AccountController extends AbstractController | |||
| $isAdmin = $accountUser?->isAdmin() ?? false; | |||
| $tab = $request->query->get('tab', $isAdmin ? 'account' : 'user'); | |||
| if (!$isAdmin && $tab === 'account') { | |||
| if (!$isAdmin && in_array($tab, ['account', 'import'], true)) { | |||
| $tab = 'user'; | |||
| } | |||
| @@ -0,0 +1,84 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Controller; | |||
| use App\Service\AccountRoleHelper; | |||
| use App\Service\MiteImportService; | |||
| use App\Entity\Central\User; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| 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; | |||
| class ImportController extends AbstractController | |||
| { | |||
| private const MAX_FILE_SIZE = 20 * 1024 * 1024; | |||
| public function __construct( | |||
| private readonly EntityManagerInterface $tenantEm, | |||
| private readonly MiteImportService $miteImportService, | |||
| private readonly AccountRoleHelper $roleHelper, | |||
| private readonly TranslatorInterface $translator, | |||
| ) {} | |||
| #[Route('/api/import/mite/preview', name: 'api_import_mite_preview', methods: ['POST'])] | |||
| public function preview(Request $request): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $file = $request->files->get('file'); | |||
| if (!$file) { | |||
| return $this->json(['error' => $this->translator->trans('app.import.error_no_file')], 400); | |||
| } | |||
| if ($file->getSize() > self::MAX_FILE_SIZE) { | |||
| return $this->json(['error' => $this->translator->trans('app.import.error_file_too_large')], 400); | |||
| } | |||
| $ext = strtolower($file->getClientOriginalExtension()); | |||
| if ($ext !== 'xml') { | |||
| return $this->json(['error' => $this->translator->trans('app.import.error_invalid_format')], 400); | |||
| } | |||
| try { | |||
| $xml = file_get_contents($file->getPathname()); | |||
| $analysis = $this->miteImportService->analyze($xml); | |||
| return $this->json($analysis); | |||
| } catch (\RuntimeException $e) { | |||
| return $this->json(['error' => $e->getMessage()], 422); | |||
| } | |||
| } | |||
| #[Route('/api/import/mite/execute', name: 'api_import_mite_execute', methods: ['POST'])] | |||
| public function execute(Request $request): JsonResponse | |||
| { | |||
| if (!$this->roleHelper->isAdmin()) { | |||
| return $this->json(['error' => $this->translator->trans('app.error.access_denied')], 403); | |||
| } | |||
| $file = $request->files->get('file'); | |||
| if (!$file) { | |||
| return $this->json(['error' => $this->translator->trans('app.import.error_no_file')], 400); | |||
| } | |||
| /** @var User $user */ | |||
| $user = $this->getUser(); | |||
| $createUsers = json_decode($request->request->get('createUsers', '[]'), true) ?? []; | |||
| $createUsers = array_map('intval', $createUsers); | |||
| try { | |||
| $xml = file_get_contents($file->getPathname()); | |||
| $result = $this->miteImportService->execute($xml, $user->getId(), $this->tenantEm, $createUsers); | |||
| return $this->json($result); | |||
| } catch (\RuntimeException $e) { | |||
| return $this->json(['error' => $e->getMessage()], 422); | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,476 @@ | |||
| <?php | |||
| declare(strict_types=1); | |||
| namespace App\Service; | |||
| use App\Entity\Central\AccountUser; | |||
| use App\Entity\Central\User; | |||
| use App\Entity\Tenant\Client; | |||
| use App\Entity\Tenant\Project; | |||
| use App\Entity\Tenant\Service; | |||
| use App\Entity\Tenant\TimeEntry; | |||
| use App\Repository\Central\AccountUserRepository; | |||
| use App\Repository\Central\UserRepository; | |||
| use Doctrine\ORM\EntityManagerInterface; | |||
| class MiteImportService | |||
| { | |||
| public function __construct( | |||
| private readonly EntityManagerInterface $centralEm, | |||
| private readonly UserRepository $userRepo, | |||
| private readonly AccountUserRepository $accountUserRepo, | |||
| private readonly TenantContext $tenantContext, | |||
| ) {} | |||
| public function analyze(string $xml): array | |||
| { | |||
| $data = $this->parse($xml); | |||
| $account = $this->tenantContext->getAccount(); | |||
| $userMapping = []; | |||
| foreach ($data['users'] as $miteUser) { | |||
| $matched = $this->userRepo->findByEmail($miteUser['email']); | |||
| $matchedInAccount = null; | |||
| if ($matched) { | |||
| $au = $this->accountUserRepo->findOneBy([ | |||
| 'account' => $account, | |||
| 'user' => $matched, | |||
| ]); | |||
| if ($au && !$au->isArchived()) { | |||
| $matchedInAccount = $matched; | |||
| } | |||
| } | |||
| $userMapping[] = [ | |||
| 'miteId' => $miteUser['id'], | |||
| 'name' => $miteUser['name'], | |||
| 'email' => $miteUser['email'], | |||
| 'role' => $miteUser['role'], | |||
| 'matched' => $matchedInAccount !== null, | |||
| 'matchedUserId' => $matchedInAccount?->getId(), | |||
| 'matchedUserName' => $matchedInAccount?->getFullName(), | |||
| 'entryCount' => $this->countEntriesForUser($data['timeEntries'], $miteUser['id']), | |||
| ]; | |||
| } | |||
| $warnings = $this->detectWarnings($data); | |||
| $dateRange = $this->getDateRange($data['timeEntries']); | |||
| return [ | |||
| 'customers' => count($data['customers']), | |||
| 'projects' => count($data['projects']), | |||
| 'services' => count($data['services']), | |||
| 'timeEntries' => count($data['timeEntries']), | |||
| 'users' => $userMapping, | |||
| 'warnings' => $warnings, | |||
| 'dateRange' => $dateRange, | |||
| ]; | |||
| } | |||
| /** | |||
| * @param int[] $createUserIds mite-User-IDs, für die ein neuer System-User angelegt werden soll | |||
| */ | |||
| public function execute(string $xml, int $currentUserId, EntityManagerInterface $tenantEm, array $createUserIds = []): array | |||
| { | |||
| $data = $this->parse($xml); | |||
| $account = $this->tenantContext->getAccount(); | |||
| $miteUserToSystemUser = []; | |||
| $usersCreated = 0; | |||
| $miteUsersById = []; | |||
| foreach ($data['users'] as $mu) { | |||
| $miteUsersById[$mu['id']] = $mu; | |||
| } | |||
| foreach ($data['users'] as $miteUser) { | |||
| $matched = $this->userRepo->findByEmail($miteUser['email']); | |||
| if ($matched) { | |||
| $au = $this->accountUserRepo->findOneBy([ | |||
| 'account' => $account, | |||
| 'user' => $matched, | |||
| ]); | |||
| if ($au && !$au->isArchived()) { | |||
| $miteUserToSystemUser[$miteUser['id']] = $matched->getId(); | |||
| continue; | |||
| } | |||
| } | |||
| if (in_array($miteUser['id'], $createUserIds, true)) { | |||
| $user = $this->createUserFromMite($miteUser, $account); | |||
| $miteUserToSystemUser[$miteUser['id']] = $user->getId(); | |||
| $usersCreated++; | |||
| continue; | |||
| } | |||
| $miteUserToSystemUser[$miteUser['id']] = $currentUserId; | |||
| } | |||
| $tenantEm->beginTransaction(); | |||
| try { | |||
| $serviceMap = $this->importServices($data['services'], $tenantEm); | |||
| $clientMap = $this->importClients($data['customers'], $tenantEm); | |||
| $projectMap = $this->importProjects($data['projects'], $clientMap, $tenantEm); | |||
| $tenantEm->flush(); | |||
| $entryCount = $this->importTimeEntries( | |||
| $data['timeEntries'], | |||
| $projectMap, | |||
| $serviceMap, | |||
| $miteUserToSystemUser, | |||
| $currentUserId, | |||
| $tenantEm, | |||
| ); | |||
| $tenantEm->flush(); | |||
| $tenantEm->commit(); | |||
| return [ | |||
| 'services' => count($data['services']), | |||
| 'servicesCreated' => count($serviceMap['created']), | |||
| 'clients' => count($data['customers']), | |||
| 'clientsCreated' => count($clientMap['created']), | |||
| 'projects' => count($data['projects']), | |||
| 'projectsCreated' => count($projectMap['created']), | |||
| 'timeEntries' => $entryCount, | |||
| 'usersCreated' => $usersCreated, | |||
| ]; | |||
| } catch (\Throwable $e) { | |||
| $tenantEm->rollback(); | |||
| throw $e; | |||
| } | |||
| } | |||
| private function parse(string $xml): array | |||
| { | |||
| libxml_use_internal_errors(true); | |||
| $doc = simplexml_load_string($xml); | |||
| if ($doc === false) { | |||
| $errors = libxml_get_errors(); | |||
| libxml_clear_errors(); | |||
| $msg = !empty($errors) ? $errors[0]->message : 'Unbekannter Fehler'; | |||
| throw new \RuntimeException('XML-Parsing fehlgeschlagen: ' . trim($msg)); | |||
| } | |||
| $customers = []; | |||
| foreach ($doc->customers->customer ?? [] as $c) { | |||
| $customers[] = [ | |||
| 'id' => (int) $c->id, | |||
| 'name' => (string) $c->name, | |||
| 'hourlyRate' => (int) $c->{'hourly-rate'}, | |||
| 'note' => (string) $c->note, | |||
| 'archived' => ((string) $c->archived) === 'true', | |||
| ]; | |||
| } | |||
| $projects = []; | |||
| foreach ($doc->projects->project ?? [] as $p) { | |||
| $projects[] = [ | |||
| 'id' => (int) $p->id, | |||
| 'customerId' => (int) $p->{'customer-id'}, | |||
| 'name' => (string) $p->name, | |||
| 'hourlyRate' => (int) $p->{'hourly-rate'}, | |||
| 'note' => (string) $p->note, | |||
| 'archived' => ((string) $p->archived) === 'true', | |||
| 'budget' => (int) $p->budget, | |||
| ]; | |||
| } | |||
| $services = []; | |||
| foreach ($doc->services->service ?? [] as $s) { | |||
| $services[] = [ | |||
| 'id' => (int) $s->id, | |||
| 'name' => (string) $s->name, | |||
| 'billable' => ((string) $s->billable) === 'true', | |||
| 'hourlyRate' => (int) $s->{'hourly-rate'}, | |||
| 'note' => (string) $s->note, | |||
| 'archived' => ((string) $s->archived) === 'true', | |||
| ]; | |||
| } | |||
| $timeEntries = []; | |||
| foreach ($doc->{'time-entries'}->{'time-entry'} ?? [] as $te) { | |||
| $timeEntries[] = [ | |||
| 'id' => (int) $te->id, | |||
| 'dateAt' => (string) $te->{'date-at'}, | |||
| 'minutes' => (int) $te->minutes, | |||
| 'projectId' => (int) $te->{'project-id'}, | |||
| 'serviceId' => (int) $te->{'service-id'}, | |||
| 'userId' => (int) $te->{'user-id'}, | |||
| 'note' => (string) $te->note, | |||
| 'billable' => ((string) $te->billable) === 'true', | |||
| 'locked' => ((string) $te->locked) === 'true', | |||
| ]; | |||
| } | |||
| $users = []; | |||
| foreach ($doc->users->user ?? [] as $u) { | |||
| $users[] = [ | |||
| 'id' => (int) $u->id, | |||
| 'name' => (string) $u->name, | |||
| 'email' => (string) $u->email, | |||
| 'role' => (string) $u->role, | |||
| 'archived' => ((string) $u->archived) === 'true', | |||
| ]; | |||
| } | |||
| return compact('customers', 'projects', 'services', 'timeEntries', 'users'); | |||
| } | |||
| /** | |||
| * @return array{entities: array<int, Service>, created: int[]} | |||
| */ | |||
| private function importServices(array $miteServices, EntityManagerInterface $em): array | |||
| { | |||
| $repo = $em->getRepository(Service::class); | |||
| $map = ['entities' => [], 'created' => []]; | |||
| foreach ($miteServices as $ms) { | |||
| $existing = $repo->findOneBy(['name' => $ms['name']]); | |||
| if ($existing) { | |||
| $map['entities'][$ms['id']] = $existing; | |||
| continue; | |||
| } | |||
| $service = new Service(); | |||
| $service->setName($ms['name']); | |||
| $service->setBillable($ms['billable']); | |||
| $service->setHourlyRate($this->centsToDecimal($ms['hourlyRate'])); | |||
| if ($ms['note'] !== '') { | |||
| $service->setNote($ms['note']); | |||
| } | |||
| if ($ms['archived']) { | |||
| $service->setArchivedAt(new \DateTimeImmutable()); | |||
| } | |||
| $em->persist($service); | |||
| $map['entities'][$ms['id']] = $service; | |||
| $map['created'][] = $ms['id']; | |||
| } | |||
| return $map; | |||
| } | |||
| /** | |||
| * @return array{entities: array<int, Client>, created: int[]} | |||
| */ | |||
| private function importClients(array $miteCustomers, EntityManagerInterface $em): array | |||
| { | |||
| $repo = $em->getRepository(Client::class); | |||
| $map = ['entities' => [], 'created' => []]; | |||
| foreach ($miteCustomers as $mc) { | |||
| $existing = $repo->findOneBy(['name' => $mc['name']]); | |||
| if ($existing) { | |||
| $map['entities'][$mc['id']] = $existing; | |||
| continue; | |||
| } | |||
| $client = new Client(); | |||
| $client->setName($mc['name']); | |||
| if ($mc['hourlyRate'] > 0) { | |||
| $client->setHourlyRate($this->centsToDecimal($mc['hourlyRate'])); | |||
| } | |||
| if ($mc['note'] !== '') { | |||
| $client->setNote($mc['note']); | |||
| } | |||
| if ($mc['archived']) { | |||
| $client->setArchivedAt(new \DateTimeImmutable()); | |||
| } | |||
| $em->persist($client); | |||
| $map['entities'][$mc['id']] = $client; | |||
| $map['created'][] = $mc['id']; | |||
| } | |||
| return $map; | |||
| } | |||
| /** | |||
| * @return array{entities: array<int, Project>, created: int[]} | |||
| */ | |||
| private function importProjects(array $miteProjects, array $clientMap, EntityManagerInterface $em): array | |||
| { | |||
| $repo = $em->getRepository(Project::class); | |||
| $map = ['entities' => [], 'created' => []]; | |||
| foreach ($miteProjects as $mp) { | |||
| $client = $clientMap['entities'][$mp['customerId']] ?? null; | |||
| if (!$client) { | |||
| continue; | |||
| } | |||
| $existing = $repo->findOneBy(['name' => $mp['name'], 'client' => $client]); | |||
| if ($existing) { | |||
| $map['entities'][$mp['id']] = $existing; | |||
| continue; | |||
| } | |||
| $project = new Project(); | |||
| $project->setName($mp['name']); | |||
| $project->setClient($client); | |||
| if ($mp['hourlyRate'] > 0) { | |||
| $project->setHourlyRate($this->centsToDecimal($mp['hourlyRate'])); | |||
| } | |||
| if ($mp['note'] !== '') { | |||
| $project->setNote($mp['note']); | |||
| } | |||
| if ($mp['archived']) { | |||
| $project->setArchivedAt(new \DateTimeImmutable()); | |||
| } | |||
| $em->persist($project); | |||
| $map['entities'][$mp['id']] = $project; | |||
| $map['created'][] = $mp['id']; | |||
| } | |||
| return $map; | |||
| } | |||
| private function importTimeEntries( | |||
| array $miteEntries, | |||
| array $projectMap, | |||
| array $serviceMap, | |||
| array $userMapping, | |||
| int $fallbackUserId, | |||
| EntityManagerInterface $em, | |||
| ): int { | |||
| $count = 0; | |||
| foreach ($miteEntries as $me) { | |||
| $project = $projectMap['entities'][$me['projectId']] ?? null; | |||
| if (!$project) { | |||
| continue; | |||
| } | |||
| $service = $serviceMap['entities'][$me['serviceId']] ?? null; | |||
| $entry = new TimeEntry(); | |||
| $entry->setDate(new \DateTimeImmutable($me['dateAt'])); | |||
| $entry->setDuration($me['minutes']); | |||
| $entry->setProject($project); | |||
| $entry->setService($service); | |||
| $entry->setUserId($userMapping[$me['userId']] ?? $fallbackUserId); | |||
| if ($me['note'] !== '') { | |||
| $entry->setNote($me['note']); | |||
| } | |||
| if ($me['locked']) { | |||
| $entry->setInvoiced(true); | |||
| } | |||
| $em->persist($entry); | |||
| $count++; | |||
| if ($count % 100 === 0) { | |||
| $em->flush(); | |||
| } | |||
| } | |||
| return $count; | |||
| } | |||
| private function createUserFromMite(array $miteUser, \App\Entity\Central\Account $account): User | |||
| { | |||
| $existing = $this->userRepo->findByEmail($miteUser['email']); | |||
| if ($existing) { | |||
| $user = $existing; | |||
| } else { | |||
| [$firstName, $lastName] = $this->splitName($miteUser['name']); | |||
| $user = new User(); | |||
| $user->setEmail($miteUser['email']); | |||
| $user->setFirstName($firstName); | |||
| $user->setLastName($lastName); | |||
| $this->centralEm->persist($user); | |||
| } | |||
| $existingAu = $this->accountUserRepo->findOneBy([ | |||
| 'account' => $account, | |||
| 'user' => $user, | |||
| ]); | |||
| if (!$existingAu) { | |||
| $accountUser = new AccountUser(); | |||
| $accountUser->setAccount($account); | |||
| $accountUser->setUser($user); | |||
| $accountUser->setRole($this->mapMiteRole($miteUser['role'])); | |||
| $this->centralEm->persist($accountUser); | |||
| } | |||
| $this->centralEm->flush(); | |||
| return $user; | |||
| } | |||
| private function splitName(string $fullName): array | |||
| { | |||
| $parts = preg_split('/\s+/', trim($fullName), 2); | |||
| return [$parts[0], $parts[1] ?? '']; | |||
| } | |||
| private function mapMiteRole(string $miteRole): string | |||
| { | |||
| return match ($miteRole) { | |||
| 'owner', 'admin' => AccountUser::ROLE_MEMBER, | |||
| 'coworker' => AccountUser::ROLE_MEMBER, | |||
| default => AccountUser::ROLE_TRACKER, | |||
| }; | |||
| } | |||
| private function centsToDecimal(int $cents): string | |||
| { | |||
| return number_format($cents / 100, 2, '.', ''); | |||
| } | |||
| private function countEntriesForUser(array $timeEntries, int $miteUserId): int | |||
| { | |||
| return count(array_filter($timeEntries, fn(array $te) => $te['userId'] === $miteUserId)); | |||
| } | |||
| private function detectWarnings(array $data): array | |||
| { | |||
| $warnings = []; | |||
| $budgetCount = count(array_filter($data['projects'], fn(array $p) => $p['budget'] > 0)); | |||
| if ($budgetCount > 0) { | |||
| $warnings[] = sprintf('%d Projekte haben Budgets — werden nicht importiert.', $budgetCount); | |||
| } | |||
| $lockedCount = count(array_filter($data['timeEntries'], fn(array $te) => $te['locked'])); | |||
| if ($lockedCount > 0) { | |||
| $warnings[] = sprintf('%d Einträge sind in mite gesperrt — werden als „abgerechnet" importiert.', $lockedCount); | |||
| } | |||
| $archivedCustomers = count(array_filter($data['customers'], fn(array $c) => $c['archived'])); | |||
| $archivedProjects = count(array_filter($data['projects'], fn(array $p) => $p['archived'])); | |||
| $archivedServices = count(array_filter($data['services'], fn(array $s) => $s['archived'])); | |||
| if ($archivedCustomers + $archivedProjects + $archivedServices > 0) { | |||
| $parts = []; | |||
| if ($archivedCustomers > 0) $parts[] = "$archivedCustomers Kunden"; | |||
| if ($archivedProjects > 0) $parts[] = "$archivedProjects Projekte"; | |||
| if ($archivedServices > 0) $parts[] = "$archivedServices Leistungen"; | |||
| $warnings[] = implode(', ', $parts) . ' sind archiviert — werden als archiviert importiert.'; | |||
| } | |||
| $warnings[] = 'Stundensätze werden von Cent (mite) in Euro umgerechnet.'; | |||
| return $warnings; | |||
| } | |||
| private function getDateRange(array $timeEntries): array | |||
| { | |||
| if (empty($timeEntries)) { | |||
| return ['from' => null, 'to' => null]; | |||
| } | |||
| $dates = array_map(fn(array $te) => $te['dateAt'], $timeEntries); | |||
| sort($dates); | |||
| return [ | |||
| 'from' => $dates[0], | |||
| 'to' => $dates[count($dates) - 1], | |||
| ]; | |||
| } | |||
| } | |||
| @@ -2,7 +2,7 @@ | |||
| {% extends 'base.html.twig' %} | |||
| {% block title %} | |||
| {% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| {% if tab == 'import' %}{{ 'app.import.tab'|trans }}{% elseif tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| {% endblock %} | |||
| {% block body %} | |||
| @@ -23,6 +23,31 @@ | |||
| changeLabel: {{ 'app.account.change_label'|trans|json_encode|raw }}, | |||
| cancelLabel: {{ 'app.account.cancel_label'|trans|json_encode|raw }}, | |||
| errorGeneric: {{ 'app.account.error_generic'|trans|json_encode|raw }}, | |||
| importAnalyze: {{ 'app.import.btn_analyze'|trans|json_encode|raw }}, | |||
| importAnalyzing: {{ 'app.import.btn_analyzing'|trans|json_encode|raw }}, | |||
| importExecute: {{ 'app.import.btn_execute'|trans|json_encode|raw }}, | |||
| importExecuting: {{ 'app.import.btn_executing'|trans|json_encode|raw }}, | |||
| importSuccess: {{ 'app.import.success'|trans|json_encode|raw }}, | |||
| importConfirm: {{ 'app.import.confirm'|trans|json_encode|raw }}, | |||
| importErrorNoFile: {{ 'app.import.error_no_file'|trans|json_encode|raw }}, | |||
| importLabelClients: {{ 'app.import.label_clients'|trans|json_encode|raw }}, | |||
| importLabelProjects: {{ 'app.import.label_projects'|trans|json_encode|raw }}, | |||
| importLabelServices: {{ 'app.import.label_services'|trans|json_encode|raw }}, | |||
| importLabelEntries: {{ 'app.import.label_entries'|trans|json_encode|raw }}, | |||
| importLabelUsers: {{ 'app.import.label_users'|trans|json_encode|raw }}, | |||
| importLabelDateRange: {{ 'app.import.label_date_range'|trans|json_encode|raw }}, | |||
| importLabelWarnings: {{ 'app.import.label_warnings'|trans|json_encode|raw }}, | |||
| importUserMatched: {{ 'app.import.user_matched'|trans|json_encode|raw }}, | |||
| importUserFallback: {{ 'app.import.user_fallback'|trans|json_encode|raw }}, | |||
| importNewLabel: {{ 'app.import.new_label'|trans|json_encode|raw }}, | |||
| importExistingLabel: {{ 'app.import.existing_label'|trans|json_encode|raw }}, | |||
| importResultClients: {{ 'app.import.result_clients'|trans|json_encode|raw }}, | |||
| importResultProjects: {{ 'app.import.result_projects'|trans|json_encode|raw }}, | |||
| importResultServices: {{ 'app.import.result_services'|trans|json_encode|raw }}, | |||
| importResultEntries: {{ 'app.import.result_entries'|trans|json_encode|raw }}, | |||
| importResultUsers: {{ 'app.import.result_users'|trans|json_encode|raw }}, | |||
| importUserAssignMe: {{ 'app.import.user_assign_me'|trans|json_encode|raw }}, | |||
| importUserCreate: {{ 'app.import.user_create'|trans|json_encode|raw }}, | |||
| }, | |||
| }; | |||
| </script> | |||
| @@ -31,7 +56,7 @@ | |||
| <div class="account-header"> | |||
| <h1 class="account-header__title"> | |||
| {% if tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| {% if tab == 'import' %}{{ 'app.import.tab'|trans }}{% elseif tab == 'account' %}{{ 'app.account.page_title_account'|trans }}{% else %}{{ 'app.account.page_title_user'|trans }}{% endif %} | |||
| </h1> | |||
| {% if isAdmin %} | |||
| @@ -44,11 +69,69 @@ | |||
| class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | |||
| {{ 'app.account.tab_user'|trans }} | |||
| </a> | |||
| <a href="{{ path('account_index', {tab: 'import'}) }}" | |||
| class="account-tab{% if tab == 'import' %} account-tab--active{% endif %}"> | |||
| {{ 'app.import.tab'|trans }} | |||
| </a> | |||
| </nav> | |||
| {% endif %} | |||
| </div> | |||
| <div class="account-content"> | |||
| {# ── Import-Tab (nur Admin) ────────────────────────────────────────── #} | |||
| {% if tab == 'import' and isAdmin %} | |||
| <div class="account-card"> | |||
| <h2 class="import-section__title">{{ 'app.import.title_mite'|trans }}</h2> | |||
| <p class="import-section__desc">{{ 'app.import.desc_mite'|trans }}</p> | |||
| <div class="import-upload" id="import-upload"> | |||
| <label class="import-upload__area" id="import-drop-area"> | |||
| <input type="file" id="import-file" accept=".xml" hidden /> | |||
| <span class="import-upload__icon"> | |||
| <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> | |||
| <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> | |||
| <polyline points="17 8 12 3 7 8"/> | |||
| <line x1="12" y1="3" x2="12" y2="15"/> | |||
| </svg> | |||
| </span> | |||
| <span class="import-upload__text">{{ 'app.import.upload_text'|trans }}</span> | |||
| <span class="import-upload__hint">{{ 'app.import.upload_hint'|trans }}</span> | |||
| </label> | |||
| <div class="import-upload__file-info" id="import-file-info" hidden> | |||
| <span id="import-file-name"></span> | |||
| <button type="button" class="import-upload__remove" id="import-file-remove">×</button> | |||
| </div> | |||
| </div> | |||
| <div class="import-actions" id="import-actions"> | |||
| <button type="button" class="btn btn-primary" id="btn-import-analyze" disabled> | |||
| {{ 'app.import.btn_analyze'|trans }} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| <div class="account-card" id="import-preview" hidden> | |||
| <h2 class="import-section__title">{{ 'app.import.title_preview'|trans }}</h2> | |||
| <div id="import-preview-content"></div> | |||
| <div class="import-actions import-actions--preview"> | |||
| <button type="button" class="btn btn-primary" id="btn-import-execute"> | |||
| {{ 'app.import.btn_execute'|trans }} | |||
| </button> | |||
| <button type="button" class="btn btn-secondary" id="btn-import-reset"> | |||
| {{ 'app.import.btn_reset'|trans }} | |||
| </button> | |||
| </div> | |||
| </div> | |||
| <div class="account-card import-result" id="import-result" hidden> | |||
| <h2 class="import-section__title">{{ 'app.import.title_result'|trans }}</h2> | |||
| <div id="import-result-content"></div> | |||
| </div> | |||
| {% else %} | |||
| <div class="account-card"> | |||
| {# ── Account-Tab (nur Admin) ────────────────────────────────────────── #} | |||
| @@ -226,6 +309,8 @@ | |||
| </div> | |||
| {% endif %} | |||
| {% endif %}{# Ende Import-else #} | |||
| </div> | |||
| </div> | |||
| @@ -27,6 +27,8 @@ | |||
| services: {{ 'app.statistics.services'|trans|json_encode|raw }}, | |||
| rest: {{ 'app.statistics.rest'|trans|json_encode|raw }}, | |||
| noService: {{ 'app.statistics.no_service'|trans|json_encode|raw }}, | |||
| totalHours: {{ 'app.statistics.total_hours'|trans({'%hours%': '__HOURS__'})|json_encode|raw }}, | |||
| totalRevenue: {{ 'app.statistics.total_revenue'|trans({'%revenue%': '__REVENUE__'})|json_encode|raw }}, | |||
| } | |||
| }; | |||
| </script> | |||
| @@ -84,6 +86,8 @@ | |||
| </div> | |||
| </div> | |||
| <div class="statistics-total" id="stats-total" hidden></div> | |||
| <div class="statistics-chart-wrap" id="stats-chart-wrap"> | |||
| <canvas id="stats-chart"></canvas> | |||
| </div> | |||
| @@ -327,6 +327,43 @@ app: | |||
| interval_half: "Halbe Stunde" | |||
| interval_hour: "Stunde" | |||
| import: | |||
| tab: "Daten-Import" | |||
| title_mite: "mite-Import" | |||
| desc_mite: "Importiere Kunden, Projekte, Leistungen und Zeiteinträge aus einem mite-Backup (XML). Einträge werden zu den bestehenden Daten hinzugefügt — ein doppelter Import erzeugt doppelte Einträge." | |||
| upload_text: "XML-Datei auswählen oder hierher ziehen" | |||
| upload_hint: "mite-Backup im XML-Format, max. 20 MB" | |||
| btn_analyze: "Datei analysieren" | |||
| btn_analyzing: "Wird analysiert…" | |||
| btn_execute: "Import starten" | |||
| btn_executing: "Wird importiert…" | |||
| btn_reset: "Abbrechen" | |||
| title_preview: "Vorschau" | |||
| title_result: "Ergebnis" | |||
| confirm: "Bist du sicher? Die Einträge werden zu den bestehenden Daten hinzugefügt. Ein erneuter Import erzeugt Duplikate." | |||
| success: "Import erfolgreich abgeschlossen." | |||
| error_no_file: "Bitte eine XML-Datei auswählen." | |||
| error_file_too_large: "Die Datei ist zu groß (max. 20 MB)." | |||
| error_invalid_format: "Nur XML-Dateien werden unterstützt." | |||
| label_clients: "Kunden" | |||
| label_projects: "Projekte" | |||
| label_services: "Leistungen" | |||
| label_entries: "Zeiteinträge" | |||
| label_users: "Benutzer-Zuordnung" | |||
| label_date_range: "Zeitraum" | |||
| label_warnings: "Hinweise" | |||
| user_matched: "zugeordnet zu" | |||
| user_fallback: "wird dir zugeordnet" | |||
| user_assign_me: "mir zuordnen" | |||
| user_create: "User anlegen" | |||
| new_label: "neu" | |||
| existing_label: "vorhanden" | |||
| result_clients: "Kunden angelegt" | |||
| result_projects: "Projekte angelegt" | |||
| result_services: "Leistungen angelegt" | |||
| result_entries: "Zeiteinträge importiert" | |||
| result_users: "Benutzer angelegt" | |||
| forgot_password: | |||
| page_title: "Passwort vergessen – spawntree" | |||
| subtitle: "Gib deine E-Mail ein und wir schicken dir einen Reset-Link." | |||
| @@ -469,6 +506,8 @@ app: | |||
| services: "Leistungen" | |||
| rest: "Rest" | |||
| no_service: "Ohne Leistung" | |||
| total_hours: "Gesamt: %hours%" | |||
| total_revenue: "Gesamt: %revenue%" | |||
| stopwatch: | |||
| title: "Stoppuhr" | |||