From de98924fd392028c2cb93d7802f9b557b639ad27 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 21 May 2026 14:45:14 +0200 Subject: [PATCH 1/9] feat(absences) : fondation backend du module de gestion des absences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Module type Payfit (étapes 1+2 de la spec V1) : demande d'absence, validation admin, soldes à jour. - Enums : AbsenceType, AbsenceStatus, HalfDay, ContractType, FamilySituation - Entités : AbsencePolicy, AbsenceBalance, AbsenceRequest + champs RH sur User - Services : PublicHolidayProvider (fériés FR métropole en PHP pur, Computus), AbsenceDayCalculator (décompte jours ouvrés/ouvrables + demi-journées, TDD), AbsenceBalanceService (périodes + pending/taken/recrédit) - API Platform : providers/processors (création, approve/reject/cancel) + RBAC me/admin, contrôleurs preview (dry-run), upload/download justificatif, calendrier - Migrations : une par table + colonnes RH user (DEFAULT puis DROP DEFAULT) - Fixtures : 5 policies par défaut, salariés démo, soldes et demandes - Tests unitaires : PublicHolidayProvider, AbsenceDayCalculator (12 tests) Co-Authored-By: Claude Opus 4.7 (1M context) --- config/services.yaml | 9 + migrations/Version20260521123520.php | 42 +++ migrations/Version20260521123521.php | 49 +++ migrations/Version20260521123522.php | 64 ++++ migrations/Version20260521123523.php | 59 ++++ .../Absence/AbsenceCalendarController.php | 43 +++ ...AbsenceJustificationDownloadController.php | 64 ++++ .../AbsenceJustificationUploadController.php | 86 +++++ .../Absence/AbsencePreviewController.php | 95 +++++ src/DataFixtures/AppFixtures.php | 107 ++++++ src/Entity/AbsenceBalance.php | 163 +++++++++ src/Entity/AbsencePolicy.php | 171 +++++++++ src/Entity/AbsenceRequest.php | 326 ++++++++++++++++++ src/Entity/User.php | 176 +++++++++- src/Enum/AbsenceStatus.php | 23 ++ src/Enum/AbsenceType.php | 34 ++ src/Enum/ContractType.php | 25 ++ src/Enum/FamilySituation.php | 25 ++ src/Enum/HalfDay.php | 19 + src/Repository/AbsenceBalanceRepository.php | 31 ++ src/Repository/AbsencePolicyRepository.php | 26 ++ src/Repository/AbsenceRequestRepository.php | 74 ++++ src/Service/AbsenceBalanceService.php | 122 +++++++ src/Service/AbsenceDayCalculator.php | 73 ++++ src/Service/PublicHolidayProvider.php | 86 +++++ src/State/AbsenceBalanceProvider.php | 73 ++++ src/State/AbsenceCancelProcessor.php | 75 ++++ src/State/AbsenceRequestProcessor.php | 91 +++++ src/State/AbsenceRequestProvider.php | 82 +++++ src/State/AbsenceReviewProcessor.php | 80 +++++ .../Unit/Service/AbsenceDayCalculatorTest.php | 92 +++++ .../Service/PublicHolidayProviderTest.php | 72 ++++ 32 files changed, 2554 insertions(+), 3 deletions(-) create mode 100644 migrations/Version20260521123520.php create mode 100644 migrations/Version20260521123521.php create mode 100644 migrations/Version20260521123522.php create mode 100644 migrations/Version20260521123523.php create mode 100644 src/Controller/Absence/AbsenceCalendarController.php create mode 100644 src/Controller/Absence/AbsenceJustificationDownloadController.php create mode 100644 src/Controller/Absence/AbsenceJustificationUploadController.php create mode 100644 src/Controller/Absence/AbsencePreviewController.php create mode 100644 src/Entity/AbsenceBalance.php create mode 100644 src/Entity/AbsencePolicy.php create mode 100644 src/Entity/AbsenceRequest.php create mode 100644 src/Enum/AbsenceStatus.php create mode 100644 src/Enum/AbsenceType.php create mode 100644 src/Enum/ContractType.php create mode 100644 src/Enum/FamilySituation.php create mode 100644 src/Enum/HalfDay.php create mode 100644 src/Repository/AbsenceBalanceRepository.php create mode 100644 src/Repository/AbsencePolicyRepository.php create mode 100644 src/Repository/AbsenceRequestRepository.php create mode 100644 src/Service/AbsenceBalanceService.php create mode 100644 src/Service/AbsenceDayCalculator.php create mode 100644 src/Service/PublicHolidayProvider.php create mode 100644 src/State/AbsenceBalanceProvider.php create mode 100644 src/State/AbsenceCancelProcessor.php create mode 100644 src/State/AbsenceRequestProcessor.php create mode 100644 src/State/AbsenceRequestProvider.php create mode 100644 src/State/AbsenceReviewProcessor.php create mode 100644 tests/Unit/Service/AbsenceDayCalculatorTest.php create mode 100644 tests/Unit/Service/PublicHolidayProviderTest.php diff --git a/config/services.yaml b/config/services.yaml index f2713cc..1708a92 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -9,6 +9,7 @@ parameters: task_document_upload_dir: '%kernel.project_dir%/var/uploads/documents' avatar_upload_dir: '%kernel.project_dir%/var/uploads/avatars' + absence_justification_upload_dir: '%kernel.project_dir%/var/uploads/justificatifs' imports: - { resource: version.yaml } @@ -44,3 +45,11 @@ services: App\Controller\UserAvatarController: arguments: $avatarUploadDir: '%avatar_upload_dir%' + + App\Controller\Absence\AbsenceJustificationUploadController: + arguments: + $uploadDir: '%absence_justification_upload_dir%' + + App\Controller\Absence\AbsenceJustificationDownloadController: + arguments: + $uploadDir: '%absence_justification_upload_dir%' diff --git a/migrations/Version20260521123520.php b/migrations/Version20260521123520.php new file mode 100644 index 0000000..8a5fe59 --- /dev/null +++ b/migrations/Version20260521123520.php @@ -0,0 +1,42 @@ +addSql(<<<'SQL' + CREATE TABLE absence_policy ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + type VARCHAR(32) NOT NULL, + days_per_year DOUBLE PRECISION DEFAULT NULL, + days_per_event DOUBLE PRECISION DEFAULT NULL, + justification_required BOOLEAN NOT NULL, + notice_days INT NOT NULL, + count_working_days_only BOOLEAN NOT NULL, + active BOOLEAN NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE UNIQUE INDEX uniq_absence_policy_type ON absence_policy (type)'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE absence_policy'); + } +} diff --git a/migrations/Version20260521123521.php b/migrations/Version20260521123521.php new file mode 100644 index 0000000..ea7f4fb --- /dev/null +++ b/migrations/Version20260521123521.php @@ -0,0 +1,49 @@ +addSql(<<<'SQL' + CREATE TABLE absence_balance ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + type VARCHAR(32) NOT NULL, + period VARCHAR(16) NOT NULL, + acquired DOUBLE PRECISION NOT NULL, + taken DOUBLE PRECISION NOT NULL, + pending DOUBLE PRECISION NOT NULL, + user_id INT NOT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_65723A76A76ED395 ON absence_balance (user_id)'); + $this->addSql('CREATE UNIQUE INDEX uniq_absence_balance_user_type_period ON absence_balance (user_id, type, period)'); + $this->addSql(<<<'SQL' + ALTER TABLE + absence_balance + ADD + CONSTRAINT FK_65723A76A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE absence_balance DROP CONSTRAINT FK_65723A76A76ED395'); + $this->addSql('DROP TABLE absence_balance'); + } +} diff --git a/migrations/Version20260521123522.php b/migrations/Version20260521123522.php new file mode 100644 index 0000000..c41736b --- /dev/null +++ b/migrations/Version20260521123522.php @@ -0,0 +1,64 @@ +addSql(<<<'SQL' + CREATE TABLE absence_request ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + type VARCHAR(32) NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + start_half_day VARCHAR(16) DEFAULT NULL, + end_half_day VARCHAR(16) DEFAULT NULL, + counted_days DOUBLE PRECISION NOT NULL, + reason TEXT DEFAULT NULL, + justification_file_name VARCHAR(255) DEFAULT NULL, + status VARCHAR(16) NOT NULL, + rejection_reason TEXT DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + reviewed_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + user_id INT NOT NULL, + reviewed_by_id INT DEFAULT NULL, + PRIMARY KEY (id) + ) + SQL); + $this->addSql('CREATE INDEX IDX_F211AA17A76ED395 ON absence_request (user_id)'); + $this->addSql('CREATE INDEX IDX_F211AA17FC6B21F1 ON absence_request (reviewed_by_id)'); + $this->addSql(<<<'SQL' + ALTER TABLE + absence_request + ADD + CONSTRAINT FK_F211AA17A76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE NOT DEFERRABLE + SQL); + $this->addSql(<<<'SQL' + ALTER TABLE + absence_request + ADD + CONSTRAINT FK_F211AA17FC6B21F1 FOREIGN KEY (reviewed_by_id) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE + SQL); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17A76ED395'); + $this->addSql('ALTER TABLE absence_request DROP CONSTRAINT FK_F211AA17FC6B21F1'); + $this->addSql('DROP TABLE absence_request'); + } +} diff --git a/migrations/Version20260521123523.php b/migrations/Version20260521123523.php new file mode 100644 index 0000000..0cbb8d1 --- /dev/null +++ b/migrations/Version20260521123523.php @@ -0,0 +1,59 @@ +addSql('ALTER TABLE "user" ADD is_employee BOOLEAN NOT NULL DEFAULT false'); + $this->addSql('ALTER TABLE "user" ADD hire_date DATE DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD end_date DATE DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD contract_type VARCHAR(16) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD work_time_ratio DOUBLE PRECISION NOT NULL DEFAULT 1.0'); + $this->addSql('ALTER TABLE "user" ADD annual_leave_days DOUBLE PRECISION NOT NULL DEFAULT 25.0'); + $this->addSql("ALTER TABLE \"user\" ADD reference_period_start VARCHAR(5) NOT NULL DEFAULT '06-01'"); + $this->addSql('ALTER TABLE "user" ADD initial_leave_balance DOUBLE PRECISION NOT NULL DEFAULT 0'); + $this->addSql('ALTER TABLE "user" ADD family_situation VARCHAR(16) DEFAULT NULL'); + $this->addSql('ALTER TABLE "user" ADD nb_children INT NOT NULL DEFAULT 0'); + + // Defaults were only needed to backfill existing rows; the ORM mapping + // carries no DB default, so drop them to keep the schema in sync. + $this->addSql('ALTER TABLE "user" ALTER is_employee DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER work_time_ratio DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER annual_leave_days DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER reference_period_start DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER initial_leave_balance DROP DEFAULT'); + $this->addSql('ALTER TABLE "user" ALTER nb_children DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE "user" DROP is_employee'); + $this->addSql('ALTER TABLE "user" DROP hire_date'); + $this->addSql('ALTER TABLE "user" DROP end_date'); + $this->addSql('ALTER TABLE "user" DROP contract_type'); + $this->addSql('ALTER TABLE "user" DROP work_time_ratio'); + $this->addSql('ALTER TABLE "user" DROP annual_leave_days'); + $this->addSql('ALTER TABLE "user" DROP reference_period_start'); + $this->addSql('ALTER TABLE "user" DROP initial_leave_balance'); + $this->addSql('ALTER TABLE "user" DROP family_situation'); + $this->addSql('ALTER TABLE "user" DROP nb_children'); + } +} diff --git a/src/Controller/Absence/AbsenceCalendarController.php b/src/Controller/Absence/AbsenceCalendarController.php new file mode 100644 index 0000000..aec8f07 --- /dev/null +++ b/src/Controller/Absence/AbsenceCalendarController.php @@ -0,0 +1,43 @@ +query->get('from', ''); + $toRaw = (string) $request->query->get('to', ''); + + if ('' === $fromRaw || '' === $toRaw) { + throw new UnprocessableEntityHttpException('Query parameters "from" and "to" are required.'); + } + + $from = new DateTimeImmutable($fromRaw); + $to = new DateTimeImmutable($toRaw); + + $absences = $this->requestRepository->findInRange($from, $to); + + return $this->json($absences, context: ['groups' => ['absence_request:read']]); + } +} diff --git a/src/Controller/Absence/AbsenceJustificationDownloadController.php b/src/Controller/Absence/AbsenceJustificationDownloadController.php new file mode 100644 index 0000000..3a48dd8 --- /dev/null +++ b/src/Controller/Absence/AbsenceJustificationDownloadController.php @@ -0,0 +1,64 @@ +entityManager->getRepository(AbsenceRequest::class)->find($id); + if (null === $absence) { + throw new NotFoundHttpException('Absence request not found.'); + } + + if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) { + throw new AccessDeniedHttpException('You do not have access to this file.'); + } + + $fileName = $absence->getJustificationFileName(); + if (null === $fileName) { + throw new NotFoundHttpException('No justification file for this request.'); + } + + $filePath = $this->uploadDir.'/'.$fileName; + if (!file_exists($filePath)) { + throw new NotFoundHttpException('File not found on disk.'); + } + + $response = new BinaryFileResponse($filePath); + $mimeType = mime_content_type($filePath) ?: 'application/octet-stream'; + + $disposition = (str_starts_with($mimeType, 'image/') || 'application/pdf' === $mimeType) + ? ResponseHeaderBag::DISPOSITION_INLINE + : ResponseHeaderBag::DISPOSITION_ATTACHMENT; + + $response->setContentDisposition($disposition, $fileName); + $response->headers->set('Content-Type', $mimeType); + + return $response; + } +} diff --git a/src/Controller/Absence/AbsenceJustificationUploadController.php b/src/Controller/Absence/AbsenceJustificationUploadController.php new file mode 100644 index 0000000..5cfaa20 --- /dev/null +++ b/src/Controller/Absence/AbsenceJustificationUploadController.php @@ -0,0 +1,86 @@ + 'jpg', + 'image/png' => 'png', + 'image/webp' => 'webp', + 'application/pdf' => 'pdf', + ]; + + public function __construct( + private readonly EntityManagerInterface $entityManager, + private readonly Security $security, + private readonly string $uploadDir, + ) {} + + #[Route('/api/absence_requests/{id}/justificatif', name: 'absence_justification_upload', methods: ['POST'], priority: 1)] + #[IsGranted('ROLE_USER')] + public function __invoke(int $id, Request $request): JsonResponse + { + $absence = $this->entityManager->getRepository(AbsenceRequest::class)->find($id); + if (null === $absence) { + throw new NotFoundHttpException('Absence request not found.'); + } + + if (!$this->security->isGranted('ROLE_ADMIN') && $absence->getUser() !== $this->security->getUser()) { + throw new AccessDeniedHttpException('You can only attach a file to your own request.'); + } + + $file = $request->files->get('file'); + if (null === $file || !$file->isValid()) { + throw new BadRequestHttpException('No valid file uploaded.'); + } + if ($file->getSize() > self::MAX_FILE_SIZE) { + throw new BadRequestHttpException('File size exceeds 10 MB limit.'); + } + + $mimeType = $file->getMimeType() ?: 'application/octet-stream'; + if (!isset(self::MIME_TO_EXTENSION[$mimeType])) { + throw new BadRequestHttpException(sprintf('File type "%s" is not allowed (PDF or image only).', $mimeType)); + } + + $fileName = Uuid::v4()->toRfc4122().'.'.self::MIME_TO_EXTENSION[$mimeType]; + + if (!is_dir($this->uploadDir)) { + mkdir($this->uploadDir, 0o775, true); + } + + // Remove a previously uploaded file if any + $previous = $absence->getJustificationFileName(); + if (null !== $previous && file_exists($this->uploadDir.'/'.$previous)) { + unlink($this->uploadDir.'/'.$previous); + } + + $file->move($this->uploadDir, $fileName); + + $absence->setJustificationFileName($fileName); + $this->entityManager->flush(); + + return $this->json($absence, context: ['groups' => ['absence_request:read']]); + } +} diff --git a/src/Controller/Absence/AbsencePreviewController.php b/src/Controller/Absence/AbsencePreviewController.php new file mode 100644 index 0000000..c313cc2 --- /dev/null +++ b/src/Controller/Absence/AbsencePreviewController.php @@ -0,0 +1,95 @@ + $payload */ + $payload = json_decode($request->getContent(), true) ?? []; + + $type = AbsenceType::tryFrom((string) ($payload['type'] ?? '')); + if (null === $type) { + throw new UnprocessableEntityHttpException('Unknown absence type.'); + } + + $startRaw = (string) ($payload['startDate'] ?? ''); + $endRaw = (string) ($payload['endDate'] ?? ''); + if ('' === $startRaw || '' === $endRaw) { + throw new UnprocessableEntityHttpException('Start date and end date are required.'); + } + + $start = new DateTimeImmutable($startRaw); + $end = new DateTimeImmutable($endRaw); + if ($end < $start) { + throw new UnprocessableEntityHttpException('End date must be on or after start date.'); + } + + $policy = $this->policyRepository->findOneByType($type); + $workingDaysOnly = $policy?->isCountWorkingDaysOnly() ?? true; + + $countedDays = $this->calculator->countWorkingDays( + $start, + $end, + isset($payload['startHalfDay']) ? HalfDay::tryFrom((string) $payload['startHalfDay']) : null, + isset($payload['endHalfDay']) ? HalfDay::tryFrom((string) $payload['endHalfDay']) : null, + $workingDaysOnly, + ); + + $user = $this->security->getUser(); + assert($user instanceof User); + + $available = null; + $projectedAvailable = null; + $period = null; + + if ($type->decrementsBalance()) { + $period = $this->balanceService->periodFor($user, $type, $start); + $balance = $this->balanceRepository->findOneForPeriod($user, $type, $period); + $available = $balance?->getAvailable() ?? 0.0; + $projectedAvailable = $available - $countedDays; + } + + return $this->json([ + 'countedDays' => $countedDays, + 'period' => $period, + 'available' => $available, + 'projectedAvailable' => $projectedAvailable, + 'justificationRequired' => $policy?->isJustificationRequired() ?? false, + ]); + } +} diff --git a/src/DataFixtures/AppFixtures.php b/src/DataFixtures/AppFixtures.php index a6a1c03..90efbe8 100644 --- a/src/DataFixtures/AppFixtures.php +++ b/src/DataFixtures/AppFixtures.php @@ -4,6 +4,9 @@ declare(strict_types=1); namespace App\DataFixtures; +use App\Entity\AbsenceBalance; +use App\Entity\AbsencePolicy; +use App\Entity\AbsenceRequest; use App\Entity\Client; use App\Entity\ClientTicket; use App\Entity\MailConfiguration; @@ -19,6 +22,10 @@ use App\Entity\TimeEntry; use App\Entity\User; use App\Entity\Workflow; use App\Entity\ZimbraConfiguration; +use App\Enum\AbsenceStatus; +use App\Enum\AbsenceType; +use App\Enum\ContractType; +use App\Enum\FamilySituation; use App\Enum\RecurrenceType; use App\Enum\StatusCategory; use DateTimeImmutable; @@ -722,6 +729,106 @@ class AppFixtures extends Fixture $mailConfig->setEnabled(false); $manager->persist($mailConfig); + // ============================================= + // Absence management — policies, employees, balances, requests + // ============================================= + + // Default policies for the 5 absence types (legal defaults, editable by admin) + $policyData = [ + // [type, daysPerYear, daysPerEvent, justifRequired, noticeDays, workingDaysOnly] + [AbsenceType::PaidLeave, 25.0, null, false, 30, true], + [AbsenceType::MarriagePacs, null, 4.0, true, 0, true], + [AbsenceType::ParentalLeave, null, null, true, 30, true], + [AbsenceType::Bereavement, null, 3.0, true, 0, true], + [AbsenceType::SickLeave, null, null, true, 0, true], + ]; + + foreach ($policyData as [$type, $daysPerYear, $daysPerEvent, $justif, $notice, $workingDaysOnly]) { + $policy = new AbsencePolicy(); + $policy->setType($type); + $policy->setDaysPerYear($daysPerYear); + $policy->setDaysPerEvent($daysPerEvent); + $policy->setJustificationRequired($justif); + $policy->setNoticeDays($notice); + $policy->setCountWorkingDaysOnly($workingDaysOnly); + $policy->setActive(true); + $manager->persist($policy); + } + + // Mark internal users as employees + $admin->setIsEmployee(true); + $admin->setHireDate(new DateTimeImmutable('2020-01-15')); + $admin->setContractType(ContractType::Cdi); + $admin->setFamilySituation(FamilySituation::Married); + $admin->setNbChildren(2); + + $userAlice->setIsEmployee(true); + $userAlice->setHireDate(new DateTimeImmutable('2022-09-01')); + $userAlice->setContractType(ContractType::Cdi); + $userAlice->setFamilySituation(FamilySituation::Single); + + $userBob->setIsEmployee(true); + $userBob->setHireDate(new DateTimeImmutable('2023-03-10')); + $userBob->setContractType(ContractType::Cdd); + $userBob->setWorkTimeRatio(0.8); + $userBob->setFamilySituation(FamilySituation::Pacsed); + $userBob->setNbChildren(1); + + // Paid-leave balances for the current reference period (June 1st → May 31st) + $cpPeriod = '2025-2026'; + $balanceData = [ + // [user, acquired, taken, pending] + [$admin, 22.5, 5.0, 0.0], + [$userAlice, 18.0, 2.0, 5.0], + [$userBob, 14.0, 0.0, 0.0], + ]; + + foreach ($balanceData as [$bUser, $acquired, $taken, $pending]) { + $balance = new AbsenceBalance(); + $balance->setUser($bUser); + $balance->setType(AbsenceType::PaidLeave); + $balance->setPeriod($cpPeriod); + $balance->setAcquired($acquired); + $balance->setTaken($taken); + $balance->setPending($pending); + $manager->persist($balance); + } + + // Demo requests + $approvedCp = new AbsenceRequest(); + $approvedCp->setUser($admin); + $approvedCp->setType(AbsenceType::PaidLeave); + $approvedCp->setStartDate(new DateTimeImmutable('2026-04-13')); + $approvedCp->setEndDate(new DateTimeImmutable('2026-04-17')); + $approvedCp->setCountedDays(5.0); + $approvedCp->setReason('Vacances de printemps'); + $approvedCp->setStatus(AbsenceStatus::Approved); + $approvedCp->setCreatedAt(new DateTimeImmutable('2026-03-10')); + $approvedCp->setReviewedAt(new DateTimeImmutable('2026-03-11')); + $approvedCp->setReviewedBy($admin); + $manager->persist($approvedCp); + + $pendingCp = new AbsenceRequest(); + $pendingCp->setUser($userAlice); + $pendingCp->setType(AbsenceType::PaidLeave); + $pendingCp->setStartDate(new DateTimeImmutable('2026-06-15')); + $pendingCp->setEndDate(new DateTimeImmutable('2026-06-19')); + $pendingCp->setCountedDays(5.0); + $pendingCp->setStatus(AbsenceStatus::Pending); + $pendingCp->setCreatedAt(new DateTimeImmutable('-2 days')); + $manager->persist($pendingCp); + + $pendingMarriage = new AbsenceRequest(); + $pendingMarriage->setUser($userBob); + $pendingMarriage->setType(AbsenceType::MarriagePacs); + $pendingMarriage->setStartDate(new DateTimeImmutable('2026-07-06')); + $pendingMarriage->setEndDate(new DateTimeImmutable('2026-07-09')); + $pendingMarriage->setCountedDays(4.0); + $pendingMarriage->setReason('Mariage'); + $pendingMarriage->setStatus(AbsenceStatus::Pending); + $pendingMarriage->setCreatedAt(new DateTimeImmutable('-1 day')); + $manager->persist($pendingMarriage); + $manager->flush(); } } diff --git a/src/Entity/AbsenceBalance.php b/src/Entity/AbsenceBalance.php new file mode 100644 index 0000000..32977a6 --- /dev/null +++ b/src/Entity/AbsenceBalance.php @@ -0,0 +1,163 @@ + ['absence_balance:read']], + denormalizationContext: ['groups' => ['absence_balance:write']], +)] +#[ORM\Entity(repositoryClass: AbsenceBalanceRepository::class)] +#[ORM\Table(name: 'absence_balance')] +#[ORM\UniqueConstraint(name: 'uniq_absence_balance_user_type_period', columns: ['user_id', 'type', 'period'])] +class AbsenceBalance +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['absence_balance:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['absence_balance:read'])] + private ?User $user = null; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)] + #[Groups(['absence_balance:read'])] + private AbsenceType $type; + + /** Reference period, e.g. "2025-2026" for paid leave or "2025" for yearly. */ + #[ORM\Column(length: 16)] + #[Groups(['absence_balance:read'])] + private ?string $period = null; + + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['absence_balance:read', 'absence_balance:write'])] + private float $acquired = 0.0; + + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['absence_balance:read', 'absence_balance:write'])] + private float $taken = 0.0; + + /** Sum of days in PENDING requests, for information. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['absence_balance:read'])] + private float $pending = 0.0; + + #[Groups(['absence_balance:read'])] + public function getAvailable(): float + { + return $this->acquired - $this->taken; + } + + #[Groups(['absence_balance:read'])] + public function getLabel(): string + { + return $this->type->label(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getType(): AbsenceType + { + return $this->type; + } + + public function setType(AbsenceType $type): static + { + $this->type = $type; + + return $this; + } + + public function getPeriod(): ?string + { + return $this->period; + } + + public function setPeriod(string $period): static + { + $this->period = $period; + + return $this; + } + + public function getAcquired(): float + { + return $this->acquired; + } + + public function setAcquired(float $acquired): static + { + $this->acquired = $acquired; + + return $this; + } + + public function getTaken(): float + { + return $this->taken; + } + + public function setTaken(float $taken): static + { + $this->taken = $taken; + + return $this; + } + + public function getPending(): float + { + return $this->pending; + } + + public function setPending(float $pending): static + { + $this->pending = $pending; + + return $this; + } +} diff --git a/src/Entity/AbsencePolicy.php b/src/Entity/AbsencePolicy.php new file mode 100644 index 0000000..00abb17 --- /dev/null +++ b/src/Entity/AbsencePolicy.php @@ -0,0 +1,171 @@ + ['absence_policy:read']], + denormalizationContext: ['groups' => ['absence_policy:write']], + order: ['type' => 'ASC'], +)] +#[ORM\Entity(repositoryClass: AbsencePolicyRepository::class)] +#[ORM\Table(name: 'absence_policy')] +#[ORM\UniqueConstraint(name: 'uniq_absence_policy_type', columns: ['type'])] +class AbsencePolicy +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['absence_policy:read'])] + private ?int $id = null; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)] + #[Groups(['absence_policy:read', 'absence_balance:read', 'absence_request:read'])] + private AbsenceType $type; + + /** Yearly entitlement (e.g. 25 for paid leave); null when not relevant. */ + #[ORM\Column(type: Types::FLOAT, nullable: true)] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private ?float $daysPerYear = null; + + /** Days granted per event (e.g. 4 for marriage); null when not relevant. */ + #[ORM\Column(type: Types::FLOAT, nullable: true)] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private ?float $daysPerEvent = null; + + #[ORM\Column] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private bool $justificationRequired = false; + + /** Minimum notice period in days (e.g. 30 for paid leave, 0 for sick leave). */ + #[ORM\Column] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private int $noticeDays = 0; + + /** true => "jours ouvrés" (Mon-Fri), false => "jours ouvrables" (Mon-Sat). */ + #[ORM\Column] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private bool $countWorkingDaysOnly = true; + + #[ORM\Column] + #[Groups(['absence_policy:read', 'absence_policy:write'])] + private bool $active = true; + + #[Groups(['absence_policy:read'])] + public function getLabel(): string + { + return $this->type->label(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getType(): AbsenceType + { + return $this->type; + } + + public function setType(AbsenceType $type): static + { + $this->type = $type; + + return $this; + } + + public function getDaysPerYear(): ?float + { + return $this->daysPerYear; + } + + public function setDaysPerYear(?float $daysPerYear): static + { + $this->daysPerYear = $daysPerYear; + + return $this; + } + + public function getDaysPerEvent(): ?float + { + return $this->daysPerEvent; + } + + public function setDaysPerEvent(?float $daysPerEvent): static + { + $this->daysPerEvent = $daysPerEvent; + + return $this; + } + + public function isJustificationRequired(): bool + { + return $this->justificationRequired; + } + + public function setJustificationRequired(bool $justificationRequired): static + { + $this->justificationRequired = $justificationRequired; + + return $this; + } + + public function getNoticeDays(): int + { + return $this->noticeDays; + } + + public function setNoticeDays(int $noticeDays): static + { + $this->noticeDays = $noticeDays; + + return $this; + } + + public function isCountWorkingDaysOnly(): bool + { + return $this->countWorkingDaysOnly; + } + + public function setCountWorkingDaysOnly(bool $countWorkingDaysOnly): static + { + $this->countWorkingDaysOnly = $countWorkingDaysOnly; + + return $this; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(bool $active): static + { + $this->active = $active; + + return $this; + } +} diff --git a/src/Entity/AbsenceRequest.php b/src/Entity/AbsenceRequest.php new file mode 100644 index 0000000..5045bdf --- /dev/null +++ b/src/Entity/AbsenceRequest.php @@ -0,0 +1,326 @@ + ['absence_request:read']], + denormalizationContext: ['groups' => ['absence_request:write']], + order: ['createdAt' => 'DESC'], +)] +#[ORM\Entity(repositoryClass: AbsenceRequestRepository::class)] +#[ORM\Table(name: 'absence_request')] +class AbsenceRequest +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['absence_request:read'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + #[Groups(['absence_request:read'])] + private ?User $user = null; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: AbsenceType::class)] + #[Groups(['absence_request:read', 'absence_request:write'])] + #[Assert\NotNull] + private ?AbsenceType $type = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Groups(['absence_request:read', 'absence_request:write'])] + #[Assert\NotNull] + private ?DateTimeImmutable $startDate = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE)] + #[Groups(['absence_request:read', 'absence_request:write'])] + #[Assert\NotNull] + private ?DateTimeImmutable $endDate = null; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)] + #[Groups(['absence_request:read', 'absence_request:write'])] + private ?HalfDay $startHalfDay = null; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: HalfDay::class)] + #[Groups(['absence_request:read', 'absence_request:write'])] + private ?HalfDay $endHalfDay = null; + + /** Number of deducted days, computed server-side at creation. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['absence_request:read'])] + private float $countedDays = 0.0; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['absence_request:read', 'absence_request:write'])] + private ?string $reason = null; + + #[ORM\Column(length: 255, nullable: true)] + #[Groups(['absence_request:read'])] + private ?string $justificationFileName = null; + + #[ORM\Column(type: Types::STRING, length: 16, enumType: AbsenceStatus::class)] + #[Groups(['absence_request:read'])] + private AbsenceStatus $status = AbsenceStatus::Pending; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + #[Groups(['absence_request:read', 'absence_request:write'])] + private ?string $rejectionReason = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE)] + #[Groups(['absence_request:read'])] + private ?DateTimeImmutable $createdAt = null; + + #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] + #[Groups(['absence_request:read'])] + private ?DateTimeImmutable $reviewedAt = null; + + #[ORM\ManyToOne(targetEntity: User::class)] + #[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')] + #[Groups(['absence_request:read'])] + private ?User $reviewedBy = null; + + #[Groups(['absence_request:read'])] + public function getLabel(): ?string + { + return $this->type?->label(); + } + + #[Groups(['absence_request:read'])] + public function getJustificationUrl(): ?string + { + if (null === $this->justificationFileName) { + return null; + } + + return '/api/absence_requests/'.$this->id.'/justificatif'; + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getType(): ?AbsenceType + { + return $this->type; + } + + public function setType(?AbsenceType $type): static + { + $this->type = $type; + + return $this; + } + + public function getStartDate(): ?DateTimeImmutable + { + return $this->startDate; + } + + public function setStartDate(?DateTimeImmutable $startDate): static + { + $this->startDate = $startDate; + + return $this; + } + + public function getEndDate(): ?DateTimeImmutable + { + return $this->endDate; + } + + public function setEndDate(?DateTimeImmutable $endDate): static + { + $this->endDate = $endDate; + + return $this; + } + + public function getStartHalfDay(): ?HalfDay + { + return $this->startHalfDay; + } + + public function setStartHalfDay(?HalfDay $startHalfDay): static + { + $this->startHalfDay = $startHalfDay; + + return $this; + } + + public function getEndHalfDay(): ?HalfDay + { + return $this->endHalfDay; + } + + public function setEndHalfDay(?HalfDay $endHalfDay): static + { + $this->endHalfDay = $endHalfDay; + + return $this; + } + + public function getCountedDays(): float + { + return $this->countedDays; + } + + public function setCountedDays(float $countedDays): static + { + $this->countedDays = $countedDays; + + return $this; + } + + public function getReason(): ?string + { + return $this->reason; + } + + public function setReason(?string $reason): static + { + $this->reason = $reason; + + return $this; + } + + public function getJustificationFileName(): ?string + { + return $this->justificationFileName; + } + + public function setJustificationFileName(?string $justificationFileName): static + { + $this->justificationFileName = $justificationFileName; + + return $this; + } + + public function getStatus(): AbsenceStatus + { + return $this->status; + } + + public function setStatus(AbsenceStatus $status): static + { + $this->status = $status; + + return $this; + } + + public function getRejectionReason(): ?string + { + return $this->rejectionReason; + } + + public function setRejectionReason(?string $rejectionReason): static + { + $this->rejectionReason = $rejectionReason; + + return $this; + } + + public function getCreatedAt(): ?DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getReviewedAt(): ?DateTimeImmutable + { + return $this->reviewedAt; + } + + public function setReviewedAt(?DateTimeImmutable $reviewedAt): static + { + $this->reviewedAt = $reviewedAt; + + return $this; + } + + public function getReviewedBy(): ?User + { + return $this->reviewedBy; + } + + public function setReviewedBy(?User $reviewedBy): static + { + $this->reviewedBy = $reviewedBy; + + return $this; + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 11067e0..e796ae4 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -10,6 +10,8 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; +use App\Enum\ContractType; +use App\Enum\FamilySituation; use App\Repository\UserRepository; use App\State\MeProvider; use App\State\UserPasswordHasherProcessor; @@ -48,11 +50,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] - #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] private ?int $id = null; #[ORM\Column(length: 180, unique: true)] - #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'user:write', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] private ?string $username = null; /** @var list */ @@ -87,6 +89,54 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[Groups(['me:read', 'user:list', 'user:write'])] private Collection $allowedProjects; + // --- HR / absence management fields --- + + /** Whether this user is an employee subject to absence management. */ + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private bool $isEmployee = false; + + /** Hiring date — start of paid-leave acquisition. */ + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?DateTimeImmutable $hireDate = null; + + #[ORM\Column(type: Types::DATE_IMMUTABLE, nullable: true)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?DateTimeImmutable $endDate = null; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: ContractType::class)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?ContractType $contractType = null; + + /** Work-time ratio: 1.0 = full time, 0.8 = 4 days out of 5. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $workTimeRatio = 1.0; + + /** Yearly paid-leave entitlement in worked days (default 25 = jours ouvrés). */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $annualLeaveDays = 25.0; + + /** Reference period start as MM-DD (default 06-01, 1st of June). */ + #[ORM\Column(length: 5)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private string $referencePeriodStart = '06-01'; + + /** Paid-leave already acquired when the module is rolled out. */ + #[ORM\Column(type: Types::FLOAT)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private float $initialLeaveBalance = 0.0; + + #[ORM\Column(type: Types::STRING, length: 16, nullable: true, enumType: FamilySituation::class)] + #[Groups(['me:read', 'user:list', 'user:write'])] + private ?FamilySituation $familySituation = null; + + #[ORM\Column] + #[Groups(['me:read', 'user:list', 'user:write'])] + private int $nbChildren = 0; + public function __construct() { $this->createdAt = new DateTimeImmutable(); @@ -217,7 +267,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface return $this; } - #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read'])] + #[Groups(['me:read', 'task:read', 'user:list', 'time_entry:read', 'client_ticket:read', 'absence_request:read', 'absence_balance:read'])] public function getAvatarUrl(): ?string { if (null === $this->avatarFileName) { @@ -243,4 +293,124 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface { $this->plainPassword = null; } + + public function isEmployee(): bool + { + return $this->isEmployee; + } + + public function setIsEmployee(bool $isEmployee): static + { + $this->isEmployee = $isEmployee; + + return $this; + } + + public function getHireDate(): ?DateTimeImmutable + { + return $this->hireDate; + } + + public function setHireDate(?DateTimeImmutable $hireDate): static + { + $this->hireDate = $hireDate; + + return $this; + } + + public function getEndDate(): ?DateTimeImmutable + { + return $this->endDate; + } + + public function setEndDate(?DateTimeImmutable $endDate): static + { + $this->endDate = $endDate; + + return $this; + } + + public function getContractType(): ?ContractType + { + return $this->contractType; + } + + public function setContractType(?ContractType $contractType): static + { + $this->contractType = $contractType; + + return $this; + } + + public function getWorkTimeRatio(): float + { + return $this->workTimeRatio; + } + + public function setWorkTimeRatio(float $workTimeRatio): static + { + $this->workTimeRatio = $workTimeRatio; + + return $this; + } + + public function getAnnualLeaveDays(): float + { + return $this->annualLeaveDays; + } + + public function setAnnualLeaveDays(float $annualLeaveDays): static + { + $this->annualLeaveDays = $annualLeaveDays; + + return $this; + } + + public function getReferencePeriodStart(): string + { + return $this->referencePeriodStart; + } + + public function setReferencePeriodStart(string $referencePeriodStart): static + { + $this->referencePeriodStart = $referencePeriodStart; + + return $this; + } + + public function getInitialLeaveBalance(): float + { + return $this->initialLeaveBalance; + } + + public function setInitialLeaveBalance(float $initialLeaveBalance): static + { + $this->initialLeaveBalance = $initialLeaveBalance; + + return $this; + } + + public function getFamilySituation(): ?FamilySituation + { + return $this->familySituation; + } + + public function setFamilySituation(?FamilySituation $familySituation): static + { + $this->familySituation = $familySituation; + + return $this; + } + + public function getNbChildren(): int + { + return $this->nbChildren; + } + + public function setNbChildren(int $nbChildren): static + { + $this->nbChildren = $nbChildren; + + return $this; + } } diff --git a/src/Enum/AbsenceStatus.php b/src/Enum/AbsenceStatus.php new file mode 100644 index 0000000..ce45714 --- /dev/null +++ b/src/Enum/AbsenceStatus.php @@ -0,0 +1,23 @@ + 'En attente', + self::Approved => 'Approuvée', + self::Rejected => 'Refusée', + self::Cancelled => 'Annulée', + }; + } +} diff --git a/src/Enum/AbsenceType.php b/src/Enum/AbsenceType.php new file mode 100644 index 0000000..c868e32 --- /dev/null +++ b/src/Enum/AbsenceType.php @@ -0,0 +1,34 @@ + 'Congés payés', + self::MarriagePacs => 'Mariage / PACS', + self::ParentalLeave => 'Congé parental', + self::Bereavement => 'Décès proche', + self::SickLeave => 'Arrêt maladie', + }; + } + + /** + * Whether taking this absence decrements a balance. + * Sick leave is managed by social security and has no balance. + */ + public function decrementsBalance(): bool + { + return self::SickLeave !== $this; + } +} diff --git a/src/Enum/ContractType.php b/src/Enum/ContractType.php new file mode 100644 index 0000000..fe1d7b1 --- /dev/null +++ b/src/Enum/ContractType.php @@ -0,0 +1,25 @@ + 'CDI', + self::Cdd => 'CDD', + self::Internship => 'Stage', + self::Apprentice => 'Alternance', + self::Other => 'Autre', + }; + } +} diff --git a/src/Enum/FamilySituation.php b/src/Enum/FamilySituation.php new file mode 100644 index 0000000..938498b --- /dev/null +++ b/src/Enum/FamilySituation.php @@ -0,0 +1,25 @@ + 'Célibataire', + self::Married => 'Marié(e)', + self::Pacsed => 'Pacsé(e)', + self::Divorced => 'Divorcé(e)', + self::Widowed => 'Veuf(ve)', + }; + } +} diff --git a/src/Enum/HalfDay.php b/src/Enum/HalfDay.php new file mode 100644 index 0000000..fa868f3 --- /dev/null +++ b/src/Enum/HalfDay.php @@ -0,0 +1,19 @@ + 'Matin', + self::Afternoon => 'Après-midi', + }; + } +} diff --git a/src/Repository/AbsenceBalanceRepository.php b/src/Repository/AbsenceBalanceRepository.php new file mode 100644 index 0000000..9e74b73 --- /dev/null +++ b/src/Repository/AbsenceBalanceRepository.php @@ -0,0 +1,31 @@ + + */ +class AbsenceBalanceRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AbsenceBalance::class); + } + + public function findOneForPeriod(User $user, AbsenceType $type, string $period): ?AbsenceBalance + { + return $this->findOneBy([ + 'user' => $user, + 'type' => $type, + 'period' => $period, + ]); + } +} diff --git a/src/Repository/AbsencePolicyRepository.php b/src/Repository/AbsencePolicyRepository.php new file mode 100644 index 0000000..b1467a7 --- /dev/null +++ b/src/Repository/AbsencePolicyRepository.php @@ -0,0 +1,26 @@ + + */ +class AbsencePolicyRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AbsencePolicy::class); + } + + public function findOneByType(AbsenceType $type): ?AbsencePolicy + { + return $this->findOneBy(['type' => $type]); + } +} diff --git a/src/Repository/AbsenceRequestRepository.php b/src/Repository/AbsenceRequestRepository.php new file mode 100644 index 0000000..620755b --- /dev/null +++ b/src/Repository/AbsenceRequestRepository.php @@ -0,0 +1,74 @@ + + */ +class AbsenceRequestRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, AbsenceRequest::class); + } + + /** + * Whether the user already has a PENDING or APPROVED absence that overlaps + * the given date range. Two ranges overlap when start_a <= end_b and + * end_a >= start_b. + */ + public function hasOverlap( + User $user, + DateTimeInterface $startDate, + DateTimeInterface $endDate, + ?int $excludeId = null, + ): bool { + $qb = $this->createQueryBuilder('a') + ->select('COUNT(a.id)') + ->andWhere('a.user = :user') + ->andWhere('a.status IN (:statuses)') + ->andWhere('a.startDate <= :endDate') + ->andWhere('a.endDate >= :startDate') + ->setParameter('user', $user) + ->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved]) + ->setParameter('startDate', $startDate->format('Y-m-d')) + ->setParameter('endDate', $endDate->format('Y-m-d')) + ; + + if (null !== $excludeId) { + $qb->andWhere('a.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return (int) $qb->getQuery()->getSingleScalarResult() > 0; + } + + /** + * Absences (approved or pending) overlapping a date range, all employees — + * used by the admin calendar view. + * + * @return AbsenceRequest[] + */ + public function findInRange(DateTimeInterface $from, DateTimeInterface $to): array + { + return $this->createQueryBuilder('a') + ->andWhere('a.status IN (:statuses)') + ->andWhere('a.startDate <= :to') + ->andWhere('a.endDate >= :from') + ->setParameter('statuses', [AbsenceStatus::Pending, AbsenceStatus::Approved]) + ->setParameter('from', $from->format('Y-m-d')) + ->setParameter('to', $to->format('Y-m-d')) + ->orderBy('a.startDate', 'ASC') + ->getQuery() + ->getResult() + ; + } +} diff --git a/src/Service/AbsenceBalanceService.php b/src/Service/AbsenceBalanceService.php new file mode 100644 index 0000000..399cb64 --- /dev/null +++ b/src/Service/AbsenceBalanceService.php @@ -0,0 +1,122 @@ +format('Y'); + } + + $year = (int) $date->format('Y'); + $startMonthDay = $user->getReferencePeriodStart(); // e.g. "06-01" + $currentMonthDay = $date->format('m-d'); + + $startYear = $currentMonthDay >= $startMonthDay ? $year : $year - 1; + + return sprintf('%d-%d', $startYear, $startYear + 1); + } + + public function getOrCreateBalance(User $user, AbsenceType $type, string $period): AbsenceBalance + { + $balance = $this->balanceRepository->findOneForPeriod($user, $type, $period); + + if (null === $balance) { + $balance = new AbsenceBalance() + ->setUser($user) + ->setType($type) + ->setPeriod($period) + ; + $this->entityManager->persist($balance); + } + + return $balance; + } + + /** Reserve the requested days in the PENDING bucket. */ + public function reservePending(AbsenceRequest $request): void + { + if (!$this->shouldTrack($request)) { + return; + } + + $balance = $this->balanceForRequest($request); + $balance->setPending($balance->getPending() + $request->getCountedDays()); + } + + /** Move reserved days from PENDING to TAKEN on approval. */ + public function applyApproval(AbsenceRequest $request): void + { + if (!$this->shouldTrack($request)) { + return; + } + + $balance = $this->balanceForRequest($request); + $balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays())); + $balance->setTaken($balance->getTaken() + $request->getCountedDays()); + } + + /** + * Give days back when a request is cancelled or rejected. + * + * @param bool $wasApproved true if the request had already been approved + * (days were in TAKEN), false if still PENDING + */ + public function release(AbsenceRequest $request, bool $wasApproved): void + { + if (!$this->shouldTrack($request)) { + return; + } + + $balance = $this->balanceForRequest($request); + + if ($wasApproved) { + $balance->setTaken(max(0.0, $balance->getTaken() - $request->getCountedDays())); + } else { + $balance->setPending(max(0.0, $balance->getPending() - $request->getCountedDays())); + } + } + + private function balanceForRequest(AbsenceRequest $request): AbsenceBalance + { + /** @var User $user */ + $user = $request->getUser(); + $type = $request->getType(); + $period = $this->periodFor($user, $type, $request->getStartDate()); + + return $this->getOrCreateBalance($user, $type, $period); + } + + private function shouldTrack(AbsenceRequest $request): bool + { + $type = $request->getType(); + + return null !== $type && $type->decrementsBalance() && null !== $request->getUser(); + } +} diff --git a/src/Service/AbsenceDayCalculator.php b/src/Service/AbsenceDayCalculator.php new file mode 100644 index 0000000..fd8ed5a --- /dev/null +++ b/src/Service/AbsenceDayCalculator.php @@ -0,0 +1,73 @@ + "jours ouvrés" (Mon-Fri), + * false => "jours ouvrables" (Mon-Sat, Sunday excluded) + */ + public function countWorkingDays( + DateTimeImmutable $start, + DateTimeImmutable $end, + ?HalfDay $startHalfDay = null, + ?HalfDay $endHalfDay = null, + bool $workingDaysOnly = true, + ): float { + $start = $start->setTime(0, 0); + $end = $end->setTime(0, 0); + + if ($end < $start) { + return 0.0; + } + + $days = 0.0; + $period = new DatePeriod($start, new DateInterval('P1D'), $end->modify('+1 day')); + + foreach ($period as $day) { + $weekday = (int) $day->format('N'); // 1 (Mon) .. 7 (Sun) + + if (7 === $weekday) { + continue; // Sunday: never counted + } + if (6 === $weekday && $workingDaysOnly) { + continue; // Saturday: only counted for "jours ouvrables" + } + if ($this->holidayProvider->isHoliday($day)) { + continue; + } + + ++$days; + } + + if ($days <= 0.0) { + return 0.0; + } + + if (null !== $startHalfDay) { + $days -= 0.5; + } + if (null !== $endHalfDay) { + $days -= 0.5; + } + + return max(0.0, $days); + } +} diff --git a/src/Service/PublicHolidayProvider.php b/src/Service/PublicHolidayProvider.php new file mode 100644 index 0000000..1897757 --- /dev/null +++ b/src/Service/PublicHolidayProvider.php @@ -0,0 +1,86 @@ +> cache of holidays per year */ + private array $cache = []; + + /** + * @return array map of 'Y-m-d' => label, sorted by date + */ + public function getHolidays(int $year): array + { + if (isset($this->cache[$year])) { + return $this->cache[$year]; + } + + $easter = $this->easterSunday($year); + $easterMonday = $easter->modify('+1 day'); + $ascension = $easter->modify('+39 days'); + $whitMonday = $easter->modify('+50 days'); + + $holidays = [ + sprintf('%d-01-01', $year) => 'Jour de l\'an', + $easterMonday->format('Y-m-d') => 'Lundi de Pâques', + sprintf('%d-05-01', $year) => 'Fête du Travail', + sprintf('%d-05-08', $year) => 'Victoire 1945', + $ascension->format('Y-m-d') => 'Ascension', + $whitMonday->format('Y-m-d') => 'Lundi de Pentecôte', + sprintf('%d-07-14', $year) => 'Fête nationale', + sprintf('%d-08-15', $year) => 'Assomption', + sprintf('%d-11-01', $year) => 'Toussaint', + sprintf('%d-11-11', $year) => 'Armistice 1918', + sprintf('%d-12-25', $year) => 'Noël', + ]; + + ksort($holidays); + + return $this->cache[$year] = $holidays; + } + + public function isHoliday(DateTimeInterface $date): bool + { + $holidays = $this->getHolidays((int) $date->format('Y')); + + return isset($holidays[$date->format('Y-m-d')]); + } + + /** + * Easter Sunday date for the given year (Gregorian Computus). + */ + private function easterSunday(int $year): DateTimeImmutable + { + $a = $year % 19; + $b = intdiv($year, 100); + $c = $year % 100; + $d = intdiv($b, 4); + $e = $b % 4; + $f = intdiv($b + 8, 25); + $g = intdiv($b - $f + 1, 3); + $h = (19 * $a + $b - $d - $g + 15) % 30; + $i = intdiv($c, 4); + $k = $c % 4; + $l = (32 + 2 * $e + 2 * $i - $h - $k) % 7; + $m = intdiv($a + 11 * $h + 22 * $l, 451); + + $month = intdiv($h + $l - 7 * $m + 114, 31); + $day = (($h + $l - 7 * $m + 114) % 31) + 1; + + return new DateTimeImmutable(sprintf('%04d-%02d-%02d', $year, $month, $day)); + } +} diff --git a/src/State/AbsenceBalanceProvider.php b/src/State/AbsenceBalanceProvider.php new file mode 100644 index 0000000..d155efa --- /dev/null +++ b/src/State/AbsenceBalanceProvider.php @@ -0,0 +1,73 @@ + + */ +final readonly class AbsenceBalanceProvider implements ProviderInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceBalance|array|null + { + $user = $this->security->getUser(); + assert($user instanceof User); + + $repo = $this->entityManager->getRepository(AbsenceBalance::class); + $isAdmin = $this->security->isGranted('ROLE_ADMIN'); + + if (isset($uriVariables['id'])) { + $balance = $repo->find($uriVariables['id']); + if (null === $balance) { + return null; + } + if (!$isAdmin && $balance->getUser() !== $user) { + return null; + } + + return $balance; + } + + $qb = $repo->createQueryBuilder('b') + ->orderBy('b.type', 'ASC') + ; + + if (!$isAdmin) { + $qb->andWhere('b.user = :user')->setParameter('user', $user); + } + + $filters = $context['filters'] ?? []; + + if (isset($filters['type'])) { + $qb->andWhere('b.type = :type')->setParameter('type', $filters['type']); + } + if (isset($filters['period'])) { + $qb->andWhere('b.period = :period')->setParameter('period', $filters['period']); + } + if ($isAdmin && isset($filters['user'])) { + $qb->andWhere('b.user = :filterUser') + ->setParameter('filterUser', self::extractId($filters['user'])) + ; + } + + return $qb->getQuery()->getResult(); + } + + private static function extractId(string $value): int + { + return is_numeric($value) ? (int) $value : (int) basename($value); + } +} diff --git a/src/State/AbsenceCancelProcessor.php b/src/State/AbsenceCancelProcessor.php new file mode 100644 index 0000000..59b9e0f --- /dev/null +++ b/src/State/AbsenceCancelProcessor.php @@ -0,0 +1,75 @@ + + */ +final readonly class AbsenceCancelProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private AbsenceBalanceService $balanceService, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest + { + assert($data instanceof AbsenceRequest); + + // Cancellation carries no payload: keep the persisted content intact. + $previous = $context['previous_data'] ?? null; + if ($previous instanceof AbsenceRequest) { + $data->setType($previous->getType()); + $data->setStartDate($previous->getStartDate()); + $data->setEndDate($previous->getEndDate()); + $data->setStartHalfDay($previous->getStartHalfDay()); + $data->setEndHalfDay($previous->getEndHalfDay()); + $data->setReason($previous->getReason()); + $data->setCountedDays($previous->getCountedDays()); + $data->setStatus($previous->getStatus()); + } + + $user = $this->security->getUser(); + $isAdmin = $this->security->isGranted('ROLE_ADMIN'); + $status = $data->getStatus(); + + if (AbsenceStatus::Pending === $status) { + $this->balanceService->release($data, false); + } elseif (AbsenceStatus::Approved === $status) { + if (!$isAdmin) { + throw new AccessDeniedHttpException('Only an admin can cancel an approved request.'); + } + $this->balanceService->release($data, true); + } else { + throw new ConflictHttpException('This request can no longer be cancelled.'); + } + + // An employee may only cancel their own request (admins can cancel any). + if (!$isAdmin && $data->getUser() !== $user) { + throw new AccessDeniedHttpException('You can only cancel your own requests.'); + } + + $data->setStatus(AbsenceStatus::Cancelled); + + $this->entityManager->flush(); + + return $data; + } +} diff --git a/src/State/AbsenceRequestProcessor.php b/src/State/AbsenceRequestProcessor.php new file mode 100644 index 0000000..4322195 --- /dev/null +++ b/src/State/AbsenceRequestProcessor.php @@ -0,0 +1,91 @@ + + */ +final readonly class AbsenceRequestProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private AbsenceDayCalculator $calculator, + private AbsencePolicyRepository $policyRepository, + private AbsenceRequestRepository $requestRepository, + private AbsenceBalanceService $balanceService, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest + { + assert($data instanceof AbsenceRequest); + + $user = $this->security->getUser(); + assert($user instanceof User); + + $type = $data->getType(); + $startDate = $data->getStartDate(); + $endDate = $data->getEndDate(); + + if (null === $type || null === $startDate || null === $endDate) { + throw new UnprocessableEntityHttpException('Type, start date and end date are required.'); + } + + if ($endDate < $startDate) { + throw new UnprocessableEntityHttpException('End date must be on or after start date.'); + } + + $policy = $this->policyRepository->findOneByType($type); + if (null === $policy || !$policy->isActive()) { + throw new UnprocessableEntityHttpException('This absence type is not available.'); + } + + if ($this->requestRepository->hasOverlap($user, $startDate, $endDate)) { + throw new ConflictHttpException('This request overlaps an existing absence.'); + } + + $countedDays = $this->calculator->countWorkingDays( + $startDate, + $endDate, + $data->getStartHalfDay(), + $data->getEndHalfDay(), + $policy->isCountWorkingDaysOnly(), + ); + + if ($countedDays <= 0.0) { + throw new UnprocessableEntityHttpException('The selected range contains no working day.'); + } + + $data->setUser($user); + $data->setCountedDays($countedDays); + $data->setStatus(AbsenceStatus::Pending); + $data->setRejectionReason(null); + $data->setCreatedAt(new DateTimeImmutable()); + + $this->entityManager->persist($data); + $this->balanceService->reservePending($data); + $this->entityManager->flush(); + + return $data; + } +} diff --git a/src/State/AbsenceRequestProvider.php b/src/State/AbsenceRequestProvider.php new file mode 100644 index 0000000..0e05508 --- /dev/null +++ b/src/State/AbsenceRequestProvider.php @@ -0,0 +1,82 @@ + + */ +final readonly class AbsenceRequestProvider implements ProviderInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest|array|null + { + $user = $this->security->getUser(); + assert($user instanceof User); + + $repo = $this->entityManager->getRepository(AbsenceRequest::class); + $isAdmin = $this->security->isGranted('ROLE_ADMIN'); + + // Single item: owner or admin only + if (isset($uriVariables['id'])) { + $request = $repo->find($uriVariables['id']); + if (null === $request) { + return null; + } + if (!$isAdmin && $request->getUser() !== $user) { + return null; + } + + return $request; + } + + $qb = $repo->createQueryBuilder('a') + ->orderBy('a.createdAt', 'DESC') + ; + + if (!$isAdmin) { + $qb->andWhere('a.user = :user')->setParameter('user', $user); + } + + $filters = $context['filters'] ?? []; + + if (isset($filters['status'])) { + $qb->andWhere('a.status = :status')->setParameter('status', $filters['status']); + } + if (isset($filters['type'])) { + $qb->andWhere('a.type = :type')->setParameter('type', $filters['type']); + } + if (isset($filters['year']) && is_numeric($filters['year'])) { + $year = (int) $filters['year']; + $qb->andWhere('a.startDate <= :yearEnd') + ->andWhere('a.endDate >= :yearStart') + ->setParameter('yearStart', sprintf('%d-01-01', $year)) + ->setParameter('yearEnd', sprintf('%d-12-31', $year)) + ; + } + if ($isAdmin && isset($filters['user'])) { + $qb->andWhere('a.user = :filterUser') + ->setParameter('filterUser', self::extractId($filters['user'])) + ; + } + + return $qb->getQuery()->getResult(); + } + + private static function extractId(string $value): int + { + return is_numeric($value) ? (int) $value : (int) basename($value); + } +} diff --git a/src/State/AbsenceReviewProcessor.php b/src/State/AbsenceReviewProcessor.php new file mode 100644 index 0000000..9e8a881 --- /dev/null +++ b/src/State/AbsenceReviewProcessor.php @@ -0,0 +1,80 @@ + + */ +final readonly class AbsenceReviewProcessor implements ProcessorInterface +{ + public function __construct( + private EntityManagerInterface $entityManager, + private Security $security, + private AbsenceBalanceService $balanceService, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): AbsenceRequest + { + assert($data instanceof AbsenceRequest); + + $isApprove = str_contains((string) $operation->getUriTemplate(), 'approve'); + $newRejectionReason = $data->getRejectionReason(); + + // Reviewing must never alter the request content: restore everything + // from the persisted state, only status/review fields may change. + $previous = $context['previous_data'] ?? null; + if ($previous instanceof AbsenceRequest) { + $data->setType($previous->getType()); + $data->setStartDate($previous->getStartDate()); + $data->setEndDate($previous->getEndDate()); + $data->setStartHalfDay($previous->getStartHalfDay()); + $data->setEndHalfDay($previous->getEndHalfDay()); + $data->setReason($previous->getReason()); + $data->setCountedDays($previous->getCountedDays()); + } + + if (AbsenceStatus::Pending !== $data->getStatus()) { + throw new ConflictHttpException('Only a pending request can be reviewed.'); + } + + $admin = $this->security->getUser(); + assert($admin instanceof User); + + if ($isApprove) { + $data->setStatus(AbsenceStatus::Approved); + $data->setRejectionReason(null); + $this->balanceService->applyApproval($data); + } else { + if (null === $newRejectionReason || '' === trim($newRejectionReason)) { + throw new UnprocessableEntityHttpException('A reason is required when rejecting a request.'); + } + $data->setStatus(AbsenceStatus::Rejected); + $data->setRejectionReason($newRejectionReason); + $this->balanceService->release($data, false); + } + + $data->setReviewedAt(new DateTimeImmutable()); + $data->setReviewedBy($admin); + + $this->entityManager->flush(); + + return $data; + } +} diff --git a/tests/Unit/Service/AbsenceDayCalculatorTest.php b/tests/Unit/Service/AbsenceDayCalculatorTest.php new file mode 100644 index 0000000..591763e --- /dev/null +++ b/tests/Unit/Service/AbsenceDayCalculatorTest.php @@ -0,0 +1,92 @@ +calculator = new AbsenceDayCalculator(new PublicHolidayProvider()); + } + + public function testFullWeekIsFiveWorkingDays(): void + { + // Mon 2026-06-01 to Fri 2026-06-05, no holidays that week + self::assertSame(5.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-01'), + new DateTimeImmutable('2026-06-05'), + )); + } + + public function testWeekendIsSkipped(): void + { + // Fri 2026-06-05 to Mon 2026-06-08 => Fri + Mon = 2 + self::assertSame(2.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-05'), + new DateTimeImmutable('2026-06-08'), + )); + } + + public function testHolidayIsSkipped(): void + { + // Thu 2026-05-07 to Fri 2026-05-08 (Victoire 1945) => Thu only = 1 + self::assertSame(1.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-05-07'), + new DateTimeImmutable('2026-05-08'), + )); + } + + public function testHalfDayStartSubtractsHalf(): void + { + self::assertSame(4.5, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-01'), + new DateTimeImmutable('2026-06-05'), + HalfDay::Afternoon, + )); + } + + public function testBothHalfDaysSubtractOne(): void + { + self::assertSame(4.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-01'), + new DateTimeImmutable('2026-06-05'), + HalfDay::Afternoon, + HalfDay::Morning, + )); + } + + public function testSingleHalfDay(): void + { + self::assertSame(0.5, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-01'), + new DateTimeImmutable('2026-06-01'), + HalfDay::Morning, + )); + } + + public function testWorkingDaysVsOpenDays(): void + { + // Fri 2026-06-05 to Mon 2026-06-08, "ouvrables" includes Saturday + // => Fri + Sat + Mon = 3 (Sunday always skipped) + self::assertSame(3.0, $this->calculator->countWorkingDays( + new DateTimeImmutable('2026-06-05'), + new DateTimeImmutable('2026-06-08'), + null, + null, + false, + )); + } +} diff --git a/tests/Unit/Service/PublicHolidayProviderTest.php b/tests/Unit/Service/PublicHolidayProviderTest.php new file mode 100644 index 0000000..ff1716c --- /dev/null +++ b/tests/Unit/Service/PublicHolidayProviderTest.php @@ -0,0 +1,72 @@ +provider = new PublicHolidayProvider(); + } + + public function testReturnsElevenHolidaysForMetropole(): void + { + self::assertCount(11, $this->provider->getHolidays(2026)); + } + + public function testFixedHolidaysHaveCorrectLabels(): void + { + $holidays = $this->provider->getHolidays(2026); + + self::assertSame('Jour de l\'an', $holidays['2026-01-01']); + self::assertSame('Fête du Travail', $holidays['2026-05-01']); + self::assertSame('Victoire 1945', $holidays['2026-05-08']); + self::assertSame('Fête nationale', $holidays['2026-07-14']); + self::assertSame('Assomption', $holidays['2026-08-15']); + self::assertSame('Toussaint', $holidays['2026-11-01']); + self::assertSame('Armistice 1918', $holidays['2026-11-11']); + self::assertSame('Noël', $holidays['2026-12-25']); + } + + /** + * Easter 2026 is April 5th, so Easter Monday is April 6th, + * Ascension is May 14th and Whit Monday is May 25th. + */ + public function testEasterBasedHolidays2026(): void + { + $holidays = $this->provider->getHolidays(2026); + + self::assertSame('Lundi de Pâques', $holidays['2026-04-06']); + self::assertSame('Ascension', $holidays['2026-05-14']); + self::assertSame('Lundi de Pentecôte', $holidays['2026-05-25']); + } + + /** + * Easter 2025 is April 20th, so Easter Monday is April 21st. + */ + public function testEasterBasedHolidays2025(): void + { + $holidays = $this->provider->getHolidays(2025); + + self::assertSame('Lundi de Pâques', $holidays['2025-04-21']); + } + + public function testIsHoliday(): void + { + self::assertTrue($this->provider->isHoliday(new DateTimeImmutable('2026-05-01'))); + self::assertTrue($this->provider->isHoliday(new DateTimeImmutable('2026-05-14'))); + self::assertFalse($this->provider->isHoliday(new DateTimeImmutable('2026-05-02'))); + self::assertFalse($this->provider->isHoliday(new DateTimeImmutable('2026-06-01'))); + } +} From 2a0b202d32cbc64e2132b07f5a826838d266ec66 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 22 May 2026 11:31:31 +0200 Subject: [PATCH 2/9] feat(absences) : avancement module absences + suppression du portail client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux lots regroupés sur la branche feat/absence-management. Suppression complète du portail client : - retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER - supprime l'entité ClientTicket (+ repo, states, relations), User.client et User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc ROLE_CLIENT de MailAccessChecker - front : pages /portal, layout portal, composants client-ticket/, AdminClientTicketTab, services/dto/i18n/docs associés - fixtures : retire les users client-liot / client-acme - migration Version20260522110000 (drop client_ticket, user_allowed_projects, colonnes liées ; task_document.task_id -> NOT NULL) - tests : retire les cas obsolètes testant le blocage des clients sur le mail Module gestion des absences (WIP) : - entités / migrations (Version20260521160000, Version20260522090000) - pages absences.vue / team-absences.vue, composants frontend/components/absence/ - services front, AccrueLeaveCommand, PublicHolidayController Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 23 +- config/packages/security.yaml | 4 +- .../2026-05-22-employee-management-reorg.md | 571 ++++++++++++++++++ ...21-absence-request-form-redesign-design.md | 119 ++++ ...-05-22-employee-management-reorg-design.md | 96 +++ .../absence/AbsenceBalanceAdjustDrawer.vue | 69 +++ .../absence/AbsenceBalanceCards.vue | 119 ++++ .../components/absence/AbsenceCalendar.vue | 143 +++++ .../components/absence/AbsenceDateField.vue | 73 +++ .../absence/AbsenceDetailDrawer.vue | 193 ++++++ .../absence/AbsenceRejectDrawer.vue | 69 +++ .../absence/AbsenceRequestDrawer.vue | 296 +++++++++ .../components/absence/EmployeeDrawer.vue | 163 +++++ .../admin/AdminAbsencePolicyTab.vue | 76 +++ .../components/admin/AdminClientTicketTab.vue | 382 ------------ frontend/components/admin/WorkflowDrawer.vue | 5 +- .../client-ticket/ClientTicketDetailModal.vue | 353 ----------- .../client-ticket/ProjectClientTickets.vue | 333 ---------- frontend/components/client/ClientDrawer.vue | 5 +- .../components/mail/MailCreateTaskModal.vue | 8 +- .../components/mail/MailLinkTaskModal.vue | 2 +- .../notification/NotificationBell.vue | 14 +- frontend/components/project/ProjectDrawer.vue | 11 +- .../project/ProjectWorkflowSwitchModal.vue | 2 +- frontend/components/task/TaskBulkActions.vue | 10 +- frontend/components/task/TaskCard.vue | 6 - .../components/task/TaskDocumentUpload.vue | 7 +- frontend/components/task/TaskEffortDrawer.vue | 5 +- frontend/components/task/TaskGitSection.vue | 2 +- frontend/components/task/TaskGroupDrawer.vue | 5 +- frontend/components/task/TaskModal.vue | 93 +-- .../components/task/TaskPriorityDrawer.vue | 5 +- frontend/components/task/TaskTagDrawer.vue | 5 +- .../time-tracking/TimeEntryDrawer.vue | 9 +- .../time-tracking/TimeTrackingCalendar.vue | 30 + .../TimeTrackingExportDrawer.vue | 13 +- frontend/components/ui/StatusBadge.vue | 32 + frontend/components/user/UserDrawer.vue | 103 +--- frontend/composables/useAbsenceHelpers.ts | 93 +++ .../composables/useClientTicketHelpers.ts | 48 -- frontend/content/help/01-getting-started.md | 5 +- frontend/content/help/05-tasks-detail.md | 4 - frontend/content/help/06-client-portal.md | 43 -- frontend/content/help/07-admin.md | 5 +- frontend/content/help/10-messaging.md | 2 +- frontend/i18n/locales/fr.json | 247 ++++++-- frontend/layouts/default.vue | 90 ++- frontend/layouts/portal.vue | 87 --- frontend/middleware/auth.global.ts | 11 +- frontend/package-lock.json | 8 +- frontend/package.json | 2 +- frontend/pages/absences.vue | 168 ++++++ frontend/pages/admin.vue | 2 + frontend/pages/help.vue | 2 - frontend/pages/index.vue | 6 +- frontend/pages/login.vue | 4 +- frontend/pages/mail.vue | 20 - frontend/pages/my-tasks.vue | 14 +- frontend/pages/portal/index.vue | 84 --- frontend/pages/portal/projects/[id]/index.vue | 282 --------- .../pages/portal/projects/[id]/new-ticket.vue | 133 ---- frontend/pages/profile.vue | 29 +- frontend/pages/projects/[id]/archives.vue | 2 +- .../pages/projects/[id]/client-tickets.vue | 269 --------- frontend/pages/projects/[id]/index.vue | 12 +- frontend/pages/team-absences.vue | 479 +++++++++++++++ frontend/pages/time-tracking.vue | 6 +- frontend/services/absences.ts | 137 +++++ frontend/services/client-tickets.ts | 46 -- frontend/services/dto/absence.ts | 93 +++ frontend/services/dto/client-ticket.ts | 34 -- frontend/services/dto/notification.ts | 1 - frontend/services/dto/task.ts | 8 - frontend/services/dto/user-data.ts | 29 +- frontend/services/task-documents.ts | 13 +- makefile | 3 + migrations/Version20260521160000.php | 30 + migrations/Version20260522090000.php | 39 ++ migrations/Version20260522110000.php | 102 ++++ src/Command/AccrueLeaveCommand.php | 151 +++++ .../Absence/PublicHolidayController.php | 46 ++ .../TaskDocumentDownloadController.php | 11 - src/DataFixtures/AppFixtures.php | 100 +-- src/Doctrine/ProjectAllowedExtension.php | 66 -- src/Entity/AbsenceBalance.php | 47 +- src/Entity/ClientTicket.php | 282 --------- src/Entity/Notification.php | 17 - src/Entity/Project.php | 4 +- src/Entity/Task.php | 17 - src/Entity/TaskDocument.php | 41 +- src/Entity/TimeEntry.php | 17 - src/Entity/User.php | 65 +- src/Mcp/Tool/Serializer.php | 38 +- .../Tool/TimeEntry/CreateTimeEntryTool.php | 10 - .../Tool/TimeEntry/ListTimeEntriesTool.php | 5 - .../Tool/TimeEntry/UpdateTimeEntryTool.php | 10 - src/Repository/ClientTicketRepository.php | 45 -- src/Repository/UserRepository.php | 21 + src/Security/MailAccessChecker.php | 5 - src/Service/NotificationService.php | 74 --- src/State/ClientTicketNumberProcessor.php | 70 --- src/State/ClientTicketProvider.php | 80 --- src/State/ClientTicketStatusProcessor.php | 74 --- src/State/TaskDocumentProcessor.php | 38 +- src/State/TaskDocumentProvider.php | 29 +- .../Mail/MailFoldersControllerTest.php | 13 - .../Mail/MailMessagesControllerTest.php | 13 - .../Mail/MailSyncTriggerControllerTest.php | 13 - .../MailTaskIntegrationControllerTest.php | 26 - 109 files changed, 3918 insertions(+), 3656 deletions(-) create mode 100644 docs/superpowers/plans/2026-05-22-employee-management-reorg.md create mode 100644 docs/superpowers/specs/2026-05-21-absence-request-form-redesign-design.md create mode 100644 docs/superpowers/specs/2026-05-22-employee-management-reorg-design.md create mode 100644 frontend/components/absence/AbsenceBalanceAdjustDrawer.vue create mode 100644 frontend/components/absence/AbsenceBalanceCards.vue create mode 100644 frontend/components/absence/AbsenceCalendar.vue create mode 100644 frontend/components/absence/AbsenceDateField.vue create mode 100644 frontend/components/absence/AbsenceDetailDrawer.vue create mode 100644 frontend/components/absence/AbsenceRejectDrawer.vue create mode 100644 frontend/components/absence/AbsenceRequestDrawer.vue create mode 100644 frontend/components/absence/EmployeeDrawer.vue create mode 100644 frontend/components/admin/AdminAbsencePolicyTab.vue delete mode 100644 frontend/components/admin/AdminClientTicketTab.vue delete mode 100644 frontend/components/client-ticket/ClientTicketDetailModal.vue delete mode 100644 frontend/components/client-ticket/ProjectClientTickets.vue create mode 100644 frontend/components/ui/StatusBadge.vue create mode 100644 frontend/composables/useAbsenceHelpers.ts delete mode 100644 frontend/composables/useClientTicketHelpers.ts delete mode 100644 frontend/content/help/06-client-portal.md delete mode 100644 frontend/layouts/portal.vue create mode 100644 frontend/pages/absences.vue delete mode 100644 frontend/pages/portal/index.vue delete mode 100644 frontend/pages/portal/projects/[id]/index.vue delete mode 100644 frontend/pages/portal/projects/[id]/new-ticket.vue delete mode 100644 frontend/pages/projects/[id]/client-tickets.vue create mode 100644 frontend/pages/team-absences.vue create mode 100644 frontend/services/absences.ts delete mode 100644 frontend/services/client-tickets.ts create mode 100644 frontend/services/dto/absence.ts delete mode 100644 frontend/services/dto/client-ticket.ts create mode 100644 migrations/Version20260521160000.php create mode 100644 migrations/Version20260522090000.php create mode 100644 migrations/Version20260522110000.php create mode 100644 src/Command/AccrueLeaveCommand.php create mode 100644 src/Controller/Absence/PublicHolidayController.php delete mode 100644 src/Doctrine/ProjectAllowedExtension.php delete mode 100644 src/Entity/ClientTicket.php delete mode 100644 src/Repository/ClientTicketRepository.php delete mode 100644 src/Service/NotificationService.php delete mode 100644 src/State/ClientTicketNumberProcessor.php delete mode 100644 src/State/ClientTicketProvider.php delete mode 100644 src/State/ClientTicketStatusProcessor.php diff --git a/CLAUDE.md b/CLAUDE.md index 5d26d72..87575ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,10 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. ## Structure ``` -src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, ClientTicket, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration) +src/Entity/ # Entités Doctrine (User, Client, Project, Task, TaskStatus, TaskEffort, TaskPriority, TaskTag, TaskGroup, TimeEntry, GiteaConfiguration, Notification, TaskDocument, BookStackConfiguration, TaskBookStackLink, TaskRecurrence, ZimbraConfiguration) src/ApiResource/ # Ressources API Platform (si découplées des entités) (ZimbraSettings, ZimbraTestConnection) src/Enum/ # PHP enums (RecurrenceType) -src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, ClientTicket*Provider/Processor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler) +src/State/ # Providers et Processors API Platform (MeProvider, AppVersionProvider, ActiveTimeEntryProvider, UserPasswordHasherProcessor, TaskNumberProcessor, NotificationProvider, Gitea*Provider, Gitea*Processor, ZimbraSettingsProvider/Processor, ZimbraTestConnectionProvider, TaskCalendarProcessor, RecurrenceHandler) src/Service/ # Services métier (NotificationService, CalDavService, RecurrenceCalculator) src/Controller/ # Controllers custom Symfony (NotificationUnreadCountController, MarkAllReadController, UserAvatarController, TaskDocumentDownloadController) src/Mcp/Tool/ # MCP tools organisés par domaine (Project/, Task/, TaskMeta/, TimeEntry/, Reference/) @@ -31,12 +31,12 @@ migrations/ # Migrations Doctrine docs/plans/ # Plans d'implémentation docs/superpowers/ # Plans et specs superpowers frontend/ # App Nuxt 4 -frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin, portal/, portal/projects/[id], portal/projects/[id]/new-ticket) -frontend/layouts/ # Layouts (default, portal) -frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, client-ticket/, notification/) — inclut admin/AdminZimbraTab -frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useClientTicketHelpers, useAvatarService) +frontend/pages/ # Pages (index, login, my-tasks, profile, projects, projects/[id], projects/[id]/groups, projects/[id]/archives, time-tracking, admin) +frontend/layouts/ # Layouts (default) +frontend/components/ # Composants Vue organisés en sous-dossiers (ui/, client/, project/, task/, user/, admin/, time-tracking/, notification/) — inclut admin/AdminZimbraTab +frontend/composables/# Composables (useApi, useAppVersion, useNotifications, useAvatarService) frontend/stores/ # Stores Pinia (auth, ui, timer) -frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, client-tickets, notifications, task-documents, zimbra, task-recurrences) +frontend/services/ # Services API (auth, clients, gitea, projects, tasks, task-statuses, task-efforts, task-groups, task-priorities, task-tags, users, time-entries, notifications, task-documents, zimbra, task-recurrences) frontend/services/dto/ # Types TypeScript frontend/i18n/locales/ # Fichiers de traduction (langDir résolu depuis i18n/) ``` @@ -85,13 +85,13 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Routes API préfixées `/api` (via `config/routes/api_platform.yaml`) - Le login (`/login_check`) est hors prefix `/api`, nginx réécrit `REQUEST_URI` vers `/login_check` - PHP CS Fixer : règles Symfony + PSR-12 + strict types -- Rôles : `ROLE_ADMIN`, `ROLE_USER`, `ROLE_CLIENT` — hiérarchie dans `security.yaml` -- `User::getRoles()` n'ajoute PAS `ROLE_USER` si l'user a `ROLE_CLIENT` (isolation) +- Rôles : `ROLE_ADMIN`, `ROLE_USER` — hiérarchie dans `security.yaml` +- `User::getRoles()` ajoute toujours `ROLE_USER` - PostgreSQL : `LIKE` sur colonne JSON ne marche pas → utiliser `roles::text LIKE` via native SQL - Controllers custom sous `/api/` : ajouter `priority: 1` sur `#[Route]` pour éviter le conflit avec API Platform `{id}` - Serialization : pour embarquer une relation (pas IRI), ajouter le groupe du parent aux propriétés de l'entité cible - Upload fichiers : utiliser `$file->getMimeType()` (pas `getClientMimeType()`) pour valider côté serveur — nécessite `symfony/mime` -- Auth endpoints mixtes (ROLE_USER + ROLE_CLIENT) : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique +- Endpoints ouverts à tout utilisateur authentifié : utiliser `#[IsGranted('IS_AUTHENTICATED_FULLY')]` au lieu d'un rôle spécifique ### Frontend @@ -102,8 +102,6 @@ Exemples : `feat : add login page`, `fix(auth) : prevent null token crash` - Traductions dans `frontend/i18n/locales/` (le module résout `langDir` depuis `i18n/`) - 4 espaces d'indentation - MalioSelect : options `{ label: string, value: string | number | null }` — accepte les valeurs **string** (enums string OK, ex `category`/`StatusCategory`), pas seulement `number` (vérifié dans la source `Select.vue` : `modelValue: string | number | null`). L'option vide `null` n'est ajoutée que si `empty-option-label` est passé (ne pas le passer pour un champ requis). Largeur via `group-class` (pas de prop `minWidth`/`min-width`). ⚠️ Le `COMPONENTS.md` de la lib est inexact sur ce composant (il indique une clé `text` et une prop `minWidth` inexistantes) : la clé d'affichage réelle est `label`. Ne jamais modifier la lib `malio-layer-ui` depuis ce projet. -- Portal client : pages sous `/portal/`, layout `portal.vue`, middleware redirige `ROLE_CLIENT` (sans `ROLE_ADMIN`) vers `/portal` -- Users admin+client : ne pas bloquer — vérifier `ROLE_CLIENT && !ROLE_ADMIN` pour les restrictions ### Composants UI @@ -138,7 +136,6 @@ La librairie `@malio/layer-ui` fournit les composants de formulaire et d'action. - User admin : `admin` / `admin` (ROLE_ADMIN) - Users internes : `alice` / `alice`, `bob` / `bob`, `charlie` / `charlie` (ROLE_USER) -- Users client : `client-liot` / `client` (ROLE_CLIENT, client LIOT → SIRH), `client-acme` / `client` (ROLE_CLIENT, client ACME → CRM) - API token admin (dev) : `dev-mcp-token-for-testing-only-do-not-use-in-production` - ZimbraConfiguration : serverUrl `https://mail.ovh.com`, username `lesstime@ovh.fr`, enabled false - TaskRecurrence (hebdomadaire lun/mer/ven) attachée à la tâche "Réunion de suivi hebdomadaire" (SIRH) diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 820b46a..35949cd 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,6 +1,6 @@ security: role_hierarchy: - ROLE_ADMIN: [ROLE_USER, ROLE_CLIENT] + ROLE_ADMIN: [ROLE_USER] # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords password_hashers: @@ -64,7 +64,7 @@ security: - { path: ^/api/version, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: PUBLIC_ACCESS, methods: [ GET ] } - { path: ^/_mcp, roles: IS_AUTHENTICATED_FULLY } - # Mail : requiert authentification (les checks ROLE_USER/ROLE_CLIENT sont dans MailAccessChecker) + # Mail : requiert authentification (le check ROLE_USER est dans MailAccessChecker) - { path: ^/api/mail, roles: IS_AUTHENTICATED_FULLY } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } diff --git a/docs/superpowers/plans/2026-05-22-employee-management-reorg.md b/docs/superpowers/plans/2026-05-22-employee-management-reorg.md new file mode 100644 index 0000000..ed84e08 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-employee-management-reorg.md @@ -0,0 +1,571 @@ +# Réorganisation gestion employés — Plan d'implémentation + +> **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:** Sortir l'édition des informations RH du `UserDrawer` (qui ne garde que la case « Employé ») vers un onglet « Employés » dédié dans `team-absences`, avec une liste users⋈soldes et un drawer d'édition. + +**Architecture:** Réorganisation 100 % frontend. Les champs employé existent déjà sur l'entité `User` (backend) et le DTO `UserData`/`UserWrite` ; la persistance passe par `usersService.update()` (PATCH partiel, sans écrasement). La liste de l'onglet joint `usersService.getAll()` (filtré `isEmployee`) avec `absenceService.getBalances({ type: 'cp' })`. + +**Tech Stack:** Nuxt 4 / Vue 3 Composition API, TypeScript, composants `@malio/layer-ui` (MalioDate, MalioSelect, MalioInputText, MalioDataTable, MalioDrawer, MalioCheckbox), i18n `@nuxtjs/i18n`. + +**Conventions projet :** +- 4 espaces d'indentation, TypeScript strict. +- Pas de framework de test frontend → vérification au navigateur (serveur dev sur `http://localhost:3002`, Chrome DevTools MCP) + compilation HMR sans erreur console. +- **Commits gérés par l'utilisateur** : ne committer qu'après son feu vert explicite (règle CLAUDE.md). Les étapes « Commit » sont fournies mais à déclencher sur demande. Format : `() : `. + +--- + +### Task 1 : Clés i18n + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` (objet `absences.admin`) + +- [ ] **Step 1 : Ajouter l'onglet et le bloc `employees`** + +Dans `absences.admin`, ajouter `"employees"` à `tabs`, et un nouveau bloc `employees`. Repérer le bloc existant : + +```json +"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes" }, +``` + +le remplacer par : + +```json +"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes", "employees": "Employés" }, +``` + +puis ajouter, à la suite des clés de `admin` (par ex. après `"adjust"`), le bloc : + +```json +"employees": { + "columns": { + "name": "Nom", + "contract": "Contrat", + "cpTaken": "CP pris", + "cpRemaining": "CP restants" + }, + "empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.", + "noContract": "—", + "drawer": { + "title": "Informations employé", + "save": "Enregistrer" + }, + "fields": { + "hireDate": "Date d'embauche", + "endDate": "Date de sortie", + "contractType": "Type de contrat", + "familySituation": "Situation familiale", + "workTimeRatio": "Temps de travail (ex : 1.0)", + "annualLeaveDays": "CP annuels (jours)", + "referencePeriodStart": "Début période réf. (MM-DD)", + "initialLeaveBalance": "Solde CP initial", + "nbChildren": "Nombre d'enfants" + }, + "contract": { + "cdi": "CDI", + "cdd": "CDD", + "stage": "Stage", + "alternance": "Alternance", + "autre": "Autre" + }, + "family": { + "celibataire": "Célibataire", + "marie": "Marié(e)", + "pacse": "Pacsé(e)", + "divorce": "Divorcé(e)", + "veuf": "Veuf(ve)" + } +} +``` + +- [ ] **Step 2 : Vérifier la validité JSON** + +Run: `cd frontend && python3 -c "import json; json.load(open('i18n/locales/fr.json')); print('OK')"` +Expected: `OK` + +- [ ] **Step 3 : Commit** (sur feu vert utilisateur) + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat(absences) : clés i18n onglet et drawer employés" +``` + +--- + +### Task 2 : Composant `EmployeeDrawer.vue` + +**Files:** +- Create: `frontend/components/absence/EmployeeDrawer.vue` + +- [ ] **Step 1 : Créer le composant** + +Crée `frontend/components/absence/EmployeeDrawer.vue` avec ce contenu exact : + +```vue + + + +``` + +- [ ] **Step 2 : Vérifier la compilation** + +Le serveur dev (`http://localhost:3002`) recompile à la sauvegarde. Vérifier qu'aucune erreur de compilation/HMR n'apparaît dans la console du terminal `make dev-nuxt` ni dans la console navigateur. (Le composant n'est pas encore monté ; cette étape ne fait que valider la syntaxe.) + +- [ ] **Step 3 : Commit** (sur feu vert utilisateur) + +```bash +git add frontend/components/absence/EmployeeDrawer.vue +git commit -m "feat(absences) : drawer d'édition des informations employé" +``` + +--- + +### Task 3 : Onglet « Employés » dans `team-absences` + +**Files:** +- Modify: `frontend/pages/team-absences.vue` + +- [ ] **Step 1 : Ajouter l'import du service users et le type** + +Après les imports existants (`useAbsenceHelpers`), ajouter : + +```ts +import { useUserService } from "~/services/users"; +import type { UserData } from "~/services/dto/user-data"; +``` + +Et après la déclaration `type BalanceRow = ...`, ajouter le type de ligne : + +```ts +type EmployeeRow = UserData & { + contractText: string; + cpTakenText: string; + cpRemainingText: string; +}; +``` + +- [ ] **Step 2 : Ajouter l'onglet à `tabs`** + +Remplacer le tableau `tabs` (qui se termine par l'onglet `balances`) en ajoutant l'entrée employés : + +```ts +const tabs = [ + { + key: "requests", + label: t("absences.admin.tabs.requests"), + icon: "mdi:format-list-bulleted", + }, + { + key: "calendar", + label: t("absences.admin.tabs.calendar"), + icon: "mdi:calendar-month", + }, + { + key: "balances", + label: t("absences.admin.tabs.balances"), + icon: "mdi:scale-balance", + }, + { + key: "employees", + label: t("absences.admin.tabs.employees"), + icon: "mdi:account-group", + }, +]; +``` + +- [ ] **Step 3 : Ajouter l'état, les colonnes et les lignes de l'onglet** + +Après `const balances = ref([]);`, ajouter : + +```ts +const employees = ref([]); +const employeeDrawerOpen = ref(false); +const selectedEmployee = ref(null); +``` + +Après `const balanceRows = computed(...)`, ajouter colonnes + lignes : + +```ts +const employeeColumns = [ + { key: "username", label: t("absences.admin.employees.columns.name") }, + { key: "contractText", label: t("absences.admin.employees.columns.contract") }, + { key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") }, + { key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") }, +]; + +const employeeRows = computed(() => { + // Map user.id -> solde CP de la période courante. + const cpByUser = new Map(); + for (const b of balances.value) { + if (b.type === "cp") cpByUser.set(b.user.id, b); + } + const dash = t("absences.admin.employees.noContract"); + return employees.value.map((u) => { + const cp = cpByUser.get(u.id); + return { + ...u, + contractText: u.contractType ?? dash, + cpTakenText: cp ? formatDays(cp.taken) : dash, + cpRemainingText: cp ? formatDays(cp.available) : dash, + }; + }); +}); +``` + +- [ ] **Step 4 : Ajouter le chargement et l'ouverture du drawer** + +Après `async function loadBalances() {...}`, ajouter : + +```ts +async function loadEmployees() { + const all = await useUserService().getAll(); + employees.value = all.filter((u) => u.isEmployee); +} + +function openEmployee(item: Record) { + selectedEmployee.value = item as EmployeeRow; + employeeDrawerOpen.value = true; +} +``` + +Puis inclure `loadEmployees()` au montage. Remplacer le `onMounted` existant : + +```ts +onMounted(async () => { + await Promise.all([reloadRequests(), loadBalances()]); +}); +``` + +par : + +```ts +onMounted(async () => { + await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]); +}); +``` + +- [ ] **Step 5 : Ajouter le slot d'onglet dans le template** + +Juste après la fermeture du slot `` de l'onglet `#balances` (avant ``), ajouter : + +```vue + + +``` + +- [ ] **Step 6 : Monter le drawer employé** + +Après le composant `` (avant `` de fin de template), ajouter : + +```vue + +``` + +- [ ] **Step 7 : Vérification navigateur** + +Aller sur `http://localhost:3002/team-absences`, onglet « Employés ». Vérifier : liste des users `isEmployee` avec Nom / Contrat / CP pris / CP restants ; clic sur une ligne ouvre le drawer ; aucune erreur console. + +- [ ] **Step 8 : Commit** (sur feu vert utilisateur) + +```bash +git add frontend/pages/team-absences.vue +git commit -m "feat(absences) : onglet Employés (liste + ouverture drawer)" +``` + +--- + +### Task 4 : Allègement du `UserDrawer` + +**Files:** +- Modify: `frontend/components/user/UserDrawer.vue` + +- [ ] **Step 1 : Réduire le bloc RH du template à la seule case** + +Remplacer le bloc (lignes ~74-107) : + +```vue + +
+ + +
+ +
+
+``` + +par : + +```vue + +
+ +

