| @@ -20,7 +20,12 @@ | |||||
| "Skill(run)", | "Skill(run)", | ||||
| "Bash(ddev describe *)", | "Bash(ddev describe *)", | ||||
| "Bash(curl *)", | "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) { | function renderDonuts(data, metric) { | ||||
| if (!data?.distribution) return; | if (!data?.distribution) return; | ||||
| const d = data.distribution; | const d = data.distribution; | ||||
| @@ -321,6 +342,7 @@ async function loadAndRender(range, metric, userId) { | |||||
| cachedData = await res.json(); | cachedData = await res.json(); | ||||
| renderChart(cachedData, metric); | renderChart(cachedData, metric); | ||||
| renderDonuts(cachedData, metric); | renderDonuts(cachedData, metric); | ||||
| updateTotal(cachedData, metric); | |||||
| } catch { | } catch { | ||||
| wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | wrap.innerHTML = '<p class="statistics-error">' + t('errorLoad') + '</p>'; | ||||
| } | } | ||||
| @@ -353,6 +375,7 @@ document.addEventListener('DOMContentLoaded', () => { | |||||
| if (cachedData) { | if (cachedData) { | ||||
| renderChart(cachedData, metricSelect.value); | renderChart(cachedData, metricSelect.value); | ||||
| renderDonuts(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 ─────────────────────────────────────────────────────────────────── | // ─── Toast ─────────────────────────────────────────────────────────────────── | ||||
| .account-toast { | .account-toast { | ||||
| position: fixed; | 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 ────────────────────────────────────────────────────────────────── | // ─── Chart ────────────────────────────────────────────────────────────────── | ||||
| .statistics-chart-wrap { | .statistics-chart-wrap { | ||||
| position: relative; | position: relative; | ||||
| @@ -69,5 +69,13 @@ services: | |||||
| tags: | tags: | ||||
| - { name: doctrine.middleware, connection: tenant } | - { 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: | App\Controller\InviteController: | ||||
| arguments: ~ | arguments: ~ | ||||
| @@ -37,7 +37,7 @@ class AccountController extends AbstractController | |||||
| $isAdmin = $accountUser?->isAdmin() ?? false; | $isAdmin = $accountUser?->isAdmin() ?? false; | ||||
| $tab = $request->query->get('tab', $isAdmin ? 'account' : 'user'); | $tab = $request->query->get('tab', $isAdmin ? 'account' : 'user'); | ||||
| if (!$isAdmin && $tab === 'account') { | |||||
| if (!$isAdmin && in_array($tab, ['account', 'import'], true)) { | |||||
| $tab = 'user'; | $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' %} | {% extends 'base.html.twig' %} | ||||
| {% block title %} | {% 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 %} | {% endblock %} | ||||
| {% block body %} | {% block body %} | ||||
| @@ -23,6 +23,31 @@ | |||||
| changeLabel: {{ 'app.account.change_label'|trans|json_encode|raw }}, | changeLabel: {{ 'app.account.change_label'|trans|json_encode|raw }}, | ||||
| cancelLabel: {{ 'app.account.cancel_label'|trans|json_encode|raw }}, | cancelLabel: {{ 'app.account.cancel_label'|trans|json_encode|raw }}, | ||||
| errorGeneric: {{ 'app.account.error_generic'|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> | </script> | ||||
| @@ -31,7 +56,7 @@ | |||||
| <div class="account-header"> | <div class="account-header"> | ||||
| <h1 class="account-header__title"> | <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> | </h1> | ||||
| {% if isAdmin %} | {% if isAdmin %} | ||||
| @@ -44,11 +69,69 @@ | |||||
| class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | class="account-tab{% if tab == 'user' %} account-tab--active{% endif %}"> | ||||
| {{ 'app.account.tab_user'|trans }} | {{ 'app.account.tab_user'|trans }} | ||||
| </a> | </a> | ||||
| <a href="{{ path('account_index', {tab: 'import'}) }}" | |||||
| class="account-tab{% if tab == 'import' %} account-tab--active{% endif %}"> | |||||
| {{ 'app.import.tab'|trans }} | |||||
| </a> | |||||
| </nav> | </nav> | ||||
| {% endif %} | {% endif %} | ||||
| </div> | </div> | ||||
| <div class="account-content"> | <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"> | <div class="account-card"> | ||||
| {# ── Account-Tab (nur Admin) ────────────────────────────────────────── #} | {# ── Account-Tab (nur Admin) ────────────────────────────────────────── #} | ||||
| @@ -226,6 +309,8 @@ | |||||
| </div> | </div> | ||||
| {% endif %} | {% endif %} | ||||
| {% endif %}{# Ende Import-else #} | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| @@ -27,6 +27,8 @@ | |||||
| services: {{ 'app.statistics.services'|trans|json_encode|raw }}, | services: {{ 'app.statistics.services'|trans|json_encode|raw }}, | ||||
| rest: {{ 'app.statistics.rest'|trans|json_encode|raw }}, | rest: {{ 'app.statistics.rest'|trans|json_encode|raw }}, | ||||
| noService: {{ 'app.statistics.no_service'|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> | </script> | ||||
| @@ -84,6 +86,8 @@ | |||||
| </div> | </div> | ||||
| </div> | </div> | ||||
| <div class="statistics-total" id="stats-total" hidden></div> | |||||
| <div class="statistics-chart-wrap" id="stats-chart-wrap"> | <div class="statistics-chart-wrap" id="stats-chart-wrap"> | ||||
| <canvas id="stats-chart"></canvas> | <canvas id="stats-chart"></canvas> | ||||
| </div> | </div> | ||||
| @@ -327,6 +327,43 @@ app: | |||||
| interval_half: "Halbe Stunde" | interval_half: "Halbe Stunde" | ||||
| interval_hour: "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: | forgot_password: | ||||
| page_title: "Passwort vergessen – spawntree" | page_title: "Passwort vergessen – spawntree" | ||||
| subtitle: "Gib deine E-Mail ein und wir schicken dir einen Reset-Link." | subtitle: "Gib deine E-Mail ein und wir schicken dir einen Reset-Link." | ||||
| @@ -469,6 +506,8 @@ app: | |||||
| services: "Leistungen" | services: "Leistungen" | ||||
| rest: "Rest" | rest: "Rest" | ||||
| no_service: "Ohne Leistung" | no_service: "Ohne Leistung" | ||||
| total_hours: "Gesamt: %hours%" | |||||
| total_revenue: "Gesamt: %revenue%" | |||||
| stopwatch: | stopwatch: | ||||
| title: "Stoppuhr" | title: "Stoppuhr" | ||||