Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b185accdbb | ||
| a4bda53f57 | |||
|
|
c255000a5e | ||
| b8b9368ad0 | |||
|
|
10a0ab0809 | ||
| 055f1187f9 | |||
|
|
f3ed359d3f | ||
| 906c245451 | |||
|
|
100ab340d4 | ||
| 0257e59671 | |||
|
|
f9979c9a19 | ||
| 1091147100 | |||
|
|
fd154a59fb | ||
| 967e3311e5 | |||
|
|
04c5279946 | ||
| b25d40f3d8 | |||
| e654516b82 | |||
|
|
b07146e78d | ||
| b1bf363fa1 | |||
| c13cab6b59 | |||
|
|
3752785ed1 | ||
| ab44b5439d | |||
| 699d09e2f4 | |||
| b62a19513d |
@@ -26,7 +26,8 @@
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
"Bash(git commit:*)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
11
CLAUDE.md
11
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
|
||||
@@ -84,6 +85,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,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.70'
|
||||
app.version: '0.1.80'
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -245,6 +245,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 +275,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
|
||||
|
||||
|
||||
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>
|
||||
@@ -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>
|
||||
|
||||
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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
568
frontend/data/documentation-content.ts
Normal file
568
frontend/data/documentation-content.ts
Normal file
@@ -0,0 +1,568 @@
|
||||
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.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: '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' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -148,6 +148,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">
|
||||
@@ -259,6 +260,7 @@ const {
|
||||
submitContractUpdate,
|
||||
submitCreateContract,
|
||||
submitFractionedDays,
|
||||
submitPaidLeaveDays,
|
||||
submitRttPayment,
|
||||
suspensionForms,
|
||||
isSuspensionSubmitting,
|
||||
|
||||
@@ -13,6 +13,7 @@ export type EmployeeLeaveSummary = {
|
||||
previousYearAcquiredDays: number
|
||||
previousYearTakenDays: number
|
||||
previousYearRemainingDays: number
|
||||
previousYearPaidDays: number
|
||||
presenceDaysByMonth: Record<string, number>
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -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 = [],
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -765,6 +778,13 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
return null !== $balance ? $balance->getFractionedDays() : 0.0;
|
||||
}
|
||||
|
||||
private 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 resolveCurrentLeaveYear(DateTimeImmutable $today): int
|
||||
{
|
||||
$year = (int) $today->format('Y');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user