+ Les informations RH (contrat, dates, CP…) se gèrent dans Absences équipe → onglet Employés. +

+
+``` + +- [ ] **Step 2 : Nettoyer l'état du formulaire** + +Dans `const form = reactive({...})`, supprimer les champs détaillés et ne garder que `isEmployee`. Résultat : + +```ts +const form = reactive({ + username: '', + password: '', + roles: [] as string[], + clientId: null as number | null, + allowedProjectIds: [] as number[], + isEmployee: false, +}) +``` + +- [ ] **Step 3 : Nettoyer l'hydratation à l'ouverture** + +Dans le `watch(() => props.modelValue, ...)`, supprimer toutes les lignes `form.hireDate = ...` → `form.nbChildren = ...` des deux branches (`props.item` et `else`). Conserver `form.isEmployee = props.item.isEmployee ?? false` (branche édition) et `form.isEmployee = false` (branche création). + +- [ ] **Step 4 : Ne plus envoyer les champs détaillés dans le payload** + +Dans `handleSubmit`, réduire le `payload` aux champs de compte + `isEmployee` : + +```ts + const payload: UserWrite = { + username: form.username.trim(), + roles: form.roles, + client: form.clientId !== null ? `/api/clients/${form.clientId}` : null, + allowedProjects: form.clientId !== null + ? form.allowedProjectIds.map((id) => `/api/projects/${id}`) + : [], + isEmployee: form.isEmployee, + } + if (form.password) { + payload.plainPassword = form.password + } +``` + +- [ ] **Step 5 : Supprimer les imports/constantes devenus inutiles** + +Dans le ` diff --git a/frontend/components/absence/AbsenceBalanceCards.vue b/frontend/components/absence/AbsenceBalanceCards.vue new file mode 100644 index 0000000..4d831d8 --- /dev/null +++ b/frontend/components/absence/AbsenceBalanceCards.vue @@ -0,0 +1,119 @@ + + + diff --git a/frontend/components/absence/AbsenceCalendar.vue b/frontend/components/absence/AbsenceCalendar.vue new file mode 100644 index 0000000..604bf76 --- /dev/null +++ b/frontend/components/absence/AbsenceCalendar.vue @@ -0,0 +1,143 @@ + + + diff --git a/frontend/components/absence/AbsenceDateField.vue b/frontend/components/absence/AbsenceDateField.vue new file mode 100644 index 0000000..fbf31e9 --- /dev/null +++ b/frontend/components/absence/AbsenceDateField.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/components/absence/AbsenceDetailDrawer.vue b/frontend/components/absence/AbsenceDetailDrawer.vue new file mode 100644 index 0000000..1ba3820 --- /dev/null +++ b/frontend/components/absence/AbsenceDetailDrawer.vue @@ -0,0 +1,193 @@ + + + diff --git a/frontend/components/absence/AbsenceRejectDrawer.vue b/frontend/components/absence/AbsenceRejectDrawer.vue new file mode 100644 index 0000000..1828743 --- /dev/null +++ b/frontend/components/absence/AbsenceRejectDrawer.vue @@ -0,0 +1,69 @@ + + + diff --git a/frontend/components/absence/AbsenceRequestDrawer.vue b/frontend/components/absence/AbsenceRequestDrawer.vue new file mode 100644 index 0000000..35b4ce0 --- /dev/null +++ b/frontend/components/absence/AbsenceRequestDrawer.vue @@ -0,0 +1,296 @@ + + + diff --git a/frontend/components/absence/EmployeeDrawer.vue b/frontend/components/absence/EmployeeDrawer.vue new file mode 100644 index 0000000..ae1029e --- /dev/null +++ b/frontend/components/absence/EmployeeDrawer.vue @@ -0,0 +1,163 @@ + + + diff --git a/frontend/components/admin/AdminAbsencePolicyTab.vue b/frontend/components/admin/AdminAbsencePolicyTab.vue new file mode 100644 index 0000000..290df97 --- /dev/null +++ b/frontend/components/admin/AdminAbsencePolicyTab.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend/components/admin/AdminClientTicketTab.vue b/frontend/components/admin/AdminClientTicketTab.vue deleted file mode 100644 index 85f7b54..0000000 --- a/frontend/components/admin/AdminClientTicketTab.vue +++ /dev/null @@ -1,382 +0,0 @@ - - - - - diff --git a/frontend/components/admin/WorkflowDrawer.vue b/frontend/components/admin/WorkflowDrawer.vue index bf2b206..3ffd57d 100644 --- a/frontend/components/admin/WorkflowDrawer.vue +++ b/frontend/components/admin/WorkflowDrawer.vue @@ -1,5 +1,8 @@ - - - - diff --git a/frontend/components/client-ticket/ProjectClientTickets.vue b/frontend/components/client-ticket/ProjectClientTickets.vue deleted file mode 100644 index 83995d3..0000000 --- a/frontend/components/client-ticket/ProjectClientTickets.vue +++ /dev/null @@ -1,333 +0,0 @@ - - - - - diff --git a/frontend/components/client/ClientDrawer.vue b/frontend/components/client/ClientDrawer.vue index 1c1a536..918b188 100644 --- a/frontend/components/client/ClientDrawer.vue +++ b/frontend/components/client/ClientDrawer.vue @@ -1,5 +1,8 @@