Compare commits

...

42 Commits

Author SHA1 Message Date
gitea-actions
187a634cc8 chore: bump version to v0.1.85
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 31s
2026-04-14 13:08:56 +00:00
0897154460 feat : ajout d'un écran pour le récap congés et RTT
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 15:08:45 +02:00
gitea-actions
11331da6a1 chore: bump version to v0.1.84
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-14 09:25:55 +00:00
399fd7335e fix : exclusion de certain jour férié et affichage différent des jours férié dans la page d'heure
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 11:25:44 +02:00
gitea-actions
46cb7f1a16 chore: bump version to v0.1.83
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 35s
2026-04-14 06:38:09 +00:00
b934f4d81f Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:38:01 +02:00
77c1cdcbbd fix : on masque la validation chef site 2026-04-14 08:37:54 +02:00
gitea-actions
de302d9ded chore: bump version to v0.1.82
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 22s
2026-04-14 06:25:17 +00:00
ef18210bf7 fix : export du récap congés
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-14 08:24:43 +02:00
gitea-actions
055d92153b chore: bump version to v0.1.81
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m19s
2026-04-13 07:41:52 +00:00
4cd30de3e3 feat : ajout d'un onglet formation
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-13 09:41:36 +02:00
gitea-actions
b185accdbb chore: bump version to v0.1.80
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 26s
2026-04-08 06:47:00 +00:00
a4bda53f57 fix : split deficit weeks by weekdays count when no hours worked
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
When a week spans two months and has zero worked hours (e.g. RTT
all week), the proportional split by worked minutes gave 0 to both
months. Now falls back to splitting by weekday count.
2026-04-08 08:17:07 +02:00
gitea-actions
c255000a5e chore: bump version to v0.1.79
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 33s
2026-04-03 13:18:40 +00:00
b8b9368ad0 [#SIRH-6] Faire une doc de type wiki (#14)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #14
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-03 13:18:32 +00:00
gitea-actions
10a0ab0809 chore: bump version to v0.1.78
All checks were successful
Build & Push Docker Image / build (push) Successful in 38s
Auto Tag Develop / tag (push) Successful in 6s
2026-04-02 12:34:10 +00:00
055f1187f9 fix : wording
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-02 14:34:00 +02:00
gitea-actions
f3ed359d3f chore: bump version to v0.1.77
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 57s
2026-04-02 10:05:12 +00:00
906c245451 feat(deploy) : add maintenance mode with automatic toggle during deploy
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-02 11:56:13 +02:00
gitea-actions
100ab340d4 chore: bump version to v0.1.76
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 39s
2026-04-02 08:55:05 +00:00
0257e59671 [#SIRH-21] Revoir l'affichage des RTT pour les semaines qui se chevauchent (#13)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #13
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 08:54:55 +00:00
gitea-actions
f9979c9a19 chore: bump version to v0.1.75
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build & Push Docker Image / build (push) Successful in 30s
2026-04-02 06:59:10 +00:00
1091147100 [#SIRH-20] Ajouter pour les forfaits le paiement de congés N-1 (#12)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #12
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-02 06:59:03 +00:00
gitea-actions
fd154a59fb chore: bump version to v0.1.74
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 14:44:07 +00:00
967e3311e5 docs : update doc deployment-docker.md
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 16:43:54 +02:00
gitea-actions
04c5279946 chore: bump version to v0.1.73
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 15s
2026-03-31 12:46:03 +00:00
b25d40f3d8 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 14:45:55 +02:00
e654516b82 docs : fix JWT key generation and permissions in deployment doc 2026-03-31 14:32:12 +02:00
gitea-actions
b07146e78d chore: bump version to v0.1.72
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 19s
2026-03-31 10:13:39 +00:00
b1bf363fa1 Merge remote-tracking branch 'origin/develop' into develop
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 12:13:31 +02:00
c13cab6b59 fix(deploy) : run console commands as www-data to prevent permission issues 2026-03-31 12:11:58 +02:00
gitea-actions
3752785ed1 chore: bump version to v0.1.71
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 17s
2026-03-31 09:50:51 +00:00
ab44b5439d fix(deploy) : add minimal .env file in Docker image for Symfony boot
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 10:38:59 +02:00
699d09e2f4 docs : add nginx install to deployment prerequisites 2026-03-31 10:35:32 +02:00
b62a19513d docs : improve Docker deployment documentation 2026-03-31 09:33:05 +02:00
gitea-actions
3d69346d24 chore: bump version to v0.1.70
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 18s
2026-03-31 07:11:36 +00:00
ea849a4fdd Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
2026-03-31 09:11:28 +02:00
7b3dcc3c54 fix : déploiement docker 2026-03-31 09:11:00 +02:00
gitea-actions
c6ab8e3624 chore: bump version to v0.1.69
Some checks failed
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:57:47 +00:00
f3b65c0617 fix(deploy) : use REGISTRY_TOKEN for Docker registry authentication
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-31 08:57:27 +02:00
gitea-actions
15ce234737 chore: bump version to v0.1.68
Some checks failed
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Failing after 6s
2026-03-31 06:38:47 +00:00
caffb74cbf [#INFRA-142] Revoir le système de déploiement (#11)
All checks were successful
Auto Tag Develop / tag (push) Successful in 7s
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Co-authored-by: gitea-actions <gitea-actions@local>
Reviewed-on: #11
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-31 06:38:38 +00:00
92 changed files with 4004 additions and 366 deletions

View File

@@ -24,7 +24,10 @@
"Bash(npx xlsx-cli:*)",
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
"Bash(pip3 install:*)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(python3:*)"
]
}
}

23
.dockerignore Normal file
View File

@@ -0,0 +1,23 @@
.git
.gitea
.env.local
.env.test
docker/
deploy/docker/docker-compose.prod.yml
deploy/docker/deploy.sh
deploy/docker/.env.example
frontend/node_modules
frontend/.nuxt
frontend/.output
var/
LOG/
docs/
doc/
tests/
*.sql
*.xlsx
*.png
*.md
!composer.lock
!symfony.lock
!frontend/package-lock.json

3
.env
View File

@@ -38,6 +38,9 @@ DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&ch
###> app ###
RTT_START_DATE=2026-02-23
# Comma-separated list of public holiday labels to exclude from the government API response
# (typically the "journée de solidarité" worked in many companies)
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"
###< app ###
###> nelmio/cors-bundle ###

View File

@@ -0,0 +1,30 @@
name: Build & Push Docker Image
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
- name: Build Docker image
run: |
docker build \
-f deploy/docker/Dockerfile.prod \
-t gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }} \
-t gitea.malio.fr/malio-dev/sirh:latest \
.
- name: Push Docker image
run: |
docker push gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }}
docker push gitea.malio.fr/malio-dev/sirh:latest

View File

@@ -1,65 +0,0 @@
name: Build Release Artefact
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
persist-credentials: false
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: "8.4"
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "lts/*"
- name: Install backend deps (prod)
env:
APP_ENV: prod
APP_DEBUG: "0"
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
- name: Build frontend (static)
run: |
cd frontend
npm ci
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
test -f .output/public/index.html
- name: Build artefact
shell: bash
run: |
set -euo pipefail
mkdir -p release
tar -czf "release/sirh-${GITHUB_REF_NAME}.tar.gz" \
bin \
config \
migrations \
public \
src \
templates \
vendor \
composer.json \
composer.lock \
symfony.lock \
frontend/.output
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/sirh-${{ github.ref_name }}.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}

View File

@@ -1,5 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="db-forest-configuration">
<data version="2">.
----------------------------------------
1:0:9cad43df-2147-4989-b7a4-443067034884
2:0:ae622167-c834-4e7b-87a5-c1721036f5dc
3:0:f407a514-c6b4-4b26-9555-445a85892502
4:0:09e221b8-067a-488b-9c1d-4e155a333079
5:0:9d8c1ad3-2491-4642-964a-666003c14128
.</data>
</component>
<component name="db-tree-configuration">
<option name="data" value="----------------------------------------&#10;1:0:9cad43df-2147-4989-b7a4-443067034884&#10;2:0:ae622167-c834-4e7b-87a5-c1721036f5dc&#10;3:0:f407a514-c6b4-4b26-9555-445a85892502&#10;4:0:09e221b8-067a-488b-9c1d-4e155a333079&#10;" />
</component>

View File

@@ -2,6 +2,7 @@
## Mandatory Rules
- Any functional change MUST update `doc/` in the same intervention
- Any functional change MUST update the in-app documentation (`frontend/data/documentation-content.ts`) in the same intervention
- At the end of every feature addition or functional modification, update this CLAUDE.md to reflect new patterns, rules, or conventions introduced
## Commands
@@ -34,6 +35,13 @@
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
- Driver periods (`isDriver=true` on `EmployeeContractPeriod`): separate screen `/driver-hours`, uses `dayHoursMinutes`/`nightHoursMinutes` + meal/overnight flags on `WorkHour`
## Fériés
- Source : API gouv via `PublicHolidayService` (cache 30j)
- Exclusions : env `EXCLUDED_PUBLIC_HOLIDAYS` (CSV de libellés), défaut `"Lundi de Pentecôte"`. Le filtre s'applique après le cache, côté service, donc frontend et calculs backend voient la même liste.
- Écrans Heures / Heures Conducteurs (vue jour) : le nom du férié est affiché en badge `#b3e5fc` avec icône `mdi:calendar-star` dans la colonne Absence (distinct du pill absence). Bouton "Modifier" absence masqué sur férié (comme pour les formations).
- Création/édition d'absence bloquée sur un férié
- Saisie d'heures (ou de jours de présence) autorisée sur un férié — nécessaire pour éviter un déficit hebdomadaire (la référence hebdo n'est pas réduite par les fériés)
## Validation Rules
- `isValid` (RH): locks line for everyone (admin can only untoggle validation)
- `isSiteValid` (site manager): locks for non-admin, admin can still edit
@@ -48,11 +56,33 @@
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.
- Scope : `ROLE_ADMIN` → tous les employés, `ROLE_USER` (chef de site) → employés de ses sites, `ROLE_SELF` → sa ligne
- Cutoff temporel : fin de la semaine S-2 (dimanche 23:59:59). Formule : `dimanche(lundi_semaine_courante 14j)`. Pas de gate `isValid`.
- Helper : `App\Util\LeaveRecapCutoff::resolveCutoff()`
- Colonnes : Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT — identiques au PDF
- Service partagé : `LeaveRecapRowBuilder` consommé par `LeaveRecapPrintProvider` (as-of today) et `EmployeeLeaveRecapProvider` (as-of cutoff)
- `EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate` qui cappe l'accrual et les absences sur l'année cible (`null` = comportement live inchangé)
- Pas d'export PDF depuis cet écran
- Doc détaillée : `doc/leave-recap-screen.md`
## Frais (MileageAllowance)
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
## Formations
- Onglet "Formation" sur la fiche employé (admin uniquement)
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
- Tri tableau par `startDate DESC`
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
## Frontend Patterns
### Table styling (standard across all pages)
@@ -84,6 +114,16 @@
- Keep backend PHP DTOs aligned with frontend TS DTOs (`frontend/services/dto/*`)
- Update unit tests when constructor/service signatures change
## In-App Documentation
- Content: `frontend/data/documentation-content.ts` — structured TypeScript data with all user-facing documentation
- Types: `frontend/types/documentation.ts` — DocSection, DocArticle, DocBlock
- Composable: `frontend/composables/useDocumentation.ts` — role-based filtering (employee < site_manager < admin)
- Components: `frontend/components/documentation/` — DocumentationPage, DocumentationSection, DocumentationArticle
- Page: `frontend/pages/documentation.vue`
- 3 access levels: `employee` (ROLE_SELF), `site_manager` (ROLE_USER), `admin` (ROLE_ADMIN) — cumulative (admin sees everything)
- Each section/article has a `requiredLevel` that controls visibility
- When adding or modifying a feature, update the corresponding section in `documentation-content.ts`
## Language
- UI is in French
- User communicates in French

View File

@@ -1,4 +1,5 @@
# SIRH
Application de gestion des absences employée
## Importer un dump de prod en dev
@@ -22,3 +23,5 @@ docker compose exec -T db psql -U root -d sirh < sirh.sql
```sql
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
```
sudo -u postgres pg_dump --no-owner --no-privileges --clean --if-exists sirh_prod > /tmp/sirh_prod_$(date +%F).sql
scp user@<serveur>:/tmp/sirh_prod_2026-04-14.dump ~/workspace/

View File

@@ -25,6 +25,7 @@ services:
App\Service\PublicHolidayService:
arguments:
$holidayUrl: '%env(HOLIDAY_URL)%'
$excludedLabels: '%env(default::EXCLUDED_PUBLIC_HOLIDAYS)%'
App\Service\Rtt\RttRecoveryComputationService:
arguments:
@@ -37,6 +38,7 @@ services:
App\Repository\Contract\AbsenceReadRepositoryInterface: '@App\Repository\AbsenceRepository'
App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface: '@App\Repository\EmployeeContractPeriodRepository'
App\Repository\Contract\EmployeeScopedRepositoryInterface: '@App\Repository\EmployeeRepository'
App\Repository\Contract\FormationReadRepositoryInterface: '@App\Repository\FormationRepository'
App\Repository\Contract\WorkHourReadRepositoryInterface: '@App\Repository\WorkHourRepository'
App\Service\Contracts\EmployeeContractPeriodManagerInterface: '@App\Service\Contracts\EmployeeContractPeriodManager'

View File

@@ -1,2 +1,2 @@
parameters:
app.version: '0.1.67'
app.version: '0.1.85'

View File

@@ -0,0 +1,26 @@
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=change-me
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=change-me
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
EXCLUDED_PUBLIC_HOLIDAYS="Lundi de Pentecôte"

View File

