docs : add time entry export implementation plan (LST-41)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
777
docs/superpowers/plans/2026-03-24-time-entry-export.md
Normal file
@@ -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
|
||||
<?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"
|
||||
```
|
||||
Reference in New Issue
Block a user