778 lines
24 KiB
Markdown
778 lines
24 KiB
Markdown
# 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"
|
||
```
|