@@ -0,0 +1,80 @@
# --- Stage 1: Build backend ---
FROM php:8.4-cli AS backend-build
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
unzip curl git \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
COPY bin bin/
COPY config config/
COPY migrations migrations/
COPY public public/
COPY src src/
COPY templates templates/
# --- Stage 2: Build frontend ---
FROM node:lts-alpine AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
ENV CI=1 \
NUXT_TELEMETRY_DISABLED=1 \
NUXT_PUBLIC_API_BASE=/api \
NUXT_PUBLIC_APP_BASE=/
RUN npm run generate
# --- Stage 3: Production image ---
FROM php:8.4-fpm AS production
RUN apt-get update && apt-get install -y \
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
nginx supervisor \
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
&& rm -rf /var/lib/apt/lists/*
# PHP production config
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
# PHP-FPM: forward worker output to stderr for docker logs
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
# Nginx: log to stdout/stderr
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# Remove default nginx site
RUN rm -f /etc/nginx/sites-enabled/default
# Configs
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/sirh.conf
# Backend from stage 1
COPY --from=backend-build /app /var/www/html
# Frontend from stage 2
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
RUN echo "APP_ENV=prod" > /var/www/html/.env
# Permissions
RUN mkdir -p /var/www/html/var \
&& chown -R www-data:www-data /var/www/html/var
WORKDIR /var/www/html
EXPOSE 80
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]

34
deploy/docker/deploy.sh Executable file
View File

@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Enabling maintenance mode..."
touch maintenance.on
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
echo "==> Disabling maintenance mode..."
rm -f maintenance.on
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"

View File

@@ -0,0 +1,13 @@
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped

46
deploy/docker/nginx.conf Normal file
View File

@@ -0,0 +1,46 @@
server {
listen 80;
server_name _;
root /var/www/html/frontend/.output/public;
index index.html;
access_log /dev/stdout;
error_log /dev/stderr;
location ^~ /api/ {
root /var/www/html/public;
try_files $uri /index.php?$query_string;
}
location ^~ /bundles/ {
root /var/www/html/public;
try_files $uri =404;
}
location = /api/login_check {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param PATH_INFO /login_check;
fastcgi_param REQUEST_URI /login_check;
fastcgi_pass 127.0.0.1:9000;
}
location ~ ^/index\.php(/|$) {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
fastcgi_pass 127.0.0.1:9000;
internal;
}
location ~ \.php$ {
return 404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@@ -0,0 +1,28 @@
[supervisord]
nodaemon=true
user=root
logfile=/dev/null
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT
[program:nginx]
command=nginx -g "daemon off;"
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
stopasgroup=true
stopsignal=QUIT

50
deploy/maintenance.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Maintenance en cours</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: #1f2937;
}
.container {
text-align: center;
padding: 3rem;
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
max-width: 500px;
width: 90%;
}
.icon {
font-size: 4rem;
margin-bottom: 1.5rem;
}
h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
color: #111827;
}
p {
font-size: 1rem;
color: #6b7280;
line-height: 1.6;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">&#128736;</div>
<h1>Maintenance en cours</h1>
<p>L'application est temporairement indisponible pour mise a jour. Elle sera de retour dans quelques instants.</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,29 @@
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/public;
# Maintenance mode : si le fichier maintenance.on existe, renvoyer la page 503
if (-f /var/www/sirh/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

363
doc/deployment-docker.md Normal file
View File

@@ -0,0 +1,363 @@
# Deploiement Docker — SIRH
## Pre-requis
### Docker
```bash
# Ubuntu
sudo apt update
sudo apt install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker $USER
```
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
### Nginx
```bash
sudo apt install -y nginx
sudo systemctl enable nginx
sudo systemctl start nginx
```
### PostgreSQL
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
Il doit etre installe et accessible avant de deployer SIRH.
Creer la base de donnees pour SIRH :
```bash
cd /var/www/postgres
docker compose exec postgres psql -U admin
```
```sql
-- Si le user n'existe pas encore
CREATE USER malio WITH PASSWORD 'motdepasse';
-- Creer la base
CREATE DATABASE sirh_prod OWNER malio;
\q
```
---
## Premiere installation (nouvelle machine)
Guide complet pour mettre en ligne SIRH sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
### 1. Installer les pre-requis
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
### 2. Creer le dossier de deploiement
```bash
sudo mkdir -p /var/www/sirh
sudo chown -R $(whoami):$(whoami) /var/www/sirh
cd /var/www/sirh
```
### 3. Se connecter au registry Docker de Gitea
```bash
docker login gitea.malio.fr
```
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO-DEV`
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
### 4. Creer les fichiers de deploiement
Creer `docker-compose.yml` :
```yaml
services:
app:
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
container_name: sirh-app
env_file: .env
ports:
- "8080:80"
volumes:
- ./config/jwt:/var/www/html/config/jwt:ro
- ./uploads:/var/www/html/var/uploads
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
```
Creer `deploy.sh` :
```bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
TAG="${1:-latest}"
export SIRH_IMAGE_TAG="$TAG"
echo "==> Deploying sirh:${TAG}..."
echo "==> Pulling image..."
docker compose pull
echo "==> Starting container..."
docker compose up -d
echo "==> Waiting for container to be ready..."
sleep 3
echo "==> Running migrations..."
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
echo "==> Clearing cache..."
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
echo "==> Deployed v${VERSION}"
```
Rendre executable :
```bash
chmod +x deploy.sh
```
### 5. Configurer l'environnement
Creer `.env` avec les variables suivantes :
```env
# Symfony
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=<generer avec: openssl rand -hex 32>
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
# JWT
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
JWT_COOKIE_SECURE=1
JWT_COOKIE_SAMESITE=lax
JWT_TOKEN_TTL=86400
JWT_COOKIE_TTL=86400
# CORS
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
# App
DEFAULT_URI=https://sirh.malio-dev.fr
APP_SHARE_DIR=var/share
RTT_START_DATE=2026-02-23
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
```
### 6. Generer les cles JWT
```bash
mkdir -p config/jwt
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
```
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
```bash
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
```
### 7. Creer le dossier uploads
```bash
mkdir -p uploads
```
### 8. Configurer Nginx systeme
Creer `/etc/nginx/sites-available/sirh.conf` :
```nginx
server {
listen 80;
server_name sirh.malio-dev.fr;
root /var/www/sirh/public;
# Maintenance mode
if (-f /var/www/sirh/maintenance.on) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
rewrite ^(.*)$ /maintenance.html break;
}
location = /maintenance.html {
internal;
}
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Copier la page de maintenance et activer le site :
```bash
cp deploy/maintenance.html /var/www/sirh/public/maintenance.html
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
sudo nginx -t && sudo systemctl reload nginx
```
### 9. Deployer
```bash
./deploy.sh
```
### 10. Importer les donnees (optionnel)
Si tu as un dump SQL a importer :
```bash
# Depuis ton PC, envoyer le dump vers le serveur
scp sirh.sql user@serveur:/tmp/sirh.sql
# Sur le serveur, vider la base puis importer
cd /var/www/postgres
docker compose exec -T postgres psql -U malio sirh_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
docker compose exec -T postgres psql -U malio sirh_prod < /tmp/sirh.sql
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
cd /var/www/sirh
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
# Nettoyer
rm /tmp/sirh.sql
```
### Structure finale du dossier
```
/var/www/sirh/
├── docker-compose.yml
├── deploy.sh
├── .env
├── config/jwt/
│ ├── private.pem
│ └── public.pem
├── public/
│ └── maintenance.html
└── uploads/
```
---
## Deployer une nouvelle version
Quand l'app est deja installee, deployer une mise a jour :
```bash
cd /var/www/sirh
./deploy.sh # deploie la derniere version (latest)
./deploy.sh v0.1.61 # deploie une version specifique
```
Le script active automatiquement la maintenance pendant le deploy et la desactive a la fin.
---
## Maintenance manuelle
Activer la maintenance (sans deployer) :
```bash
cd /var/www/sirh
touch maintenance.on
```
Desactiver :
```bash
rm maintenance.on
```
---
## Rollback
### Image seule (pas de changement de schema BDD)
```bash
./deploy.sh v0.1.60
```
### Avec rollback de migration
```bash
# 1. Rollback schema (pendant que la version actuelle tourne encore)
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
# 2. Deployer l'ancienne version
./deploy.sh v0.1.60
```
---
## Voir les logs
```bash
cd /var/www/sirh
docker compose logs -f # tous les logs
docker compose logs -f --tail=100 # 100 dernieres lignes
```
Logs Symfony :
```bash
docker compose exec app cat var/log/prod.log
```
---
## Migration depuis l'ancien deploiement (tar.gz)
Si l'application tourne deja en bare metal :
1. Installer Docker (voir pre-requis)
2. Creer le dossier `/var/www/sirh-docker/` (ne pas ecraser l'ancien)
3. Copier les fichiers existants :
```bash
cp /var/www/sirh/.env /var/www/sirh-docker/.env
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
```
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/sirh-docker/` (voir etape 4 ci-dessus)
5. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`

58
doc/formations.md Normal file
View File

@@ -0,0 +1,58 @@
# Formations
Onglet **Formation** accessible depuis la fiche employé. Permet de tracer les formations suivies par un salarié.
## Accès
- Réservé aux administrateurs (`ROLE_ADMIN`)
- Invisible pour les autres rôles
## Champs
| Champ | Type | Obligatoire |
| --- | --- | --- |
| Date de début | date | oui |
| Date de fin | date | oui |
| Justificatif | fichier PDF | non |
| Commentaire | texte libre | non |
## Règles de validation
- La date de fin doit être supérieure ou égale à la date de début
- Seuls les fichiers PDF sont acceptés pour le justificatif
- Un employé peut avoir plusieurs formations (aucune unicité imposée)
## Stockage
Les justificatifs PDF sont stockés dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf`, où l'année et le mois sont ceux de la date de début de la formation. Le nom d'origine du fichier est conservé en base pour l'affichage et le téléchargement.
Lors de la suppression d'une formation, le fichier associé est automatiquement supprimé du disque. Lors du remplacement d'un justificatif, l'ancien fichier est également supprimé.
## Tri
Les formations sont affichées dans le tableau par **date de début décroissante**.
## Affichage sur les autres écrans
### Écran des heures (vue jour)
Dans la colonne "Absence", lorsqu'un salarié est en formation sur la date sélectionnée, une pastille indigo **Formation** est affichée sous la pastille d'absence éventuelle. Cette pastille est uniquement informative :
- Le bouton **Modifier** de la colonne Absence est masqué : aucune création/modification/suppression d'absence n'est possible sur un jour en formation
- La gestion CRUD d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**
### Calendrier
Dans le calendrier mensuel, les formations sont affichées de deux façons :
- **Jour avec formation uniquement** : la cellule est teintée en indigo avec le code `F`
- **Jour avec absence + formation** : la cellule garde la couleur de l'absence et une icône école est ajoutée en coin supérieur droit
Une entrée "Formation" est visible dans la légende du calendrier. Les cellules contenant une formation sont **non cliquables** (aucune création/édition d'absence possible). La gestion d'une formation se fait exclusivement depuis la fiche employé, onglet **Formation**.
### Export PDF du calendrier
L'impression du calendrier d'absences reprend le même principe :
- **Jour avec formation uniquement** : cellule indigo avec le code `F`
- **Jour avec absence + formation** : le code de l'absence est suivi d'un astérisque (`*`)

View File

@@ -161,10 +161,14 @@ Documents complementaires:
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Source: API `calendrier.api.gouv.fr/jours-feries/` via `PublicHolidayService` (cache 30j)
- Exclusions configurables: variable d'env `EXCLUDED_PUBLIC_HOLIDAYS` (liste de libellés séparés par virgules). Par défaut `"Lundi de Pentecôte"` — journée de solidarité généralement travaillée. Le filtre s'applique à tous les consommateurs (frontend + calculs backend) en amont du retour du service.
- Onglet congés: jours fériés affichés sur le calendrier avec fond `rgb(179, 229, 252)` et nom au survol
- Écran Heures et Heures Conducteurs (vue jour): le nom du férié est affiché dans la colonne Absence sous forme de pill (fond `#b3e5fc`, icône `mdi:calendar-star`), distinct du pill absence
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
- absences bloquées sur jour férié (création/édition) — bouton "Modifier" masqué comme pour les formations
- saisie d'heures ou de jours de présence autorisée — les heures saisies comptent normalement dans le total hebdo et le calcul RTT
- la référence hebdomadaire n'est pas réduite par un férié: un salarié qui ne saisit rien sur un férié est en déficit de la journée correspondante
## 8) Impression absences (PDF)
@@ -245,6 +249,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- pour `FORFAIT`:
- pris: basé sur toutes les absences (demi-journées incluses)
- restants = acquis - pris (borné à 0)
- paiement congés N-1: saisie RH via `PATCH /employees/{id}/paid-leave-days` (body: `paidLeaveDays`, `year`). Stocké dans `employee_leave_balances.paid_leave_days`. Les jours payés réduisent le stock N-1 **avant** l'attribution des jours pris : `disponible_N-1 = max(0, acquis_N-1 - payés)`, puis `pris_N-1 = min(disponible_N-1, total_pris)`, surplus pris basculé sur N. Reste à prendre N-1 = `max(0, disponible_N-1 - pris_N-1)`. Uniquement pour les contrats forfait.
- report annuel:
- le reliquat (`restants`) de l'exercice précédent est reporté dans les acquis de l'exercice courant
- pour `CDI`/`CDD` non forfait: report séparé jours + samedis
@@ -274,8 +279,9 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- total mensuel des minutes de récupération
- compteur global exercice = `report N-1 + acquis N`
- attribution mensuelle des semaines:
- une semaine ISO est affichée une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en début de mois suivant, c'est le mois suivant qui porte la semaine
- une semaine ISO qui chevauche deux mois est affichée dans **les deux mois**, avec les valeurs réparties proportionnellement aux minutes travaillées de chaque portion
- le calcul des heures supplémentaires reste hebdomadaire (seuils 35h/39h/43h appliqués sur la semaine entière), seul l'affichage est scindé
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparaît en mars (avec la part des heures de lun-mar) et en avril (avec la part mer-dim)
- logique de calcul:
- base identique aux calculs d'heures supplémentaires de la vue semaine Heures
- minutes de récupération hebdomadaires = `HS totales + bonus 25% + bonus 50%`
@@ -324,6 +330,24 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau)
- Complément de l'export PDF : même logique de calcul, mais accessible aux employés et chefs de site
- Endpoint: `GET /api/leave-recap`
- Accès conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`, activé au create/edit user)
- Le flag s'applique à tous les profils, y compris admin (pas de bypass)
- Scoping :
- `ROLE_ADMIN` : tous les employés
- `ROLE_USER` (chef de site) : employés des sites autorisés (`UserSiteRole`)
- `ROLE_SELF` : uniquement son employé lié
- **Cutoff temporel** : le récap est figé à la fin de la semaine S-2 (dimanche 23:59:59)
- Formule : `cutoffDate = dimanche(lundi_semaine_courante 14 jours)`
- Exemple : mardi 14/04/2026 (S16) → dimanche 05/04/2026 (fin S14)
- `isValid` n'entre PAS en compte : cutoff purement temporel
- Les heures et absences postérieures au cutoff sont ignorées dans les calculs
- Colonnes identiques au PDF (voir §10)
- Détails techniques : voir `doc/leave-recap-screen.md`
## 11) Récapitulatif Salaire (PDF mensuel)
- Accessible depuis la page Employés via le bouton "Récap. Salaire" (réservé `ROLE_ADMIN`)

73
doc/leave-recap-screen.md Normal file
View File

@@ -0,0 +1,73 @@
# Écran Récap. congés
## Objet
Vue tableau des soldes de congés par employé, figée à un cutoff temporel (fin de semaine S-2).
Complémentaire à l'export PDF admin : mêmes colonnes, accès étendu aux employés et chefs de site.
## Cutoff
La formule est : `cutoffDate = dimanche de (lundi de la semaine courante 14 jours)`.
Exemple : mardi 14/04/2026 (S16) → **dimanche 05/04/2026 23:59:59** (fin S14).
Le cutoff est purement temporel : l'état `isValid` des heures n'entre pas en compte. Les heures
et absences postérieures au cutoff sont ignorées dans le calcul des soldes.
Implémentation : `App\Util\LeaveRecapCutoff::resolveCutoff()` côté backend, helper `parseYmd` +
`getIsoWeekNumber` côté frontend pour l'affichage du badge.
## Colonnes
Identiques au PDF :
- Nom
- Prénom
- Contrat
- CP N-1 restant
- CP N
- Samedis acquis
- RTT
Pour les admins et chefs de site, une colonne **Site** est ajoutée à gauche.
## Scoping
| Profil | Données visibles |
|---------------|-----------------------------------------|
| `ROLE_ADMIN` | Tous les employés actifs, tous sites |
| `ROLE_USER` (chef de site) | Employés actifs des sites autorisés via `UserSiteRole` |
| `ROLE_SELF` | Uniquement l'employé lié à son compte |
## Flag d'accès
Le champ `User.hasLeaveRecapAccess` (boolean, défaut `false`) conditionne :
- L'affichage de l'entrée "Récap. congés" dans la sidebar
- L'accès à la route `/leave-recap` (middleware `leave-recap-access.ts`)
- L'endpoint API `GET /api/leave-recap` (le provider renvoie `403` si le flag est faux)
Le flag s'applique même aux admins : un admin sans le flag ne voit pas l'écran. Il se configure
dans le drawer de création/édition d'un utilisateur.
## Service partagé
`App\Service\Leave\LeaveRecapRowBuilder::build(Employee $employee, DateTimeImmutable $asOfDate)`
construit une ligne de récap. Il est utilisé par :
- `LeaveRecapPrintProvider` (PDF admin) avec `$asOfDate = today`
- `EmployeeLeaveRecapProvider` (écran) avec `$asOfDate = cutoff`
## Propagation du cutoff dans les calculs
`EmployeeLeaveSummaryProvider::computeYearSummary()` accepte un `?DateTimeImmutable $asOfDate`.
Lorsqu'il est fourni et appliqué à l'année cible, il remplace "today" dans :
- `resolveAccrualCalculationEndDate()` — la borne d'accrual devient le dernier jour du mois
précédant `asOfDate` (au lieu du mois précédent today).
- `resolveTakenCalculationEndDate()` — les absences postérieures à `asOfDate` sont ignorées.
Pour les années antérieures (carry forward), le comportement reste inchangé (pas de cap).
Le RTT est capé via `RttRecoveryComputationService::computeTotalRecoveryForExercise(..., $limitDate)`
qui existait déjà, en passant `cutoff` comme date de référence.

View File

@@ -32,9 +32,9 @@ Principe:
## 4) Attribution mensuelle des semaines
- une semaine ISO est affichee une seule fois, dans le mois qui contient le **samedi** de cette semaine
- si le weekend tombe en debut du mois suivant, c'est ce mois qui porte la semaine
- pas de prorata: la totalite des minutes de recuperation de la semaine est comptee dans un seul mois
- une semaine ISO qui chevauche deux mois est affichee dans **les deux mois**, avec les valeurs reparties proportionnellement aux minutes travaillees de chaque portion
- le calcul des heures supplementaires reste hebdomadaire (seuils 35h/39h/43h appliques sur la semaine entiere), seul l'affichage est scinde
- exemple: S14 lundi-mardi en mars, mercredi-dimanche en avril → la S14 apparait en mars (part lun-mar) et en avril (part mer-dim)
## 5) Table cible

View File

@@ -45,9 +45,9 @@
<button
type="button"
class="relative flex h-8 w-full items-center justify-center overflow-hidden rounded-md border border-neutral-200 text-[11px] font-semibold text-neutral-800"
:class="isHolidayDate(day.date) ? 'cursor-not-allowed opacity-80' : ''"
:class="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation ? 'cursor-not-allowed opacity-80' : ''"
:style="getCellStyle(employee.id, day.date)"
:disabled="isHolidayDate(day.date)"
:disabled="isHolidayDate(day.date) || getCellInfo(employee.id, day.date)?.hasFormation"
@click="handleCellClick(employee, day.date)"
>
<span v-if="!getCellInfo(employee.id, day.date)?.halfLabel">
@@ -67,6 +67,13 @@
{{ getCellInfo(employee.id, day.date)?.code }}
</span>
</template>
<Icon
v-if="getCellInfo(employee.id, day.date)?.hasFormation && getCellInfo(employee.id, day.date)?.code !== 'F'"
name="mdi:school"
size="12"
class="absolute top-0 right-0 text-indigo-600 bg-white rounded-bl-md p-0.5"
title="Formation"
/>
</button>
</template>
<template v-else>
@@ -107,7 +114,7 @@ const props = defineProps<{
visibleEmployees: Employee[]
gridStyle: Record<string, string>
getCellStyle: (employeeId: number, date: string) => Record<string, string> | undefined
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string } | null
getCellInfo: (employeeId: number, date: string) => { id: number; code: string; color: string; halfLabel?: HalfDay; textColor?: string; hasFormation?: boolean } | null
formatEmployeeName: (employee: Employee) => string
isHolidayDate: (date: string) => boolean
}>()

View File

@@ -0,0 +1,26 @@
<template>
<article :id="`doc-${article.id}`" class="scroll-mt-6">
<h3 class="text-lg font-bold text-primary-500 mb-3">{{ article.title }}</h3>
<div class="space-y-3">
<template v-for="(block, idx) in article.blocks" :key="idx">
<p v-if="block.type === 'paragraph'" class="text-sm text-neutral-700 leading-relaxed">
{{ block.content }}
</p>
<ul v-else-if="block.type === 'list'" class="list-disc list-inside space-y-1 text-sm text-neutral-700 pl-2">
<li v-for="(item, i) in block.content.split('\n')" :key="i">{{ item }}</li>
</ul>
<div v-else-if="block.type === 'note'" class="bg-tertiary-500 border-l-4 border-primary-500 p-3 rounded-r-md">
<p class="text-sm text-neutral-700 leading-relaxed">{{ block.content }}</p>
</div>
</template>
</div>
</article>
</template>
<script setup lang="ts">
import type { DocArticle } from '~/types/documentation'
defineProps<{
article: DocArticle
}>()
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="h-full flex gap-8">
<!-- Table des matières -->
<nav class="w-64 flex-shrink-0 overflow-y-auto pr-4 border-r border-neutral-200">
<h1 class="text-xl font-bold text-primary-500 mb-6">Documentation</h1>
<div v-for="section in visibleSections" :key="section.id" class="mb-4">
<div class="flex items-center gap-2 mb-1">
<Icon :name="section.icon" size="18" class="text-neutral-500"/>
<span class="text-sm font-semibold text-neutral-700">{{ section.title }}</span>
</div>
<ul class="pl-7 space-y-0.5">
<li v-for="article in section.articles" :key="article.id">
<button
class="text-xs text-neutral-500 hover:text-primary-500 text-left w-full py-0.5 transition-colors"
:class="activeArticleId === article.id ? 'text-primary-500 font-bold' : ''"
@click="scrollToArticle(article.id)"
>
{{ article.title }}
</button>
</li>
</ul>
</div>
</nav>
<!-- Contenu -->
<div ref="contentRef" class="flex-1 overflow-y-auto pr-4">
<DocumentationSection
v-for="section in visibleSections"
:key="section.id"
:section="section"
/>
</div>
</div>
</template>
<script setup lang="ts">
const { visibleSections, activeArticleId, scrollToArticle } = useDocumentation()
const contentRef = ref<HTMLElement | null>(null)
onMounted(() => {
if (!contentRef.value) return
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.id.replace('doc-', '')
activeArticleId.value = id
break
}
}
},
{
root: contentRef.value,
rootMargin: '-10% 0px -80% 0px',
threshold: 0,
},
)
nextTick(() => {
const articles = contentRef.value?.querySelectorAll('[id^="doc-"]')
articles?.forEach(el => observer.observe(el))
})
onUnmounted(() => observer.disconnect())
})
</script>

View File

@@ -0,0 +1,23 @@
<template>
<section class="mb-10">
<div class="flex items-center gap-3 border-b-2 border-primary-500 pb-3 mb-6">
<Icon :name="section.icon" size="28" class="text-primary-500"/>
<h2 class="text-xl font-bold text-primary-500">{{ section.title }}</h2>
</div>
<div class="space-y-8 pl-2">
<DocumentationArticle
v-for="article in section.articles"
:key="article.id"
:article="article"
/>
</div>
</section>
</template>
<script setup lang="ts">
import type { DocSection } from '~/types/documentation'
defineProps<{
section: DocSection
}>()
</script>

View File

@@ -25,19 +25,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -68,19 +56,31 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
</div>
<button
v-if="!isHoliday"
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -147,16 +147,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
@@ -184,6 +176,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isRowLocked: (employeeId: number) => boolean
hasContractAtSelectedDate: (employeeId: number) => boolean

View File

@@ -0,0 +1,251 @@
<template>
<section class="mt-8">
<div class="overflow-hidden bg-white">
<div
class="grid grid-cols-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
<p>Date de début</p>
<p>Date de fin</p>
<p>Justificatif</p>
<p>Commentaire</p>
</div>
<div v-if="formations.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
Aucune formation.
</div>
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="item in formations"
:key="item.id"
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 cursor-pointer hover:bg-tertiary-500"
@click="onOpenEditDrawer(item)"
>
<p>{{ formatDate(item.startDate) }}</p>
<p>{{ formatDate(item.endDate) }}</p>
<p class="min-w-0">
<a
v-if="item.justificatifPath"
:href="getFormationJustificatifUrl(props.apiBase, item.id)"
target="_blank"
class="text-primary-500 hover:text-secondary-500 flex gap-2 items-center"
@click.stop
>
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
<span class="truncate">{{ item.justificatifName ?? 'Télécharger' }}</span>
</a>
<span v-else>-</span>
</p>
<p class="truncate">{{ item.comment ?? '-' }}</p>
</div>
</div>
</div>
<div class="flex justify-center mb-4 mt-8">
<button
type="button"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md text-white disabled:cursor-not-allowed disabled:opacity-50"
@click="onOpenCreateDrawer"
>
+ Ajouter
</button>
</div>
<AppDrawer v-model="isDrawerOpen" title="Formation">
<form class="space-y-4" @submit.prevent="onSubmit">
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-start-date">
Date de début <span class="text-red-600">*</span>
</label>
<input
id="formation-start-date"
v-model="form.startDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-end-date">
Date de fin <span class="text-red-600">*</span>
</label>
<input
id="formation-end-date"
v-model="form.endDate"
type="date"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
<p v-if="isDateRangeInvalid" class="mt-1 text-sm text-red-600">La date de fin doit être postérieure ou égale à la date de début.</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-justificatif">
Justificatif
</label>
<div v-if="isEditing && editingItem?.justificatifName" class="mt-1 text-sm text-neutral-500">
Fichier actuel : {{ editingItem.justificatifName }}
</div>
<input
id="formation-justificatif"
ref="justificatifInput"
type="file"
accept="application/pdf"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 file:mr-3 file:rounded file:border-0 file:bg-primary-500 file:px-3 file:py-1 file:text-sm file:text-white"
@change="onJustificatifChange"
/>
<p v-if="justificatifError" class="mt-1 text-sm text-red-600">{{ justificatifError }}</p>
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="formation-comment">
Commentaire
</label>
<textarea
id="formation-comment"
v-model="form.comment"
rows="3"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
placeholder="Commentaire..."
/>
</div>
<div v-if="isEditing" class="grid grid-cols-2 gap-3 pt-2">
<button
type="button"
class="flex items-center justify-center rounded-md bg-red-500 px-4 py-2 text-md font-semibold text-white hover:bg-red-600"
@click="onDelete"
>
Supprimer
</button>
<button
type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
Modifier
</button>
</div>
<div v-else class="flex justify-center pt-2">
<button
type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!isFormValid"
>
+ Ajouter
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
<script setup lang="ts">
import type {Formation} from '~/services/dto/formation'
import {getFormationJustificatifUrl} from '~/services/formations'
import AppDrawer from '~/components/AppDrawer.vue'
const props = defineProps<{
formations: Formation[]
apiBase: string
}>()
const emit = defineEmits<{
(event: 'create', data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'update', id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File): void
(event: 'delete', id: number): void
}>()
const isDrawerOpen = ref(false)
const isEditing = ref(false)
const editingItem = ref<Formation | null>(null)
const selectedJustificatif = ref<File | undefined>(undefined)
const justificatifInput = ref<HTMLInputElement | null>(null)
const justificatifError = ref('')
const form = reactive({
startDate: '',
endDate: '',
comment: ''
})
const isDateRangeInvalid = computed(() => {
if (!form.startDate || !form.endDate) return false
return form.endDate < form.startDate
})
const isFormValid = computed(() => {
return Boolean(form.startDate) && Boolean(form.endDate) && !isDateRangeInvalid.value && !justificatifError.value
})
const formatDate = (dateStr: string): string => {
if (!dateStr) return '-'
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
return date.toLocaleDateString('fr-FR')
}
const resetForm = () => {
form.startDate = ''
form.endDate = ''
form.comment = ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
}
const onOpenCreateDrawer = () => {
isEditing.value = false
editingItem.value = null
resetForm()
isDrawerOpen.value = true
}
const onOpenEditDrawer = (item: Formation) => {
isEditing.value = true
editingItem.value = item
form.startDate = item.startDate
form.endDate = item.endDate
form.comment = item.comment ?? ''
selectedJustificatif.value = undefined
justificatifError.value = ''
if (justificatifInput.value) {
justificatifInput.value.value = ''
}
isDrawerOpen.value = true
}
const onJustificatifChange = (event: Event) => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (file && file.type !== 'application/pdf') {
justificatifError.value = 'Seuls les fichiers PDF sont acceptés.'
selectedJustificatif.value = undefined
target.value = ''
return
}
justificatifError.value = ''
selectedJustificatif.value = file ?? undefined
}
const onSubmit = () => {
const data = {
startDate: form.startDate,
endDate: form.endDate,
comment: form.comment || undefined
}
if (isEditing.value && editingItem.value) {
emit('update', editingItem.value.id, data, selectedJustificatif.value)
} else {
emit('create', data, selectedJustificatif.value)
}
isDrawerOpen.value = false
}
const onDelete = () => {
if (!editingItem.value) return
const ok = window.confirm('Supprimer cette formation ?')
if (!ok) return
emit('delete', editingItem.value.id)
isDrawerOpen.value = false
}
</script>

View File

@@ -32,6 +32,18 @@
<p v-if="isForfaitRule" class="col-start-3 p-[10px] border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
{{ formatCount(summary?.previousYearRemainingDays) }} Jours
</p>
<div v-if="isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Année n-1 payés : </span>
<span> {{ formatCount(summary?.previousYearPaidDays) }} Jours</span>
</div>
<button
class="flex items-center"
@click="openPaidLeaveDrawer"
>
<Icon name="mdi:edit-box" size="24"/>
</button>
</div>
<div v-if="!isForfaitRule" class="col-start-4 p-[10px] flex gap-7 items-center">
<div>
<span class="uppercase font-semibold">Fractionné acquis : </span>
@@ -112,6 +124,39 @@
</div>
</form>
</AppDrawer>
<AppDrawer v-model="isPaidLeaveDrawerOpen" title="Congés N-1 payés">
<form class="space-y-4" @submit.prevent="handleSubmitPaidLeave">
<div>
<label class="text-md font-semibold text-neutral-700" for="paid-leave-days">
Nombre de jours <span class="text-red-600">*</span>
</label>
<input
id="paid-leave-days"
v-model="paidLeaveForm.days"
type="number"
step="0.5"
min="0"
class="mt-2 w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20"
/>
</div>
<div class="flex justify-end gap-3 pt-2">
<button
type="button"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="isPaidLeaveDrawerOpen = false"
>
Annuler
</button>
<button
type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
>
Enregistrer
</button>
</div>
</form>
</AppDrawer>
</section>
</template>
@@ -136,11 +181,15 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'update-fractioned-days', days: number): void
(event: 'update-paid-leave-days', days: number): void
}>()
const isFractionedDrawerOpen = ref(false)
const fractionedForm = reactive({days: 0})
const isPaidLeaveDrawerOpen = ref(false)
const paidLeaveForm = reactive({days: 0})
const openFractionedDrawer = () => {
fractionedForm.days = props.summary?.fractionedDays ?? 0
isFractionedDrawerOpen.value = true
@@ -153,6 +202,18 @@ const handleSubmitFractioned = () => {
isFractionedDrawerOpen.value = false
}
const openPaidLeaveDrawer = () => {
paidLeaveForm.days = props.summary?.previousYearPaidDays ?? 0
isPaidLeaveDrawerOpen.value = true
}
const handleSubmitPaidLeave = () => {
const value = Number(paidLeaveForm.days)
if (Number.isNaN(value) || value < 0) return
emit('update-paid-leave-days', value)
isPaidLeaveDrawerOpen.value = false
}
const monthLabels = [
'Janvier',
'Fevrier',

View File

@@ -30,7 +30,7 @@
class="rounded-md bg-primary-500 px-8 py-2 font-bold text-white hover:bg-primary-600"
@click="openPaymentDrawer"
>
+ Payer les RRT
+ Payer les RTT
</button>
</div>
</div>

View File

@@ -26,19 +26,7 @@
@change="onBulkValidationChange"
/>
</span>
<span v-else-if="isSiteManager" class="inline-flex items-center gap-2">
<span>Site</span>
<input
ref="bulkSiteValidationInput"
:checked="isBulkSiteValidationChecked"
type="checkbox"
class="h-4 w-4"
:class="canBulkToggleSiteValidation ? 'cursor-pointer' : 'cursor-not-allowed opacity-50'"
:disabled="!canBulkToggleSiteValidation"
@change="onBulkSiteValidationChange"
/>
</span>
<span v-else>Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-else-if="!isSiteManager">Site <Icon name="mdi:check-bold" class="ml-1"/></span>
<span v-if="!isAdmin">RH <Icon name="mdi:check-bold" class="ml-1"/></span>
</div>
@@ -69,19 +57,39 @@
</p>
</div>
<div class="pl-2 min-w-0 self-stretch flex flex-col gap-1 justify-between py-0.5">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<div class="flex flex-col gap-1 min-w-0">
<p
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate"
:class="getRowAbsenceLabel(employee.id) ? 'text-white' : 'invisible'"
:title="getRowAbsenceLabel(employee.id) || ''"
:style="getRowAbsenceStyle(employee.id)"
>
{{ getRowAbsenceLabel(employee.id) || '—' }}
</p>
<p
v-if="isHoliday"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-sky-900 inline-flex items-center gap-1"
style="background-color: #b3e5fc"
:title="holidayLabel || 'Férié'"
>
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
</p>
<p
v-if="hasRowFormation(employee.id)"
class="w-full min-w-0 rounded-md px-2 py-1 text-xs truncate text-white bg-indigo-500 inline-flex items-center gap-1"
:title="getRowFormationLabel(employee.id)"
>
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
</p>
</div>
<button
v-if="!hasRowFormation(employee.id) && !isHoliday"
type="button"
class="self-start text-left text-xs font-semibold underline"
:class="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isHoliday || isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
@click="onAbsenceClick(employee.id)"
>
Modifier
@@ -170,16 +178,8 @@
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
</div>
<div v-else class="text-right p-5">
<input
v-if="isSiteManager"
:checked="rows[employee.id]?.isSiteValid ?? false"
type="checkbox"
class="h-4 w-4 cursor-pointer"
:disabled="(!canToggleSiteValidation(employee.id) && !canCreateSiteValidationRowFromAbsence(employee.id)) || isSiteValidationPending(employee.id)"
@change="onToggleSiteValidation(employee.id, ($event.target as HTMLInputElement).checked)"
/>
<span v-else-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<div v-else-if="!isSiteManager" class="text-right p-5">
<span v-if="rows[employee.id]?.isSiteValid" class="text-xs font-semibold text-neutral-700">Validé</span>
<span v-else class="text-xs text-neutral-500">-</span>
</div>
<div v-if="!isAdmin">
@@ -207,6 +207,7 @@ const props = defineProps<{
isSiteManager: boolean
dayGridCols: string
isHoliday: boolean
holidayLabel: string
contractLabel: (employee: Employee) => string
isTimeTracking: (employee: Employee) => boolean
isPresenceTracking: (employee: Employee) => boolean
@@ -231,6 +232,8 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; totalMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string
getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void

View File

@@ -0,0 +1,39 @@
import { documentationSections } from '~/data/documentation-content'
import type { DocAccessLevel, DocSection } from '~/types/documentation'
const LEVEL_HIERARCHY: Record<DocAccessLevel, number> = {
employee: 0,
site_manager: 1,
admin: 2,
}
function getUserLevel(roles: string[]): number {
if (roles.includes('ROLE_ADMIN') || roles.includes('ROLE_SUPER_ADMIN')) return 2
if (roles.includes('ROLE_USER')) return 1
return 0
}
export function useDocumentation() {
const auth = useAuthStore()
const userLevel = computed(() => getUserLevel(auth.user?.roles ?? []))
const visibleSections = computed<DocSection[]>(() => {
return documentationSections
.filter(s => LEVEL_HIERARCHY[s.requiredLevel] <= userLevel.value)
.map(s => ({
...s,
articles: s.articles.filter(a => LEVEL_HIERARCHY[a.requiredLevel] <= userLevel.value),
}))
.filter(s => s.articles.length > 0)
})
const activeArticleId = ref<string | null>(null)
const scrollToArticle = (articleId: string) => {
activeArticleId.value = articleId
const el = document.getElementById(`doc-${articleId}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return { visibleSections, activeArticleId, scrollToArticle }
}

View File

@@ -71,7 +71,7 @@ export const useDriverHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr 0.8fr 0.8fr 0.8fr ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -381,7 +381,6 @@ export const useDriverHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -941,6 +940,7 @@ export const useDriverHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus' | 'observation'>('contract')
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'formation' | 'bonus' | 'observation'>('contract')
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
@@ -39,6 +39,7 @@ export const useEmployeeDetailPage = () => {
leave.resetLoaded()
rtt.resetLoaded()
mileage.resetLoaded()
formation.resetLoaded()
bonus.resetLoaded()
observation.resetLoaded()
@@ -48,6 +49,8 @@ export const useEmployeeDetailPage = () => {
await rtt.loadRttData()
} else if (activeTab.value === 'mileage') {
await mileage.loadMileageData()
} else if (activeTab.value === 'formation') {
await formation.loadFormationData()
} else if (activeTab.value === 'bonus') {
await bonus.loadBonusData()
} else if (activeTab.value === 'observation') {
@@ -62,6 +65,7 @@ export const useEmployeeDetailPage = () => {
const leave = useEmployeeLeave(employee, loadEmployee)
const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee)
const bonus = useEmployeeBonus(employee, loadEmployee)
const observation = useEmployeeObservation(employee, loadEmployee)
@@ -72,6 +76,8 @@ export const useEmployeeDetailPage = () => {
rtt.loadRttData()
} else if (tab === 'mileage' && !mileage.mileageDataLoaded.value) {
mileage.loadMileageData()
} else if (tab === 'formation' && !formation.formationDataLoaded.value) {
formation.loadFormationData()
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
bonus.loadBonusData()
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
@@ -95,6 +101,7 @@ export const useEmployeeDetailPage = () => {
...leave,
...rtt,
...mileage,
...formation,
...bonus,
...observation
}

View File

@@ -0,0 +1,73 @@
import type { Ref } from 'vue'
import type { Formation } from '~/services/dto/formation'
import type { Employee } from '~/services/dto/employee'
import {
listFormations,
createFormation,
updateFormation,
deleteFormation,
uploadFormationJustificatif
} from '~/services/formations'
export const useEmployeeFormation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
const config = useRuntimeConfig()
const apiBase = (config.public.apiBase as string) ?? '/api'
const formations = ref<Formation[]>([])
const isFormationLoading = ref(false)
const formationDataLoaded = ref(false)
const loadFormationData = async () => {
if (!employee.value || isFormationLoading.value) return
isFormationLoading.value = true
try {
formations.value = await listFormations(employee.value.id)
formationDataLoaded.value = true
} finally {
isFormationLoading.value = false
}
}
const resetLoaded = () => {
formationDataLoaded.value = false
}
const submitCreateFormation = async (data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
if (!employee.value) return
const result = await createFormation({
employeeId: employee.value.id,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
})
if (result?.id && justificatifFile) {
await uploadFormationJustificatif(apiBase, result.id, justificatifFile)
}
await reloadEmployee()
}
const submitUpdateFormation = async (id: number, data: { startDate: string; endDate: string; comment?: string }, justificatifFile?: File) => {
await updateFormation(id, data)
if (justificatifFile) {
await uploadFormationJustificatif(apiBase, id, justificatifFile)
}
await reloadEmployee()
}
const submitDeleteFormation = async (id: number) => {
await deleteFormation(id)
await reloadEmployee()
}
return {
formations,
isFormationLoading,
formationDataLoaded,
formationApiBase: apiBase,
loadFormationData,
resetLoaded,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation
}
}

View File

@@ -4,7 +4,7 @@ import type { EmployeeLeaveSummary } from '~/services/dto/employee-leave-summary
import type { Employee } from '~/services/dto/employee'
import { CONTRACT_TYPES } from '~/services/dto/contract'
import { listAbsences } from '~/services/absences'
import { getEmployeeLeaveSummary, updateFractionedDays } from '~/services/employee-leave-summary'
import { getEmployeeLeaveSummary, updateFractionedDays, updatePaidLeaveDays } from '~/services/employee-leave-summary'
import { listPublicHolidays } from '~/services/public-holidays'
export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
@@ -57,6 +57,13 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
await reloadEmployee()
}
const submitPaidLeaveDays = async (days: number) => {
if (!employee.value) return
const year = leaveSummary.value?.year ?? undefined
await updatePaidLeaveDays(employee.value.id, days, year)
await reloadEmployee()
}
return {
employeeAbsences,
leaveSummary,
@@ -65,6 +72,7 @@ export const useEmployeeLeave = (employee: Ref<Employee | null>, reloadEmployee:
leaveDataLoaded,
loadLeaveData,
resetLoaded,
submitFractionedDays
submitFractionedDays,
submitPaidLeaveDays
}
}

View File

@@ -73,7 +73,7 @@ export const useHoursPage = () => {
const dayGridCols = computed(() => {
const metricCol = '0.4fr'
const validationCols = isAdmin.value ? `${metricCol}` : `${metricCol} ${metricCol}`
const validationCols = isAdmin.value || isSiteManager.value ? `${metricCol}` : `${metricCol} ${metricCol}`
return `1.2fr 0.6fr repeat(6, 0.8fr) ${metricCol} ${metricCol} ${metricCol} ${validationCols}`
})
@@ -458,7 +458,6 @@ export const useHoursPage = () => {
if (dayRow && dayRow.hasContractAtDate === false) {
return 'Contrat non démarré'
}
if (isSelectedDateHoliday.value) return 'Férié'
if (!dayRow?.absenceLabel) return ''
if (dayRow.absenceHalf === 'AM' || dayRow.absenceHalf === 'PM') {
const halfLabel = dayRow.absenceHalf === 'AM' ? 'Matin' : 'ap.-m.'
@@ -476,6 +475,14 @@ export const useHoursPage = () => {
return { backgroundColor: dayRow.absenceColor || '#dc2626' }
}
const hasRowFormation = (employeeId: number): boolean => {
return dayContextByEmployeeId.value.get(employeeId)?.hasFormation === true
}
const getRowFormationLabel = (employeeId: number): string => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
}
const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt
if (!raw) return ''
@@ -1119,6 +1126,7 @@ export const useHoursPage = () => {
saveButtonClass,
formattedSelectedDate,
isSelectedDateHoliday,
selectedHolidayLabel,
weekDayHeaders,
shortcutButtonClass,
weekShortcutButtonClass,
@@ -1154,6 +1162,8 @@ export const useHoursPage = () => {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,

View File

@@ -0,0 +1,591 @@
import type { DocSection } from '~/types/documentation'
export const documentationSections: DocSection[] = [
// ============================================================
// EMPLOYEE LEVEL
// ============================================================
{
id: 'connexion',
title: 'Connexion et navigation',
requiredLevel: 'employee',
icon: 'mdi:login',
articles: [
{
id: 'login',
title: 'Se connecter',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Pour accéder à l\'application, rendez-vous sur la page de connexion et saisissez vos identifiants.' },
{ type: 'list', content: 'Saisissez votre nom d\'utilisateur\nSaisissez votre mot de passe\nCliquez sur le bouton "Connexion"' },
{ type: 'note', content: 'Si vous ne parvenez pas à vous connecter, contactez votre administrateur RH. Votre compte a peut-être été verrouillé.' },
],
},
{
id: 'navigation',
title: 'Naviguer dans la vue jour',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
],
},
{
id: 'perimetre',
title: 'Périmètre d\'accès',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Votre accès dépend du rôle qui vous a été attribué par l\'administrateur.' },
{ type: 'list', content: 'Employé : accès à la saisie de ses propres heures uniquement\nChef de site : accès aux heures des employés de ses sites autorisés + validation\nAdministrateur : accès complet à toutes les fonctionnalités' },
],
},
],
},
{
id: 'saisie-heures',
title: 'Saisie des heures',
requiredLevel: 'employee',
icon: 'mdi:clock-time-four-outline',
articles: [
{
id: 'saisie-time',
title: 'Mode horaire (TIME)',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En mode horaire, vous saisissez vos heures via des créneaux matin, après-midi et soir.' },
{ type: 'list', content: 'Matin : heure de début et heure de fin\nAprès-midi : heure de début et heure de fin\nSoir : heure de début et heure de fin' },
{ type: 'paragraph', content: 'Le sélecteur de temps fonctionne par tranches de 15 minutes (00, 15, 30, 45). La saisie libre est possible mais sera corrigée automatiquement.' },
{ type: 'note', content: 'Les calculs sont mis à jour automatiquement : heures de jour (06:0021:00), heures de nuit (00:0006:00 et 21:0024:00) et total.' },
{ type: 'note', content: 'Jours fériés : le nom du férié apparaît en badge bleu dans la colonne Absence. La saisie d\'heures (ou de jours de présence) reste autorisée — elle est même nécessaire pour ne pas être en déficit sur la semaine concernée. La création d\'une absence sur un férié reste bloquée.' },
],
},
{
id: 'saisie-presence',
title: 'Mode présence (PRESENCE)',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En mode présence (contrats forfait), vous indiquez simplement si vous étiez présent le matin et/ou l\'après-midi.' },
{ type: 'list', content: 'Cochez "Présent matin" pour indiquer une demi-journée de travail le matin\nCochez "Présent après-midi" pour indiquer une demi-journée l\'après-midi\nChaque demi-journée cochée compte pour 0.5 jour de présence' },
],
},
{
id: 'comprendre-calculs',
title: 'Comprendre les calculs affichés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Les colonnes de calcul sont mises à jour automatiquement en fonction de votre saisie.' },
{ type: 'list', content: 'Jour : total des heures dans la plage 06:0021:00\nNuit : total des heures dans les plages 00:0006:00 et 21:0024:00\nTotal : somme des heures de jour et de nuit' },
],
},
],
},
{
id: 'saisie-conducteurs',
title: 'Saisie conducteurs',
requiredLevel: 'employee',
icon: 'mdi:truck-outline',
articles: [
{
id: 'conducteur-heures',
title: 'Saisie des heures conducteur',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Les conducteurs disposent d\'un écran dédié accessible via le menu "Heures Conducteurs". Ils n\'apparaissent pas sur l\'écran classique des heures.' },
{ type: 'list', content: 'Heures de jour : durée au format HH:MM\nHeures de nuit : durée au format HH:MM\nHeures atelier : durée au format HH:MM\nTotal : calculé automatiquement (jour + nuit + atelier)' },
],
},
{
id: 'conducteur-indemnites',
title: 'Indemnités conducteur',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'En plus des heures, vous pouvez cocher les indemnités correspondant à votre journée.' },
{ type: 'list', content: 'Petit déjeuner\nDéjeuner\nDîner\nNuitée' },
{ type: 'paragraph', content: 'La même logique de validation s\'applique que pour les heures classiques.' },
],
},
],
},
{
id: 'absences-validations',
title: 'Absences et validations',
requiredLevel: 'employee',
icon: 'mdi:information-outline',
articles: [
{
id: 'comprendre-absences',
title: 'Comprendre les absences affichées',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Quand une absence est posée sur votre journée, elle apparaît dans la colonne dédiée avec un fond coloré selon le type d\'absence.' },
{ type: 'list', content: 'Absence du matin (AM) : verrouille le créneau matin\nAbsence de l\'après-midi (PM) : verrouille les créneaux après-midi et soir\nAbsence journée complète : verrouille tous les créneaux' },
{ type: 'note', content: 'Vous ne pouvez pas modifier les créneaux horaires verrouillés par une absence. Seul un administrateur peut retirer ou modifier l\'absence.' },
],
},
{
id: 'comprendre-validations',
title: 'Comprendre les validations',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'Vos heures passent par un processus de double validation avant d\'être définitivement enregistrées.' },
{ type: 'list', content: 'Validation chef de site : votre chef de site vérifie et valide vos heures. La ligne est alors verrouillée pour vous.\nValidation RH : l\'administrateur RH valide définitivement. La ligne est complètement verrouillée.' },
{ type: 'paragraph', content: 'Une fois validée, vous ne pouvez plus modifier la ligne. Si une correction est nécessaire, contactez votre chef de site ou l\'administrateur RH.' },
{ type: 'note', content: 'Toute vraie modification effectuée par un administrateur remet automatiquement les deux validations à zéro.' },
],
},
],
},
// ============================================================
// SITE MANAGER LEVEL
// ============================================================
{
id: 'validation-site',
title: 'Validation de site',
requiredLevel: 'site_manager',
icon: 'mdi:check-decagram-outline',
articles: [
{
id: 'role-chef-site',
title: 'Rôle du chef de site',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'En tant que chef de site, vous êtes responsable de la vérification et de la validation des heures saisies par les employés de votre site.' },
{ type: 'paragraph', content: 'Le workflow de validation suit un circuit en 3 étapes : l\'employé saisit ses heures → le chef de site valide → l\'admin RH valide définitivement.' },
],
},
{
id: 'validation-individuelle',
title: 'Validation individuelle',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Pour valider une ligne d\'heures individuellement :' },
{ type: 'list', content: 'Cochez la case de validation site sur la ligne de l\'employé\nLa ligne est immédiatement verrouillée pour l\'employé\nL\'administrateur RH peut toujours corriger une ligne que vous avez validée' },
],
},
{
id: 'validation-masse',
title: 'Validation en masse',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Pour gagner du temps, vous pouvez valider toutes les lignes en une seule action.' },
{ type: 'list', content: 'Cliquez sur le bouton de validation en masse\nToutes les lignes de la date affichée sont validées d\'un coup\nUtile quand toutes les saisies sont correctes' },
{ type: 'note', content: 'Quand toutes les lignes de votre site sont validées pour une date donnée, les administrateurs RH reçoivent automatiquement une notification.' },
],
},
{
id: 'difference-validations',
title: 'Validation site vs validation RH',
requiredLevel: 'site_manager',
blocks: [
{ type: 'paragraph', content: 'Il est important de comprendre la différence entre les deux niveaux de validation.' },
{ type: 'list', content: 'Validation site : verrouille la ligne pour les employés, mais l\'admin RH peut encore modifier\nValidation RH : verrouillage complet, seul l\'admin peut retirer cette validation\nLe chef de site ne voit pas et ne peut pas agir sur la validation RH' },
],
},
],
},
// ============================================================
// ADMIN LEVEL
// ============================================================
{
id: 'administration',
title: 'Administration',
requiredLevel: 'admin',
icon: 'mdi:cog-outline',
articles: [
{
id: 'gestion-sites',
title: 'Gestion des sites',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les sites organisent les employés et les accès dans l\'application. Chaque site possède un nom et une couleur utilisée dans toute l\'interface.' },
{ type: 'list', content: 'Créer, modifier ou supprimer un site depuis le menu "Sites"\nL\'ordre d\'affichage est modifiable par glisser-déposer\nLa couleur du site est utilisée pour identifier visuellement les employés' },
],
},
{
id: 'gestion-types-absence',
title: 'Gestion des types d\'absence',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' },
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
],
},
{
id: 'gestion-utilisateurs',
title: 'Gestion des utilisateurs',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Chaque personne qui se connecte à l\'application a un compte utilisateur distinct de sa fiche employé.' },
{ type: 'list', content: 'Nom d\'utilisateur : unique, sert de login\nMot de passe : défini à la création, modifiable\nRôle : Admin (accès complet), User (chef de site), Self (employé)\nSites autorisés : pour les chefs de site, définit leur périmètre\nAssociation employé : lie le compte à une fiche employé\nVerrouillage : un compte verrouillé ne peut plus se connecter' },
{ type: 'note', content: 'Il n\'est pas possible de supprimer un utilisateur (sécurité). Pour bloquer l\'accès, utilisez le verrouillage de compte.' },
],
},
{
id: 'taches-automatiques',
title: 'Tâches automatiques (crons)',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Deux tâches automatiques s\'exécutent quotidiennement pour gérer le report des compteurs.' },
{ type: 'list', content: 'Report congés (02h10) : déclenche le report des congés payés le 1er juin (CDI/CDD) et le 1er janvier (forfait)\nReport RTT (02h15) : déclenche le report du solde RTT le 1er juin' },
{ type: 'note', content: 'Ces tâches sont idempotentes : si elles s\'exécutent plusieurs fois, aucun doublon n\'est créé.' },
],
},
],
},
{
id: 'employes-contrats',
title: 'Employés et contrats',
requiredLevel: 'admin',
icon: 'mdi:account-group-outline',
articles: [
{
id: 'liste-employes',
title: 'Liste et recherche d\'employés',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La page Employés affiche tous les employés sous forme de cartes.' },
{ type: 'list', content: 'Recherche par nom\nFiltrage par site (multi-sélection)\nFiltrage par statut de contrat : "Avec contrat" (défaut), "Sans contrat", "Tous"\n"Avec contrat" = employés ayant une période de contrat active à la date du jour' },
],
},
{
id: 'creation-employe',
title: 'Créer un employé',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
],
},
{
id: 'types-contrat',
title: 'Types de contrat',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le type de contrat détermine le mode de suivi et les règles de calcul appliquées.' },
{ type: 'list', content: 'FORFAIT : suivi en jours (mode PRESENCE), base 218 jours/an\n35 HEURES : suivi horaire (mode TIME), 35h/semaine\n39 HEURES : suivi horaire (mode TIME), 39h/semaine\nCUSTOM : heures personnalisées (ex: 4h, 20h), 1h sup = 1h récup sans bonus\nINTERIM : travail temporaire, pas de récupération ni de congés gérés' },
{ type: 'note', content: 'Le mode de suivi (TIME ou PRESENCE) est lié au type de contrat et ne peut pas être modifié indépendamment.' },
],
},
{
id: 'suivi-contrat',
title: 'Suivi contrat et historique',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet "Suivi contrat" sur la fiche employé affiche l\'historique complet des périodes de contrat.' },
{ type: 'list', content: 'Chaque ligne : nature (CDI/CDD/INTERIM), type de contrat, date début, date fin ou "En cours"\nAjouter un contrat : disponible uniquement si le contrat en cours est clôturé\nClôturer un contrat : définir la date de fin + option "Solde de tout compte"\nSuspension : ajouter une période de suspension avec dates et commentaire' },
{ type: 'note', content: 'La case "Soldé dans le solde de tout compte" remet le report des congés à 0 pour l\'exercice suivant.' },
],
},
{
id: 'statut-conducteur',
title: 'Statut conducteur',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le statut conducteur est un flag activé sur une période de contrat. Un employé peut changer de statut conducteur selon la période.' },
{ type: 'paragraph', content: 'Un employé conducteur apparaît uniquement sur l\'écran "Heures Conducteurs" et non sur l\'écran "Heures" classique.' },
],
},
],
},
{
id: 'double-validation',
title: 'Saisie et double validation',
requiredLevel: 'admin',
icon: 'mdi:shield-check-outline',
articles: [
{
id: 'validation-rh',
title: 'Validation RH',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La validation RH est le niveau de validation le plus élevé, réservé aux administrateurs.' },
{ type: 'list', content: 'Verrouille complètement la ligne (heures et absences)\nSeul un administrateur peut retirer cette validation\nPeut être appliquée individuellement ou en masse' },
],
},
{
id: 'regles-reinitialisation',
title: 'Règles de réinitialisation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les validations sont automatiquement réinitialisées dans certaines conditions.' },
{ type: 'list', content: 'Toute vraie modification d\'une ligne remet les deux validations (site et RH) à faux\nUn enregistrement sans changement réel préserve les validations existantes\nLa date de modification est mise à jour uniquement quand un employé modifie ses propres heures' },
{ type: 'note', content: 'La date de modification est visible uniquement par les administrateurs, sous le nom de l\'employé dans la vue jour.' },
],
},
],
},
{
id: 'vue-semaine-hs',
title: 'Vue semaine et heures supplémentaires',
requiredLevel: 'admin',
icon: 'mdi:calendar-week',
articles: [
{
id: 'vue-semaine',
title: 'Vue semaine',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine est réservée aux administrateurs. Elle affiche une synthèse hebdomadaire par employé avec les heures supplémentaires calculées.' },
{ type: 'list', content: 'Filtrage par site et par employé\nDétail par jour avec totaux hebdomadaires\nColonnes de calcul : base, heures sup 25%, heures sup 50%, total récupération' },
],
},
{
id: 'calcul-hs',
title: 'Calcul des heures supplémentaires',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les règles de calcul des heures supplémentaires dépendent du type de contrat.' },
{ type: 'list', content: 'Contrats ≤ 35h : +25% de 35h à 43h, +50% au-delà de 43h\nContrats ≥ 39h : +25% de 39h à 43h, +50% au-delà de 43h\nContrats CUSTOM (4h, 25h, etc.) : 1h supplémentaire = 1h de récupération, pas de bonus\nINTERIM : aucune récupération, aucun bonus' },
{ type: 'note', content: 'En cas de déficit hebdomadaire (heures travaillées < heures contrat), le déficit est déduit du cumul RTT : d\'abord des heures à 50%, puis des heures à 25%.' },
],
},
{
id: 'vue-semaine-conducteurs',
title: 'Vue semaine conducteurs',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'La vue semaine conducteurs affiche des colonnes spécifiques.' },
{ type: 'list', content: 'Totaux jour / nuit / atelier par jour et par semaine\nPanier de nuit (PN) : affiché quand heures nuit > heures jour OU nuit ≥ 4h\nCompteurs hebdomadaires : petit déjeuner, déjeuner, dîner, nuitée\nRTT calculé sur jour + nuit + atelier (au lieu des créneaux classiques)' },
],
},
],
},
{
id: 'absences-calendrier',
title: 'Absences et calendrier',
requiredLevel: 'admin',
icon: 'mdi:calendar-blank',
articles: [
{
id: 'poser-absence',
title: 'Poser une absence',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les absences peuvent être posées depuis la vue jour des heures ou depuis le calendrier.' },
{ type: 'list', content: 'Journée complète : efface toutes les plages horaires\nDemi-journée matin (AM) : efface le créneau matin\nDemi-journée après-midi (PM) : efface les créneaux après-midi et soir' },
{ type: 'paragraph', content: 'Les absences sont stockées par jour : une absence de plusieurs jours est automatiquement découpée en entrées quotidiennes.' },
],
},
{
id: 'effet-absences-heures',
title: 'Effet sur les heures',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'impact d\'une absence sur les heures dépend du type d\'absence et du mode de suivi.' },
{ type: 'list', content: 'Standard : efface les créneaux horaires correspondants\nSi "Compté comme travaillé" en mode TIME : crédite des minutes selon le contrat actif\nSi "Compté comme travaillé" en mode PRESENCE : aucun crédit (seules les cases cochées comptent)' },
{ type: 'note', content: 'Les absences comptées comme travaillées impactent le calcul des heures supplémentaires et du RTT.' },
],
},
{
id: 'calendrier-mensuel',
title: 'Calendrier mensuel',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
],
},
],
},
{
id: 'conges-payes',
title: 'Congés payés',
requiredLevel: 'admin',
icon: 'mdi:umbrella-beach-outline',
articles: [
{
id: 'regles-cdi-cdd',
title: 'Règles CDI/CDD non-forfait',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Pour les contrats CDI et CDD (hors forfait), l\'exercice de congés va du 1er juin (N-1) au 31 mai (N).' },
{ type: 'list', content: 'Acquisition annuelle : 25 jours + 5 samedis\nAcquisition mensuelle : 2,08 jours + 0,42 samedi par mois\nProratisation en cas de début/fin ou suspension en cours de mois\nContrat 4h : 10 jours annuels, 0 samedi, 0,83 jour/mois' },
],
},
{
id: 'regles-forfait',
title: 'Règles FORFAIT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Pour les contrats forfait, l\'exercice suit l\'année civile (1er janvier au 31 décembre).' },
{ type: 'list', content: 'Calcul : jours ouvrés de l\'année 218 + bonus weekend/férié\nBonus : 1 jour par jour travaillé un weekend ou jour férié (0.5 si demi-journée)\nPas de samedis\nPas de jours en cours d\'acquisition' },
],
},
{
id: 'maladie-longue',
title: 'Arrêt maladie long',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'En cas d\'arrêt maladie de plus d\'un mois, les règles d\'acquisition sont modifiées.' },
{ type: 'list', content: 'Premier mois de maladie : acquisition normale\nAprès le premier mois : acquisition réduite (facteur 0,80)\nDétection automatique à partir des absences MALADIE consécutives (tolérance de gap ≤ 3 jours)' },
],
},
{
id: 'report-conges',
title: 'Report annuel et rollover',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le reliquat de congés de l\'exercice précédent est automatiquement reporté dans les acquis du nouvel exercice.' },
{ type: 'list', content: 'Report automatique le 1er juin (CDI/CDD non-forfait) ou 1er janvier (forfait)\nSi "Solde de tout compte" coché sur le contrat clôturé : report remis à 0\nJours fractionnés : saisie manuelle par la RH, ajoutés aux acquis' },
],
},
{
id: 'consommation-conges',
title: 'Règle de consommation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Les absences s\'imputent selon un ordre précis.' },
{ type: 'list', content: 'D\'abord sur les acquis (report N-1)\nPuis sur les jours en cours d\'acquisition\nEn cours d\'acquisition peut devenir négatif temporairement (se reconstitue avec les acquisitions suivantes)' },
{ type: 'paragraph', content: 'Compteurs visibles sur l\'onglet Congé de la fiche employé : acquis, en cours d\'acquisition, pris, restant.' },
],
},
{
id: 'ecran-recap-conges',
title: 'Écran Récap. congés',
requiredLevel: 'employee',
blocks: [
{ type: 'paragraph', content: 'L\'écran "Récap. congés" affiche un tableau figé des soldes de congés et RTT par employé. Il est accessible via la sidebar lorsque l\'accès a été activé sur le compte utilisateur.' },
{ type: 'list', content: 'Employé : voit uniquement sa propre ligne\nChef de site : voit les employés des sites qui lui sont rattachés\nAdmin : voit tous les employés, groupés par site' },
{ type: 'note', content: 'Le récap est arrêté à la fin de la semaine S-2 (dimanche). Exemple : un mardi en S16, les soldes sont calculés jusqu\'au dimanche de la S14 inclus. Les heures et absences postérieures ne sont pas comptées.' },
{ type: 'paragraph', content: 'Les colonnes affichées sont identiques à l\'export PDF admin (Nom, Prénom, Contrat, CP N-1 restant, CP N, Samedis, RTT). L\'accès à cet écran est géré par un flag sur l\'utilisateur, activé depuis le drawer de création/édition d\'un utilisateur par un admin.' },
],
},
],
},
{
id: 'rtt',
title: 'RTT (Récupération de Temps de Travail)',
requiredLevel: 'admin',
icon: 'mdi:timer-sand',
articles: [
{
id: 'rtt-principe',
title: 'Principe et exercice RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Le RTT correspond aux heures supplémentaires accumulées, converties en temps de récupération. L\'exercice RTT va du 1er juin (N-1) au 31 mai (N).' },
{ type: 'paragraph', content: 'L\'onglet RTT sur la fiche employé affiche le détail hebdomadaire regroupé par mois, avec un compteur global en heures (1 jour = 7h = 420 minutes).' },
],
},
{
id: 'rtt-compteurs',
title: 'Compteurs RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Report N-1 : solde de l\'exercice précédent\nAcquis : cumul des heures supplémentaires de l\'exercice en cours\nDisponible : report + acquis payé\nPayé : RTT convertis en salaire (soustraits du disponible)' },
{ type: 'note', content: 'Les contrats INTERIM et le mode PRESENCE n\'accumulent pas de RTT (affiché à 0).' },
],
},
{
id: 'rtt-paiement',
title: 'Paiement RTT',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'administrateur RH peut enregistrer un paiement RTT depuis l\'onglet RTT de la fiche employé.' },
{ type: 'list', content: 'Saisie : mois, nombre de minutes, taux (25% ou 50%)\nPlusieurs paiements possibles par mois\nLes heures payées sont soustraites du solde disponible' },
],
},
{
id: 'rtt-semaines-mois',
title: 'Attribution des semaines aux mois',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Chaque semaine ISO est attribuée à un seul mois dans le tableau RTT.' },
{ type: 'list', content: 'Une semaine est attribuée au mois qui contient son samedi\nSi le samedi tombe en début de mois suivant, la semaine est dans ce mois suivant' },
],
},
],
},
{
id: 'frais-primes-observations',
title: 'Frais, primes et observations',
requiredLevel: 'admin',
icon: 'mdi:account-cash-outline',
articles: [
{
id: 'frais',
title: 'Onglet Frais',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet Frais sur la fiche employé permet de saisir les frais kilométriques et les montants associés.' },
{ type: 'list', content: 'Mois : obligatoire\nKilomètres : nombre de km (optionnel)\nMontant : en euros (optionnel)\nCommentaire : optionnel\nDeux justificatifs PDF distincts : un pour les km, un pour le montant' },
{ type: 'note', content: 'Au moins un des deux champs (kilomètres ou montant) doit être supérieur à 0. Un seul enregistrement par mois par employé.' },
],
},
{
id: 'formation',
title: 'Onglet Formation',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'L\'onglet Formation sur la fiche employé permet de tracer les formations suivies par le salarié.' },
{ type: 'list', content: 'Date de début : obligatoire\nDate de fin : obligatoire (doit être postérieure ou égale à la date de début)\nJustificatif PDF : optionnel\nCommentaire : optionnel' },
{ type: 'note', content: 'Les formations sont triées par date de début décroissante. Cliquer sur une ligne permet de la modifier ou la supprimer.' },
{ type: 'paragraph', content: 'Les formations sont également affichées en consultation sur l\'écran des heures (pastille indigo "Formation" dans la colonne Absence, sans bouton Modifier) et dans le calendrier (cellule "F" indigo ou icône école si couplée à une absence, cellule non cliquable). La modification et la suppression d\'une formation se font exclusivement depuis cet onglet.' },
],
},
{
id: 'primes',
title: 'Onglet Prime',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Mois : obligatoire\nMontant en euros : obligatoire\nCommentaire : optionnel\nUne seule prime par mois par employé' },
],
},
{
id: 'observations',
title: 'Onglet Observation',
requiredLevel: 'admin',
blocks: [
{ type: 'list', content: 'Mois : obligatoire\nTexte d\'observation : obligatoire\nUne seule observation par mois par employé\nNote libre pour le suivi RH' },
],
},
],
},
{
id: 'exports',
title: 'Exports et impressions',
requiredLevel: 'admin',
icon: 'mdi:file-pdf-box',
articles: [
{
id: 'export-recap-conges',
title: 'Export récap. congés',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 portrait récapitulant les congés de tous les employés actifs.' },
{ type: 'list', content: 'Accessible depuis la page Employés (bouton "Export récap. congés")\nGénère un PDF à la date du jour\nDonnées groupées par site\nColonnes : nom, contrat, CP N-1 restant, samedi restant, CP N, RTT' },
],
},
{
id: 'export-recap-salaire',
title: 'Récapitulatif salaire',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A4 paysage avec le détail mensuel pour la paie.' },
{ type: 'list', content: 'Sélecteur de mois (défaut = mois courant)\nDonnées groupées par site\nColonnes : nom, base contrat, jours de présence cadre, heures de nuit, panier de nuit, heures RTT payées, congés (nombre + dates), maladie/AT (nombre + dates), primes conducteur (PDJ, repas, nuitée, samedi), observations' },
],
},
{
id: 'impression-absences',
title: 'Impression absences',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF A3 paysage du calendrier d\'absences avec des filtres.' },
{ type: 'list', content: 'Filtres : période (du/au), sites, nature de contrat, type de contrat\nTous les filtres sont cochés par défaut\nCalendrier coloré par type d\'absence' },
],
},
{
id: 'export-heures-annuelles',
title: 'Export heures annuelles',
requiredLevel: 'admin',
blocks: [
{ type: 'paragraph', content: 'Génère un PDF par employé avec le détail jour par jour de ses heures sur une année.' },
{ type: 'list', content: 'Accessible depuis la fiche employé (bouton imprimante)\nChoix de l\'année civile (janvier à décembre)\nColonnes adaptées au mode de suivi (TIME, PRESENCE, conducteur)\nSections séparées en cas de changement de contrat en cours d\'année' },
],
},
],
},
]

View File

@@ -42,6 +42,11 @@
"update": "Impossible de mettre à jour le frais kilométrique.",
"delete": "Impossible de supprimer le frais kilométrique."
},
"formation": {
"create": "Impossible de créer la formation.",
"update": "Impossible de mettre à jour la formation.",
"delete": "Impossible de supprimer la formation."
},
"bonus": {
"create": "Impossible de créer la prime.",
"update": "Impossible de mettre à jour la prime.",
@@ -51,6 +56,9 @@
"create": "Impossible de créer l'observation.",
"update": "Impossible de mettre à jour l'observation.",
"delete": "Impossible de supprimer l'observation."
},
"leaveRecap": {
"load": "Impossible de charger le récap des congés."
}
},
"success": {
@@ -88,6 +96,11 @@
"update": "Frais kilométrique mis à jour.",
"delete": "Frais kilométrique supprimé."
},
"formation": {
"create": "Formation créée.",
"update": "Formation mise à jour.",
"delete": "Formation supprimée."
},
"bonus": {
"create": "Prime créée.",
"update": "Prime mise à jour.",

View File

@@ -53,6 +53,17 @@
<Icon name="mdi:account-group-outline" size="24"/>
<p>Employés</p>
</NuxtLink>
<NuxtLink
v-if="hasLeaveRecapAccess"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/leave-recap')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink
to="/sites"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
@@ -84,6 +95,15 @@
<p>Utilisateurs</p>
</NuxtLink>
</template>
<NuxtLink
v-if="hasLeaveRecapAccess && !isAdmin"
to="/leave-recap"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
>
<Icon name="mdi:beach" size="24"/>
<p>Récap. congés</p>
</NuxtLink>
<NuxtLink
v-if="isSuperAdmin"
to="/audit-logs"
@@ -95,6 +115,16 @@
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
<p>Journal</p>
</NuxtLink>
<NuxtLink
to="/documentation"
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
:class="route.path.startsWith('/documentation')
? 'bg-tertiary-500 text-primary-500 font-bold'
: ''"
>
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
<p>Documentation</p>
</NuxtLink>
</nav>
<div class="flex flex-col gap-2 items-center p-4">
@@ -118,5 +148,6 @@ const {version} = useAppVersion()
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
const isDriver = computed(() => auth.user?.isDriver ?? false)
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
const route = useRoute()
</script>

View File

@@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(async () => {
const auth = useAuthStore()
if (!auth.checked) {
await auth.ensureSession()
}
if (!auth.user?.hasLeaveRecapAccess) {
return navigateTo('/')
}
})

View File

@@ -44,6 +44,7 @@
<option value="contract_suspension">Suspension</option>
<option value="rtt_payment">Paiement RTT</option>
<option value="fractioned_days">Jours fractionnés</option>
<option value="paid_leave_days">Congés N-1 payés</option>
</select>
</div>
<button
@@ -241,6 +242,7 @@ const entityTypeLabel = (type: string): string => {
contract_suspension: 'Suspension',
rtt_payment: 'RTT',
fractioned_days: 'Fract.',
paid_leave_days: 'Congés payés',
}
return map[type] ?? type
}

View File

@@ -49,6 +49,10 @@
<div :style="{ backgroundColor: type.color }" class="h-4 w-4 rounded"></div>
<p>{{ type.label }}</p>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded bg-indigo-500"></div>
<p>FORMATION</p>
</div>
</div>
</div>
@@ -99,6 +103,8 @@ import {HALF_DAYS} from '~/services/dto/half-day'
import {listEmployees, updateEmployeeOrder} from '~/services/employees'
import {listAbsenceTypes} from '~/services/absence-types'
import {createAbsence, deleteAbsence, listAbsences, updateAbsence} from '~/services/absences'
import {listFormationsByDateRange} from '~/services/formations'
import type {Formation} from '~/services/dto/formation'
import {listPublicHolidays} from '~/services/public-holidays'
import {getDaysInMonth, normalizeDate, parseYmd, toYmd} from '~/utils/date'
import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/employee'
@@ -163,6 +169,7 @@ const visibleEmployees = computed(() => {
// Données de référence et absences du mois affiché.
const absenceTypes = ref<AbsenceType[]>([])
const absences = ref<Absence[]>([])
const formations = ref<Formation[]>([])
const publicHolidays = ref<Record<string, string>>({})
// États UI.
@@ -384,12 +391,18 @@ const loadAbsences = async () => {
})
}
const loadFormations = async () => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
formations.value = await listFormationsByDateRange(monthStart, monthEnd)
}
onMounted(async () => {
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences()])
await Promise.all([loadEmployees(), loadAbsenceTypes(), loadPublicHolidays(), loadAbsences(), loadFormations()])
})
watch([selectedMonth, selectedYear, selectedSiteIds], async () => {
await loadAbsences()
await Promise.all([loadAbsences(), loadFormations()])
})
watch(selectedYear, async () => {
@@ -441,6 +454,42 @@ const cellAbsenceMap = computed(() => {
return map
})
// Indexation des formations par cellule pour un lookup O(1).
const cellFormationMap = computed(() => {
const set = new Set<string>()
const monthStart = monthStartDate.value
const monthEnd = monthEndDate.value
for (const formation of formations.value) {
const employeeId = formation.employee?.id
if (!employeeId) continue
const startDate = normalizeDate(formation.startDate)
const endDate = normalizeDate(formation.endDate)
const start = parseYmd(startDate)
const end = parseYmd(endDate)
if (!start || !end) continue
const rangeStart = start < monthStart ? monthStart : start
const rangeEnd = end > monthEnd ? monthEnd : end
if (rangeEnd < rangeStart) continue
for (
let currentDate = new Date(rangeStart.getTime());
currentDate <= rangeEnd;
currentDate.setDate(currentDate.getDate() + 1)
) {
const dateKey = toYmd(currentDate.getFullYear(), currentDate.getMonth(), currentDate.getDate())
set.add(`${employeeId}-${dateKey}`)
}
}
return set
})
const hasFormationOn = (employeeId: number, date: string): boolean => {
return cellFormationMap.value.has(`${employeeId}-${date}`)
}
// Jours fériés (interdit pour la création).
const isHolidayDate = (date: string) => {
return Boolean(publicHolidays.value[date])
@@ -457,7 +506,16 @@ const getCellAbsence = (employeeId: number, date: string) => {
}
}
const absence = cellAbsenceMap.value.get(`${employeeId}-${date}`)
if (absence) return absence
if (absence) return { ...absence, hasFormation: hasFormationOn(employeeId, date) }
if (hasFormationOn(employeeId, date)) {
return {
id: 0,
code: 'F',
color: '#6366f1',
textColor: '#fff',
hasFormation: true
}
}
return null
}

View File

@@ -0,0 +1,9 @@
<template>
<DocumentationPage/>
</template>
<script setup lang="ts">
useHead({
title: 'Documentation',
})
</script>

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-row-locked="isRowLocked"
:has-contract-at-selected-date="hasContractAtSelectedDate"
@@ -174,6 +175,7 @@ const {
closeAbsenceDrawer,
formatMinutes,
isSelectedDateHoliday,
selectedHolidayLabel,
handleSave
} = useDriverHoursPage()

View File

@@ -74,6 +74,16 @@
<Icon name="mdi:account-cash-outline" size="24" class="align-self"/>
Frais
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'formation'
? 'border-primary-500 text-primary-500'
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
@click="activeTab = 'formation'"
>
<Icon name="mdi:school-outline" size="24" class="align-self"/>
Formation
</button>
<button
class="pb-2 border-b-2 flex items-center gap-3"
:class="activeTab === 'bonus'
@@ -148,6 +158,7 @@
:summary="leaveSummary"
:public-holidays="publicHolidays"
@update-fractioned-days="submitFractionedDays"
@update-paid-leave-days="submitPaidLeaveDays"
/>
</div>
<div v-else-if="showRttTab && activeTab === 'rtt'" class="h-full">
@@ -170,6 +181,20 @@
@delete="submitDeleteMileage"
/>
</div>
<div v-else-if="activeTab === 'formation'" class="h-full">
<div v-if="isFormationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<EmployeesFormationTab
v-else
class="h-full"
:formations="formations"
:api-base="formationApiBase"
@create="submitCreateFormation"
@update="submitUpdateFormation"
@delete="submitDeleteFormation"
/>
</div>
<div v-else-if="activeTab === 'bonus'" class="h-full">
<div v-if="isBonusLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
@@ -259,6 +284,7 @@ const {
submitContractUpdate,
submitCreateContract,
submitFractionedDays,
submitPaidLeaveDays,
submitRttPayment,
suspensionForms,
isSuspensionSubmitting,
@@ -273,6 +299,12 @@ const {
submitCreateMileage,
submitUpdateMileage,
submitDeleteMileage,
formations,
isFormationLoading,
formationApiBase,
submitCreateFormation,
submitUpdateFormation,
submitDeleteFormation,
bonuses,
isBonusLoading,
submitCreateBonus,

View File

@@ -43,6 +43,7 @@
:is-site-manager="isSiteManager"
:day-grid-cols="dayGridCols"
:is-holiday="isSelectedDateHoliday"
:holiday-label="selectedHolidayLabel"
:contract-label="contractLabel"
:is-time-tracking="isTimeTracking"
:is-presence-tracking="isPresenceTracking"
@@ -67,6 +68,8 @@
:get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel"
:get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer"
@@ -139,6 +142,7 @@ const {
isSubmitting,
dayGridCols,
isSelectedDateHoliday,
selectedHolidayLabel,
weekGridCols,
saveButtonClass,
formattedSelectedDate,
@@ -177,6 +181,8 @@ const {
getRowMetrics,
getRowAbsenceLabel,
getRowAbsenceStyle,
hasRowFormation,
getRowFormationLabel,
getRowUpdatedAt,
getPresenceDayValue,
openAbsenceDrawer,

View File

@@ -0,0 +1,121 @@
<template>
<div class="h-full flex flex-col overflow-hidden">
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
<span
v-if="cutoffLabel"
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
>
<Icon name="mdi:calendar-check-outline" size="18"/>
{{ cutoffLabel }}
</span>
</div>
<div
v-if="isLoading"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Chargement...
</div>
<div
v-else-if="rows.length === 0"
class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600"
>
Aucun employé à afficher.
</div>
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
<div
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
>
<span v-if="showSiteColumn" class="text-left">Site</span>
<span class="text-left">Nom</span>
<span class="text-left">Prénom</span>
<span class="text-left">Contrat</span>
<span class="text-right">CP N-1 restant</span>
<span class="text-right">CP N</span>
<span class="text-right">Samedis</span>
<span class="text-right">RTT</span>
</div>
<div class="border-x border-b border-primary-500 rounded-b-md">
<div
v-for="row in rows"
:key="row.employeeId"
:class="`grid ${gridColsClass} items-center gap-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0`"
>
<span v-if="showSiteColumn" class="truncate">
<span
v-if="row.siteName"
class="inline-block rounded-full px-3 py-1 text-sm"
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
>
{{ row.siteName }}
</span>
<span v-else class="text-neutral-500">-</span>
</span>
<span class="truncate">{{ row.lastName }}</span>
<span class="truncate">{{ row.firstName }}</span>
<span class="truncate">{{ row.contractName ?? '-' }}</span>
<span class="text-right tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
<span class="text-right tabular-nums">{{ row.cpN }}</span>
<span class="text-right tabular-nums">{{ row.acquiredSaturdays }}</span>
<span class="text-right tabular-nums">{{ row.rtt }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LeaveRecapRow } from '~/services/dto/leave-recap'
import { fetchLeaveRecap } from '~/services/leave-recap'
import { formatYmdToFr, getIsoWeekNumber, parseYmd } from '~/utils/date'
definePageMeta({ middleware: ['leave-recap-access'] })
useHead({ title: 'Récap. congés' })
const auth = useAuthStore()
const rows = ref<LeaveRecapRow[]>([])
const isLoading = ref(false)
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
const isSelfOnly = computed(() => {
const roles = auth.user?.roles ?? []
return roles.includes('ROLE_SELF') && !roles.includes('ROLE_ADMIN')
})
const showSiteColumn = computed(() => !isSelfOnly.value)
const gridColsClass = computed(() =>
showSiteColumn.value
? 'grid-cols-[1.2fr_1fr_1fr_1.2fr_140px_100px_100px_120px]'
: 'grid-cols-[1fr_1fr_1.2fr_140px_100px_100px_120px]'
)
const cutoffLabel = computed(() => {
const ymd = rows.value[0]?.cutoffDate
if (!ymd) return ''
const parsed = parseYmd(ymd)
if (!parsed) return ''
const week = getIsoWeekNumber(parsed)
return `Arrêté au ${formatYmdToFr(ymd)} (fin S${week})`
})
const formatNumber = (value: number) => {
if (!Number.isFinite(value)) return '-'
return value.toLocaleString('fr-FR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })
}
const load = async () => {
isLoading.value = true
try {
rows.value = await fetchLeaveRecap()
} finally {
isLoading.value = false
}
}
onMounted(load)
// Silence unused linter warning for isAdmin (kept for future site grouping)
void isAdmin
</script>

View File

@@ -189,6 +189,20 @@
</p>
</div>
<div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="form.hasLeaveRecapAccess"
type="checkbox"
class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
</div>
<div class="flex justify-center pt-2">
<button
type="submit"
@@ -233,7 +247,8 @@ const form = reactive({
accessMode: 'admin' as 'admin' | 'self' | 'sites',
employeeId: '' as number | '',
siteIds: [] as number[],
isLocked: false
isLocked: false,
hasLeaveRecapAccess: false
})
const validationTouched = reactive({
@@ -345,6 +360,7 @@ const resetForm = () => {
form.accessMode = 'admin'
form.siteIds = []
form.isLocked = false
form.hasLeaveRecapAccess = false
editingUser.value = null
validationTouched.username = false
validationTouched.password = false
@@ -373,6 +389,7 @@ const openEdit = (user: User) => {
form.employeeId = user.employee?.id ?? ''
form.isLocked = user.isLocked
form.hasLeaveRecapAccess = user.hasLeaveRecapAccess ?? false
const siteRoles = userAccessById.value.get(user.id) ?? []
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
@@ -427,7 +444,8 @@ const handleSubmit = async () => {
plainPassword: form.password.trim() ? form.password : undefined,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
@@ -452,7 +470,8 @@ const handleSubmit = async () => {
plainPassword: form.password,
roles,
employeeId,
isLocked: form.isLocked
isLocked: form.isLocked,
hasLeaveRecapAccess: form.hasLeaveRecapAccess
})
if (form.accessMode === 'sites' && form.siteIds.length > 0) {

View File

@@ -13,6 +13,7 @@ export type EmployeeLeaveSummary = {
previousYearAcquiredDays: number
previousYearTakenDays: number
previousYearRemainingDays: number
previousYearPaidDays: number
presenceDaysByMonth: Record<string, number>
}

View File

@@ -0,0 +1,12 @@
import type { Employee } from './employee'
export type Formation = {
id: number
startDate: string
endDate: string
comment: string | null
justificatifPath: string | null
justificatifName: string | null
createdAt: string
employee?: Employee
}

View File

@@ -0,0 +1,14 @@
export type LeaveRecapRow = {
employeeId: number
lastName: string
firstName: string
siteId: number | null
siteName: string | null
siteColor: string | null
contractName: string | null
cpN1Remaining: number
cpN: string
acquiredSaturdays: string
rtt: string
cutoffDate: string
}

View File

@@ -3,4 +3,5 @@ export type UserData = {
username: string
roles: string[]
isDriver: boolean
hasLeaveRecapAccess: boolean
}

View File

@@ -5,5 +5,6 @@ export type User = {
username: string
roles: string[]
isLocked: boolean
hasLeaveRecapAccess: boolean
employee?: Employee | null
}

View File

@@ -106,6 +106,8 @@ export type WorkHourDayContextRow = {
creditedMinutes: number
creditedPresenceUnits: number
isDriverContract?: boolean
hasFormation?: boolean
formationLabel?: string | null
}
export type WorkHourDayContext = {

View File

@@ -16,3 +16,11 @@ export const updateFractionedDays = async (employeeId: number, fractionedDays: n
return api.patch(`/employees/${employeeId}/fractioned-days`, body)
}
export const updatePaidLeaveDays = async (employeeId: number, paidLeaveDays: number, year?: number) => {
const api = useApi()
const body: Record<string, unknown> = { paidLeaveDays }
if (year) body.year = year
return api.patch(`/employees/${employeeId}/paid-leave-days`, body)
}

View File

@@ -0,0 +1,82 @@
import { $fetch } from 'ofetch'
import type { Formation } from './dto/formation'
import { extractItems } from '~/utils/api'
export const listFormations = async (employeeId: number) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{ employee: `/api/employees/${employeeId}` },
{ toast: false }
)
return extractItems<Formation>(data)
}
export const listFormationsByDateRange = async (from: string, to: string) => {
const api = useApi()
const data = await api.get<Formation[] | { 'hydra:member'?: Formation[] }>(
'/formations',
{
'startDate[before]': to,
'endDate[after]': from
},
{ toast: false }
)
return extractItems<Formation>(data)
}
export const createFormation = async (data: {
employeeId: number
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.post<Formation>('/formations', {
employee: `/api/employees/${data.employeeId}`,
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.create',
toastErrorKey: 'errors.formation.create'
})
}
export const updateFormation = async (id: number, data: {
startDate: string
endDate: string
comment?: string
}) => {
const api = useApi()
return api.patch<Formation>(`/formations/${id}`, {
startDate: data.startDate,
endDate: data.endDate,
comment: data.comment
}, {
toastSuccessKey: 'success.formation.update',
toastErrorKey: 'errors.formation.update'
})
}
export const deleteFormation = async (id: number) => {
const api = useApi()
return api.delete(`/formations/${id}`, {}, {
toastSuccessKey: 'success.formation.delete',
toastErrorKey: 'errors.formation.delete'
})
}
export const uploadFormationJustificatif = async (baseURL: string, id: number, file: File) => {
const formData = new FormData()
formData.append('file', file)
return $fetch(`${baseURL}/formations/${id}/justificatif`, {
method: 'POST',
body: formData,
credentials: 'include'
})
}
export const getFormationJustificatifUrl = (baseURL: string, id: number): string => {
return `${baseURL}/formations/${id}/justificatif`
}

View File

@@ -0,0 +1,12 @@
import type { LeaveRecapRow } from './dto/leave-recap'
import { extractItems } from '~/utils/api'
export const fetchLeaveRecap = async (): Promise<LeaveRecapRow[]> => {
const api = useApi()
const data = await api.get<LeaveRecapRow[] | { 'hydra:member'?: LeaveRecapRow[] }>(
'/leave-recap',
{},
{ toastErrorKey: 'errors.leaveRecap.load' }
)
return extractItems<LeaveRecapRow>(data)
}

View File

@@ -17,6 +17,7 @@ export const createUser = async (payload: {
roles: string[]
employeeId?: number | null
isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => {
const api = useApi()
return api.post<User>(
@@ -26,7 +27,8 @@ export const createUser = async (payload: {
plainPassword: payload.plainPassword,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
},
{
toastSuccessKey: 'success.user.create',
@@ -41,13 +43,15 @@ export const updateUser = async (id: number, payload: {
roles: string[]
employeeId?: number | null
isLocked?: boolean
hasLeaveRecapAccess?: boolean
}) => {
const api = useApi()
const body: Record<string, unknown> = {
username: payload.username,
roles: payload.roles,
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
isLocked: payload.isLocked ?? false
isLocked: payload.isLocked ?? false,
hasLeaveRecapAccess: payload.hasLeaveRecapAccess ?? false
}
if (payload.plainPassword) {

View File

@@ -0,0 +1,21 @@
export type DocAccessLevel = 'employee' | 'site_manager' | 'admin'
export interface DocBlock {
type: 'paragraph' | 'list' | 'note'
content: string
}
export interface DocArticle {
id: string
title: string
requiredLevel: DocAccessLevel
blocks: DocBlock[]
}
export interface DocSection {
id: string
title: string
requiredLevel: DocAccessLevel
icon: string
articles: DocArticle[]
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260402064647 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add paid_leave_days column to employee_leave_balances for forfait N-1 leave payment';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_leave_balances ADD paid_leave_days DOUBLE PRECISION DEFAULT 0 NOT NULL');
$this->addSql("COMMENT ON COLUMN employee_leave_balances.paid_leave_days IS 'Jours de conges N-1 payes par la RH (forfait uniquement).'");
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE employee_leave_balances DROP paid_leave_days');
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260413120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create formations table';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE formations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, comment TEXT DEFAULT NULL, justificatif_path VARCHAR(255) DEFAULT NULL, justificatif_name VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_FORMATION_EMPLOYEE ON formations (employee_id)');
$this->addSql('ALTER TABLE formations ADD CONSTRAINT FK_FORMATION_EMPLOYEE FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE formations DROP CONSTRAINT FK_FORMATION_EMPLOYEE');
$this->addSql('DROP TABLE formations');
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260414100000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add has_leave_recap_access flag on users';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD has_leave_recap_access BOOLEAN DEFAULT FALSE NOT NULL');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP has_leave_recap_access');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\EmployeeLeaveRecapProvider;
#[ApiResource(
operations: [
new GetCollection(
uriTemplate: '/leave-recap',
paginationEnabled: false,
security: "is_granted('ROLE_USER')",
provider: EmployeeLeaveRecapProvider::class,
),
],
)]
final class EmployeeLeaveRecap
{
public int $employeeId = 0;
public string $lastName = '';
public string $firstName = '';
public ?int $siteId = null;
public ?string $siteName = null;
public ?string $siteColor = null;
public ?string $contractName = null;
public float $cpN1Remaining = 0.0;
public string $cpN = '-';
public string $acquiredSaturdays = '-';
public string $rtt = '-';
public string $cutoffDate = '';
}

View File

@@ -34,6 +34,7 @@ final class EmployeeLeaveSummary
public float $previousYearAcquiredDays = 0.0;
public float $previousYearTakenDays = 0.0;
public float $previousYearRemainingDays = 0.0;
public float $previousYearPaidDays = 0.0;
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = [];

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\ApiResource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\State\EmployeePaidLeaveDaysProcessor;
use App\State\EmployeePaidLeaveDaysProvider;
#[ApiResource(
operations: [
new Patch(
uriTemplate: '/employees/{id}/paid-leave-days',
security: "is_granted('ROLE_ADMIN')",
provider: EmployeePaidLeaveDaysProvider::class,
processor: EmployeePaidLeaveDaysProcessor::class
),
],
paginationEnabled: false
)]
final class EmployeePaidLeaveDaysInput
{
public float $paidLeaveDays = 0.0;
public ?int $year = null;
}

View File

@@ -6,6 +6,9 @@ namespace App\Dto\Rtt;
final class WeekRecoveryDetail
{
/**
* @param array<string, int> $dailyMinutes date (Y-m-d) => worked minutes
*/
public function __construct(
public int $overtimeMinutes = 0,
public int $base25Minutes = 0,
@@ -13,5 +16,6 @@ final class WeekRecoveryDetail
public int $base50Minutes = 0,
public int $bonus50Minutes = 0,
public int $totalMinutes = 0,
public array $dailyMinutes = [],
) {}
}

