Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6819bc68a | ||
| 6153175ca0 | |||
|
|
49a1c07ed1 | ||
| 9fe2397386 | |||
|
|
bf3f7b35a5 | ||
| 5c251800fa | |||
| e34e928264 | |||
|
|
f7dc9b6988 | ||
| b0de877b27 | |||
| 59f05717bf | |||
|
|
f96fd64767 | ||
| 523d4f296b |
@@ -7,7 +7,21 @@
|
|||||||
"Bash(docker compose:*)",
|
"Bash(docker compose:*)",
|
||||||
"Bash(make test:*)",
|
"Bash(make test:*)",
|
||||||
"Bash(grep:*)",
|
"Bash(grep:*)",
|
||||||
"Bash(docker exec:*)"
|
"Bash(docker exec:*)",
|
||||||
|
"Bash(php8.3 bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee 2>&1)",
|
||||||
|
"Read(//usr/bin/**)",
|
||||||
|
"Read(//usr/local/bin/**)",
|
||||||
|
"Bash(command -v php8.2)",
|
||||||
|
"Bash(command -v php8.1)",
|
||||||
|
"Bash(ls /usr/bin/php*)",
|
||||||
|
"Read(//opt/**)",
|
||||||
|
"Read(//home/m-tristan/.nix-profile/**)",
|
||||||
|
"Read(//home/m-tristan/.local/bin/**)",
|
||||||
|
"Bash(env)",
|
||||||
|
"Bash(ls /home/m-tristan/workspace/SIRH/docker* /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null; cat /home/m-tristan/workspace/SIRH/Makefile 2>/dev/null | grep -E \"\\(phpunit|test|php\\)\" | head -20)",
|
||||||
|
"Bash(which python3:*)",
|
||||||
|
"Bash(sudo apt-get:*)",
|
||||||
|
"Bash(npx xlsx-cli:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.24'
|
app.version: '0.1.29'
|
||||||
|
|||||||
@@ -169,11 +169,13 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- acquis annuel samedi: `5`
|
- acquis annuel samedi: `5`
|
||||||
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
||||||
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
||||||
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
||||||
- contrat `4h`:
|
- contrat `4h`:
|
||||||
- acquis annuel CP: `10`
|
- acquis annuel CP: `10`
|
||||||
- acquis annuel samedi: `0`
|
- acquis annuel samedi: `0`
|
||||||
- en cours d'acquisition: `0.83` jour/mois
|
- en cours d'acquisition: `0.83` jour/mois
|
||||||
|
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||||
- contrat `FORFAIT`:
|
- contrat `FORFAIT`:
|
||||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||||
@@ -198,13 +200,16 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
|||||||
- lecture des compteurs:
|
- lecture des compteurs:
|
||||||
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
- `acquis` = droits reportés de l'exercice N-1 (après application des règles de soldé)
|
||||||
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
- `en cours d'acquisition` = total droits générés sur l'exercice N (jours + samedis en cours), sans detail séparé en UI
|
||||||
|
- `en cours d'acquisition` est arrêté au dernier jour du mois précédent
|
||||||
- règle de consommation:
|
- règle de consommation:
|
||||||
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
- les absences s'imputent d'abord sur `acquis`, puis sur `en cours d'acquisition`
|
||||||
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
- la prise sur `en cours d'acquisition` est autorisée (usage anticipé)
|
||||||
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
- `en cours d'acquisition` peut devenir négatif si la prise dépasse le généré (ex: `2.08 - 3 = -0.92`), puis se reconstitue avec les acquisitions suivantes
|
||||||
- date d'arret de calcul:
|
- date d'arret de calcul:
|
||||||
- les compteurs sont calculés jusqu'au dernier jour du mois précédent (le mois en cours est exclu)
|
- `reste à prendre` est calculé en prévisionnel jusqu'à la fin de l'exercice
|
||||||
- exemple: au `04/03/2026`, l'arret de calcul est le `28/02/2026` (ou `29/02` en année bissextile)
|
- les absences futures déjà posées sur l'exercice sont déduites du `reste à prendre`
|
||||||
|
- `en cours d'acquisition` reste calculé jusqu'au dernier jour du mois précédent
|
||||||
|
- exemple: au `11/03/2026`, l'exercice `2026` déduit les absences posées jusqu'au `31/05/2026`, mais l'acquisition reste arrêtée au `28/02/2026`
|
||||||
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
- hors périmètre phase 1: `INTERIM` (retour non supporté)
|
||||||
- onglet `RTT`:
|
- onglet `RTT`:
|
||||||
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
- endpoint de synthèse: `GET /api/employees/{id}/rtt-summary?year=YYYY`
|
||||||
|
|||||||
484
docs/plans/2026-03-09-rtt-paid-hours.md
Normal file
484
docs/plans/2026-03-09-rtt-paid-hours.md
Normal file
@@ -0,0 +1,484 @@
|
|||||||
|
# RTT : Affichage en heures + Paiement RTT
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Afficher les RTT en heures (plus en jours), permettre le paiement RTT via drawer avec majoration 25%/50%, stocker en BDD et afficher par mois.
|
||||||
|
|
||||||
|
**Architecture:** Nouvelle entity `EmployeeRttPayment` (employee, year, month, minutes, rate). Le provider RTT agrège les paiements par mois et les soustrait du disponible. Le frontend ajoute un drawer de saisie et deux lignes par mois (25% / 50%).
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony + Doctrine + API Platform (backend), Nuxt 4 + Vue 3 + TypeScript + Tailwind (frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contexte existant
|
||||||
|
|
||||||
|
- **Entity:** `EmployeeRttBalance` dans `src/Entity/EmployeeRttBalance.php` - stocke `openingMinutes` par exercice
|
||||||
|
- **Provider:** `src/State/EmployeeRttSummaryProvider.php` - calcule `availableMinutes = carry + currentYearRecovery`
|
||||||
|
- **Service:** `src/Service/Rtt/RttRecoveryComputationService.php` - calcul semaine par semaine
|
||||||
|
- **Frontend:** `frontend/components/employees/RttTab.vue` - affichage grille mois/semaines
|
||||||
|
- **DTO backend:** `src/ApiResource/EmployeeRttSummary.php` - champs `availableMinutes`, `weeks[]`
|
||||||
|
- **DTO frontend:** `frontend/services/dto/employee-rtt-summary.ts`
|
||||||
|
- Les minutes sont la base de calcul. 1 jour = 7h = 420 minutes.
|
||||||
|
- Exercice RTT : 1er juin N-1 -> 31 mai N (year = N)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Migration - Créer la table `employee_rtt_payments`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version20260309140000.php`
|
||||||
|
|
||||||
|
**Step 1: Créer la migration**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260309140000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create employee_rtt_payments table for RTT paid hours tracking.';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql(<<<'SQL'
|
||||||
|
CREATE TABLE employee_rtt_payments (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
employee_id INT NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||||
|
year INT NOT NULL,
|
||||||
|
month INT NOT NULL,
|
||||||
|
minutes INT NOT NULL,
|
||||||
|
rate VARCHAR(10) NOT NULL,
|
||||||
|
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL DEFAULT NOW()
|
||||||
|
)
|
||||||
|
SQL);
|
||||||
|
$this->addSql('CREATE INDEX idx_rtt_payment_employee_year ON employee_rtt_payments (employee_id, year)');
|
||||||
|
$this->addSql("COMMENT ON TABLE employee_rtt_payments IS 'Paiements RTT par employe, mois et taux de majoration.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.rate IS 'Taux de majoration: 25 ou 50.'");
|
||||||
|
$this->addSql("COMMENT ON COLUMN employee_rtt_payments.minutes IS 'Minutes RTT payees pour ce mois et ce taux.'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('DROP TABLE employee_rtt_payments');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Exécuter la migration**
|
||||||
|
|
||||||
|
Run: `make migrate` ou `docker compose exec php php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
Expected: Migration exécutée sans erreur
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/Version20260309140000.php
|
||||||
|
git commit -m "feat(rtt): add employee_rtt_payments table"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Entity + Repository `EmployeeRttPayment`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/Entity/EmployeeRttPayment.php`
|
||||||
|
- Create: `src/Repository/EmployeeRttPaymentRepository.php`
|
||||||
|
|
||||||
|
**Step 1: Créer l'entity**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use App\Repository\EmployeeRttPaymentRepository;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: EmployeeRttPaymentRepository::class)]
|
||||||
|
#[ORM\Table(name: 'employee_rtt_payments')]
|
||||||
|
#[ORM\Index(columns: ['employee_id', 'year'], name: 'idx_rtt_payment_employee_year')]
|
||||||
|
class EmployeeRttPayment
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')]
|
||||||
|
private ?Employee $employee = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $year = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
private int $month = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'integer', options: ['comment' => 'Minutes RTT payees pour ce mois et ce taux.'])]
|
||||||
|
private int $minutes = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(length: 10, options: ['comment' => 'Taux de majoration: 25 ou 50.'])]
|
||||||
|
private string $rate = '25';
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
|
private DateTimeImmutable $updatedAt;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$now = new DateTimeImmutable();
|
||||||
|
$this->createdAt = $now;
|
||||||
|
$this->updatedAt = $now;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Getters & setters (getId, getEmployee/setEmployee, getYear/setYear,
|
||||||
|
// getMonth/setMonth, getMinutes/setMinutes, getRate/setRate, touch)
|
||||||
|
// Suivre le même pattern que EmployeeLeaveBalance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Créer le repository**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Repository;
|
||||||
|
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\EmployeeRttPayment;
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends ServiceEntityRepository<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
final class EmployeeRttPaymentRepository extends ServiceEntityRepository
|
||||||
|
{
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, EmployeeRttPayment::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<EmployeeRttPayment>
|
||||||
|
*/
|
||||||
|
public function findByEmployeeAndYear(Employee $employee, int $year): array
|
||||||
|
{
|
||||||
|
return $this->createQueryBuilder('p')
|
||||||
|
->andWhere('p.employee = :employee')
|
||||||
|
->andWhere('p.year = :year')
|
||||||
|
->setParameter('employee', $employee)
|
||||||
|
->setParameter('year', $year)
|
||||||
|
->orderBy('p.month', 'ASC')
|
||||||
|
->getQuery()
|
||||||
|
->getResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Vérifier le lint**
|
||||||
|
|
||||||
|
Run: `docker compose exec php php -l src/Entity/EmployeeRttPayment.php && docker compose exec php php -l src/Repository/EmployeeRttPaymentRepository.php`
|
||||||
|
Expected: No syntax errors
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/EmployeeRttPayment.php src/Repository/EmployeeRttPaymentRepository.php
|
||||||
|
git commit -m "feat(rtt): add EmployeeRttPayment entity and repository"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: API Resource + Provider + Processor pour le paiement RTT
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ApiResource/EmployeeRttPaymentInput.php`
|
||||||
|
- Create: `src/State/EmployeeRttPaymentProvider.php`
|
||||||
|
- Create: `src/State/EmployeeRttPaymentProcessor.php`
|
||||||
|
|
||||||
|
**Step 1: Créer l'API Resource**
|
||||||
|
|
||||||
|
Endpoint: `PATCH /employees/{id}/rtt-payments` (ROLE_ADMIN)
|
||||||
|
Body: `{ "month": 3, "minutes": 120, "rate": "25" }`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/ApiResource/EmployeeRttPaymentInput.php
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Patch(
|
||||||
|
uriTemplate: '/employees/{id}/rtt-payments',
|
||||||
|
security: "is_granted('ROLE_ADMIN')",
|
||||||
|
provider: EmployeeRttPaymentProvider::class,
|
||||||
|
processor: EmployeeRttPaymentProcessor::class
|
||||||
|
),
|
||||||
|
],
|
||||||
|
paginationEnabled: false
|
||||||
|
)]
|
||||||
|
final class EmployeeRttPaymentInput
|
||||||
|
{
|
||||||
|
public int $month = 0;
|
||||||
|
public int $minutes = 0;
|
||||||
|
public string $rate = '25';
|
||||||
|
public ?int $year = null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Créer le Provider** (retourne un DTO vide, même pattern que `EmployeeFractionedDaysProvider`)
|
||||||
|
|
||||||
|
**Step 3: Créer le Processor**
|
||||||
|
|
||||||
|
Logique:
|
||||||
|
- Valider `rate` in `['25', '50']`, `month` in `[1..12]`, `minutes >= 0`
|
||||||
|
- Résoudre l'année (même logique exercice RTT que le provider existant)
|
||||||
|
- Persister un `EmployeeRttPayment`
|
||||||
|
|
||||||
|
**Step 4: Vérifier le lint**
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ApiResource/EmployeeRttPaymentInput.php src/State/EmployeeRttPaymentProvider.php src/State/EmployeeRttPaymentProcessor.php
|
||||||
|
git commit -m "feat(rtt): add PATCH endpoint for RTT payment"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Modifier le DTO + Provider RTT pour inclure les paiements par mois
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ApiResource/EmployeeRttSummary.php`
|
||||||
|
- Modify: `src/State/EmployeeRttSummaryProvider.php`
|
||||||
|
- Create: `src/Dto/Rtt/RttMonthPayment.php`
|
||||||
|
- Modify: `frontend/services/dto/employee-rtt-summary.ts`
|
||||||
|
|
||||||
|
**Step 1: Créer le DTO mois-paiement**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Dto/Rtt/RttMonthPayment.php
|
||||||
|
final class RttMonthPayment
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public int $month,
|
||||||
|
public int $paidMinutes25 = 0,
|
||||||
|
public int $paidMinutes50 = 0,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter au summary backend**
|
||||||
|
|
||||||
|
Dans `EmployeeRttSummary.php`, ajouter:
|
||||||
|
```php
|
||||||
|
public int $totalPaidMinutes = 0;
|
||||||
|
|
||||||
|
/** @var list<RttMonthPayment> */
|
||||||
|
public array $monthPayments = [];
|
||||||
|
```
|
||||||
|
|
||||||
|
Et modifier `availableMinutes` pour soustraire les paiements:
|
||||||
|
```php
|
||||||
|
$summary->availableMinutes = $summary->carryFromPreviousYearMinutes
|
||||||
|
+ $summary->currentYearRecoveryMinutes
|
||||||
|
- $summary->totalPaidMinutes;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Modifier le provider** pour charger les paiements via le repository et les agréger par mois
|
||||||
|
|
||||||
|
Dans `EmployeeRttSummaryProvider::provide()`:
|
||||||
|
- Charger `$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $year)`
|
||||||
|
- Agréger par mois + rate pour construire les `RttMonthPayment`
|
||||||
|
- Calculer `totalPaidMinutes = sum(minutes)`
|
||||||
|
- Soustraire du `availableMinutes`
|
||||||
|
|
||||||
|
**Step 4: Mettre à jour le DTO frontend**
|
||||||
|
|
||||||
|
Dans `frontend/services/dto/employee-rtt-summary.ts`:
|
||||||
|
```typescript
|
||||||
|
export type RttMonthPayment = {
|
||||||
|
month: number
|
||||||
|
paidMinutes25: number
|
||||||
|
paidMinutes50: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EmployeeRttSummary = {
|
||||||
|
// ... champs existants ...
|
||||||
|
totalPaidMinutes: number
|
||||||
|
monthPayments: RttMonthPayment[]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): include paid hours in RTT summary by month"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Frontend - Afficher les RTT en heures + lignes payées
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/employees/RttTab.vue`
|
||||||
|
|
||||||
|
**Step 1: Changer l'affichage du header de jours en heures**
|
||||||
|
|
||||||
|
Ligne 4 actuelle:
|
||||||
|
```html
|
||||||
|
<p>...: {{ formatDays(summary?.availableMinutes ?? 0) }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
Remplacer par:
|
||||||
|
```html
|
||||||
|
<p>...: {{ formatMinutes(summary?.availableMinutes ?? 0) }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter les 2 lignes de paiement par mois**
|
||||||
|
|
||||||
|
Après la ligne `Heure payée` existante (ligne 33-34), remplacer par 2 lignes distinctes:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="py-[6px] pl-3 border-r border-b border-primary-500">Heure payée 25%</div>
|
||||||
|
<div class="py-[6px] pl-3 border-b border-primary-500">{{ formatMinutes(getMonthPaid25(month.month)) }}</div>
|
||||||
|
<div class="py-[6px] pl-3 border-r border-primary-500">Heure payée 50%</div>
|
||||||
|
<div class="py-[6px] pl-3">{{ formatMinutes(getMonthPaid50(month.month)) }}</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Ajouter les helpers computed**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const paymentsByMonth = computed(() => {
|
||||||
|
const map = new Map<number, { paid25: number; paid50: number }>()
|
||||||
|
for (const mp of props.summary?.monthPayments ?? []) {
|
||||||
|
map.set(mp.month, { paid25: mp.paidMinutes25, paid50: mp.paidMinutes50 })
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
const getMonthPaid25 = (month: number) => paymentsByMonth.value.get(month)?.paid25 ?? 0
|
||||||
|
const getMonthPaid50 = (month: number) => paymentsByMonth.value.get(month)?.paid50 ?? 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): display hours instead of days + paid hours per month"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Frontend - Drawer de paiement RTT
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/components/employees/RttTab.vue`
|
||||||
|
- Modify: `frontend/services/employee-rtt-summary.ts`
|
||||||
|
- Modify: `frontend/composables/useEmployeeDetailPage.ts`
|
||||||
|
- Modify: `frontend/pages/employees/[id].vue`
|
||||||
|
|
||||||
|
**Step 1: Ajouter le service API**
|
||||||
|
|
||||||
|
Dans `frontend/services/employee-rtt-summary.ts`:
|
||||||
|
```typescript
|
||||||
|
export const createRttPayment = async (
|
||||||
|
employeeId: number,
|
||||||
|
month: number,
|
||||||
|
minutes: number,
|
||||||
|
rate: '25' | '50',
|
||||||
|
year?: number
|
||||||
|
) => {
|
||||||
|
const api = useApi()
|
||||||
|
const body: Record<string, unknown> = { month, minutes, rate }
|
||||||
|
if (year) body.year = year
|
||||||
|
return api.patch(`/employees/${employeeId}/rtt-payments`, body)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Ajouter au composable**
|
||||||
|
|
||||||
|
Dans `useEmployeeDetailPage.ts`:
|
||||||
|
- Import `createRttPayment`
|
||||||
|
- Ajouter `submitRttPayment(month, minutes, rate)` qui appelle l'API puis `loadEmployee()`
|
||||||
|
- Exposer dans le return
|
||||||
|
|
||||||
|
**Step 3: Passer l'event dans la page**
|
||||||
|
|
||||||
|
Dans `frontend/pages/employees/[id].vue`:
|
||||||
|
- Destructurer `submitRttPayment`
|
||||||
|
- Ajouter `@submit-rtt-payment="submitRttPayment"` sur `<EmployeesRttTab>`
|
||||||
|
|
||||||
|
**Step 4: Ajouter le drawer dans RttTab.vue**
|
||||||
|
|
||||||
|
Même pattern que le drawer fractionnés dans LeaveTab:
|
||||||
|
- Import `AppDrawer`
|
||||||
|
- State: `isPaymentDrawerOpen`, `paymentForm: { month, minutes, rate }`
|
||||||
|
- Bouton "Payer les RTT" ouvre le drawer
|
||||||
|
- Formulaire avec:
|
||||||
|
- Select mois (Janvier..Décembre)
|
||||||
|
- Input number "Nombre d'heures" (step 0.5, converti en minutes au submit)
|
||||||
|
- Select rate: "25%" / "50%"
|
||||||
|
- Boutons Annuler / Enregistrer
|
||||||
|
- Emit `submit-rtt-payment` au submit
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(rtt): add payment drawer with month/hours/rate fields"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Documentation fonctionnelle
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `doc/functional-rules.md`
|
||||||
|
|
||||||
|
**Step 1: Mettre à jour la section RTT**
|
||||||
|
|
||||||
|
Ajouter après les règles RTT existantes:
|
||||||
|
- Paiement RTT: saisie RH via `PATCH /employees/{id}/rtt-payments`
|
||||||
|
- Stocké dans `employee_rtt_payments` (employee, year, month, minutes, rate)
|
||||||
|
- Les heures payées sont soustraites du disponible RTT
|
||||||
|
- Affichage: 2 lignes par mois (25% et 50%)
|
||||||
|
- L'affichage global RTT est en heures (plus en jours)
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "docs: update functional rules with RTT payment feature"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Résumé des fichiers
|
||||||
|
|
||||||
|
| Action | Fichier |
|
||||||
|
|--------|---------|
|
||||||
|
| Create | `migrations/Version20260309140000.php` |
|
||||||
|
| Create | `src/Entity/EmployeeRttPayment.php` |
|
||||||
|
| Create | `src/Repository/EmployeeRttPaymentRepository.php` |
|
||||||
|
| Create | `src/ApiResource/EmployeeRttPaymentInput.php` |
|
||||||
|
| Create | `src/State/EmployeeRttPaymentProvider.php` |
|
||||||
|
| Create | `src/State/EmployeeRttPaymentProcessor.php` |
|
||||||
|
| Create | `src/Dto/Rtt/RttMonthPayment.php` |
|
||||||
|
| Modify | `src/ApiResource/EmployeeRttSummary.php` |
|
||||||
|
| Modify | `src/State/EmployeeRttSummaryProvider.php` |
|
||||||
|
| Modify | `frontend/services/dto/employee-rtt-summary.ts` |
|
||||||
|
| Modify | `frontend/services/employee-rtt-summary.ts` |
|
||||||
|
| Modify | `frontend/components/employees/RttTab.vue` |
|
||||||
|
| Modify | `frontend/composables/useEmployeeDetailPage.ts` |
|
||||||
|
| Modify | `frontend/pages/employees/[id].vue` |
|
||||||
|
| Modify | `doc/functional-rules.md` |
|
||||||
273
docs/superpowers/plans/2026-03-12-employee-entry-date.md
Normal file
273
docs/superpowers/plans/2026-03-12-employee-entry-date.md
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
# Employee Entry Date Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add an `entryDate` field to Employee, automatically populated from `contractStartDate` at creation.
|
||||||
|
|
||||||
|
**Architecture:** New nullable `DATE` column on `employees` table. The `EmployeeWriteProcessor` sets `entryDate` from the first contract period's start date during employee creation. Exposed read-only in the API. No fallback needed — existing employees will be updated manually in prod DB.
|
||||||
|
|
||||||
|
**Tech Stack:** Symfony/Doctrine (backend), Nuxt/Vue/TypeScript (frontend)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Action | Responsibility |
|
||||||
|
|------|--------|----------------|
|
||||||
|
| `src/Entity/Employee.php` | Modify | Add `entryDate` column + getter/setter, expose in `employee:read` |
|
||||||
|
| `src/State/EmployeeWriteProcessor.php` | Modify | Set `entryDate` from `contractStartDate` on creation |
|
||||||
|
| `migrations/Version20260312120000.php` | Create | Add `entry_date` column to `employees` table |
|
||||||
|
| `tests/State/EmployeeWriteProcessorTest.php` | Modify | Assert `entryDate` is set on new employee |
|
||||||
|
| `frontend/services/dto/employee.ts` | Modify | Add `entryDate` field to `Employee` type |
|
||||||
|
| `frontend/pages/employees/index.vue` | Modify | Display entry date in employee list |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Backend
|
||||||
|
|
||||||
|
### Task 1: Migration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `migrations/Version20260312120000.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the migration file**
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260312120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add entry_date column to employees table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL COMMENT \'(DC2Type:date_immutable)\'');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP entry_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run migration**
|
||||||
|
|
||||||
|
Run: `php bin/console doctrine:migrations:migrate --no-interaction`
|
||||||
|
Expected: Migration applied successfully
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add migrations/Version20260312120000.php
|
||||||
|
git commit -m "feat : ajout colonne entry_date sur employees"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Entity — add `entryDate` property
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/Entity/Employee.php:56-61` (insert after `displayOrder`)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the column, getter, and setter to Employee entity**
|
||||||
|
|
||||||
|
Add after the `displayOrder` property (line 58):
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
private ?\DateTimeImmutable $entryDate = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
Add getter and setter after `setDisplayOrder()` (after line 167):
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function getEntryDate(): ?\DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->entryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryDate(?\DateTimeImmutable $entryDate): self
|
||||||
|
{
|
||||||
|
$this->entryDate = $entryDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify schema is in sync**
|
||||||
|
|
||||||
|
Run: `php bin/console doctrine:schema:validate`
|
||||||
|
Expected: OK (or only existing unrelated warnings)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/Entity/Employee.php
|
||||||
|
git commit -m "feat : ajout propriete entryDate sur Employee"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Set `entryDate` on employee creation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/State/EmployeeWriteProcessor.php:60-71`
|
||||||
|
- Modify: `tests/State/EmployeeWriteProcessorTest.php`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Add `use ApiPlatform\Metadata\Post;` to the imports at the top of the test file (alongside the existing `Delete` and `Patch` imports).
|
||||||
|
|
||||||
|
Then add this test method to `EmployeeWriteProcessorTest`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function testSetsEntryDateOnNewEmployee(): void
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName('Jane');
|
||||||
|
$employee->setLastName('Doe');
|
||||||
|
$employee->setContractStartDate('2026-04-01');
|
||||||
|
$employee->setContractNature('CDI');
|
||||||
|
|
||||||
|
$contract = new Contract()
|
||||||
|
->setName('35h')
|
||||||
|
->setTrackingMode(Contract::TRACKING_TIME)
|
||||||
|
->setWeeklyHours(35);
|
||||||
|
$employee->setContract($contract);
|
||||||
|
|
||||||
|
$persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$removeProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
|
||||||
|
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
|
||||||
|
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
|
||||||
|
|
||||||
|
$persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->willReturn($employee);
|
||||||
|
|
||||||
|
$periodManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('ensureContractPeriodExists');
|
||||||
|
|
||||||
|
$processor = new EmployeeWriteProcessor(
|
||||||
|
$persistProcessor,
|
||||||
|
$removeProcessor,
|
||||||
|
$entityManager,
|
||||||
|
$periodRepository,
|
||||||
|
$changeRequestFactory,
|
||||||
|
$periodManager
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->process($employee, new Post());
|
||||||
|
|
||||||
|
self::assertNotNull($employee->getEntryDate());
|
||||||
|
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
|
||||||
|
Expected: FAIL — `entryDate` is null
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement — set entryDate in EmployeeWriteProcessor**
|
||||||
|
|
||||||
|
In `src/State/EmployeeWriteProcessor.php`, inside the `if ($isNew)` block (line 60-71), add **before** `return $result;` (line 71):
|
||||||
|
|
||||||
|
```php
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
```
|
||||||
|
|
||||||
|
The full block becomes:
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($isNew) {
|
||||||
|
$startDate = $changeRequest->contractStartDate ?? new DateTimeImmutable('1970-01-01');
|
||||||
|
$nature = $changeRequest->contractNature ?? ContractNature::CDI;
|
||||||
|
$this->periodManager->ensureContractPeriodExists(
|
||||||
|
employee: $data,
|
||||||
|
contract: $currentContract,
|
||||||
|
startDate: $startDate,
|
||||||
|
endDate: $changeRequest->contractEndDate,
|
||||||
|
nature: $nature
|
||||||
|
);
|
||||||
|
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php --filter=testSetsEntryDateOnNewEmployee`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run all EmployeeWriteProcessor tests**
|
||||||
|
|
||||||
|
Run: `php bin/phpunit tests/State/EmployeeWriteProcessorTest.php`
|
||||||
|
Expected: All tests pass (no regression)
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/State/EmployeeWriteProcessor.php tests/State/EmployeeWriteProcessorTest.php
|
||||||
|
git commit -m "feat : remplissage automatique entryDate a la creation employe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 2: Frontend
|
||||||
|
|
||||||
|
### Task 4: Update frontend DTO and display entry date
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/services/dto/employee.ts:14-25`
|
||||||
|
- Modify: `frontend/pages/employees/index.vue`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `entryDate` to the Employee DTO**
|
||||||
|
|
||||||
|
In `frontend/services/dto/employee.ts:24`, add `entryDate` after `displayOrder`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
displayOrder?: number
|
||||||
|
entryDate?: string | null
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Display entry date in the employee card hover overlay**
|
||||||
|
|
||||||
|
In `frontend/pages/employees/index.vue`, inside the hover overlay `<div>` (line 49-54), add a new line after the "Site" line (after line 53):
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<p><strong>Entree :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses string splitting instead of `new Date()` to avoid timezone parsing issues with date-only strings.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify in browser**
|
||||||
|
|
||||||
|
1. Check the API response for an employee: `GET /api/employees` should include `entryDate` field (confirms backend `employee:read` group works)
|
||||||
|
2. Open the employee list page, hover over a card — entry date should appear in the overlay
|
||||||
|
3. Create a new employee, verify the entry date shows the contract start date
|
||||||
|
4. Existing employees without entry date should show "-"
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/services/dto/employee.ts frontend/pages/employees/index.vue
|
||||||
|
git commit -m "feat : affichage date d'entree dans la liste employes"
|
||||||
|
```
|
||||||
@@ -7,68 +7,67 @@
|
|||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 px-4 pb-6">
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
|
||||||
to="/"
|
|
||||||
class="hidden flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
|
||||||
active-class="bg-tertiary-500 text-primary-500 font-bold"
|
|
||||||
>
|
|
||||||
Tableau de bord
|
|
||||||
</NuxtLink>
|
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/calendar"
|
to="/calendar"
|
||||||
class="flex items-center gap-3 px-4 pb-3 pt-6 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
class="flex items-center gap-2 pb-2 pt-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 border-t border-secondary-500"
|
||||||
:class="route.path.startsWith('/calendar')
|
:class="route.path.startsWith('/calendar')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Calendrier
|
<Icon name="mdi:calendar-blank" size="24"/>
|
||||||
|
<p>Calendrier</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/hours"
|
to="/hours"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/hours')
|
:class="route.path.startsWith('/hours')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Heures
|
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||||
|
<p>Heures</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/employees"
|
to="/employees"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/employees')
|
:class="route.path.startsWith('/employees')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Employés
|
<Icon name="mdi:account-group-outline" size="24"/>
|
||||||
|
<p>Employés</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/sites"
|
to="/sites"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/sites')
|
:class="route.path.startsWith('/sites')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Sites
|
<Icon name="mdi:business" size="24"/>
|
||||||
|
<p>Sites</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/absence-types"
|
to="/absence-types"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/absence-types')
|
:class="route.path.startsWith('/absence-types')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Types d'absence
|
<Icon name="mdi:umbrella-beach-outline" size="24"/>
|
||||||
|
<p>Types d'absence</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/users"
|
to="/users"
|
||||||
class="flex items-center gap-3 px-4 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
class="flex items-center gap-3 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||||
:class="route.path.startsWith('/users')
|
:class="route.path.startsWith('/users')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
>
|
>
|
||||||
Utilisateurs
|
<Icon name="mdi:account-outline" size="24"/>
|
||||||
|
<p>Utilisateurs</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -12,9 +12,12 @@
|
|||||||
|
|
||||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
<div>
|
||||||
|
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||||
|
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="font-bold text-[20px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
|
||||||
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
||||||
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
||||||
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
||||||
|
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const handleSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
await auth.login(username.value, password.value)
|
await auth.login(username.value, password.value)
|
||||||
|
|
||||||
await router.push('/')
|
await router.push('/calendar')
|
||||||
} finally {
|
} finally {
|
||||||
isSubmitting.value = false
|
isSubmitting.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ export type Employee = {
|
|||||||
currentContractEndDate?: string | null
|
currentContractEndDate?: string | null
|
||||||
contractHistory?: ContractHistoryItem[]
|
contractHistory?: ContractHistoryItem[]
|
||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
|
entryDate?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
26
migrations/Version20260312120000.php
Normal file
26
migrations/Version20260312120000.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260312120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add entry_date column to employees table';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees ADD entry_date DATE DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employees DROP entry_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ use DateTimeImmutable;
|
|||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Context;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
|
||||||
|
|
||||||
#[ApiResource(
|
#[ApiResource(
|
||||||
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
normalizationContext: ['groups' => ['employee:read', 'site:read']],
|
||||||
@@ -57,6 +59,11 @@ class Employee
|
|||||||
#[Groups(['employee:read', 'employee:write'])]
|
#[Groups(['employee:read', 'employee:write'])]
|
||||||
private int $displayOrder = 0;
|
private int $displayOrder = 0;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
#[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
|
||||||
|
private ?DateTimeImmutable $entryDate = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'datetime_immutable')]
|
#[ORM\Column(type: 'datetime_immutable')]
|
||||||
private DateTimeImmutable $createdAt;
|
private DateTimeImmutable $createdAt;
|
||||||
|
|
||||||
@@ -166,6 +173,18 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getEntryDate(): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $this->entryDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEntryDate(?DateTimeImmutable $entryDate): self
|
||||||
|
{
|
||||||
|
$this->entryDate = $entryDate;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getContractNature(): ?string
|
public function getContractNature(): ?string
|
||||||
{
|
{
|
||||||
return $this->contractNature;
|
return $this->contractNature;
|
||||||
|
|||||||
@@ -117,14 +117,14 @@ final readonly class LeaveBalanceComputationService
|
|||||||
{
|
{
|
||||||
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
if (LeaveRuleCode::FORFAIT_218 === $ruleCode) {
|
||||||
return [
|
return [
|
||||||
new DateTimeImmutable(sprintf('%d-01-01', $year)),
|
new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year)),
|
||||||
new DateTimeImmutable(sprintf('%d-12-31', $year)),
|
new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new DateTimeImmutable(sprintf('%d-06-01', $year - 1)),
|
new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $year - 1)),
|
||||||
new DateTimeImmutable(sprintf('%d-05-31', $year)),
|
new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $year)),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +145,7 @@ final readonly class LeaveBalanceComputationService
|
|||||||
|
|
||||||
$oldestStartDate = null;
|
$oldestStartDate = null;
|
||||||
foreach ($history as $item) {
|
foreach ($history as $item) {
|
||||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
|
$start = $this->parseYmdDate($item->startDate);
|
||||||
if (!$start) {
|
if (!$start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -197,14 +197,14 @@ final readonly class LeaveBalanceComputationService
|
|||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$earliest = null;
|
$earliest = null;
|
||||||
foreach ($employee->getContractHistory() as $period) {
|
foreach ($employee->getContractHistory() as $period) {
|
||||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
|
$start = $this->parseYmdDate($period->startDate);
|
||||||
if (!$start) {
|
if (!$start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$end = null;
|
$end = null;
|
||||||
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
||||||
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
|
$end = $this->parseYmdDate($period->endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($start > $to) {
|
if ($start > $to) {
|
||||||
@@ -268,11 +268,37 @@ final readonly class LeaveBalanceComputationService
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
|
$periodStart = $this->normalizeDate($periodStart);
|
||||||
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
|
$periodEnd = $this->normalizeDate($periodEnd);
|
||||||
+ 1;
|
$coveredMonths = 0.0;
|
||||||
|
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
|
||||||
|
while ($cursor <= $periodEnd) {
|
||||||
|
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
|
||||||
|
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||||
|
if ($monthEnd > $periodEnd) {
|
||||||
|
$monthEnd = $periodEnd;
|
||||||
|
}
|
||||||
|
|
||||||
return min($annualCap, $monthsElapsed * $accrualPerMonth);
|
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||||
|
$daysInMonth = (int) $cursor->format('t');
|
||||||
|
$coveredMonths += $coveredDays / $daysInMonth;
|
||||||
|
|
||||||
|
$cursor = $cursor->modify('first day of next month');
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($annualCap, $coveredMonths * $accrualPerMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
|
||||||
|
|
||||||
|
return $date instanceof DateTimeImmutable ? $date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $date->setTime(0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
private function countBusinessDays(DateTimeImmutable $from, DateTimeImmutable $to): int
|
||||||
|
|||||||
@@ -168,13 +168,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$carrySaturdays = 0.0;
|
$carrySaturdays = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$calculationEnd = $this->resolveCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
|
||||||
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
|
||||||
|
$generatedDays = $leavePolicy['accrualPerMonth'] > 0.0
|
||||||
? $this->computeAccruedDaysFromStart(
|
? $this->computeAccruedDaysFromStart(
|
||||||
$leavePolicy['acquiredDays'],
|
$leavePolicy['acquiredDays'],
|
||||||
$leavePolicy['accrualPerMonth'],
|
$leavePolicy['accrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd
|
$accrualCalculationEnd
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
$generatedSaturdays = $leavePolicy['saturdayAccrualPerMonth'] > 0.0
|
||||||
@@ -182,14 +183,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$leavePolicy['acquiredSaturdays'],
|
$leavePolicy['acquiredSaturdays'],
|
||||||
$leavePolicy['saturdayAccrualPerMonth'],
|
$leavePolicy['saturdayAccrualPerMonth'],
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd
|
$accrualCalculationEnd
|
||||||
)
|
)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
|
||||||
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
[$takenDays, $takenSaturdays] = $this->computeTakenAbsences(
|
||||||
$absences,
|
$absences,
|
||||||
$effectiveFrom,
|
$effectiveFrom,
|
||||||
$calculationEnd,
|
$takenCalculationEnd,
|
||||||
$leavePolicy['countOnlyCp'],
|
$leavePolicy['countOnlyCp'],
|
||||||
$leavePolicy['splitSaturdays']
|
$leavePolicy['splitSaturdays']
|
||||||
);
|
);
|
||||||
@@ -279,14 +280,14 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
): ?DateTimeImmutable {
|
): ?DateTimeImmutable {
|
||||||
$earliest = null;
|
$earliest = null;
|
||||||
foreach ($employee->getContractHistory() as $period) {
|
foreach ($employee->getContractHistory() as $period) {
|
||||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $period->startDate);
|
$start = $this->parseYmdDate($period->startDate);
|
||||||
if (!$start instanceof DateTimeImmutable) {
|
if (!$start instanceof DateTimeImmutable) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$end = null;
|
$end = null;
|
||||||
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
if (null !== $period->endDate && '' !== trim($period->endDate)) {
|
||||||
$end = DateTimeImmutable::createFromFormat('Y-m-d', $period->endDate);
|
$end = $this->parseYmdDate($period->endDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($start > $to) {
|
if ($start > $to) {
|
||||||
@@ -343,17 +344,28 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$monthsElapsed = ((int) $periodEnd->format('Y') - (int) $periodStart->format('Y')) * 12
|
$periodStart = $this->normalizeDate($periodStart);
|
||||||
+ ((int) $periodEnd->format('n') - (int) $periodStart->format('n'))
|
$periodEnd = $this->normalizeDate($periodEnd);
|
||||||
+ 1;
|
$coveredMonths = 0.0;
|
||||||
if ($monthsElapsed < 0) {
|
$cursor = $periodStart->modify('first day of this month')->setTime(0, 0);
|
||||||
return 0.0;
|
while ($cursor <= $periodEnd) {
|
||||||
|
$monthStart = $cursor > $periodStart ? $cursor : $periodStart;
|
||||||
|
$monthEnd = $cursor->modify('last day of this month')->setTime(0, 0);
|
||||||
|
if ($monthEnd > $periodEnd) {
|
||||||
|
$monthEnd = $periodEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
$coveredDays = ((int) $monthEnd->diff($monthStart)->format('%a')) + 1;
|
||||||
|
$daysInMonth = (int) $cursor->format('t');
|
||||||
|
$coveredMonths += $coveredDays / $daysInMonth;
|
||||||
|
|
||||||
|
$cursor = $cursor->modify('first day of next month');
|
||||||
}
|
}
|
||||||
|
|
||||||
return min($acquiredDays, $monthsElapsed * $accrualPerMonth);
|
return min($acquiredDays, $coveredMonths * $accrualPerMonth);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveCalculationEndDate(
|
private function resolveAccrualCalculationEndDate(
|
||||||
string $ruleCode,
|
string $ruleCode,
|
||||||
int $year,
|
int $year,
|
||||||
DateTimeImmutable $periodEnd,
|
DateTimeImmutable $periodEnd,
|
||||||
@@ -372,6 +384,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
$lastDayPreviousMonth = $today
|
$lastDayPreviousMonth = $today
|
||||||
->modify('first day of this month')
|
->modify('first day of this month')
|
||||||
->modify('-1 day')
|
->modify('-1 day')
|
||||||
|
->setTime(0, 0)
|
||||||
;
|
;
|
||||||
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
$end = $lastDayPreviousMonth < $periodEnd ? $lastDayPreviousMonth : $periodEnd;
|
||||||
}
|
}
|
||||||
@@ -379,7 +392,25 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
// Cap at contract end date if the employee has left.
|
// Cap at contract end date if the employee has left.
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw);
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
|
$end = $contractEnd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveTakenCalculationEndDate(
|
||||||
|
DateTimeImmutable $periodEnd,
|
||||||
|
Employee $employee
|
||||||
|
): ?DateTimeImmutable {
|
||||||
|
$end = $periodEnd;
|
||||||
|
|
||||||
|
// Cap at contract end date if the employee has left.
|
||||||
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
|
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $end) {
|
||||||
$end = $contractEnd;
|
$end = $contractEnd;
|
||||||
}
|
}
|
||||||
@@ -501,8 +532,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
private function resolveLeavePeriodBounds(int $leaveYear): array
|
private function resolveLeavePeriodBounds(int $leaveYear): array
|
||||||
{
|
{
|
||||||
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
// Exercice CP "2026" = du 1er juin 2025 au 31 mai 2026.
|
||||||
$from = new DateTimeImmutable(sprintf('%d-06-01', $leaveYear - 1));
|
$from = new DateTimeImmutable(sprintf('%d-06-01 00:00:00', $leaveYear - 1));
|
||||||
$to = new DateTimeImmutable(sprintf('%d-05-31', $leaveYear));
|
$to = new DateTimeImmutable(sprintf('%d-05-31 00:00:00', $leaveYear));
|
||||||
|
|
||||||
return [$from, $to];
|
return [$from, $to];
|
||||||
}
|
}
|
||||||
@@ -512,12 +543,12 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
*/
|
*/
|
||||||
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
private function resolveForfaitYearBounds(Employee $employee, int $year): array
|
||||||
{
|
{
|
||||||
$from = new DateTimeImmutable(sprintf('%d-01-01', $year));
|
$from = new DateTimeImmutable(sprintf('%d-01-01 00:00:00', $year));
|
||||||
$to = new DateTimeImmutable(sprintf('%d-12-31', $year));
|
$to = new DateTimeImmutable(sprintf('%d-12-31 00:00:00', $year));
|
||||||
|
|
||||||
$contractStartRaw = $employee->getCurrentContractStartDate();
|
$contractStartRaw = $employee->getCurrentContractStartDate();
|
||||||
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
if (null !== $contractStartRaw && '' !== trim($contractStartRaw)) {
|
||||||
$contractStart = DateTimeImmutable::createFromFormat('Y-m-d', $contractStartRaw);
|
$contractStart = $this->parseYmdDate($contractStartRaw);
|
||||||
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
if ($contractStart instanceof DateTimeImmutable && $contractStart > $from) {
|
||||||
$from = $contractStart;
|
$from = $contractStart;
|
||||||
}
|
}
|
||||||
@@ -525,7 +556,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$contractEndRaw = $employee->getCurrentContractEndDate();
|
$contractEndRaw = $employee->getCurrentContractEndDate();
|
||||||
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
if (null !== $contractEndRaw && '' !== trim($contractEndRaw)) {
|
||||||
$contractEnd = DateTimeImmutable::createFromFormat('Y-m-d', $contractEndRaw);
|
$contractEnd = $this->parseYmdDate($contractEndRaw);
|
||||||
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
if ($contractEnd instanceof DateTimeImmutable && $contractEnd < $to) {
|
||||||
$to = $contractEnd;
|
$to = $contractEnd;
|
||||||
}
|
}
|
||||||
@@ -563,7 +594,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
|
|
||||||
$oldestStartDate = null;
|
$oldestStartDate = null;
|
||||||
foreach ($history as $item) {
|
foreach ($history as $item) {
|
||||||
$start = DateTimeImmutable::createFromFormat('Y-m-d', $item->startDate);
|
$start = $this->parseYmdDate($item->startDate);
|
||||||
if (!$start) {
|
if (!$start) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -592,6 +623,18 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
|||||||
return $firstYear;
|
return $firstYear;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parseYmdDate(string $value): ?DateTimeImmutable
|
||||||
|
{
|
||||||
|
$date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value));
|
||||||
|
|
||||||
|
return $date instanceof DateTimeImmutable ? $date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeDate(DateTimeImmutable $date): DateTimeImmutable
|
||||||
|
{
|
||||||
|
return $date->setTime(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<Absence> $absences
|
* @param list<Absence> $absences
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
nature: $nature
|
nature: $nature
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$data->setEntryDate($startDate);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
71
tests/Service/Leave/LeaveBalanceComputationServiceTest.php
Normal file
71
tests/Service/Leave/LeaveBalanceComputationServiceTest.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Service\Leave;
|
||||||
|
|
||||||
|
use App\Service\Leave\LeaveBalanceComputationService;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class LeaveBalanceComputationServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testComputeAccruedDaysProratesPartialFirstMonth(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
|
$result = $method->invoke(
|
||||||
|
$service,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(18.125, $result, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputeAccruedDaysTotalMatchesAlainCase(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
|
$days = $method->invoke(
|
||||||
|
$service,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
$saturdays = $method->invoke(
|
||||||
|
$service,
|
||||||
|
5.0,
|
||||||
|
5.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputeAccruedDaysIncludesLastDayOfMonthDespiteTimeComponents(): void
|
||||||
|
{
|
||||||
|
$service = new ReflectionClass(LeaveBalanceComputationService::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(LeaveBalanceComputationService::class)->getMethod('computeAccruedDays');
|
||||||
|
|
||||||
|
$result = $method->invoke(
|
||||||
|
$service,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2026-02-01 12:50:18'),
|
||||||
|
new DateTimeImmutable('2026-02-28 00:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
71
tests/State/EmployeeLeaveSummaryProviderTest.php
Normal file
71
tests/State/EmployeeLeaveSummaryProviderTest.php
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\State;
|
||||||
|
|
||||||
|
use App\State\EmployeeLeaveSummaryProvider;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class EmployeeLeaveSummaryProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testComputeAccruedDaysFromStartProratesPartialFirstMonth(): void
|
||||||
|
{
|
||||||
|
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||||
|
|
||||||
|
$result = $method->invoke(
|
||||||
|
$provider,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(18.125, $result, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputeAccruingDaysTotalMatchesAlainCase(): void
|
||||||
|
{
|
||||||
|
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||||
|
|
||||||
|
$days = $method->invoke(
|
||||||
|
$provider,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
$saturdays = $method->invoke(
|
||||||
|
$provider,
|
||||||
|
5.0,
|
||||||
|
5.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2025-06-10'),
|
||||||
|
new DateTimeImmutable('2026-02-28')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(21.75, $days + $saturdays, 0.0001);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testComputeAccruedDaysFromStartIncludesLastDayOfMonth(): void
|
||||||
|
{
|
||||||
|
$provider = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->newInstanceWithoutConstructor();
|
||||||
|
$method = new ReflectionClass(EmployeeLeaveSummaryProvider::class)->getMethod('computeAccruedDaysFromStart');
|
||||||
|
|
||||||
|
$result = $method->invoke(
|
||||||
|
$provider,
|
||||||
|
25.0,
|
||||||
|
25.0 / 12.0,
|
||||||
|
new DateTimeImmutable('2026-02-01 12:50:18'),
|
||||||
|
new DateTimeImmutable('2026-02-28 00:00:00')
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertEqualsWithDelta(25.0 / 12.0, $result, 0.0001);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ namespace App\Tests\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Delete;
|
use ApiPlatform\Metadata\Delete;
|
||||||
use ApiPlatform\Metadata\Patch;
|
use ApiPlatform\Metadata\Patch;
|
||||||
|
use ApiPlatform\Metadata\Post;
|
||||||
use ApiPlatform\State\ProcessorInterface;
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
@@ -194,6 +195,54 @@ final class EmployeeWriteProcessorTest extends TestCase
|
|||||||
self::assertSame($employee, $result);
|
self::assertSame($employee, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSetsEntryDateOnNewEmployee(): void
|
||||||
|
{
|
||||||
|
$employee = new Employee();
|
||||||
|
$employee->setFirstName('Jane');
|
||||||
|
$employee->setLastName('Doe');
|
||||||
|
$employee->setContractStartDate('2026-04-01');
|
||||||
|
$employee->setContractNature('CDI');
|
||||||
|
|
||||||
|
$contract = new Contract()
|
||||||
|
->setName('35h')
|
||||||
|
->setTrackingMode(Contract::TRACKING_TIME)
|
||||||
|
->setWeeklyHours(35)
|
||||||
|
;
|
||||||
|
$employee->setContract($contract);
|
||||||
|
|
||||||
|
$persistProcessor = $this->createMock(ProcessorInterface::class);
|
||||||
|
$removeProcessor = $this->createStub(ProcessorInterface::class);
|
||||||
|
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||||
|
$periodRepository = $this->createStub(EmployeeContractPeriodReadRepositoryInterface::class);
|
||||||
|
$changeRequestFactory = new EmployeeContractChangeRequestFactory();
|
||||||
|
$periodManager = $this->createMock(EmployeeContractPeriodManagerInterface::class);
|
||||||
|
|
||||||
|
$persistProcessor
|
||||||
|
->expects(self::once())
|
||||||
|
->method('process')
|
||||||
|
->willReturn($employee)
|
||||||
|
;
|
||||||
|
|
||||||
|
$periodManager
|
||||||
|
->expects(self::once())
|
||||||
|
->method('ensureContractPeriodExists')
|
||||||
|
;
|
||||||
|
|
||||||
|
$processor = new EmployeeWriteProcessor(
|
||||||
|
$persistProcessor,
|
||||||
|
$removeProcessor,
|
||||||
|
$entityManager,
|
||||||
|
$periodRepository,
|
||||||
|
$changeRequestFactory,
|
||||||
|
$periodManager
|
||||||
|
);
|
||||||
|
|
||||||
|
$processor->process($employee, new Post());
|
||||||
|
|
||||||
|
self::assertNotNull($employee->getEntryDate());
|
||||||
|
self::assertSame('2026-04-01', $employee->getEntryDate()->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
public function testDeleteOperationDelegatesToRemoveProcessor(): void
|
public function testDeleteOperationDelegatesToRemoveProcessor(): void
|
||||||
{
|
{
|
||||||
$employee = $this->buildEmployeeWithId(45);
|
$employee = $this->buildEmployeeWithId(45);
|
||||||
|
|||||||
Reference in New Issue
Block a user