ソースを参照

import and statistics

master
コミット
ac2f54c42b
13個のファイルの変更6959行の追加4行の削除
  1. +6
    -1
      .claude/settings.local.json
  2. +5800
    -0
      httpdocs/_backups/spawntree-2026-06-22-backup.xml
  3. +216
    -0
      httpdocs/assets/scripts/account.js
  4. +23
    -0
      httpdocs/assets/scripts/statistics.js
  5. +206
    -0
      httpdocs/assets/styles/components/_account.scss
  6. +9
    -0
      httpdocs/assets/styles/sections/_statistics.scss
  7. +8
    -0
      httpdocs/config/services.yaml
  8. +1
    -1
      httpdocs/src/Controller/AccountController.php
  9. +84
    -0
      httpdocs/src/Controller/ImportController.php
  10. +476
    -0
      httpdocs/src/Service/MiteImportService.php
  11. +87
    -2
      httpdocs/templates/account/index.html.twig
  12. +4
    -0
      httpdocs/templates/report/statistics.html.twig
  13. +39
    -0
      httpdocs/translations/messages.de.yaml

+ 6
- 1
.claude/settings.local.json ファイルの表示

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

+ 5800
- 0
httpdocs/_backups/spawntree-2026-06-22-backup.xml
ファイル差分が大きすぎるため省略します
ファイルの表示


+ 216
- 0
httpdocs/assets/scripts/account.js ファイルの表示

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

+ 23
- 0
httpdocs/assets/scripts/statistics.js ファイルの表示

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

+ 206
- 0
httpdocs/assets/styles/components/_account.scss ファイルの表示

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


+ 9
- 0
httpdocs/assets/styles/sections/_statistics.scss ファイルの表示

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


+ 8
- 0
httpdocs/config/services.yaml ファイルの表示

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

+ 1
- 1
httpdocs/src/Controller/AccountController.php ファイルの表示

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



+ 84
- 0
httpdocs/src/Controller/ImportController.php ファイルの表示

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

+ 476
- 0
httpdocs/src/Service/MiteImportService.php ファイルの表示

@@ -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],
];
}
}

+ 87
- 2
httpdocs/templates/account/index.html.twig ファイルの表示

@@ -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">&times;</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>


+ 4
- 0
httpdocs/templates/report/statistics.html.twig ファイルの表示

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


+ 39
- 0
httpdocs/translations/messages.de.yaml ファイルの表示

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


読み込み中…
キャンセル
保存