View File

@@ -17,8 +17,16 @@ final class DayContextRow
public int $creditedMinutes = 0,
public float $creditedPresenceUnits = 0.0,
public bool $isDriverContract = false,
public bool $hasFormation = false,
public ?string $formationLabel = null,
) {}
public function setFormation(string $label): void
{
$this->hasFormation = true;
$this->formationLabel = $label;
}
public function addAbsence(
?string $label,
?string $color,
@@ -64,7 +72,10 @@ final class DayContextRow
* absentMorning:bool,
* absentAfternoon:bool,
* creditedMinutes:int,
* creditedPresenceUnits:float
* creditedPresenceUnits:float,
* isDriverContract:bool,
* hasFormation:bool,
* formationLabel:?string
* }
*/
public function toArray(): array
@@ -80,6 +91,8 @@ final class DayContextRow
'creditedMinutes' => $this->creditedMinutes,
'creditedPresenceUnits' => $this->creditedPresenceUnits,
'isDriverContract' => $this->isDriverContract,
'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel,
];
}

View File

@@ -57,6 +57,9 @@ class EmployeeLeaveBalance
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de fractionnement saisis par la RH.'])]
private float $fractionedDays = 0.0;
#[ORM\Column(type: 'float', options: ['default' => 0, 'comment' => 'Jours de conges N-1 payes par la RH (forfait uniquement).'])]
private float $paidLeaveDays = 0.0;
#[ORM\Column(type: 'boolean', options: ['default' => false, 'comment' => 'Indique si le solde de l exercice est fige (verrouille RH).'])]
private bool $isLocked = false;
@@ -222,6 +225,18 @@ class EmployeeLeaveBalance
return $this;
}
public function getPaidLeaveDays(): float
{
return $this->paidLeaveDays;
}
public function setPaidLeaveDays(float $paidLeaveDays): self
{
$this->paidLeaveDays = $paidLeaveDays;
return $this;
}
public function isLocked(): bool
{
return $this->isLocked;

192
src/Entity/Formation.php Normal file
View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Entity;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Metadata\ApiFilter;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Repository\FormationRepository;
use App\State\FormationDeleteProcessor;
use App\State\FormationJustificatifDownloadProvider;
use App\State\FormationJustificatifUploadProcessor;
use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource(
operations: [
new Get(
security: "is_granted('ROLE_ADMIN')"
),
new GetCollection(
security: "is_granted('ROLE_ADMIN')"
),
new Post(
security: "is_granted('ROLE_ADMIN')"
),
new Patch(
security: "is_granted('ROLE_ADMIN')"
),
new Delete(
security: "is_granted('ROLE_ADMIN')",
processor: FormationDeleteProcessor::class,
),
new Post(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
deserialize: false,
processor: FormationJustificatifUploadProcessor::class,
),
new Get(
uriTemplate: '/formations/{id}/justificatif',
security: "is_granted('ROLE_ADMIN')",
provider: FormationJustificatifDownloadProvider::class,
),
],
normalizationContext: [
'groups' => ['formation:read', 'employee:read'],
'datetime_format' => 'Y-m-d',
],
denormalizationContext: [
'groups' => ['formation:write'],
'datetime_format' => 'Y-m-d',
],
order: ['startDate' => 'DESC'],
paginationEnabled: false,
)]
#[ApiFilter(DateFilter::class, properties: ['startDate', 'endDate'])]
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
#[ORM\Entity(repositoryClass: FormationRepository::class)]
#[ORM\Table(name: 'formations')]
class Formation
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
#[Groups(['formation:read'])]
private ?int $id = null;
#[ORM\ManyToOne(targetEntity: Employee::class)]
#[ORM\JoinColumn(nullable: false)]
#[Groups(['formation:read', 'formation:write'])]
private ?Employee $employee = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $startDate = null;
#[ORM\Column(type: 'date_immutable')]
#[Groups(['formation:read', 'formation:write'])]
private ?DateTimeImmutable $endDate = null;
#[ORM\Column(type: 'text', nullable: true)]
#[Groups(['formation:read', 'formation:write'])]
private ?string $comment = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifPath = null;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
#[Groups(['formation:read'])]
private ?string $justificatifName = null;
#[ORM\Column(type: 'datetime_immutable')]
#[Groups(['formation:read'])]
private DateTimeImmutable $createdAt;
public function __construct()
{
$this->createdAt = new DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getEmployee(): ?Employee
{
return $this->employee;
}
public function setEmployee(?Employee $employee): self
{
$this->employee = $employee;
return $this;
}
public function getStartDate(): ?DateTimeImmutable
{
return $this->startDate;
}
public function setStartDate(?DateTimeImmutable $startDate): self
{
$this->startDate = $startDate;
return $this;
}
public function getEndDate(): ?DateTimeImmutable
{
return $this->endDate;
}
public function setEndDate(?DateTimeImmutable $endDate): self
{
$this->endDate = $endDate;
return $this;
}
public function getComment(): ?string
{
return $this->comment;
}
public function setComment(?string $comment): self
{
$this->comment = $comment;
return $this;
}
public function getJustificatifPath(): ?string
{
return $this->justificatifPath;
}
public function setJustificatifPath(?string $justificatifPath): self
{
$this->justificatifPath = $justificatifPath;
return $this;
}
public function getJustificatifName(): ?string
{
return $this->justificatifName;
}
public function setJustificatifName(?string $justificatifName): self
{
$this->justificatifName = $justificatifName;
return $this;
}
public function getCreatedAt(): DateTimeImmutable
{
return $this->createdAt;
}
}

