Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11331da6a1 | ||
| 399fd7335e | |||
|
|
46cb7f1a16 | ||
| b934f4d81f | |||
| 77c1cdcbbd | |||
|
|
de302d9ded | ||
| ef18210bf7 | |||
|
|
055d92153b | ||
| 4cd30de3e3 | |||
|
|
b185accdbb | ||
| a4bda53f57 | |||
|
|
c255000a5e | ||
| b8b9368ad0 | |||
|
|
10a0ab0809 | ||
| 055f1187f9 | |||
|
|
f3ed359d3f | ||
| 906c245451 | |||
|
|
100ab340d4 | ||
| 0257e59671 | |||
|
|
f9979c9a19 | ||
| 1091147100 | |||
|
|
fd154a59fb | ||
| 967e3311e5 | |||
|
|
04c5279946 | ||
| b25d40f3d8 | |||
| e654516b82 | |||
|
|
b07146e78d | ||
| b1bf363fa1 | |||
| c13cab6b59 | |||
|
|
3752785ed1 | ||
| ab44b5439d | |||
| 699d09e2f4 | |||
| b62a19513d | |||
|
|
3d69346d24 | ||
| ea849a4fdd | |||
| 7b3dcc3c54 | |||
|
|
c6ab8e3624 | ||
| f3b65c0617 |
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.env
3
.env
@@ -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 ###
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
echo "${{ secrets.RELEASE_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
|
||||
10
.idea/db-forest-config.xml
generated
10
.idea/db-forest-config.xml
generated
@@ -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="---------------------------------------- 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 " />
|
||||
</component>
|
||||
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -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
|
||||
@@ -53,6 +61,17 @@
|
||||
- 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 +103,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
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# SIRH
|
||||
|
||||
Application de gestion des absences employée
|
||||
|
||||
## Importer un dump de prod en dev
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.68'
|
||||
app.version: '0.1.84'
|
||||
|
||||
@@ -23,3 +23,4 @@ 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"
|
||||
|
||||
@@ -67,6 +67,9 @@ 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
|
||||
|
||||
@@ -8,6 +8,9 @@ export SIRH_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying sirh:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
@@ -18,11 +21,14 @@ echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T app php bin/console cache:warmup --env=prod
|
||||
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}"
|
||||
|
||||
50
deploy/maintenance.html
Normal file
50
deploy/maintenance.html
Normal 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">🛠</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>
|
||||
@@ -2,6 +2,23 @@ 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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Pre-requis
|
||||
|
||||
Installer Docker et Docker Compose sur la machine :
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu
|
||||
@@ -18,72 +18,267 @@ sudo usermod -aG docker $USER
|
||||
|
||||
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||
|
||||
## Premier deploiement
|
||||
### Nginx
|
||||
|
||||
### 1. Creer le dossier de deploiement
|
||||
```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
|
||||
```
|
||||
|
||||
### 2. Copier les fichiers depuis le repo
|
||||
|
||||
```bash
|
||||
cp deploy/docker/docker-compose.prod.yml /var/www/sirh/docker-compose.yml
|
||||
cp deploy/docker/deploy.sh /var/www/sirh/deploy.sh
|
||||
cp deploy/docker/.env.example /var/www/sirh/.env
|
||||
chmod +x /var/www/sirh/deploy.sh
|
||||
```
|
||||
|
||||
### 3. Configurer l'environnement
|
||||
|
||||
Editer `/var/www/sirh/.env` avec les vraies valeurs :
|
||||
- `APP_SECRET` : generer avec `openssl rand -hex 32`
|
||||
- `DATABASE_URL` : `postgresql://user:pass@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8`
|
||||
- `JWT_PASSPHRASE` : generer avec `openssl rand -hex 32`
|
||||
|
||||
### 4. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
mkdir -p config/jwt
|
||||
docker run --rm -v $(pwd)/config/jwt:/jwt php:8.4-cli bash -c \
|
||||
"apt-get update -qq && apt-get install -y -qq openssl > /dev/null && \
|
||||
openssl genpkey -algorithm RSA -out /jwt/private.pem -pkeyopt rsa_keygen_bits:4096 && \
|
||||
openssl rsa -pubout -in /jwt/private.pem -out /jwt/public.pem"
|
||||
```
|
||||
|
||||
### 5. Creer le dossier uploads
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/sirh/uploads
|
||||
```
|
||||
|
||||
### 6. Configurer le login au registry Gitea
|
||||
### 3. Se connecter au registry Docker de Gitea
|
||||
|
||||
```bash
|
||||
docker login gitea.malio.fr
|
||||
# Username: ton user Gitea
|
||||
# Password: ton token Gitea
|
||||
```
|
||||
|
||||
### 7. Configurer Nginx systeme
|
||||
- **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
|
||||
sudo cp deploy/nginx/sirh-docker.conf /etc/nginx/sites-available/sirh.conf
|
||||
#!/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
|
||||
```
|
||||
|
||||
### 8. Deployer
|
||||
### 9. Deployer
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## Deployer une release
|
||||
### 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
|
||||
@@ -91,6 +286,27 @@ cd /var/www/sirh
|
||||
./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)
|
||||
@@ -103,33 +319,45 @@ cd /var/www/sirh
|
||||
|
||||
```bash
|
||||
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||
docker compose exec -T app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||
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
|
||||
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 :
|
||||
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. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||
5. Mettre a jour Nginx systeme : remplacer la conf par `deploy/nginx/sirh-docker.conf`
|
||||
6. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||
7. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
|
||||
8. Verifier que tout marche, puis supprimer l'ancien dossier
|
||||
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
58
doc/formations.md
Normal 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 (`*`)
|
||||
@@ -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%`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
26
frontend/components/documentation/DocumentationArticle.vue
Normal file
26
frontend/components/documentation/DocumentationArticle.vue
Normal 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>
|
||||
67
frontend/components/documentation/DocumentationPage.vue
Normal file
67
frontend/components/documentation/DocumentationPage.vue
Normal 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>
|
||||
23
frontend/components/documentation/DocumentationSection.vue
Normal file
23
frontend/components/documentation/DocumentationSection.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
251
frontend/components/employees/FormationTab.vue
Normal file
251
frontend/components/employees/FormationTab.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
39
frontend/composables/useDocumentation.ts
Normal file
39
frontend/composables/useDocumentation.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
73
frontend/composables/useEmployeeFormation.ts
Normal file
73
frontend/composables/useEmployeeFormation.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
580
frontend/data/documentation-content.ts
Normal file
580
frontend/data/documentation-content.ts
Normal file
@@ -0,0 +1,580 @@
|
||||
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:00–21:00), heures de nuit (00:00–06:00 et 21:00–24: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:00–21:00\nNuit : total des heures dans les plages 00:00–06:00 et 21:00–24: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: '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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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.",
|
||||
@@ -88,6 +93,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.",
|
||||
|
||||
@@ -95,6 +95,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">
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
9
frontend/pages/documentation.vue
Normal file
9
frontend/pages/documentation.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<DocumentationPage/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
useHead({
|
||||
title: 'Documentation',
|
||||
})
|
||||
</script>
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type EmployeeLeaveSummary = {
|
||||
previousYearAcquiredDays: number
|
||||
previousYearTakenDays: number
|
||||
previousYearRemainingDays: number
|
||||
previousYearPaidDays: number
|
||||
presenceDaysByMonth: Record<string, number>
|
||||
}
|
||||
|
||||
|
||||
12
frontend/services/dto/formation.ts
Normal file
12
frontend/services/dto/formation.ts
Normal 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
|
||||
}
|
||||
@@ -106,6 +106,8 @@ export type WorkHourDayContextRow = {
|
||||
creditedMinutes: number
|
||||
creditedPresenceUnits: number
|
||||
isDriverContract?: boolean
|
||||
hasFormation?: boolean
|
||||
formationLabel?: string | null
|
||||
}
|
||||
|
||||
export type WorkHourDayContext = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
82
frontend/services/formations.ts
Normal file
82
frontend/services/formations.ts
Normal 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`
|
||||
}
|
||||
21
frontend/types/documentation.ts
Normal file
21
frontend/types/documentation.ts
Normal 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[]
|
||||
}
|
||||
27
migrations/Version20260402064647.php
Normal file
27
migrations/Version20260402064647.php
Normal 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');
|
||||
}
|
||||
}
|
||||
29
migrations/Version20260413120000.php
Normal file
29
migrations/Version20260413120000.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
27
src/ApiResource/EmployeePaidLeaveDaysInput.php
Normal file
27
src/ApiResource/EmployeePaidLeaveDaysInput.php
Normal 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;
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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
192
src/Entity/Formation.php
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/Repository/Contract/FormationReadRepositoryInterface.php
Normal file
26
src/Repository/Contract/FormationReadRepositoryInterface.php
Normal 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;
|
||||
}
|
||||
74
src/Repository/FormationRepository.php
Normal file
74
src/Repository/FormationRepository.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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): ?array
|
||||
{
|
||||
$firstYear = max($this->resolveFirstComputationYear($employee), $targetYear - 1);
|
||||
if ($targetYear < $firstYear) {
|
||||
@@ -269,13 +280,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 +330,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,
|
||||
|
||||
101
src/State/EmployeePaidLeaveDaysProcessor.php
Normal file
101
src/State/EmployeePaidLeaveDaysProcessor.php
Normal 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');
|
||||
}
|
||||
}
|
||||
17
src/State/EmployeePaidLeaveDaysProvider.php
Normal file
17
src/State/EmployeePaidLeaveDaysProvider.php
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
42
src/State/FormationDeleteProcessor.php
Normal file
42
src/State/FormationDeleteProcessor.php
Normal 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;
|
||||
}
|
||||
}
|
||||
53
src/State/FormationJustificatifDownloadProvider.php
Normal file
53
src/State/FormationJustificatifDownloadProvider.php
Normal 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;
|
||||
}
|
||||
}
|
||||
75
src/State/FormationJustificatifUploadProcessor.php
Normal file
75
src/State/FormationJustificatifUploadProcessor.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,13 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
||||
|
||||
if (null !== $yearSummary) {
|
||||
if ($isForfait) {
|
||||
$paidLeaveDays = $this->leaveSummaryProvider->resolvePaidLeaveDays($employee, $yearSummary['ruleCode'], $leaveYear);
|
||||
if ($paidLeaveDays > 0.0) {
|
||||
$recomputed = $this->leaveSummaryProvider->computeYearSummary($employee, $leaveYear, $paidLeaveDays);
|
||||
if (null !== $recomputed) {
|
||||
$yearSummary = $recomputed;
|
||||
}
|
||||
}
|
||||
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
|
||||
$cpN = (string) round($yearSummary['acquiredDays'], 2);
|
||||
$acquiredSaturdays = '-';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user