From 040cbfc58808db33a78a8ad1e90f07add205865d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Tue, 24 Mar 2026 15:54:06 +0100 Subject: [PATCH] docs : add time entry export implementation plan (LST-41) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-03-24-time-entry-export.md | 777 ++++++++++++++++++ 1 file changed, 777 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-24-time-entry-export.md diff --git a/docs/superpowers/plans/2026-03-24-time-entry-export.md b/docs/superpowers/plans/2026-03-24-time-entry-export.md new file mode 100644 index 0000000..1381497 --- /dev/null +++ b/docs/superpowers/plans/2026-03-24-time-entry-export.md @@ -0,0 +1,777 @@ +# Time Entry XLSX Export — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add XLSX export of time tracking data with detail + summary sheets for CIR/JEI tax documents. + +**Architecture:** Custom Symfony controller generates XLSX via PhpSpreadsheet, returns BinaryFileResponse. Frontend adds an export button on time-tracking page that triggers download with current filters. + +**Tech Stack:** PHP 8.4, Symfony 8.0, PhpSpreadsheet, Nuxt 4 / Vue 3 + +**Spec:** `docs/superpowers/specs/2026-03-24-time-entry-export-design.md` + +--- + +### Task 1: Install PhpSpreadsheet + +**Files:** +- Modify: `composer.json` + +- [ ] **Step 1: Install the dependency** + +```bash +docker exec -t php-lesstime-fpm composer require phpoffice/phpspreadsheet +``` + +- [ ] **Step 2: Verify installation** + +```bash +docker exec -t php-lesstime-fpm php -r "require 'vendor/autoload.php'; new \PhpOffice\PhpSpreadsheet\Spreadsheet(); echo 'OK';" +``` + +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add composer.json composer.lock +git commit -m "chore : add phpoffice/phpspreadsheet dependency for time entry export" +``` + +--- + +### Task 2: Add repository method for filtered time entries + +**Files:** +- Modify: `src/Repository/TimeEntryRepository.php` + +- [ ] **Step 1: Add `findForExport` method** + +Add this method to `TimeEntryRepository`: + +```php +/** + * @param int[]|null $tagIds + * @return TimeEntry[] + */ +public function findForExport( + \DateTimeImmutable $after, + \DateTimeImmutable $before, + ?User $user = null, + ?Project $project = null, + ?array $tagIds = null, +): array { + $qb = $this->createQueryBuilder('te') + ->andWhere('te.startedAt >= :after') + ->andWhere('te.startedAt < :before') + ->setParameter('after', $after) + ->setParameter('before', $before) + ->orderBy('te.startedAt', 'ASC'); + + if (null !== $user) { + $qb->andWhere('te.user = :user') + ->setParameter('user', $user); + } + + if (null !== $project) { + $qb->andWhere('te.project = :project') + ->setParameter('project', $project); + } + + if (null !== $tagIds && [] !== $tagIds) { + $qb->join('te.tags', 'tag') + ->andWhere('tag.id IN (:tagIds)') + ->setParameter('tagIds', $tagIds); + } + + return $qb->getQuery()->getResult(); +} +``` + +- [ ] **Step 2: Add missing use statements if needed** + +Ensure these imports are at the top of the file: +```php +use App\Entity\Project; +use App\Entity\User; +``` + +- [ ] **Step 3: Verify no syntax errors** + +```bash +docker exec -t php-lesstime-fpm php -l src/Repository/TimeEntryRepository.php +``` + +Expected: `No syntax errors detected` + +- [ ] **Step 4: Commit** + +```bash +git add src/Repository/TimeEntryRepository.php +git commit -m "feat : add findForExport repository method for time entries" +``` + +--- + +### Task 3: Create TimeEntryExportService + +**Files:** +- Create: `src/Service/TimeEntryExportService.php` + +- [ ] **Step 1: Create the service with all three sheets** + +Create `src/Service/TimeEntryExportService.php`: + +```php + 'Janvier', 2 => 'Février', 3 => 'Mars', 4 => 'Avril', + 5 => 'Mai', 6 => 'Juin', 7 => 'Juillet', 8 => 'Août', + 9 => 'Septembre', 10 => 'Octobre', 11 => 'Novembre', 12 => 'Décembre', + ]; + + /** + * @param TimeEntry[] $timeEntries + * + * @return string Path to the generated temp file + */ + public function generate(array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): string + { + $spreadsheet = new Spreadsheet(); + + $this->buildDetailSheet($spreadsheet, $timeEntries); + $this->buildProjectRecapSheet($spreadsheet, $timeEntries); + $this->buildMonthRecapSheet($spreadsheet, $timeEntries, $from, $to); + + $spreadsheet->setActiveSheetIndex(0); + + $tempFile = tempnam(sys_get_temp_dir(), 'export_temps_') . '.xlsx'; + $writer = new Xlsx($spreadsheet); + $writer->save($tempFile); + + return $tempFile; + } + + /** + * @param TimeEntry[] $timeEntries + */ + private function buildDetailSheet(Spreadsheet $spreadsheet, array $timeEntries): void + { + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('Détail'); + + // Headers + foreach (self::DETAIL_HEADERS as $col => $header) { + $colLetter = Coordinate::stringFromColumnIndex($col + 1); + $sheet->setCellValue("{$colLetter}1", $header); + } + $this->boldRow($sheet, 1, \count(self::DETAIL_HEADERS)); + + // Data rows + $row = 2; + foreach ($timeEntries as $entry) { + $duration = $this->computeDuration($entry); + $task = $entry->getTask(); + $taskLabel = ''; + if (null !== $task) { + $project = $task->getProject(); + $code = $project?->getCode() ?? ''; + $taskLabel = $code . '-' . $task->getNumber() . ' - ' . $task->getTitle(); + } + + $tagLabels = $entry->getTags()->map(fn ($t) => $t->getLabel() ?? '')->toArray(); + + $sheet->setCellValue("A{$row}", $entry->getStartedAt()->format('Y-m-d')); + $sheet->setCellValue("B{$row}", $entry->getUser()?->getUsername() ?? ''); + $sheet->setCellValue("C{$row}", $entry->getProject()?->getName() ?? ''); + $sheet->setCellValue("D{$row}", $taskLabel); + $sheet->setCellValue("E{$row}", $entry->getTitle() ?? ''); + $sheet->setCellValue("F{$row}", implode(', ', $tagLabels)); + $sheet->setCellValue("G{$row}", $entry->getStartedAt()->format('H:i')); + $sheet->setCellValue("H{$row}", $entry->getStoppedAt()?->format('H:i') ?? ''); + $sheet->setCellValue("I{$row}", round($duration, 2)); + $sheet->setCellValue("J{$row}", $entry->getDescription() ?? ''); + + ++$row; + } + + // Total row + if ($row > 2) { + $sheet->setCellValue("H{$row}", 'Total'); + $sheet->getStyle("H{$row}")->getFont()->setBold(true); + $sheet->setCellValue("I{$row}", "=SUM(I2:I" . ($row - 1) . ')'); + $sheet->getStyle("I{$row}")->getFont()->setBold(true); + } + + // Auto-size columns + foreach (range('A', 'J') as $col) { + $sheet->getColumnDimension($col)->setAutoSize(true); + } + } + + /** + * @param TimeEntry[] $timeEntries + */ + private function buildProjectRecapSheet(Spreadsheet $spreadsheet, array $timeEntries): void + { + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('Récap par projet'); + + // Aggregate: user → project → hours + $data = []; + $projects = []; + $users = []; + + foreach ($timeEntries as $entry) { + $userName = $entry->getUser()?->getUsername() ?? 'Inconnu'; + $projectName = $entry->getProject()?->getName() ?? 'Sans projet'; + $duration = $this->computeDuration($entry); + + $users[$userName] = true; + $projects[$projectName] = true; + $data[$userName][$projectName] = ($data[$userName][$projectName] ?? 0) + $duration; + } + + ksort($users); + ksort($projects); + $projectList = array_keys($projects); + $userList = array_keys($users); + + // Headers + $sheet->setCellValue('A1', 'Utilisateur'); + $col = 2; + foreach ($projectList as $project) { + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}1", $project); + ++$col; + } + $totalLetter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$totalLetter}1", 'Total'); + $this->boldRow($sheet, 1, $col); + + // Data rows + $row = 2; + foreach ($userList as $user) { + $sheet->setCellValue("A{$row}", $user); + $col = 2; + $userTotal = 0; + foreach ($projectList as $project) { + $val = round($data[$user][$project] ?? 0, 2); + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", $val); + $userTotal += $val; + ++$col; + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($userTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + ++$row; + } + + // Total row + $sheet->setCellValue("A{$row}", 'Total'); + $sheet->getStyle("A{$row}")->getFont()->setBold(true); + $col = 2; + foreach ($projectList as $project) { + $projectTotal = 0; + foreach ($userList as $user) { + $projectTotal += $data[$user][$project] ?? 0; + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($projectTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + ++$col; + } + // Grand total + $grandTotal = 0; + foreach ($data as $userData) { + foreach ($userData as $hours) { + $grandTotal += $hours; + } + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + + // Auto-size + for ($c = 1; $c <= $col; ++$c) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true); + } + } + + /** + * @param TimeEntry[] $timeEntries + */ + private function buildMonthRecapSheet(Spreadsheet $spreadsheet, array $timeEntries, \DateTimeImmutable $from, \DateTimeImmutable $to): void + { + $sheet = $spreadsheet->createSheet(); + $sheet->setTitle('Récap par mois'); + + // Build month columns from the date range + $months = []; + $current = $from->modify('first day of this month'); + $end = $to->modify('first day of this month'); + while ($current <= $end) { + $key = $current->format('Y-m'); + $label = self::MONTH_NAMES[(int) $current->format('n')] . ' ' . $current->format('Y'); + $months[$key] = $label; + $current = $current->modify('+1 month'); + } + + // Aggregate: user → month-key → hours + $data = []; + $users = []; + + foreach ($timeEntries as $entry) { + $userName = $entry->getUser()?->getUsername() ?? 'Inconnu'; + $monthKey = $entry->getStartedAt()->format('Y-m'); + $duration = $this->computeDuration($entry); + + $users[$userName] = true; + $data[$userName][$monthKey] = ($data[$userName][$monthKey] ?? 0) + $duration; + } + + ksort($users); + $userList = array_keys($users); + $monthKeys = array_keys($months); + + // Headers + $sheet->setCellValue('A1', 'Utilisateur'); + $col = 2; + foreach ($months as $label) { + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}1", $label); + ++$col; + } + $totalLetter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$totalLetter}1", 'Total'); + $this->boldRow($sheet, 1, $col); + + // Data rows + $row = 2; + foreach ($userList as $user) { + $sheet->setCellValue("A{$row}", $user); + $col = 2; + $userTotal = 0; + foreach ($monthKeys as $monthKey) { + $val = round($data[$user][$monthKey] ?? 0, 2); + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", $val); + $userTotal += $val; + ++$col; + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($userTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + ++$row; + } + + // Total row + $sheet->setCellValue("A{$row}", 'Total'); + $sheet->getStyle("A{$row}")->getFont()->setBold(true); + $col = 2; + foreach ($monthKeys as $monthKey) { + $monthTotal = 0; + foreach ($userList as $user) { + $monthTotal += $data[$user][$monthKey] ?? 0; + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($monthTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + ++$col; + } + $grandTotal = 0; + foreach ($data as $userData) { + foreach ($userData as $hours) { + $grandTotal += $hours; + } + } + $letter = Coordinate::stringFromColumnIndex($col); + $sheet->setCellValue("{$letter}{$row}", round($grandTotal, 2)); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + + // Auto-size + for ($c = 1; $c <= $col; ++$c) { + $sheet->getColumnDimension(Coordinate::stringFromColumnIndex($c))->setAutoSize(true); + } + } + + private function computeDuration(TimeEntry $entry): float + { + $start = $entry->getStartedAt(); + $end = $entry->getStoppedAt(); + + if (null === $start || null === $end) { + return 0; + } + + return ($end->getTimestamp() - $start->getTimestamp()) / 3600; + } + + private function boldRow(Worksheet $sheet, int $row, int $colCount): void + { + for ($c = 1; $c <= $colCount; ++$c) { + $letter = Coordinate::stringFromColumnIndex($c); + $sheet->getStyle("{$letter}{$row}")->getFont()->setBold(true); + } + } +} +``` + +- [ ] **Step 2: Verify no syntax errors** + +```bash +docker exec -t php-lesstime-fpm php -l src/Service/TimeEntryExportService.php +``` + +Expected: `No syntax errors detected` + +- [ ] **Step 3: Commit** + +```bash +git add src/Service/TimeEntryExportService.php +git commit -m "feat : add TimeEntryExportService generating XLSX with detail and recap sheets" +``` + +--- + +### Task 4: Create TimeEntryExportController + +**Files:** +- Create: `src/Controller/TimeEntryExportController.php` + +- [ ] **Step 1: Create the controller** + +Create `src/Controller/TimeEntryExportController.php`: + +```php +query->getString('after'); + $beforeStr = $request->query->getString('before'); + + if ('' === $afterStr || '' === $beforeStr) { + throw new BadRequestHttpException('Les paramètres "after" et "before" sont obligatoires.'); + } + + try { + $after = new \DateTimeImmutable($afterStr); + $before = new \DateTimeImmutable($beforeStr); + } catch (\Exception) { + throw new BadRequestHttpException('Format de date invalide. Utilisez YYYY-MM-DD.'); + } + + // Max range: 12 months + if ($after->modify('+12 months') < $before) { + throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.'); + } + + // Authorization: non-admin users can only export their own data + $user = null; + if (!$this->security->isGranted('ROLE_ADMIN')) { + /** @var User $user */ + $user = $this->security->getUser(); + } else { + $userId = $request->query->getInt('user'); + if ($userId > 0) { + $user = $this->entityManager->getRepository(User::class)->find($userId); + } + } + + $project = null; + $projectId = $request->query->getInt('project'); + if ($projectId > 0) { + $project = $this->entityManager->getRepository(Project::class)->find($projectId); + } + + /** @var int[] $tagIds */ + $tagIds = array_filter( + array_map('intval', (array) $request->query->all('tags')), + fn (int $id) => $id > 0, + ); + + $entries = $this->timeEntryRepository->findForExport( + $after, + $before, + $user, + $project, + $tagIds ?: null, + ); + + $tempFile = $this->exportService->generate($entries, $after, $before); + + $filename = sprintf('export-temps-%s_%s.xlsx', $after->format('Y-m-d'), $before->format('Y-m-d')); + + $response = new BinaryFileResponse($tempFile); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->deleteFileAfterSend(true); + + return $response; + } +} +``` + +- [ ] **Step 2: Verify no syntax errors** + +```bash +docker exec -t php-lesstime-fpm php -l src/Controller/TimeEntryExportController.php +``` + +Expected: `No syntax errors detected` + +- [ ] **Step 3: Clear cache and verify route is registered** + +```bash +docker exec -t php-lesstime-fpm php bin/console cache:clear +docker exec -t php-lesstime-fpm php bin/console debug:router | grep time_entry_export +``` + +Expected: line showing `time_entry_export` route mapped to `GET /api/time_entries/export` + +- [ ] **Step 4: Commit** + +```bash +git add src/Controller/TimeEntryExportController.php +git commit -m "feat : add TimeEntryExportController with auth, validation, and filters" +``` + +--- + +### Task 5: Manual backend smoke test + +- [ ] **Step 1: Test missing params returns 400** + +```bash +docker exec -t php-lesstime-fpm php bin/console debug:router time_entry_export +``` + +Then via curl (using admin fixture token): + +```bash +curl -s -o /dev/null -w "%{http_code}" -b "BEARER=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | grep -o '"token":"[^"]*"' | cut -d'"' -f4)" "http://localhost:8082/api/time_entries/export" +``` + +Expected: `400` + +- [ ] **Step 2: Test valid export returns XLSX** + +```bash +TOKEN=$(curl -s -X POST http://localhost:8082/login_check -H 'Content-Type: application/json' -d '{"username":"admin","password":"admin"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") +curl -s -o /tmp/test-export.xlsx -w "%{http_code}" -b "BEARER=${TOKEN}" "http://localhost:8082/api/time_entries/export?after=2025-01-01&before=2026-12-31" +echo "" +file /tmp/test-export.xlsx +``` + +Expected: HTTP `200`, file type contains `Microsoft Excel` or `Zip archive` + +- [ ] **Step 3: Commit (no changes — verification only)** + +--- + +### Task 6: Add frontend export method and i18n + +**Files:** +- Modify: `frontend/services/time-entries.ts` +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add `getExportUrl` method to time-entries service** + +Add this function inside `useTimeEntryService()` before the `return` statement in `frontend/services/time-entries.ts`: + +```typescript +function getExportUrl(params: { + after: string + before: string + user?: number + project?: number + tags?: number[] +}): string { + const query = new URLSearchParams() + query.set('after', params.after) + query.set('before', params.before) + if (params.user) query.set('user', String(params.user)) + if (params.project) query.set('project', String(params.project)) + if (params.tags?.length) { + params.tags.forEach(id => query.append('tags[]', String(id))) + } + return `/api/time_entries/export?${query.toString()}` +} +``` + +Update the return statement to include `getExportUrl`: + +```typescript +return { getByDateRange, getActive, create, update, remove, getExportUrl } +``` + +- [ ] **Step 2: Add i18n key** + +In `frontend/i18n/locales/fr.json`, add `"export": "Exporter"` inside the `"timeEntries"` object. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/services/time-entries.ts frontend/i18n/locales/fr.json +git commit -m "feat : add getExportUrl to time-entries service and i18n key" +``` + +--- + +### Task 7: Add export button to time-tracking page + +**Files:** +- Modify: `frontend/pages/time-tracking.vue` + +- [ ] **Step 1: Add export button in template** + +In `frontend/pages/time-tracking.vue`, find the `
` containing the `MalioSelect` for tags (the last filter). After its closing `
`, add: + +```vue + +``` + +- [ ] **Step 2: Add export function in script** + +Add this function in the `