View File

@@ -90,6 +90,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[SerializedName('isLocked')]
private bool $isLocked = false;
#[ORM\Column(type: 'boolean', options: ['default' => false])]
#[Groups(['user:write'])]
#[SerializedName('hasLeaveRecapAccess')]
private bool $hasLeaveRecapAccess = false;
/**
* @var Collection<int, UserSiteRole>
*/
@@ -224,6 +229,20 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
#[Groups(['user:read'])]
#[SerializedName('hasLeaveRecapAccess')]
public function hasLeaveRecapAccess(): bool
{
return $this->hasLeaveRecapAccess;
}
public function setHasLeaveRecapAccess(bool $hasLeaveRecapAccess): self
{
$this->hasLeaveRecapAccess = $hasLeaveRecapAccess;
return $this;
}
#[Groups(['user:read'])]
public function getIsDriver(): bool
{

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Repository\Contract;
use App\Entity\Employee;
use App\Entity\Formation;
use DateTimeImmutable;
interface FormationReadRepositoryInterface
{
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array;
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array;
}

View File

@@ -0,0 +1,74 @@
<?php
declare(strict_types=1);
namespace App\Repository;
use App\Entity\Employee;
use App\Entity\Formation;
use App\Repository\Contract\FormationReadRepositoryInterface;
use DateTimeImmutable;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Formation>
*/
final class FormationRepository extends ServiceEntityRepository implements FormationReadRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Formation::class);
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateAndEmployees(DateTimeImmutable $date, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :date')
->andWhere('f.endDate >= :date')
->andWhere('f.employee IN (:employees)')
->setParameter('date', $date)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
/**
* @param list<Employee> $employees
*
* @return list<Formation>
*/
public function findByDateRangeAndEmployees(DateTimeImmutable $from, DateTimeImmutable $to, array $employees): array
{
if ([] === $employees) {
return [];
}
$qb = $this->createQueryBuilder('f')
->leftJoin('f.employee', 'e')
->addSelect('e')
->andWhere('f.startDate <= :to')
->andWhere('f.endDate >= :from')
->andWhere('f.employee IN (:employees)')
->setParameter('from', $from)
->setParameter('to', $to)
->setParameter('employees', $employees)
;
// @var list<Formation>
return $qb->getQuery()->getResult();
}
}

