# 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 `