Files
Lesstime/docs/superpowers/plans/2026-03-24-time-entry-export.md
Matthieu 040cbfc588 docs : add time entry export implementation plan (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 15:54:06 +01:00

778 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\TimeEntry;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class TimeEntryExportService
{
private const array DETAIL_HEADERS = [
'Date', 'Utilisateur', 'Projet', 'Tâche', 'Titre',
'Tags', 'Début', 'Fin', 'Durée (h)', 'Description',
];
private const array MONTH_NAMES = [
1 => '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
<?php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Project;
use App\Entity\User;
use App\Repository\TimeEntryRepository;
use App\Service\TimeEntryExportService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class TimeEntryExportController extends AbstractController
{
public function __construct(
private readonly TimeEntryRepository $timeEntryRepository,
private readonly TimeEntryExportService $exportService,
private readonly EntityManagerInterface $entityManager,
private readonly Security $security,
) {}
#[Route('/api/time_entries/export', name: 'time_entry_export', methods: ['GET'], priority: 1)]
#[IsGranted('ROLE_USER')]
public function __invoke(Request $request): BinaryFileResponse
{
$afterStr = $request->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 `<div>` containing the `MalioSelect` for tags (the last filter). After its closing `</div>`, add:
```vue
<button
class="flex shrink-0 items-center gap-2 rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 transition"
@click="exportTimeEntries"
>
<Icon name="mdi:download" size="18" />
{{ $t('timeEntries.export') }}
</button>
```
- [ ] **Step 2: Add export function in script**
Add this function in the `<script setup>` section, after the existing helper functions (near `loadEntries`):
```typescript
function getExportDateRange(): { after: string, before: string } {
if (Array.isArray(selectedDateFilter.value) && selectedDateFilter.value.length === 2) {
return {
after: selectedDateFilter.value[0].toISOString().slice(0, 10),
before: selectedDateFilter.value[1].toISOString().slice(0, 10),
}
}
const end = new Date(startDate.value)
end.setDate(end.getDate() + (viewMode.value === 'day' ? 1 : 7))
return {
after: startDate.value.toISOString().slice(0, 10),
before: end.toISOString().slice(0, 10),
}
}
function exportTimeEntries() {
const { after, before } = getExportDateRange()
const url = timeEntryService.getExportUrl({
after,
before,
user: selectedUserId.value ?? undefined,
project: selectedProjectId.value ?? undefined,
tags: selectedTagId.value ? [selectedTagId.value] : undefined,
})
const a = document.createElement('a')
a.href = url
a.download = ''
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
```
- [ ] **Step 3: Verify dev server compiles without errors**
```bash
cd frontend && npx nuxi typecheck
```
Expected: no errors (or only pre-existing ones)
- [ ] **Step 4: Commit**
```bash
git add frontend/pages/time-tracking.vue
git commit -m "feat : add export button to time-tracking page"
```
---
### Task 8: End-to-end manual test
- [ ] **Step 1: Start dev server and test in browser**
1. Open `http://localhost:3002/time-tracking`
2. Verify the "Exporter" button appears in the filter bar
3. Select a date range with existing time entries
4. Click "Exporter"
5. Verify an `.xlsx` file downloads
- [ ] **Step 2: Open the XLSX and verify structure**
1. Feuille "Détail" — rows with Date, Utilisateur, Projet, etc. + total row
2. Feuille "Récap par projet" — users × projects cross-table
3. Feuille "Récap par mois" — users × months cross-table
- [ ] **Step 3: Test as non-admin user**
1. Log in as `alice` / `alice`
2. Export — verify only Alice's entries appear (even if user filter was different)
- [ ] **Step 4: Run PHP CS Fixer**
```bash
make php-cs-fixer-allow-risky
```
Fix any issues, then commit if needed:
```bash
git add -A && git commit -m "style : fix code style for time entry export"
```