View File

@@ -0,0 +1,180 @@
<?php
declare(strict_types=1);
namespace App\Service\Leave;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\State\EmployeeLeaveSummaryProvider;
use DateTimeImmutable;
use Throwable;
final readonly class LeaveRecapRowBuilder
{
public function __construct(
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private WorkHourRepository $workHourRepository,
) {}
/**
* Builds a leave recap row for one employee.
*
* - $asOfDate = null → live behavior (identical to legacy PDF export): accrual capped at
* previous month end, ALL booked absences counted (incl. future ones), RTT uses today
* - $asOfDate = non-null → frozen snapshot at that date: accrual capped at the previous
* month end before asOfDate, absences after asOfDate excluded, RTT uses asOfDate
*
* @return array{
* lastName: string,
* firstName: string,
* contractName: ?string,
* cpN1Remaining: float|string,
* cpN: string,
* acquiredSaturdays: string,
* rtt: string
* }
*/
public function build(Employee $employee, ?DateTimeImmutable $asOfDate = null): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$rttReference = $asOfDate ?? new DateTimeImmutable('today');
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, 0.0, $asOfDate);
if (null !== $yearSummary) {
if ($isForfait) {
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
if ($paidLeaveDays > 0.0) {
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays, $asOfDate);
if (null !== $recomputed) {
$yearSummary = $recomputed;
}
}
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $rttReference));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $reference): int
{
$month = (int) $reference->format('n');
$year = (int) $reference->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $reference->format('N');
$limitDate = 7 === $isoDay ? $reference : $reference->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $reference->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $reference);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -17,11 +17,22 @@ use Throwable;
final readonly class PublicHolidayService implements PublicHolidayServiceInterface
{
/**
* @var list<string>
*/
private array $excludedLabels;
public function __construct(
private HttpClientInterface $client,
private string $holidayUrl,
private CacheInterface $cache,
) {}
string $excludedLabels = '',
) {
$this->excludedLabels = array_values(array_filter(
array_map('trim', explode(',', $excludedLabels)),
static fn (string $label): bool => '' !== $label,
));
}
/**
* @throws TransportExceptionInterface
@@ -35,7 +46,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$zone = strtolower(trim($zone));
$key = "public_holidays_{$zone}_all";
return $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}.json";
@@ -56,6 +67,8 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
@@ -70,7 +83,7 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
$years = trim($years);
$key = "public_holidays_{$zone}_{$years}";
return $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
$holidays = $this->cache->get($key, function (ItemInterface $item) use ($zone, $years): array {
$item->expiresAfter(30 * 86400);
$url = $this->holidayUrl."{$zone}/{$years}.json";
@@ -88,5 +101,24 @@ final readonly class PublicHolidayService implements PublicHolidayServiceInterfa
return json_decode($response->getContent(), true);
});
return $this->applyExclusions($holidays);
}
/**
* @param array<string, string> $holidays
*
* @return array<string, string>
*/
private function applyExclusions(array $holidays): array
{
if ([] === $this->excludedLabels) {
return $holidays;
}
return array_filter(
$holidays,
fn (string $label): bool => !in_array($label, $this->excludedLabels, true),
);
}
}

