feat(time-tracking) : migrate TimeEntry into TimeTracking module (back)
First business module of Phase 2 (LST-64, rodage). Strangler-style, additive move — no behavioural change to the public API or MCP tools. - New module App\Module\TimeTracking (TimeTrackingModule, id "time-tracking", declares time-tracking.entries.view/export permissions in the RBAC catalog; operation security left on ROLE_USER, not re-wired here). - Move TimeEntry entity, repository (now interface + Doctrine impl bound in services.yaml), ActiveTimeEntryProvider, export service/controller and the 4 MCP TimeEntry tools into the module. #[ApiResource] (operations, security, uriTemplates /time_entries/*), filters and serialization groups preserved. - Doctrine mapping "TimeTracking" added; table time_entry unchanged. - Sidebar item gated with module "time-tracking" (SidebarFilter disables the route when the module is inactive). - Timestampable/Blamable adopted (first adopter): additive migration adds created_at/updated_at/created_by/updated_by (nullable, FK SET NULL) + COMMENT ON COLUMN. Functional test confirms created_at on persist and updated_at refresh on update — the suspected preUpdate recompute issue does not occur (Doctrine ORM 3.6.2 recomputes change sets after preUpdate). 159 tests green, schema mapping valid, php-cs-fixer clean.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Domain\Entity;
|
||||
|
||||
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
|
||||
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
|
||||
use ApiPlatform\Metadata\ApiFilter;
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskTag;
|
||||
use App\Module\TimeTracking\Infrastructure\ApiPlatform\State\ActiveTimeEntryProvider;
|
||||
use App\Module\TimeTracking\Infrastructure\Doctrine\DoctrineTimeEntryRepository;
|
||||
use App\Shared\Domain\Contract\BlamableInterface;
|
||||
use App\Shared\Domain\Contract\TimestampableInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\DBAL\Types\Types;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(security: "is_granted('ROLE_USER')"),
|
||||
new GetCollection(
|
||||
name: 'time_entries_range',
|
||||
uriTemplate: '/time_entries/range',
|
||||
description: 'List time entries for a bounded date range without pagination (used by the time-tracking calendar)',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new GetCollection(
|
||||
name: 'active_time_entry',
|
||||
uriTemplate: '/time_entries/active',
|
||||
provider: ActiveTimeEntryProvider::class,
|
||||
description: 'Get the active timer for the current user',
|
||||
paginationEnabled: false,
|
||||
security: "is_granted('ROLE_USER')",
|
||||
),
|
||||
new Get(security: "is_granted('ROLE_USER')"),
|
||||
new Post(security: "is_granted('ROLE_USER')"),
|
||||
new Patch(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
|
||||
new Delete(security: "is_granted('ROLE_ADMIN') or object.getUser() == user"),
|
||||
],
|
||||
normalizationContext: ['groups' => ['time_entry:read']],
|
||||
denormalizationContext: ['groups' => ['time_entry:write']],
|
||||
order: ['startedAt' => 'DESC'],
|
||||
)]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['user' => 'exact', 'project' => 'exact', 'tags' => 'exact'])]
|
||||
#[ApiFilter(DateFilter::class, properties: ['startedAt'])]
|
||||
#[ORM\Entity(repositoryClass: DoctrineTimeEntryRepository::class)]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_active_timer', columns: ['user_id'], options: ['where' => '(stopped_at IS NULL)'])]
|
||||
class TimeEntry implements TimestampableInterface, BlamableInterface
|
||||
{
|
||||
use TimestampableBlamableTrait;
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
#[Groups(['time_entry:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 255, nullable: true)]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?string $title = null;
|
||||
|
||||
#[ORM\Column(type: Types::TEXT, nullable: true)]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?string $description = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE)]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?DateTimeImmutable $startedAt = null;
|
||||
|
||||
#[ORM\Column(type: Types::DATETIMETZ_IMMUTABLE, nullable: true)]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?DateTimeImmutable $stoppedAt = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: UserInterface::class)]
|
||||
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?UserInterface $user = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Project::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?Project $project = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Task::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private ?Task $task = null;
|
||||
|
||||
/** @var Collection<int, TaskTag> */
|
||||
#[ORM\ManyToMany(targetEntity: TaskTag::class)]
|
||||
#[ORM\JoinTable(
|
||||
name: 'time_entry_task_type',
|
||||
joinColumns: [new ORM\JoinColumn(name: 'time_entry_id', referencedColumnName: 'id')],
|
||||
inverseJoinColumns: [new ORM\JoinColumn(name: 'task_type_id', referencedColumnName: 'id')],
|
||||
)]
|
||||
#[Groups(['time_entry:read', 'time_entry:write'])]
|
||||
private Collection $tags;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->tags = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function setTitle(?string $title): static
|
||||
{
|
||||
$this->title = $title;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): ?string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(?string $description): static
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStartedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->startedAt;
|
||||
}
|
||||
|
||||
public function setStartedAt(DateTimeImmutable $startedAt): static
|
||||
{
|
||||
$this->startedAt = $startedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getStoppedAt(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->stoppedAt;
|
||||
}
|
||||
|
||||
public function setStoppedAt(?DateTimeImmutable $stoppedAt): static
|
||||
{
|
||||
$this->stoppedAt = $stoppedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getUser(): ?UserInterface
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function setUser(?UserInterface $user): static
|
||||
{
|
||||
$this->user = $user;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getProject(): ?Project
|
||||
{
|
||||
return $this->project;
|
||||
}
|
||||
|
||||
public function setProject(?Project $project): static
|
||||
{
|
||||
$this->project = $project;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTask(): ?Task
|
||||
{
|
||||
return $this->task;
|
||||
}
|
||||
|
||||
public function setTask(?Task $task): static
|
||||
{
|
||||
$this->task = $task;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/** @return Collection<int, TaskTag> */
|
||||
public function getTags(): Collection
|
||||
{
|
||||
return $this->tags;
|
||||
}
|
||||
|
||||
public function addTag(TaskTag $tag): static
|
||||
{
|
||||
if (!$this->tags->contains($tag)) {
|
||||
$this->tags->add($tag);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeTag(TaskTag $tag): static
|
||||
{
|
||||
$this->tags->removeElement($tag);
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Domain\Repository;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
|
||||
interface TimeEntryRepositoryInterface
|
||||
{
|
||||
public function createQueryBuilder(string $alias, ?string $indexBy = null): QueryBuilder;
|
||||
|
||||
public function findById(int $id): ?TimeEntry;
|
||||
|
||||
public function findActiveByUser(UserInterface $user): ?TimeEntry;
|
||||
|
||||
/**
|
||||
* @param null|UserInterface[] $users
|
||||
* @param null|Project[] $projects
|
||||
* @param null|int[] $tagIds
|
||||
*
|
||||
* @return TimeEntry[]
|
||||
*/
|
||||
public function findForExport(
|
||||
DateTimeImmutable $after,
|
||||
DateTimeImmutable $before,
|
||||
?array $users = null,
|
||||
?array $projects = null,
|
||||
?array $tagIds = null,
|
||||
): array;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\ApiPlatform\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
/**
|
||||
* @implements ProviderInterface<TimeEntry>
|
||||
*/
|
||||
final readonly class ActiveTimeEntryProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$entry = $this->timeEntryRepository->findActiveByUser($user);
|
||||
|
||||
return $entry ? [$entry] : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Controller;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Module\Core\Domain\Entity\User;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Module\TimeTracking\Infrastructure\Export\TimeEntryExportService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
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 TimeEntryRepositoryInterface $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.');
|
||||
}
|
||||
|
||||
if ($after->modify('+12 months') < $before) {
|
||||
throw new BadRequestHttpException('La plage de dates ne peut pas dépasser 12 mois.');
|
||||
}
|
||||
|
||||
// --- Users ---
|
||||
$users = null;
|
||||
if (!$this->security->isGranted('ROLE_ADMIN')) {
|
||||
/** @var User $currentUser */
|
||||
$currentUser = $this->security->getUser();
|
||||
$users = [$currentUser];
|
||||
} else {
|
||||
/** @var int[] $userIds */
|
||||
$userIds = array_filter(
|
||||
array_map('intval', (array) $request->query->all('users')),
|
||||
fn (int $id) => $id > 0,
|
||||
);
|
||||
if ([] !== $userIds) {
|
||||
$users = $this->entityManager->getRepository(User::class)->findBy(['id' => $userIds]);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Client (filter projects by client) ---
|
||||
$clientId = $request->query->getInt('client');
|
||||
$clientProjects = null;
|
||||
if ($clientId > 0) {
|
||||
$clientProjects = $this->entityManager->getRepository(Project::class)->findBy(['client' => $clientId]);
|
||||
}
|
||||
|
||||
// --- Projects ---
|
||||
$projects = null;
|
||||
|
||||
/** @var int[] $projectIds */
|
||||
$projectIds = array_filter(
|
||||
array_map('intval', (array) $request->query->all('projects')),
|
||||
fn (int $id) => $id > 0,
|
||||
);
|
||||
if ([] !== $projectIds) {
|
||||
$projects = $this->entityManager->getRepository(Project::class)->findBy(['id' => $projectIds]);
|
||||
}
|
||||
|
||||
// Merge: if both client and projects are set, intersect; if only client, use client projects
|
||||
if (null !== $clientProjects && null !== $projects) {
|
||||
$clientProjectIds = array_map(fn (Project $p) => $p->getId(), $clientProjects);
|
||||
$projects = array_values(array_filter($projects, fn (Project $p) => in_array($p->getId(), $clientProjectIds, true)));
|
||||
if ([] === $projects) {
|
||||
$projects = null;
|
||||
// No matching projects — force empty result by using a dummy condition
|
||||
$entries = [];
|
||||
$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;
|
||||
}
|
||||
} elseif (null !== $clientProjects) {
|
||||
$projects = $clientProjects;
|
||||
}
|
||||
|
||||
/** @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,
|
||||
$users ?: null,
|
||||
$projects ?: null,
|
||||
$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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Doctrine;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Shared\Domain\Contract\UserInterface;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<TimeEntry>
|
||||
*/
|
||||
class DoctrineTimeEntryRepository extends ServiceEntityRepository implements TimeEntryRepositoryInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, TimeEntry::class);
|
||||
}
|
||||
|
||||
public function findById(int $id): ?TimeEntry
|
||||
{
|
||||
return $this->find($id);
|
||||
}
|
||||
|
||||
public function findActiveByUser(UserInterface $user): ?TimeEntry
|
||||
{
|
||||
return $this->findOneBy([
|
||||
'user' => $user,
|
||||
'stoppedAt' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param null|UserInterface[] $users
|
||||
* @param null|Project[] $projects
|
||||
* @param null|int[] $tagIds
|
||||
*
|
||||
* @return TimeEntry[]
|
||||
*/
|
||||
public function findForExport(
|
||||
DateTimeImmutable $after,
|
||||
DateTimeImmutable $before,
|
||||
?array $users = null,
|
||||
?array $projects = 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 !== $users && [] !== $users) {
|
||||
$qb->andWhere('te.user IN (:users)')
|
||||
->setParameter('users', $users)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $projects && [] !== $projects) {
|
||||
$qb->andWhere('te.project IN (:projects)')
|
||||
->setParameter('projects', $projects)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $tagIds && [] !== $tagIds) {
|
||||
$qb->join('te.tags', 'tag')
|
||||
->andWhere('tag.id IN (:tagIds)')
|
||||
->setParameter('tagIds', $tagIds)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Export;
|
||||
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use DateTimeImmutable;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
|
||||
use function count;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
|
||||
use App\Module\TimeTracking\Domain\Entity\TimeEntry;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'create-time-entry', description: 'Create a time entry. If stoppedAt is null, creates an active timer. Only one active timer per user is allowed.')]
|
||||
class CreateTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly DoctrineUserRepository $userRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int[] $tagIds IDs of the tags to attach
|
||||
*/
|
||||
public function __invoke(
|
||||
int $userId,
|
||||
string $startedAt,
|
||||
?string $title = null,
|
||||
?string $stoppedAt = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$user = $this->userRepository->find($userId);
|
||||
if (null === $user) {
|
||||
throw new InvalidArgumentException(sprintf('User with ID %d not found.', $userId));
|
||||
}
|
||||
|
||||
// Check for existing active timer if creating a new active one
|
||||
if (null === $stoppedAt) {
|
||||
$activeEntry = $this->timeEntryRepository->findActiveByUser($user);
|
||||
if (null !== $activeEntry) {
|
||||
throw new InvalidArgumentException(sprintf('User "%s" already has an active timer (ID %d). Stop it before starting a new one.', $user->getUsername(), $activeEntry->getId()));
|
||||
}
|
||||
}
|
||||
|
||||
$entry = new TimeEntry();
|
||||
$entry->setUser($user);
|
||||
$entry->setStartedAt(new DateTimeImmutable($startedAt));
|
||||
|
||||
if (null !== $title) {
|
||||
$entry->setTitle($title);
|
||||
}
|
||||
if (null !== $stoppedAt) {
|
||||
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
|
||||
}
|
||||
if (null !== $description) {
|
||||
$entry->setDescription($description);
|
||||
}
|
||||
if (null !== $projectId) {
|
||||
$project = $this->projectRepository->find($projectId);
|
||||
if (null === $project) {
|
||||
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
|
||||
}
|
||||
$entry->setProject($project);
|
||||
}
|
||||
if (null !== $taskId) {
|
||||
$task = $this->taskRepository->find($taskId);
|
||||
if (null === $task) {
|
||||
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
|
||||
}
|
||||
$entry->setTask($task);
|
||||
}
|
||||
if (null !== $tagIds) {
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tag = $this->taskTagRepository->find($tagId);
|
||||
if (null === $tag) {
|
||||
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
|
||||
}
|
||||
$entry->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->persist($entry);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::timeEntry($entry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'delete-time-entry', description: 'Delete a time entry permanently')]
|
||||
class DeleteTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(int $id): string
|
||||
{
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$entry = $this->timeEntryRepository->findById($id);
|
||||
|
||||
if (null === $entry) {
|
||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
$this->entityManager->remove($entry);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode([
|
||||
'success' => true,
|
||||
'message' => 'Time entry deleted.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use DateTimeImmutable;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
#[McpTool(name: 'list-time-entries', description: 'List time entries with optional filters. Duration is computed in minutes and null for active timers.')]
|
||||
class ListTimeEntriesTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
public function __invoke(
|
||||
?int $userId = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?string $startDate = null,
|
||||
?string $endDate = null,
|
||||
int $limit = 100,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$limit = min($limit, 200);
|
||||
|
||||
$qb = $this->timeEntryRepository->createQueryBuilder('te')
|
||||
->leftJoin('te.user', 'u')->addSelect('u')
|
||||
->leftJoin('te.project', 'p')->addSelect('p')
|
||||
->leftJoin('te.task', 't')->addSelect('t')
|
||||
->leftJoin('te.tags', 'tg')->addSelect('tg')
|
||||
->orderBy('te.startedAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
;
|
||||
|
||||
if (null !== $userId) {
|
||||
$qb->andWhere('u.id = :userId')->setParameter('userId', $userId);
|
||||
}
|
||||
if (null !== $projectId) {
|
||||
$qb->andWhere('p.id = :projectId')->setParameter('projectId', $projectId);
|
||||
}
|
||||
if (null !== $taskId) {
|
||||
$qb->andWhere('t.id = :taskId')->setParameter('taskId', $taskId);
|
||||
}
|
||||
if (null !== $startDate) {
|
||||
$qb->andWhere('te.startedAt >= :startDate')
|
||||
->setParameter('startDate', new DateTimeImmutable($startDate.' 00:00:00'))
|
||||
;
|
||||
}
|
||||
if (null !== $endDate) {
|
||||
$qb->andWhere('te.startedAt <= :endDate')
|
||||
->setParameter('endDate', new DateTimeImmutable($endDate.' 23:59:59'))
|
||||
;
|
||||
}
|
||||
|
||||
$entries = $qb->getQuery()->getResult();
|
||||
|
||||
return json_encode(array_map(Serializer::timeEntry(...), $entries));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking\Infrastructure\Mcp\Tool;
|
||||
|
||||
use App\Mcp\Tool\Serializer;
|
||||
use App\Module\TimeTracking\Domain\Repository\TimeEntryRepositoryInterface;
|
||||
use App\Repository\ProjectRepository;
|
||||
use App\Repository\TaskRepository;
|
||||
use App\Repository\TaskTagRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use InvalidArgumentException;
|
||||
use Mcp\Capability\Attribute\McpTool;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
#[McpTool(name: 'update-time-entry', description: 'Update a time entry. Use to stop an active timer by providing stoppedAt, or to correct start time. userId is not updatable.')]
|
||||
class UpdateTimeEntryTool
|
||||
{
|
||||
public function __construct(
|
||||
private readonly TimeEntryRepositoryInterface $timeEntryRepository,
|
||||
private readonly ProjectRepository $projectRepository,
|
||||
private readonly TaskRepository $taskRepository,
|
||||
private readonly TaskTagRepository $taskTagRepository,
|
||||
private readonly EntityManagerInterface $entityManager,
|
||||
private readonly Security $security,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param int[] $tagIds IDs of the tags to attach
|
||||
*/
|
||||
public function __invoke(
|
||||
int $id,
|
||||
?string $title = null,
|
||||
?string $startedAt = null,
|
||||
?string $stoppedAt = null,
|
||||
?int $projectId = null,
|
||||
?int $taskId = null,
|
||||
?array $tagIds = null,
|
||||
?string $description = null,
|
||||
): string {
|
||||
if (!$this->security->isGranted('ROLE_USER')) {
|
||||
throw new AccessDeniedException('Access denied: ROLE_USER required.');
|
||||
}
|
||||
|
||||
$entry = $this->timeEntryRepository->findById($id);
|
||||
|
||||
if (null === $entry) {
|
||||
throw new InvalidArgumentException(sprintf('TimeEntry with ID %d not found.', $id));
|
||||
}
|
||||
|
||||
if (null !== $title) {
|
||||
$entry->setTitle($title);
|
||||
}
|
||||
if (null !== $startedAt) {
|
||||
$entry->setStartedAt(new DateTimeImmutable($startedAt));
|
||||
}
|
||||
if (null !== $stoppedAt) {
|
||||
$entry->setStoppedAt(new DateTimeImmutable($stoppedAt));
|
||||
}
|
||||
if (null !== $description) {
|
||||
$entry->setDescription($description);
|
||||
}
|
||||
if (null !== $projectId) {
|
||||
$project = $this->projectRepository->find($projectId);
|
||||
if (null === $project) {
|
||||
throw new InvalidArgumentException(sprintf('Project with ID %d not found.', $projectId));
|
||||
}
|
||||
$entry->setProject($project);
|
||||
}
|
||||
if (null !== $taskId) {
|
||||
$task = $this->taskRepository->find($taskId);
|
||||
if (null === $task) {
|
||||
throw new InvalidArgumentException(sprintf('Task with ID %d not found.', $taskId));
|
||||
}
|
||||
$entry->setTask($task);
|
||||
}
|
||||
if (null !== $tagIds) {
|
||||
foreach ($entry->getTags()->toArray() as $existingTag) {
|
||||
$entry->removeTag($existingTag);
|
||||
}
|
||||
foreach ($tagIds as $tagId) {
|
||||
$tag = $this->taskTagRepository->find($tagId);
|
||||
if (null === $tag) {
|
||||
throw new InvalidArgumentException(sprintf('TaskTag with ID %d not found.', $tagId));
|
||||
}
|
||||
$entry->addTag($tag);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return json_encode(Serializer::timeEntry($entry));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Module\TimeTracking;
|
||||
|
||||
use App\Shared\Domain\Module\ModuleInterface;
|
||||
|
||||
final class TimeTrackingModule implements ModuleInterface
|
||||
{
|
||||
public static function id(): string
|
||||
{
|
||||
return 'time-tracking';
|
||||
}
|
||||
|
||||
public static function label(): string
|
||||
{
|
||||
return 'Suivi des temps';
|
||||
}
|
||||
|
||||
public static function isRequired(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permissions RBAC fin du Module TimeTracking (2.1).
|
||||
*
|
||||
* Additif : alimente le catalogue RBAC. La sécurité des opérations API
|
||||
* reste en ROLE_USER (non recâblée ici).
|
||||
*
|
||||
* @return list<array{code: string, label: string}>
|
||||
*/
|
||||
public static function permissions(): array
|
||||
{
|
||||
return [
|
||||
['code' => 'time-tracking.entries.view', 'label' => 'Voir les saisies de temps'],
|
||||
['code' => 'time-tracking.entries.export', 'label' => 'Exporter les saisies de temps'],
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user