View File

@@ -46,7 +46,7 @@ final readonly class RttRecoveryComputationService
}
/**
* @return list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
* @return list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}>
*/
public function buildWeeksForExercise(DateTimeImmutable $from, DateTimeImmutable $to): array
{
@@ -61,10 +61,7 @@ final readonly class RttRecoveryComputationService
$effectiveEnd = $end > $to ? $to : $end;
if ($effectiveEnd >= $effectiveStart) {
$saturday = $start->modify('+5 days');
$monthAnchor = $saturday < $from ? $from : ($saturday > $to ? $to : $saturday);
$weeks[] = [
'month' => (int) $monthAnchor->format('n'),
$weeks[] = [
'weekNumber' => (int) $effectiveStart->format('W'),
'start' => $start,
'end' => $end,
@@ -82,7 +79,6 @@ final readonly class RttRecoveryComputationService
$weeks = $this->buildWeeksForExercise($from, $to);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
@@ -108,7 +104,7 @@ final readonly class RttRecoveryComputationService
}
/**
* @param list<array{month:int,weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weeks
*
* @return array<string, WeekRecoveryDetail>
*/
@@ -189,6 +185,7 @@ final readonly class RttRecoveryComputationService
}
$weeklyTotalMinutes = 0;
$dailyWorkedMinutes = [];
$employeeContractsByDate = [];
foreach ($weekDays as $date) {
$employeeContractsByDate[$date] = $contractsByDate[$employeeId][$date] ?? null;
@@ -198,6 +195,7 @@ final readonly class RttRecoveryComputationService
$metrics = $metricsByDate[$date] ?? new WorkMetrics();
$metrics->addCreditedMinutes($creditedByDate[$date] ?? 0);
$weeklyTotalMinutes += $metrics->totalMinutes;
$dailyWorkedMinutes[$date] = $metrics->totalMinutes;
}
if ([] === $weekDays) {
@@ -244,6 +242,7 @@ final readonly class RttRecoveryComputationService
base50Minutes: $base50,
bonus50Minutes: $bonus50,
totalMinutes: $totalMinutes,
dailyMinutes: $dailyWorkedMinutes,
);
}

View File

@@ -6,9 +6,11 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use App\Enum\ContractNature;
use App\Enum\HalfDay;
use App\Repository\AbsenceRepository;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Repository\EmployeeRepository;
use App\Service\PublicHolidayServiceInterface;
use DateInterval;
@@ -30,6 +32,7 @@ class AbsencePrintProvider implements ProviderInterface
private readonly RequestStack $requestStack,
private EmployeeRepository $employeeRepository,
private AbsenceRepository $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private PublicHolidayServiceInterface $publicHolidayService,
) {}
@@ -58,24 +61,27 @@ class AbsencePrintProvider implements ProviderInterface
$workContractIds = $this->parseIds($request->query->get('workContracts'));
$contractNatures = $this->parseContractNatures($request->query->get('contractNatures'));
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$employees = $this->loadEmployees($siteIds, $contractNatures, $workContractIds);
$absences = $this->loadAbsences($fromDate, $toDate, $employees);
$formations = $this->formationRepository->findByDateRangeAndEmployees($fromDate, $toDate, $employees);
$days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$days = $this->buildDays($fromDate, $toDate);
$absenceMap = $this->buildAbsenceMap($absences, $fromDate, $toDate);
$formationMap = $this->buildFormationMap($formations, $fromDate, $toDate);
$holidayMap = $this->buildHolidayMap($fromDate, $toDate);
$options = new Options();
$options->set('isRemoteEnabled', true);
$dompdf = new Dompdf($options);
$html = $this->twig->render('absence/print.html.twig', [
'from' => $fromDate,
'to' => $toDate,
'days' => $days,
'employees' => $employees,
'absenceMap' => $absenceMap,
'holidayMap' => $holidayMap,
'from' => $fromDate,
'to' => $toDate,
'days' => $days,
'employees' => $employees,
'absenceMap' => $absenceMap,
'formationMap' => $formationMap,
'holidayMap' => $holidayMap,
]);
$dompdf->loadHtml($html);
@@ -203,6 +209,37 @@ class AbsencePrintProvider implements ProviderInterface
return $map;
}
/**
* @param list<Formation> $formations
*
* @return array<int, array<string, bool>>
*/
private function buildFormationMap(array $formations, DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId) {
continue;
}
$formationStart = DateTimeImmutable::createFromInterface($formation->getStartDate());
$formationEnd = DateTimeImmutable::createFromInterface($formation->getEndDate());
$start = max($formationStart, $from);
$end = min($formationEnd, $to);
$current = $start;
while ($current <= $end) {
$map[$employeeId][$current->format('Y-m-d')] = true;
$current = $current->add(new DateInterval('P1D'));
}
}
return $map;
}
private function buildHolidayMap(DateTimeImmutable $from, DateTimeImmutable $to): array
{
$map = [];

View File

@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Repository\AuditLogRepository;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -59,7 +60,7 @@ class AuditLogProvider implements ProviderInterface
'description' => $log->getDescription(),
'changes' => $log->getChanges(),
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
'createdAt' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
'createdAt' => $log->getCreatedAt()->setTimezone(new DateTimeZone('Europe/Paris'))->format('Y-m-d H:i:s'),
];
}

View File

@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeeLeaveRecap;
use App\Entity\Employee;
use App\Entity\User;
use App\Repository\EmployeeRepository;
use App\Security\EmployeeScopeService;
use App\Service\Leave\LeaveRecapRowBuilder;
use App\Util\LeaveRecapCutoff;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
final readonly class EmployeeLeaveRecapProvider implements ProviderInterface
{
public function __construct(
private Security $security,
private EmployeeRepository $employeeRepository,
private EmployeeScopeService $employeeScopeService,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
) {}
/**
* @return list<EmployeeLeaveRecap>
*/
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
{
$user = $this->security->getUser();
if (!$user instanceof User) {
throw new AccessDeniedHttpException('Authentication required.');
}
if (!$user->hasLeaveRecapAccess()) {
throw new AccessDeniedHttpException('Leave recap access not granted.');
}
$cutoff = LeaveRecapCutoff::resolveCutoff(new DateTimeImmutable('today'));
$cutoffYmd = $cutoff->format('Y-m-d');
$employees = $this->resolveScopedEmployees($user);
$rows = [];
foreach ($employees as $employee) {
if (!$employee->getHasActiveContract()) {
continue;
}
$row = $this->rowBuilder->build($employee, $cutoff);
$resource = new EmployeeLeaveRecap();
$resource->employeeId = (int) $employee->getId();
$resource->lastName = $row['lastName'] ?? '';
$resource->firstName = $row['firstName'] ?? '';
$site = $employee->getSite();
$resource->siteId = $site?->getId();
$resource->siteName = $site?->getName();
$resource->siteColor = $site?->getColor();
$resource->contractName = $row['contractName'] ?? null;
$resource->cpN1Remaining = is_numeric($row['cpN1Remaining']) ? (float) $row['cpN1Remaining'] : 0.0;
$resource->cpN = (string) $row['cpN'];
$resource->acquiredSaturdays = (string) $row['acquiredSaturdays'];
$resource->rtt = (string) $row['rtt'];
$resource->cutoffDate = $cutoffYmd;
$rows[] = $resource;
$this->entityManager->clear();
}
usort($rows, static function (EmployeeLeaveRecap $a, EmployeeLeaveRecap $b): int {
$siteCmp = strcmp((string) ($a->siteName ?? 'zzz'), (string) ($b->siteName ?? 'zzz'));
if (0 !== $siteCmp) {
return $siteCmp;
}
$lastCmp = strcmp($a->lastName, $b->lastName);
if (0 !== $lastCmp) {
return $lastCmp;
}
return strcmp($a->firstName, $b->firstName);
});
return $rows;
}
/**
* @return list<Employee>
*/
private function resolveScopedEmployees(User $user): array
{
if (in_array('ROLE_ADMIN', $user->getRoles(), true)) {
return $this->employeeRepository->findForPrintBySiteIds([]);
}
if (in_array('ROLE_SELF', $user->getRoles(), true)) {
$employee = $user->getEmployee();
return $employee instanceof Employee ? [$employee] : [];
}
$siteIds = $this->employeeScopeService->getAllowedSiteIds($user);
if ([] === $siteIds) {
return [];
}
return $this->employeeRepository->findForPrintBySiteIds($siteIds);
}
}

View File

@@ -93,6 +93,16 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
}
$fractionedDays = $this->resolveFractionedDays($employee, $yearSummary['ruleCode'], $year);
$paidLeaveDays = $this->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $year);
// For forfait contracts, paid days reduce N-1 stock before taken-day attribution.
// Recompute with paidLeaveDays so taken days shift from N-1 to N when N-1 is consumed by payment.
if ($paidLeaveDays > 0.0) {
$yearSummary = $this->computeYearSummary($employee, $year, $paidLeaveDays);
if (null === $yearSummary) {
return $summary;
}
}
$summary->isSupported = true;
$summary->ruleCode = $yearSummary['ruleCode'];
@@ -107,6 +117,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearAcquiredDays = $yearSummary['previousYearAcquiredDays'];
$summary->previousYearTakenDays = $yearSummary['previousYearTakenDays'];
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays;
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
@@ -129,7 +140,7 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* previousYearRemainingDays: float
* }
*/
public function computeYearSummary(Employee $employee, int $targetYear): ?array
public function computeYearSummary(Employee $employee, int $targetYear, float $paidLeaveDays = 0.0, ?DateTimeImmutable $asOfDate = null): ?array
{
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
if ($targetYear < $firstYear) {
@@ -185,8 +196,9 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$carrySaturdays = 0.0;
}
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee);
$effectiveAsOfDate = ($year === $targetYear) ? $asOfDate : null;
$accrualCalculationEnd = $this->resolveAccrualCalculationEndDate($leavePolicy['ruleCode'], $year, $to, $employee, $effectiveAsOfDate);
$takenCalculationEnd = $this->resolveTakenCalculationEndDate($to, $employee, $effectiveAsOfDate);
$suspensions = $this->suspensionDaysCalculator->applyFirstMonthGrace(
$this->resolveSuspensionsForPeriod($employee, $effectiveFrom, $to)
);
@@ -269,13 +281,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
} else {
// Forfait: no "en cours d'acquisition" counter, all rights are in acquired.
// Suspensions do not impact forfait 218 leave calculation.
// Taken days are first deducted from N-1 carry, then from current year.
$previousYearAcquired = $carryDays;
$takenFromPrevious = min(max(0.0, $previousYearAcquired), $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
// Paid days reduce N-1 stock first, then taken days are attributed to what remains in N-1.
$previousYearAcquired = $carryDays;
$effectivePaidDays = ($year === $targetYear) ? $paidLeaveDays : 0.0;
$availableAfterPayment = max(0.0, $previousYearAcquired - $effectivePaidDays);
$takenFromPrevious = min($availableAfterPayment, $takenDays);
$previousYearTaken = $takenFromPrevious;
$takenFromCurrent = $takenDays - $takenFromPrevious;
$previousYearRemaining = max(0.0, $previousYearAcquired - $takenFromPrevious);
$previousYearRemaining = max(0.0, $availableAfterPayment - $takenFromPrevious);
$acquiredDays = $leavePolicy['acquiredDays'];
$accruingDays = 0.0;
@@ -317,6 +331,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
return $this->resolveCurrentLeaveYear($today);
}
public function resolvePaidLeaveDays(Employee $employee, string $ruleCode, int $year): float
{
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
return null !== $balance ? $balance->getPaidLeaveDays() : 0.0;
}
private function resolveEffectivePeriodStart(
Employee $employee,
DateTimeImmutable $from,
@@ -469,19 +490,20 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
string $ruleCode,
int $year,
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$today = new DateTimeImmutable('today');
$reference = $asOfDate ?? new DateTimeImmutable('today');
$currentYear = LeaveRuleCode::FORFAIT_218->value === $ruleCode
? (int) $today->format('Y')
: $this->resolveCurrentLeaveYear($today);
? (int) $reference->format('Y')
: $this->resolveCurrentLeaveYear($reference);
if ($year < $currentYear) {
$end = $periodEnd;
} elseif ($year > $currentYear) {
$end = null;
} else {
$lastDayPreviousMonth = $today
$lastDayPreviousMonth = $reference
->modify('first day of this month')
->modify('-1 day')
->setTime(0, 0)
@@ -503,10 +525,15 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
private function resolveTakenCalculationEndDate(
DateTimeImmutable $periodEnd,
Employee $employee
Employee $employee,
?DateTimeImmutable $asOfDate = null
): ?DateTimeImmutable {
$end = $periodEnd;
if ($asOfDate instanceof DateTimeImmutable && $asOfDate < $end) {
$end = $asOfDate;
}
// Cap at contract end date if the employee has left.
$contractEndRaw = $employee->getCurrentContractEndDate();
if (null !== $end && null !== $contractEndRaw && '' !== trim($contractEndRaw)) {

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\EmployeePaidLeaveDaysInput;
use App\Entity\Employee;
use App\Entity\EmployeeLeaveBalance;
use App\Enum\ContractType;
use App\Enum\LeaveRuleCode;
use App\Repository\EmployeeLeaveBalanceRepository;
use App\Repository\EmployeeRepository;
use App\Service\AuditLogger;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
final readonly class EmployeePaidLeaveDaysProcessor implements ProcessorInterface
{
public function __construct(
private EmployeeRepository $employeeRepository,
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
private EntityManagerInterface $entityManager,
private AuditLogger $auditLogger,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeePaidLeaveDaysInput
{
if (!$data instanceof EmployeePaidLeaveDaysInput) {
throw new UnprocessableEntityHttpException('Invalid payload.');
}
$employeeId = (int) ($uriVariables['id'] ?? 0);
if ($employeeId <= 0) {
throw new UnprocessableEntityHttpException('id must be a positive integer.');
}
$employee = $this->employeeRepository->find($employeeId);
if (!$employee instanceof Employee) {
throw new NotFoundHttpException('Employee not found.');
}
$year = $data->year ?? $this->resolveCurrentYear($employee);
$ruleCode = $this->resolveRuleCode($employee);
$balance = $this->leaveBalanceRepository->findOneByEmployeeRuleAndYear($employee, $ruleCode, $year);
if (null === $balance) {
$balance = new EmployeeLeaveBalance();
$balance->setEmployee($employee);
$balance->setRuleCode($ruleCode);
$balance->setYear($year);
$this->entityManager->persist($balance);
}
$balance->setPaidLeaveDays($data->paidLeaveDays);
$balance->touch();
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
$this->auditLogger->log(
$employee,
'update',
'paid_leave_days',
$balance->getId(),
sprintf('Congés N-1 payés modifiés pour %s (année %d) : %s', $empName, $year, (string) $data->paidLeaveDays),
['new' => ['paidLeaveDays' => $data->paidLeaveDays, 'year' => $year]],
);
$this->entityManager->flush();
$data->year = $year;
return $data;
}
private function resolveRuleCode(Employee $employee): LeaveRuleCode
{
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return LeaveRuleCode::FORFAIT_218;
}
return LeaveRuleCode::CDI_CDD_NON_FORFAIT;
}
private function resolveCurrentYear(Employee $employee): int
{
$today = new DateTimeImmutable('today');
if (ContractType::FORFAIT === $employee->getContract()?->getType()) {
return (int) $today->format('Y');
}
$month = (int) $today->format('n');
return $month >= 6 ? (int) $today->format('Y') + 1 : (int) $today->format('Y');
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\EmployeePaidLeaveDaysInput;
final readonly class EmployeePaidLeaveDaysProvider implements ProviderInterface
{
public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmployeePaidLeaveDaysInput
{
return new EmployeePaidLeaveDaysInput();
}
}

View File

@@ -71,7 +71,6 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$weeks = $this->rttRecoveryService->buildWeeksForExercise($periodFrom, $periodTo);
$weekRanges = array_map(
static fn (array $week): array => [
'month' => (int) $week['month'],
'weekNumber' => (int) $week['weekNumber'],
'start' => $week['start'],
'end' => $week['end'],
@@ -118,25 +117,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
$summary->rttStartDate = $this->rttStartDate;
}
}
$summary->weeks = array_map(
static function (array $week) use ($currentByWeekStart) {
$detail = $currentByWeekStart[$week['start']->format('Y-m-d')] ?? new WeekRecoveryDetail();
return new EmployeeRttWeekSummary(
month: (int) $week['month'],
weekNumber: (int) $week['weekNumber'],
weekStart: $week['start']->format('Y-m-d'),
weekEnd: $week['end']->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
},
$weekRanges
);
$summary->weeks = $this->buildWeekSummaries($weekRanges, $currentByWeekStart, $periodFrom, $periodTo);
// Post-process: distribute deficit weeks across cumulative balance (50% first, then 25%)
$cumulative50 = $carry->base50Minutes + $carry->bonus50Minutes;
@@ -269,4 +250,88 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
return $weekEnd;
}
/**
* Build week summaries, splitting weeks that span two months into two entries
* with values distributed proportionally based on daily worked minutes.
*
* @param list<array{weekNumber:int,start:DateTimeImmutable,end:DateTimeImmutable}> $weekRanges
* @param array<string, WeekRecoveryDetail> $recoveryByWeek
*
* @return list<EmployeeRttWeekSummary>
*/
private function buildWeekSummaries(array $weekRanges, array $recoveryByWeek, DateTimeImmutable $periodFrom, DateTimeImmutable $periodTo): array
{
$result = [];
foreach ($weekRanges as $week) {
$weekStart = $week['start'];
$weekEnd = $week['end'];
$weekKey = $weekStart->format('Y-m-d');
$detail = $recoveryByWeek[$weekKey] ?? new WeekRecoveryDetail();
$effectiveStart = $weekStart < $periodFrom ? $periodFrom : $weekStart;
$effectiveEnd = $weekEnd > $periodTo ? $periodTo : $weekEnd;
$startMonth = (int) $effectiveStart->format('n');
$endMonth = (int) $effectiveEnd->format('n');
if ($startMonth === $endMonth) {
$result[] = new EmployeeRttWeekSummary(
month: $startMonth,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: $detail->overtimeMinutes,
base25Minutes: $detail->base25Minutes,
bonus25Minutes: $detail->bonus25Minutes,
base50Minutes: $detail->base50Minutes,
bonus50Minutes: $detail->bonus50Minutes,
totalMinutes: $detail->totalMinutes,
);
continue;
}
// Week spans two months — split proportionally by daily worked minutes
$monthMinutes = [];
$monthWeekdays = [];
foreach ($detail->dailyMinutes as $date => $mins) {
$m = (int) new DateTimeImmutable($date)->format('n');
$monthMinutes[$m] = ($monthMinutes[$m] ?? 0) + $mins;
$isoDay = (int) new DateTimeImmutable($date)->format('N');
if ($isoDay < 6) {
$monthWeekdays[$m] = ($monthWeekdays[$m] ?? 0) + 1;
}
}
$totalWorked = array_sum($monthMinutes);
$totalWeekdays = array_sum($monthWeekdays);
foreach ([$startMonth, $endMonth] as $month) {
if ($totalWorked > 0) {
$ratio = ($monthMinutes[$month] ?? 0) / $totalWorked;
} elseif ($totalWeekdays > 0) {
$ratio = ($monthWeekdays[$month] ?? 0) / $totalWeekdays;
} else {
$ratio = 0.0;
}
$result[] = new EmployeeRttWeekSummary(
month: $month,
weekNumber: (int) $week['weekNumber'],
weekStart: $weekStart->format('Y-m-d'),
weekEnd: $weekEnd->format('Y-m-d'),
overtimeMinutes: (int) round($detail->overtimeMinutes * $ratio),
base25Minutes: (int) round($detail->base25Minutes * $ratio),
bonus25Minutes: (int) round($detail->bonus25Minutes * $ratio),
base50Minutes: (int) round($detail->base50Minutes * $ratio),
bonus50Minutes: (int) round($detail->bonus50Minutes * $ratio),
totalMinutes: (int) round($detail->totalMinutes * $ratio),
);
}
}
return $result;
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
final readonly class FormationDeleteProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): null
{
if (!$data instanceof Formation) {
return null;
}
$justificatifPath = $data->getJustificatifPath();
if (null !== $justificatifPath) {
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (file_exists($absolutePath)) {
unlink($absolutePath);
}
}
$this->entityManager->remove($data);
$this->entityManager->flush();
return null;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\HeaderUtils;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
final readonly class FormationJustificatifDownloadProvider implements ProviderInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
{
$formation = $this->entityManager->find(Formation::class, $uriVariables['id']);
if (null === $formation) {
throw new NotFoundHttpException('Formation not found.');
}
$justificatifPath = $formation->getJustificatifPath();
if (null === $justificatifPath) {
throw new NotFoundHttpException('No justificatif found for this formation.');
}
$absolutePath = sprintf('%s/%s', $this->uploadDir, $justificatifPath);
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('Justificatif file not found.');
}
$response = new BinaryFileResponse($absolutePath);
$disposition = HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_ATTACHMENT,
$formation->getJustificatifName() ?? 'justificatif.pdf'
);
$response->headers->set('Content-Disposition', $disposition);
return $response;
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Entity\Formation;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Uid\Uuid;
final readonly class FormationJustificatifUploadProcessor implements ProcessorInterface
{
public function __construct(
private EntityManagerInterface $entityManager,
private RequestStack $requestStack,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadDir,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
{
if (!$data instanceof Formation) {
throw new BadRequestHttpException('Invalid entity.');
}
$request = $this->requestStack->getCurrentRequest();
$file = $request?->files->get('file');
if (null === $file) {
throw new BadRequestHttpException('No file uploaded.');
}
if ('application/pdf' !== $file->getMimeType()) {
throw new BadRequestHttpException('Only PDF files are accepted.');
}
$startDate = $data->getStartDate();
$year = $startDate?->format('Y') ?? date('Y');
$monthNumber = $startDate?->format('m') ?? date('m');
$relativePath = sprintf('formations/%s/%s', $year, $monthNumber);
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
if (!is_dir($absoluteDir)) {
mkdir($absoluteDir, 0o755, true);
}
$filename = Uuid::v4()->toRfc4122().'.pdf';
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
$originalName = $file->getClientOriginalName();
$previousPath = $data->getJustificatifPath();
$file->move($absoluteDir, $filename);
$data->setJustificatifPath($fullRelative);
$data->setJustificatifName($originalName);
$this->entityManager->flush();
if (null !== $previousPath) {
$previousAbsolute = sprintf('%s/%s', $this->uploadDir, $previousPath);
if (file_exists($previousAbsolute)) {
unlink($previousAbsolute);
}
}
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
}
}

View File

@@ -6,21 +6,13 @@ namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\Employee;
use App\Enum\ContractNature;
use App\Enum\ContractType;
use App\Enum\TrackingMode;
use App\Repository\EmployeeRepository;
use App\Repository\EmployeeRttBalanceRepository;
use App\Repository\EmployeeRttPaymentRepository;
use App\Repository\WorkHourRepository;
use App\Service\Rtt\RttRecoveryComputationService;
use App\Service\Leave\LeaveRecapRowBuilder;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Dompdf\Dompdf;
use Dompdf\Options;
use Symfony\Component\HttpFoundation\Response;
use Throwable;
use Twig\Environment;
class LeaveRecapPrintProvider implements ProviderInterface
@@ -28,12 +20,8 @@ class LeaveRecapPrintProvider implements ProviderInterface
public function __construct(
private Environment $twig,
private EmployeeRepository $employeeRepository,
private EmployeeLeaveSummaryProvider $leaveSummaryProvider,
private RttRecoveryComputationService $rttRecoveryService,
private EmployeeRttBalanceRepository $rttBalanceRepository,
private EmployeeRttPaymentRepository $rttPaymentRepository,
private LeaveRecapRowBuilder $rowBuilder,
private EntityManagerInterface $entityManager,
private WorkHourRepository $workHourRepository,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
@@ -59,7 +47,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
];
}
$siteGroups[$siteId]['employees'][] = $this->buildEmployeeRow($employee, $today);
$siteGroups[$siteId]['employees'][] = $this->rowBuilder->build($employee);
$this->entityManager->clear();
}
@@ -84,129 +72,4 @@ class LeaveRecapPrintProvider implements ProviderInterface
'Content-Disposition' => 'inline; filename="'.$filename.'"',
]);
}
private function buildEmployeeRow(Employee $employee, DateTimeImmutable $today): array
{
$contract = $employee->getContract();
$contractName = $contract?->getName();
$isForfait = ContractType::FORFAIT === $contract?->getType();
$nature = ContractNature::tryFrom($employee->getCurrentContractNature());
$isInterim = ContractNature::INTERIM === $nature;
$cpN1Remaining = 0.0;
$cpN = '-';
$acquiredSaturdays = '-';
$rtt = '-';
if (!$isInterim) {
$leaveYear = $this->leaveSummaryProvider->resolveLeaveYearForToday($employee);
$yearSummary = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear);
if (null !== $yearSummary) {
if ($isForfait) {
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-';
} else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2);
$cpN = (string) round($yearSummary['accruingDays'], 2);
$acquiredSaturdays = (string) round($yearSummary['remainingSaturdays'], 2);
}
}
if (!$isForfait && TrackingMode::PRESENCE->value !== $contract?->getTrackingMode()) {
try {
$rtt = $this->formatMinutes($this->computeAvailableRttMinutes($employee, $today));
} catch (Throwable) {
$rtt = '-';
}
}
}
return [
'lastName' => $employee->getLastName(),
'firstName' => $employee->getFirstName(),
'contractName' => $contractName,
'cpN1Remaining' => $cpN1Remaining,
'cpN' => $cpN,
'acquiredSaturdays' => $acquiredSaturdays,
'rtt' => $rtt,
];
}
private function computeAvailableRttMinutes(Employee $employee, DateTimeImmutable $today): int
{
$month = (int) $today->format('n');
$year = (int) $today->format('Y');
$exerciseYear = $month >= 6 ? $year + 1 : $year;
// Exclude incomplete current week: limit to last Sunday
$isoDay = (int) $today->format('N');
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
// Include the current week if all existing days are admin-validated
if (7 !== $isoDay) {
$currentWeekStart = $today->modify('monday this week');
$currentWeekEnd = $currentWeekStart->modify('+6 days');
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
$limitDate = $currentWeekEnd;
}
}
// Carry from previous exercise
$carry = 0;
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
if (null !== $balance) {
$carry = $balance->getTotalOpeningMinutes();
} else {
$previousTotal = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear - 1);
$carry = $previousTotal->totalMinutes;
}
// Current exercise (limited to completed weeks)
$current = $this->rttRecoveryService->computeTotalRecoveryForExercise($employee, $exerciseYear, $limitDate);
// Paid RTT
$paid = 0;
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
foreach ($payments as $payment) {
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
}
return $carry + $current->totalMinutes - $paid;
}
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
{
foreach ($employee->getContractPeriods() as $period) {
if ($period->getStartDate() > $today) {
continue;
}
$endDate = $period->getEndDate();
if (null === $endDate) {
continue;
}
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
return $endDate;
}
}
return $weekEnd;
}
private function formatMinutes(int $minutes): string
{
if (0 === $minutes) {
return '0 h';
}
$sign = $minutes < 0 ? '- ' : '';
$abs = abs($minutes);
$h = intdiv($abs, 60);
$m = $abs % 60;
return 0 === $m ? "{$sign}{$h} h" : "{$sign}{$h} h {$m} m";
}
}

View File

@@ -11,6 +11,7 @@ use App\Dto\WorkHours\DayContextRow;
use App\Entity\User;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
@@ -27,6 +28,7 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
private RequestStack $requestStack,
private EmployeeScopedRepositoryInterface $employeeRepository,
private AbsenceReadRepositoryInterface $absenceRepository,
private FormationReadRepositoryInterface $formationRepository,
private EmployeeContractResolver $contractResolver,
private AbsenceSegmentsResolver $absenceSegmentsResolver,
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
@@ -40,9 +42,10 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
throw new AccessDeniedHttpException('Authentication required.');
}
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$workDate = $this->resolveWorkDate();
$employees = $this->employeeRepository->findScoped($user);
$absences = $this->absenceRepository->findByDateAndEmployees($workDate, $employees);
$formations = $this->formationRepository->findByDateAndEmployees($workDate, $employees);
$rowsByEmployeeId = [];
foreach ($employees as $employee) {
@@ -87,6 +90,14 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
);
}
foreach ($formations as $formation) {
$employeeId = $formation->getEmployee()?->getId();
if (!$employeeId || !isset($rowsByEmployeeId[$employeeId])) {
continue;
}
$rowsByEmployeeId[$employeeId]->setFormation('Formation');
}
$response = new WorkHourDayContext();
$response->workDate = $dateKey;
$response->rows = array_map(

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Util;
use DateTimeImmutable;
/**
* Leave recap cutoff rule: as-of end of ISO week S-2 (Sunday 23:59:59).
*
* Example: Tuesday 2026-04-14 (S16) → Sunday 2026-04-05 23:59:59 (end of S14).
*/
final class LeaveRecapCutoff
{
public static function resolveCutoff(DateTimeImmutable $today): DateTimeImmutable
{
$currentWeekMonday = $today->modify('monday this week')->setTime(0, 0);
$cutoffWeekMonday = $currentWeekMonday->modify('-14 days');
return $cutoffWeekMonday->modify('+6 days')->setTime(23, 59, 59);
}
}

View File

@@ -84,6 +84,11 @@
background: #b3e5fc;
}
.formation {
background: #6366f1;
color: #fff;
}
.body-cell {
height: 6mm;
padding: 0 !important;
@@ -239,11 +244,15 @@
{% for day in days %}
{% set isHoliday = holidayMap[day.date] ?? null %}
{% set info = absenceMap[employee.id][day.date] ?? null %}
{% set hasFormation = formationMap[employee.id][day.date] ?? false %}
{% set isFormationOnly = hasFormation and not info and not isHoliday %}
{% set isMonthEnd = (not loop.last) and (days[loop.index].date|date('n') != day.date|date('n')) %}
{% set isWeekend = day.date|date('N') in [6, 7] %}
<td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}">
<td class="col-day body-cell{% if isMonthEnd %} month-separator{% endif %}{% if isWeekend %} weekend{% endif %}{% if isHoliday %} holiday{% endif %}{% if isFormationOnly %} formation{% endif %}" style="width: {{ dayColWidthMm }}mm;{% if info and not isHoliday and not info.half %} background-color: {{ info.color }};{% endif %}">
{% if isHoliday %}
<span class="full-cell code">Férié</span>
{% elseif isFormationOnly %}
<span class="full-cell code">F</span>
{% elseif info %}
{% if info.half %}
<table class="half-table">
@@ -259,7 +268,7 @@
</tr>
</table>
{% else %}
<span class="full-cell code">{{ info.code }}</span>
<span class="full-cell code">{{ info.code }}{% if hasFormation %}*{% endif %}</span>
{% endif %}
{% endif %}
</td>

View File

@@ -13,6 +13,7 @@ use App\Entity\User;
use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface;
use App\Repository\Contract\FormationReadRepositoryInterface;
use App\Service\Contracts\EmployeeContractResolver;
use App\Service\WorkHours\AbsenceSegmentsResolver;
use App\Service\WorkHours\WorkedHoursCreditPolicy;
@@ -34,14 +35,17 @@ final class WorkHourDayContextProviderTest extends TestCase
private Security $security;
private EmployeeScopedRepositoryInterface $employeeRepository;
private AbsenceReadRepositoryInterface $absenceRepository;
private FormationReadRepositoryInterface $formationRepository;
private RequestStack $requestStack;
protected function setUp(): void
{
$this->security = $this->createStub(Security::class);
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$this->requestStack = new RequestStack();
$this->security = $this->createStub(Security::class);
$this->employeeRepository = $this->createStub(EmployeeScopedRepositoryInterface::class);
$this->absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
$this->formationRepository = $this->createStub(FormationReadRepositoryInterface::class);
$this->formationRepository->method('findByDateAndEmployees')->willReturn([]);
$this->requestStack = new RequestStack();
}
public function testThrowsWhenAnonymous(): void
@@ -53,6 +57,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
@@ -72,6 +77,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())
@@ -97,6 +103,7 @@ final class WorkHourDayContextProviderTest extends TestCase
$this->requestStack,
$this->employeeRepository,
$this->absenceRepository,
$this->formationRepository,
$this->buildResolverStub(),
new AbsenceSegmentsResolver(),
new WorkedHoursCreditPolicy($this->buildResolverStub())