Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | |||
|
|
15ce234737 | ||
| caffb74cbf | |||
|
|
54354c4435 | ||
| 3dcdf0fb81 | |||
|
|
1a71ff6834 | ||
| 057d6bf06f | |||
|
|
e74a264b37 | ||
| 60bb3cf8c4 | |||
|
|
1a485e8780 | ||
| 5c6d42c729 | |||
|
|
3c434d20b2 | ||
| bbb020025a | |||
|
|
640bb42d3a | ||
| 50712ccb00 | |||
| 265b19a9d0 | |||
|
|
13743738fd | ||
| 085fe0c150 | |||
|
|
a1110069b5 | ||
| 4901c58ebf | |||
| 4de891579c | |||
|
|
a17d6a67cf | ||
| 29db3b5025 | |||
|
|
6df9110187 | ||
| f0dfb30566 | |||
| 049e64288e | |||
|
|
9577a70ea3 | ||
| e85f7b6f4c | |||
|
|
834b4cb695 | ||
| 17f871e82d | |||
|
|
3ec1e1f10d | ||
| 24b7512c8a | |||
| f047e3ed4b | |||
|
|
1feedd0381 | ||
| f9cd5a0143 | |||
|
|
ede7decaa7 | ||
| 2cfb05e5de | |||
|
|
0a8399a950 | ||
| 6a64cb4c58 | |||
|
|
facded4c55 | ||
| 9787231052 | |||
|
|
8563ddb08c | ||
| 353d4d9d2b | |||
|
|
8745e5e425 | ||
| 4d8c850a77 | |||
| 1974ace1f2 | |||
|
|
a99a12a759 | ||
| 548b5d63a6 | |||
|
|
ed9df4e178 | ||
| 625b4af5ba |
@@ -24,7 +24,10 @@
|
||||
"Bash(npx xlsx-cli:*)",
|
||||
"Bash(cat /home/m-tristan/.claude/projects/-home-m-tristan-workspace-SIRH/4b53d9d7-d8ae-451f-a5cc-5d4fd55f2eef/tool-results/toolu_019hng9Cu2m9wiNACuC2Wm3F.json | python3 -c \"import json,sys; data=json.load\\(sys.stdin\\); print\\(data[0]['text']\\)\" 2>/dev/null | head -2000)",
|
||||
"Bash(pip3 install:*)",
|
||||
"Bash(find:*)"
|
||||
"Bash(find:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(python3:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
23
.dockerignore
Normal file
23
.dockerignore
Normal file
@@ -0,0 +1,23 @@
|
||||
.git
|
||||
.gitea
|
||||
.env.local
|
||||
.env.test
|
||||
docker/
|
||||
deploy/docker/docker-compose.prod.yml
|
||||
deploy/docker/deploy.sh
|
||||
deploy/docker/.env.example
|
||||
frontend/node_modules
|
||||
frontend/.nuxt
|
||||
frontend/.output
|
||||
var/
|
||||
LOG/
|
||||
docs/
|
||||
doc/
|
||||
tests/
|
||||
*.sql
|
||||
*.xlsx
|
||||
*.png
|
||||
*.md
|
||||
!composer.lock
|
||||
!symfony.lock
|
||||
!frontend/package-lock.json
|
||||
30
.gitea/workflows/build-docker.yml
Normal file
30
.gitea/workflows/build-docker.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
name: Build & Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.malio.fr -u "${{ gitea.repository_owner }}" --password-stdin
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-f deploy/docker/Dockerfile.prod \
|
||||
-t gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }} \
|
||||
-t gitea.malio.fr/malio-dev/sirh:latest \
|
||||
.
|
||||
|
||||
- name: Push Docker image
|
||||
run: |
|
||||
docker push gitea.malio.fr/malio-dev/sirh:${{ github.ref_name }}
|
||||
docker push gitea.malio.fr/malio-dev/sirh:latest
|
||||
@@ -1,65 +0,0 @@
|
||||
name: Build Release Artefact
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: "8.4"
|
||||
extensions: mbstring, intl, pdo_pgsql, xml, curl, zip, gd
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
|
||||
- name: Install backend deps (prod)
|
||||
env:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
run: composer install --no-dev --optimize-autoloader --no-interaction --no-scripts
|
||||
|
||||
- name: Build frontend (static)
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
CI=1 NUXT_TELEMETRY_DISABLED=1 NUXT_PUBLIC_API_BASE=/api NUXT_PUBLIC_APP_BASE=/ npm run generate
|
||||
test -f .output/public/index.html
|
||||
|
||||
- name: Build artefact
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release
|
||||
tar -czf "release/sirh-${GITHUB_REF_NAME}.tar.gz" \
|
||||
bin \
|
||||
config \
|
||||
migrations \
|
||||
public \
|
||||
src \
|
||||
templates \
|
||||
vendor \
|
||||
composer.json \
|
||||
composer.lock \
|
||||
symfony.lock \
|
||||
frontend/.output
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/sirh-${{ github.ref_name }}.tar.gz
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
6
.idea/data_source_mapping.xml
generated
6
.idea/data_source_mapping.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$APPLICATION_CONFIG_DIR$/consoles/db/9cad43df-2147-4989-b7a4-443067034884/console_3.sql" value="9cad43df-2147-4989-b7a4-443067034884" />
|
||||
</component>
|
||||
</project>
|
||||
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>
|
||||
|
||||
37
CLAUDE.md
37
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
|
||||
@@ -43,8 +44,26 @@
|
||||
## Overtime Rules
|
||||
- Contracts <= 35h: +25% from 35h to 43h, +50% beyond
|
||||
- Contracts >= 39h: +25% from 39h to 43h, +50% beyond
|
||||
- CUSTOM contracts (weeklyHours ≠ 35 and ≠ 39, not INTERIM/FORFAIT): reference = actual contractual hours, no 25%/50% bonuses (1h overtime = 1h recovery), deficit doesn't impact balance
|
||||
- INTERIM: no overtime bonuses, no recovery time
|
||||
- Driver contracts: no overtime calculation
|
||||
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
|
||||
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
|
||||
|
||||
## Frais (MileageAllowance)
|
||||
- Onglet "Frais" (anciennement "Frais Kms") sur la fiche employé
|
||||
- Validation: mois obligatoire + au moins `kilometers > 0` ou `amount > 0`
|
||||
- Les deux champs km et montant sont optionnels individuellement mais au moins un requis
|
||||
|
||||
## Formations
|
||||
- Onglet "Formation" sur la fiche employé (admin uniquement)
|
||||
- Champs : date début, date fin, justificatif PDF optionnel, commentaire
|
||||
- Validation: dates obligatoires, `endDate >= startDate`, fichier PDF uniquement
|
||||
- Justificatif stocké dans `var/uploads/formations/{année}/{mois}/{uuid}.pdf` (année/mois = startDate)
|
||||
- Suppression et remplacement du justificatif nettoient l'ancien fichier disque
|
||||
- Tri tableau par `startDate DESC`
|
||||
- Affichage écran Heures (jour) : pill "Formation" (indigo) dans la colonne Absence. Quand une formation existe, le bouton "Modifier" de la colonne Absence est masqué (lockdown complet du jour pour la gestion d'absence)
|
||||
- Affichage Calendrier : cellule "F" (indigo) si formation seule, ou icône école en coin si formation + absence. Cellules avec formation non cliquables. Légende dédiée. PDF export : code "F" indigo ou astérisque à côté du code d'absence
|
||||
- Le CRUD formation est exclusivement géré depuis la fiche employé > onglet Formation
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
@@ -65,12 +84,28 @@
|
||||
- File uploads: `deserialize: false` on Post, access file via RequestStack
|
||||
- Upload dir: `%kernel.project_dir%/var/uploads`
|
||||
|
||||
## Audit Logging
|
||||
- All processors that modify entities impacting calculations (heures, absences, contrats, RTT) MUST inject `AuditLogger` and log create/update/delete actions
|
||||
- `AuditLogger::log()` persists without flushing — the processor's `flush()` handles both the data change and the audit entry atomically
|
||||
- Audit logs are accessible only via `ROLE_SUPER_ADMIN` (hidden role, added manually in DB)
|
||||
- Documentation: `doc/audit-logging.md`
|
||||
|
||||
## Backend Conventions
|
||||
- Prefer explicit DTOs over associative arrays
|
||||
- Business rules in backend (providers/processors/services), frontend is display/interaction only
|
||||
- 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
|
||||
@@ -17,3 +18,8 @@ Remplie la base avec le dump :
|
||||
```shell
|
||||
docker compose exec -T db psql -U root -d sirh < sirh.sql
|
||||
```
|
||||
|
||||
## Mettre SUPER_ADMIN sur un user
|
||||
```sql
|
||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'emilie';
|
||||
```
|
||||
|
||||
@@ -19,6 +19,7 @@ security:
|
||||
pattern: ^/login_check
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
json_login:
|
||||
check_path: /login_check
|
||||
username_path: username
|
||||
@@ -29,6 +30,7 @@ security:
|
||||
pattern: ^/api
|
||||
stateless: true
|
||||
provider: app_user_provider
|
||||
user_checker: App\Security\UserChecker
|
||||
jwt: ~
|
||||
logout:
|
||||
path: /api/logout
|
||||
|
||||
@@ -37,6 +37,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.46'
|
||||
app.version: '0.1.82'
|
||||
|
||||
25
deploy/docker/.env.example
Normal file
25
deploy/docker/.env.example
Normal file
@@ -0,0 +1,25 @@
|
||||
# Symfony
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=change-me
|
||||
|
||||
# Database (use host.docker.internal to reach bare-metal PostgreSQL)
|
||||
DATABASE_URL="postgresql://sirh_user:password@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=change-me
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=https://sirh.malio-dev.fr
|
||||
APP_SHARE_DIR=var/share
|
||||
RTT_START_DATE=2026-02-23
|
||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||
80
deploy/docker/Dockerfile.prod
Normal file
80
deploy/docker/Dockerfile.prod
Normal file
@@ -0,0 +1,80 @@
|
||||
# --- Stage 1: Build backend ---
|
||||
FROM php:8.4-cli AS backend-build
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
unzip curl git \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock symfony.lock ./
|
||||
RUN APP_ENV=prod APP_DEBUG=0 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction
|
||||
|
||||
COPY bin bin/
|
||||
COPY config config/
|
||||
COPY migrations migrations/
|
||||
COPY public public/
|
||||
COPY src src/
|
||||
COPY templates templates/
|
||||
|
||||
# --- Stage 2: Build frontend ---
|
||||
FROM node:lts-alpine AS frontend-build
|
||||
|
||||
WORKDIR /app/frontend
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
ENV CI=1 \
|
||||
NUXT_TELEMETRY_DISABLED=1 \
|
||||
NUXT_PUBLIC_API_BASE=/api \
|
||||
NUXT_PUBLIC_APP_BASE=/
|
||||
RUN npm run generate
|
||||
|
||||
# --- Stage 3: Production image ---
|
||||
FROM php:8.4-fpm AS production
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \
|
||||
nginx supervisor \
|
||||
&& docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# PHP production config
|
||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
# PHP-FPM: forward worker output to stderr for docker logs
|
||||
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||
&& echo "decorate_workers_output = no" >> /usr/local/etc/php-fpm.d/www.conf
|
||||
|
||||
# Nginx: log to stdout/stderr
|
||||
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
|
||||
&& ln -sf /dev/stderr /var/log/nginx/error.log
|
||||
|
||||
# Remove default nginx site
|
||||
RUN rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
# Configs
|
||||
COPY deploy/docker/supervisord.conf /etc/supervisor/conf.d/app.conf
|
||||
COPY deploy/docker/nginx.conf /etc/nginx/sites-enabled/sirh.conf
|
||||
|
||||
# Backend from stage 1
|
||||
COPY --from=backend-build /app /var/www/html
|
||||
|
||||
# Frontend from stage 2
|
||||
COPY --from=frontend-build /app/frontend/.output/public /var/www/html/frontend/.output/public
|
||||
|
||||
# Symfony needs a .env file to boot (variables are overridden by env_file in docker-compose)
|
||||
RUN echo "APP_ENV=prod" > /var/www/html/.env
|
||||
|
||||
# Permissions
|
||||
RUN mkdir -p /var/www/html/var \
|
||||
&& chown -R www-data:www-data /var/www/html/var
|
||||
|
||||
WORKDIR /var/www/html
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/app.conf"]
|
||||
34
deploy/docker/deploy.sh
Executable file
34
deploy/docker/deploy.sh
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export SIRH_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying sirh:${TAG}..."
|
||||
|
||||
echo "==> Enabling maintenance mode..."
|
||||
touch maintenance.on
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
echo "==> Disabling maintenance mode..."
|
||||
rm -f maintenance.on
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
13
deploy/docker/docker-compose.prod.yml
Normal file
13
deploy/docker/docker-compose.prod.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
|
||||
container_name: sirh-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
46
deploy/docker/nginx.conf
Normal file
46
deploy/docker/nginx.conf
Normal file
@@ -0,0 +1,46 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /var/www/html/frontend/.output/public;
|
||||
index index.html;
|
||||
|
||||
access_log /dev/stdout;
|
||||
error_log /dev/stderr;
|
||||
|
||||
location ^~ /api/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ^~ /bundles/ {
|
||||
root /var/www/html/public;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location = /api/login_check {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_param SCRIPT_NAME /index.php;
|
||||
fastcgi_param PATH_INFO /login_check;
|
||||
fastcgi_param REQUEST_URI /login_check;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
}
|
||||
|
||||
location ~ ^/index\.php(/|$) {
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
|
||||
fastcgi_param DOCUMENT_ROOT /var/www/html/public;
|
||||
fastcgi_pass 127.0.0.1:9000;
|
||||
internal;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
28
deploy/docker/supervisord.conf
Normal file
28
deploy/docker/supervisord.conf
Normal file
@@ -0,0 +1,28 @@
|
||||
[supervisord]
|
||||
nodaemon=true
|
||||
user=root
|
||||
logfile=/dev/null
|
||||
logfile_maxbytes=0
|
||||
pidfile=/var/run/supervisord.pid
|
||||
|
||||
[program:php-fpm]
|
||||
command=php-fpm -F
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
|
||||
[program:nginx]
|
||||
command=nginx -g "daemon off;"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
stopasgroup=true
|
||||
stopsignal=QUIT
|
||||
50
deploy/maintenance.html
Normal file
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>
|
||||
29
deploy/nginx/sirh-docker.conf
Normal file
29
deploy/nginx/sirh-docker.conf
Normal file
@@ -0,0 +1,29 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
root /var/www/sirh/public;
|
||||
|
||||
# Maintenance mode : si le fichier maintenance.on existe, renvoyer la page 503
|
||||
if (-f /var/www/sirh/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
57
doc/audit-logging.md
Normal file
57
doc/audit-logging.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Journal des actions (Audit Log)
|
||||
|
||||
## Objectif
|
||||
|
||||
Tracer les actions utilisateurs pour diagnostiquer rapidement les problèmes de calcul signalés.
|
||||
Quand un utilisateur signale une incohérence dans ses heures, RTT ou congés, le journal permet de voir
|
||||
exactement ce qui a été modifié, par qui, et quand.
|
||||
|
||||
## Accès
|
||||
|
||||
- **Rôle requis** : `ROLE_SUPER_ADMIN` (rôle caché, non visible dans l'interface de gestion des utilisateurs)
|
||||
- **Ajout du rôle** : directement en base de données
|
||||
```sql
|
||||
UPDATE users SET roles = '["ROLE_ADMIN","ROLE_SUPER_ADMIN"]' WHERE username = 'xxx';
|
||||
```
|
||||
- **Page** : `/audit-logs` (lien "Journal" dans la sidebar, visible uniquement avec le rôle)
|
||||
|
||||
## Actions tracées
|
||||
|
||||
| Processor | Entité | Actions |
|
||||
|---|---|---|
|
||||
| `AbsenceWriteProcessor` | Absence | create, delete |
|
||||
| `WorkHourBulkUpsertProcessor` | WorkHour | create, update, delete |
|
||||
| `WorkHourSiteValidationProcessor` | WorkHour | site_validate |
|
||||
| `WorkHourBulkValidationProcessor` | WorkHour | validate |
|
||||
| `WorkHourBulkSiteValidationProcessor` | WorkHour | site_validate |
|
||||
| `EmployeeWriteProcessor` | Employee | create, update (changement contrat) |
|
||||
| `ContractSuspensionWriteProcessor` | ContractSuspension | create, update |
|
||||
| `EmployeeRttPaymentProcessor` | EmployeeRttPayment | update |
|
||||
| `EmployeeFractionedDaysProcessor` | EmployeeLeaveBalance | update |
|
||||
|
||||
## Données stockées
|
||||
|
||||
Chaque entrée contient :
|
||||
- **employee** : l'employé concerné (FK, nullable)
|
||||
- **username** : l'utilisateur qui a effectué l'action
|
||||
- **action** : type d'action (create, update, delete, validate, site_validate)
|
||||
- **entityType** : type d'entité (work_hour, absence, employee, etc.)
|
||||
- **description** : description lisible en français
|
||||
- **changes** : diff JSON `{old: {...}, new: {...}}` avec les anciennes/nouvelles valeurs
|
||||
- **affectedDate** : date de travail ou début d'absence (pour filtrage par période)
|
||||
- **createdAt** : horodatage de l'action
|
||||
|
||||
## Filtres disponibles
|
||||
|
||||
- Par employé
|
||||
- Par plage de dates (date affectée)
|
||||
- Par type d'entité
|
||||
|
||||
## Pagination
|
||||
|
||||
Les résultats sont paginés par 50 entrées. L'API retourne `{items, total, page, perPage}` et accepte un query param `page`.
|
||||
|
||||
## Convention
|
||||
|
||||
Tout nouveau processor traitant des entités impactant les calculs (heures, absences, contrats, RTT)
|
||||
doit intégrer le service `AuditLogger` et logger les actions create/update/delete.
|
||||
363
doc/deployment-docker.md
Normal file
363
doc/deployment-docker.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Deploiement Docker — SIRH
|
||||
|
||||
## Pre-requis
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu
|
||||
sudo apt update
|
||||
sudo apt install -y ca-certificates curl gnupg
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
|
||||
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
|
||||
sudo apt update
|
||||
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
||||
sudo usermod -aG docker $USER
|
||||
```
|
||||
|
||||
Se deconnecter/reconnecter pour que le groupe `docker` prenne effet.
|
||||
|
||||
### Nginx
|
||||
|
||||
```bash
|
||||
sudo apt install -y nginx
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
```
|
||||
|
||||
### PostgreSQL
|
||||
|
||||
PostgreSQL tourne dans un conteneur Docker separe (voir le repo `infra-postgres`).
|
||||
Il doit etre installe et accessible avant de deployer SIRH.
|
||||
|
||||
Creer la base de donnees pour SIRH :
|
||||
|
||||
```bash
|
||||
cd /var/www/postgres
|
||||
docker compose exec postgres psql -U admin
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Si le user n'existe pas encore
|
||||
CREATE USER malio WITH PASSWORD 'motdepasse';
|
||||
|
||||
-- Creer la base
|
||||
CREATE DATABASE sirh_prod OWNER malio;
|
||||
\q
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Premiere installation (nouvelle machine)
|
||||
|
||||
Guide complet pour mettre en ligne SIRH sur une machine vierge. Inclut les pre-requis, la BDD et l'app.
|
||||
|
||||
### 1. Installer les pre-requis
|
||||
|
||||
Installer Docker, Nginx et PostgreSQL (voir section Pre-requis ci-dessus).
|
||||
|
||||
### 2. Creer le dossier de deploiement
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/sirh
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/sirh
|
||||
cd /var/www/sirh
|
||||
```
|
||||
|
||||
### 3. Se connecter au registry Docker de Gitea
|
||||
|
||||
```bash
|
||||
docker login gitea.malio.fr
|
||||
```
|
||||
|
||||
- **Username** : le nom d'utilisateur du compte organisation Gitea `MALIO-DEV`
|
||||
- **Password** : le token REGISTRY_TOKEN dispo dans le bitwarden
|
||||
|
||||
Le login est sauvegarde dans `~/.docker/config.json`, pas besoin de le refaire a chaque deploiement.
|
||||
|
||||
### 4. Creer les fichiers de deploiement
|
||||
|
||||
Creer `docker-compose.yml` :
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: gitea.malio.fr/malio-dev/sirh:${SIRH_IMAGE_TAG:-latest}
|
||||
container_name: sirh-app
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./config/jwt:/var/www/html/config/jwt:ro
|
||||
- ./uploads:/var/www/html/var/uploads
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Creer `deploy.sh` :
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
TAG="${1:-latest}"
|
||||
export SIRH_IMAGE_TAG="$TAG"
|
||||
|
||||
echo "==> Deploying sirh:${TAG}..."
|
||||
|
||||
echo "==> Pulling image..."
|
||||
docker compose pull
|
||||
|
||||
echo "==> Starting container..."
|
||||
docker compose up -d
|
||||
|
||||
echo "==> Waiting for container to be ready..."
|
||||
sleep 3
|
||||
|
||||
echo "==> Running migrations..."
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T -u www-data app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T -u www-data app php bin/console cache:warmup --env=prod
|
||||
|
||||
VERSION=$(docker compose exec -T app cat config/version.yaml | grep 'app.version' | awk -F"'" '{print $2}')
|
||||
echo "==> Deployed v${VERSION}"
|
||||
```
|
||||
|
||||
Rendre executable :
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
```
|
||||
|
||||
### 5. Configurer l'environnement
|
||||
|
||||
Creer `.env` avec les variables suivantes :
|
||||
|
||||
```env
|
||||
# Symfony
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=<generer avec: openssl rand -hex 32>
|
||||
|
||||
# Database (host.docker.internal = la machine hote, ou le PG tourne en Docker)
|
||||
DATABASE_URL="postgresql://malio:password@host.docker.internal:5432/sirh_prod?serverVersion=16&charset=utf8"
|
||||
|
||||
# JWT
|
||||
JWT_SECRET_KEY=%kernel.project_dir%/config/jwt/private.pem
|
||||
JWT_PUBLIC_KEY=%kernel.project_dir%/config/jwt/public.pem
|
||||
JWT_PASSPHRASE=<generer avec: openssl rand -hex 32>
|
||||
JWT_COOKIE_SECURE=1
|
||||
JWT_COOKIE_SAMESITE=lax
|
||||
JWT_TOKEN_TTL=86400
|
||||
JWT_COOKIE_TTL=86400
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_ORIGIN='^https?://sirh\.malio-dev\.fr$'
|
||||
|
||||
# App
|
||||
DEFAULT_URI=https://sirh.malio-dev.fr
|
||||
APP_SHARE_DIR=var/share
|
||||
RTT_START_DATE=2026-02-23
|
||||
HOLIDAY_URL="https://calendrier.api.gouv.fr/jours-feries/"
|
||||
```
|
||||
|
||||
### 6. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
mkdir -p config/jwt
|
||||
openssl genpkey -algorithm RSA -out config/jwt/private.pem -pkeyopt rsa_keygen_bits:4096
|
||||
openssl rsa -pubout -in config/jwt/private.pem -out config/jwt/public.pem
|
||||
```
|
||||
|
||||
Rendre les cles lisibles par le conteneur (www-data = uid 33) :
|
||||
|
||||
```bash
|
||||
sudo chown 33:33 config/jwt/private.pem config/jwt/public.pem
|
||||
sudo chmod 644 config/jwt/private.pem config/jwt/public.pem
|
||||
```
|
||||
|
||||
### 7. Creer le dossier uploads
|
||||
|
||||
```bash
|
||||
mkdir -p uploads
|
||||
```
|
||||
|
||||
### 8. Configurer Nginx systeme
|
||||
|
||||
Creer `/etc/nginx/sites-available/sirh.conf` :
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
root /var/www/sirh/public;
|
||||
|
||||
# Maintenance mode
|
||||
if (-f /var/www/sirh/maintenance.on) {
|
||||
return 503;
|
||||
}
|
||||
|
||||
error_page 503 @maintenance;
|
||||
|
||||
location @maintenance {
|
||||
rewrite ^(.*)$ /maintenance.html break;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Copier la page de maintenance et activer le site :
|
||||
|
||||
```bash
|
||||
cp deploy/maintenance.html /var/www/sirh/public/maintenance.html
|
||||
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 9. Deployer
|
||||
|
||||
```bash
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
### 10. Importer les donnees (optionnel)
|
||||
|
||||
Si tu as un dump SQL a importer :
|
||||
|
||||
```bash
|
||||
# Depuis ton PC, envoyer le dump vers le serveur
|
||||
scp sirh.sql user@serveur:/tmp/sirh.sql
|
||||
|
||||
# Sur le serveur, vider la base puis importer
|
||||
cd /var/www/postgres
|
||||
docker compose exec -T postgres psql -U malio sirh_prod -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
|
||||
docker compose exec -T postgres psql -U malio sirh_prod < /tmp/sirh.sql
|
||||
|
||||
# Creer les tables manquantes (si le dump a des erreurs de syntaxe)
|
||||
cd /var/www/sirh
|
||||
docker compose exec -u www-data app php bin/console doctrine:schema:update --force --env=prod
|
||||
|
||||
# Nettoyer
|
||||
rm /tmp/sirh.sql
|
||||
```
|
||||
|
||||
### Structure finale du dossier
|
||||
|
||||
```
|
||||
/var/www/sirh/
|
||||
├── docker-compose.yml
|
||||
├── deploy.sh
|
||||
├── .env
|
||||
├── config/jwt/
|
||||
│ ├── private.pem
|
||||
│ └── public.pem
|
||||
├── public/
|
||||
│ └── maintenance.html
|
||||
└── uploads/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deployer une nouvelle version
|
||||
|
||||
Quand l'app est deja installee, deployer une mise a jour :
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
./deploy.sh # deploie la derniere version (latest)
|
||||
./deploy.sh v0.1.61 # deploie une version specifique
|
||||
```
|
||||
|
||||
Le script active automatiquement la maintenance pendant le deploy et la desactive a la fin.
|
||||
|
||||
---
|
||||
|
||||
## Maintenance manuelle
|
||||
|
||||
Activer la maintenance (sans deployer) :
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
touch maintenance.on
|
||||
```
|
||||
|
||||
Desactiver :
|
||||
|
||||
```bash
|
||||
rm maintenance.on
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
### Image seule (pas de changement de schema BDD)
|
||||
|
||||
```bash
|
||||
./deploy.sh v0.1.60
|
||||
```
|
||||
|
||||
### Avec rollback de migration
|
||||
|
||||
```bash
|
||||
# 1. Rollback schema (pendant que la version actuelle tourne encore)
|
||||
docker compose exec -T -u www-data app php bin/console doctrine:migrations:migrate prev --no-interaction
|
||||
# 2. Deployer l'ancienne version
|
||||
./deploy.sh v0.1.60
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Voir les logs
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
docker compose logs -f # tous les logs
|
||||
docker compose logs -f --tail=100 # 100 dernieres lignes
|
||||
```
|
||||
|
||||
Logs Symfony :
|
||||
|
||||
```bash
|
||||
docker compose exec app cat var/log/prod.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration depuis l'ancien deploiement (tar.gz)
|
||||
|
||||
Si l'application tourne deja en bare metal :
|
||||
|
||||
1. Installer Docker (voir pre-requis)
|
||||
2. Creer le dossier `/var/www/sirh-docker/` (ne pas ecraser l'ancien)
|
||||
3. Copier les fichiers existants :
|
||||
```bash
|
||||
cp /var/www/sirh/.env /var/www/sirh-docker/.env
|
||||
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
|
||||
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
|
||||
```
|
||||
4. Creer `docker-compose.yml` et `deploy.sh` dans `/var/www/sirh-docker/` (voir etape 4 ci-dessus)
|
||||
5. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||
6. Se connecter au registry Gitea (voir etape 3 ci-dessus)
|
||||
7. Mettre a jour Nginx systeme avec la conf reverse proxy (voir etape 8 ci-dessus)
|
||||
8. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||
9. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
|
||||
10. Verifier que tout marche, puis renommer le dossier : `mv /var/www/sirh-docker /var/www/sirh`
|
||||
58
doc/formations.md
Normal file
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 (`*`)
|
||||
@@ -122,6 +122,10 @@ Documents complementaires:
|
||||
- Semaine en déficit (heures travaillées < heures contrat):
|
||||
- le déficit est déduit du cumul RTT : d'abord des heures à 50%, puis des heures à 25%
|
||||
- si aucun solde 50% ni 25%, les heures à 25% deviennent négatives
|
||||
- Contrats CUSTOM (heures hebdo ≠ 35h et ≠ 39h, hors INTERIM/FORFAIT):
|
||||
- référence heures sup = heures contractuelles réelles (ex: 4h → référence 4h)
|
||||
- pas de bonus 25% ni 50% : 1 heure sup = 1 heure de récupération
|
||||
- le déficit (travail < contrat) ne génère pas de récup mais n'impacte pas le solde
|
||||
- Nature `INTERIM`:
|
||||
- pas de bonus 25%
|
||||
- pas de bonus 50%
|
||||
@@ -148,9 +152,9 @@ Documents complementaires:
|
||||
- Validation: même logique que les heures classiques (`isValid`, `isSiteValid`, bulk)
|
||||
- Vue semaine:
|
||||
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
||||
- panier de nuit (PN): affiché par jour si nightMinutes > dayMinutes, et total hebdo dans la colonne Jour/Nuit sem.
|
||||
- panier de nuit (PN): affiché par jour si (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit au moins 4h de travail entre 21h et 6h), et total hebdo dans la colonne Jour/Nuit sem.
|
||||
- totaux hebdo: jour, nuit, atelier, total, compteurs petit déj/déjeuner/dîner/nuitée
|
||||
- pas de calcul d'heures supplémentaires pour les conducteurs
|
||||
- les conducteurs utilisent `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` pour le calcul RTT (au lieu des créneaux morning/afternoon/evening)
|
||||
- Le flag `isDriver` est sur `EmployeeContractPeriod` (un employé peut changer de statut chauffeur selon la période)
|
||||
- Exposé en API via un getter virtuel sur `Employee` (`employee:read`) qui résout depuis la période active
|
||||
|
||||
@@ -190,13 +194,14 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- Détail employé:
|
||||
- onglet `Suivi contrat` avec affichage de l'historique des périodes de contrat
|
||||
- chaque ligne expose: nature (`CDI`/`CDD`/`INTERIM`), contrat/temps de travail, date de début, date de fin (ou "En cours")
|
||||
- action `Clôturer`:
|
||||
- bouton actif uniquement s'il existe un contrat en cours non déjà clôturé à la date du jour
|
||||
- action `Modifier` (clôture/solde de tout compte):
|
||||
- bouton actif s'il existe un contrat en cours non clôturé, ou si le dernier contrat est terminé (sans contrat actif après)
|
||||
- ouvre un drawer en lecture seule (type/temps de travail/date de début)
|
||||
- champs saisissables:
|
||||
- `contractEndDate` (prérempli à aujourd'hui)
|
||||
- `contractEndDate` (prérempli à aujourd'hui si contrat en cours, à la date de fin existante si contrat terminé)
|
||||
- `contractPaidLeaveSettled` (checkbox "Soldé dans le solde de tout compte")
|
||||
- backend: en mode clôture, le flag `contractPaidLeaveSettled` est persisté sur la période clôturée
|
||||
- cas du contrat déjà terminé: permet de modifier `paidLeaveSettled` et le commentaire sur le dernier contrat terminé (ex: solde de tout compte CDD)
|
||||
- action `Ajouter`:
|
||||
- conserve le flux d'ajout d'un nouveau contrat via drawer dédié
|
||||
- disponible uniquement s'il n'y a pas de contrat en cours, ou si le contrat en cours a déjà une date de fin
|
||||
@@ -212,14 +217,22 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- en cours d'acquisition jours: `25/12 = 2,08` jours/mois
|
||||
- en cours d'acquisition samedis: `5/12 = 0,42` samedi/mois (non detaille en UI)
|
||||
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22 (standard mensuel)
|
||||
- arrêt maladie long (absences continues de type `M` > 1 mois):
|
||||
- premier mois de maladie (date début + 1 mois calendaire): acquisition normale (`2,50`/mois)
|
||||
- après le premier mois: acquisition réduite à `2,00`/mois (facteur `0,80` appliqué aux deux taux jours et samedis)
|
||||
- en cas de mois partiellement couvert par la période réduite, le prorata est calculé en jours calendaires (jours normaux × taux normal + jours réduits × taux réduit)
|
||||
- la détection est automatique à partir des absences MALADIE consécutives en base (tolérance de gap ≤ 3 jours)
|
||||
- samedis acquis affiches: uniquement `opening_saturdays` (report N-1)
|
||||
- contrat `4h`:
|
||||
- acquis annuel CP: `10`
|
||||
- acquis annuel samedi: `0`
|
||||
- en cours d'acquisition: `0.83` jour/mois
|
||||
- en cas de début/fin en cours de mois, l'acquisition est proratisée au nombre de jours calendaires couverts dans le mois
|
||||
- en cas de suspension en cours de mois, l'acquisition est proratisée en jours ouvrés (lun-ven hors fériés) travaillés / 22
|
||||
- contrat `FORFAIT`:
|
||||
- base annuelle: `jours ouvrés de l'exercice (lundi-vendredi, hors jours fériés métropole) - 218`
|
||||
- bonus weekend/férié: chaque jour travaillé un weekend ou jour férié donne 1 jour de congé supplémentaire (journée ≥ 5h = 1.0 jour, demi-journée > 0h et < 5h = 0.5 jour), sans plafond
|
||||
- prorata: en cas de démarrage/fin de contrat en cours d'année civile, le calcul ne couvre que l'intervalle actif du contrat dans l'année
|
||||
- reste à prendre: `acquis - absences` (toutes absences, demi-journées incluses)
|
||||
- pas de samedi (`0`)
|
||||
@@ -232,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
|
||||
@@ -261,12 +275,14 @@ 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%`
|
||||
- contrats `INTERIM` et suivi `PRESENCE`: récupération à `0`
|
||||
- date limite de calcul: uniquement les semaines terminées (jusqu'au dernier dimanche), **ou** la semaine en cours si tous les jours existants sont validés RH (`isValid = true`). En cas de fin de contrat en milieu de semaine, seuls les jours jusqu'à la date de fin sont vérifiés.
|
||||
- compteur global:
|
||||
- affiché en **jours** (1 jour = 7h = 420 minutes)
|
||||
- report:
|
||||
@@ -325,7 +341,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
| Base | Contract.name | Via EmployeeContractResolver pour le mois |
|
||||
| Jour de présence Cadre | WorkHour | Uniquement FORFAIT (PRESENCE). Somme isPresentMorning (0.5) + isPresentAfternoon (0.5) |
|
||||
| Heures de nuit | WorkHour | Non-chauffeurs: calcul intervalles nuit (00:00-06:00, 21:00-24:00). Chauffeurs: somme nightHoursMinutes |
|
||||
| Panier de nuit | WorkHour | Nombre de jours où nightMinutes > dayMinutes |
|
||||
| Panier de nuit | WorkHour | Nombre de jours où (nightMinutes > dayMinutes) OU (nightMinutes >= 240, soit 4h entre 21h-6h) |
|
||||
| Heures payés | EmployeeRttPayment | Somme base25Minutes + base50Minutes du mois, convertie en heures |
|
||||
| Congés - Nombre | Absence code 'C' | Jours (demi-journées = 0.5) |
|
||||
| Congés - Date | Absence code 'C' | Dates formatées dd/mm |
|
||||
@@ -337,7 +353,47 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
| CHAUFFEUR - samedi | WorkHour (samedi) | Samedis travaillés (chauffeurs uniquement) |
|
||||
| Observations | — | Colonne vide pour saisie manuelle |
|
||||
|
||||
## 12) Notifications
|
||||
## 12) Frais
|
||||
|
||||
- Onglet "Frais" sur la fiche employé (icône `mdi:account-cash-outline`)
|
||||
- Entité `MileageAllowance` (table `mileage_allowances`)
|
||||
- Champs:
|
||||
- `month` (mois, obligatoire)
|
||||
- `kilometers` (nombre de km, optionnel)
|
||||
- `amount` (montant en €, optionnel)
|
||||
- `comment` (commentaire, optionnel)
|
||||
- `receiptPath` / `receiptName` (justificatif Km, PDF)
|
||||
- `amountReceiptPath` / `amountReceiptName` (justificatif Montant, PDF)
|
||||
- Règle de validation:
|
||||
- le mois est obligatoire
|
||||
- au moins un des deux champs `kilometers` ou `amount` doit être > 0
|
||||
- les deux peuvent être remplis simultanément
|
||||
- Tableau: colonnes Mois, Nombre de Km, Montant €, Commentaire, Justif. Km, Justif. Montant
|
||||
- Deux justificatifs distincts (upload PDF uniquement):
|
||||
- Justificatif Km : upload via `/mileage_allowances/{id}/receipt`, téléchargement via GET même URL
|
||||
- Justificatif Montant : upload via `/mileage_allowances/{id}/amount-receipt`, téléchargement via GET même URL
|
||||
- La suppression d'un frais supprime les deux fichiers justificatifs du disque
|
||||
|
||||
## 13) Observations
|
||||
|
||||
- Onglet "Observation" sur la fiche employé (icône `mdi:note-text-outline`)
|
||||
- Entité `Observation` (table `observations`)
|
||||
- Champs:
|
||||
- `month` (mois, obligatoire)
|
||||
- `content` (texte d'observation, obligatoire)
|
||||
- Contrainte: une seule observation par mois par employé (unique sur `employee_id + month`)
|
||||
- Tableau: colonnes Mois | Observation
|
||||
- Drawer avec champs mois (`type="month"`) et textarea "Observation"
|
||||
- CRUD standard: création, modification, suppression avec confirmation
|
||||
|
||||
## 14) Verrouillage utilisateur
|
||||
|
||||
- Champ `isLocked` (boolean, default false) sur l'entité `User`
|
||||
- Un admin peut verrouiller/déverrouiller un utilisateur depuis la page Utilisateurs (checkbox dans le drawer)
|
||||
- Un utilisateur verrouillé ne peut plus se connecter (vérification via `UserChecker` sur les firewalls `login` et `api`)
|
||||
- Colonne "Statut" dans le tableau utilisateurs avec label "Actif" (vert) ou "Verrouillé" (rouge)
|
||||
|
||||
## 15) Notifications
|
||||
|
||||
- Icône cloche en topbar:
|
||||
- badge = nombre de notifications non lues
|
||||
@@ -350,3 +406,31 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- Une notification est créée uniquement quand un chef de site termine la validation complète:
|
||||
- condition: plus aucune ligne `work_hours` du site à la date concernée avec `isSiteValid = false`
|
||||
- destinataires: utilisateurs `ROLE_ADMIN`
|
||||
|
||||
## 16) Export PDF des heures annuelles
|
||||
|
||||
- Accessible depuis la fiche employé (bouton imprimante à droite du nom)
|
||||
- Ouvre un drawer pour choisir l'année (civile, Jan-Déc)
|
||||
- Génère un PDF avec le détail jour par jour des heures de l'employé
|
||||
- Seuls les jours avec heures saisies ou absence sont affichés
|
||||
|
||||
### Colonnes selon le mode de suivi
|
||||
|
||||
- **TIME (non-chauffeur)**: Date | Absence | Début matin | Fin matin | Début après-midi | Fin après-midi | Début soir | Fin soir | Total
|
||||
- **PRESENCE (forfait)**: Date | Absence | Présence matin | Présence après-midi | Total
|
||||
- **Chauffeur**: Date | Absence | Heures jour | Heures nuit | Heures atelier | Total
|
||||
|
||||
### Changement de contrat en cours d'année
|
||||
|
||||
- Si l'employé change de mode de suivi (TIME/PRESENCE) ou de statut chauffeur en cours d'année, le PDF affiche des sections séparées avec les colonnes adaptées à chaque période
|
||||
- Le nom du contrat est affiché en sous-titre de chaque section
|
||||
|
||||
### Calcul du total
|
||||
|
||||
- TIME non-chauffeur: somme des créneaux matin + après-midi + soir, plus minutes créditées des absences `countAsWorkedHours`
|
||||
- Chauffeur: `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` + minutes créditées
|
||||
- PRESENCE: 0.5 par demi-journée présente (matin/après-midi), max 1.0
|
||||
|
||||
### Nom du fichier
|
||||
|
||||
- Format: `{nom}_{prenom}_{annee}.pdf`
|
||||
|
||||
@@ -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
|
||||
}>()
|
||||
|
||||
67
frontend/components/EmployeeYearlyHoursDrawer.vue
Normal file
67
frontend/components/EmployeeYearlyHoursDrawer.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<AppDrawer v-model="drawerOpen" title="Export heures annuelles">
|
||||
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="yearly-hours-year">
|
||||
Année <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="yearly-hours-year"
|
||||
v-model="selectedYear"
|
||||
:class="selectFieldClass"
|
||||
>
|
||||
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div 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"
|
||||
>
|
||||
Imprimer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AppDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
employeeId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: boolean): void
|
||||
(event: 'submit', year: number): void
|
||||
}>()
|
||||
|
||||
const drawerOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||
const selectedYear = ref(currentYear)
|
||||
|
||||
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('submit', selectedYear.value)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (!isOpen) {
|
||||
selectedYear.value = currentYear
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
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>
|
||||
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>
|
||||
@@ -1,37 +1,49 @@
|
||||
<template>
|
||||
<section class="flex h-full min-h-0 flex-col overflow-hidden pt-8">
|
||||
<div class="grid grid-cols-4 rounded-md bg-tertiary-500 text-primary-500 text-[18px] border border-primary-500">
|
||||
<p class="col-start-1 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||
<p class="col-start-1 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Année acquis :</strong> {{
|
||||
formatCount(summary?.acquiredDays)
|
||||
}} Jours
|
||||
</p>
|
||||
<p class="col-start-2 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Pris :</strong>
|
||||
<p class="col-start-2 p-[10px] border-b border-r border-primary-500"><strong class="uppercase font-semibold">Pris :</strong>
|
||||
{{ formatCount(isForfaitRule ? currentYearTakenDays : summary?.takenDays) }} Jours
|
||||
</p>
|
||||
<p class="col-start-3 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||
<p class="col-start-3 p-[10px] border-b border-r border-b-white border-r-primary-500 bg-primary-500 text-white"><strong class="uppercase font-semibold">Reste à prendre :</strong>
|
||||
{{ formatCount(summary?.remainingDays) }} Jours
|
||||
</p>
|
||||
<p class="col-start-4 p-[10px] border-b border-b-black"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
|
||||
<p class="col-start-4 p-[10px] border-b border-primary-500"><strong class="uppercase font-semibold">En cours d'acquisition :</strong>
|
||||
{{ formatCount(summary?.accruingDays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||
<p v-if="!isForfaitRule" class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Samedi acquis :</span>
|
||||
{{ formatCount(summary?.acquiredSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-else class="col-start-1 p-[10px]"><span class="uppercase font-semibold">Année N-1 acquis :</span>
|
||||
<p v-else class="col-start-1 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Année N-1 acquis :</span>
|
||||
{{ formatCount(summary?.previousYearAcquiredDays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
|
||||
<p v-if="!isForfaitRule" class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||
{{ formatCount(summary?.takenSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-if="!isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
<p v-if="!isForfaitRule" class="col-start-3 p-[10px] border-r border-r-primary-500 bg-primary-500 text-white"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
{{ formatCount(summary?.remainingSaturdays) }} Jours
|
||||
</p>
|
||||
<p v-else class="col-start-2 p-[10px]"><span class="uppercase font-semibold">Pris :</span>
|
||||
<p v-else class="col-start-2 p-[10px] border-r border-primary-500"><span class="uppercase font-semibold">Pris :</span>
|
||||
{{ formatCount(summary?.previousYearTakenDays) }} Jours
|
||||
</p>
|
||||
<p v-if="isForfaitRule" class="col-start-3 p-[10px]"><span class="uppercase font-semibold">Reste à prendre :</span>
|
||||
<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',
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
<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">
|
||||
class="grid grid-cols-6 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Mois</p>
|
||||
<p>Nombre de Km</p>
|
||||
<p>Montant €</p>
|
||||
<p>Commentaire</p>
|
||||
<p>Justificatif</p>
|
||||
<p>Justif. Km</p>
|
||||
<p>Justif. Montant</p>
|
||||
</div>
|
||||
<div v-if="allowances.length === 0" class="px-6 py-4 text-[20px] font-bold text-primary-500 border-x border-b border-primary-500 rounded-b-md">
|
||||
Aucun frais kilométrique.
|
||||
@@ -15,22 +17,36 @@
|
||||
<div
|
||||
v-for="item in allowances"
|
||||
: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"
|
||||
class="grid grid-cols-6 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>{{ formatMonth(item.month) }}</p>
|
||||
<p>{{ item.kilometers }}</p>
|
||||
<p>{{ item.amount ? item.amount + ' €' : '-' }}</p>
|
||||
<p>{{ item.comment ?? '-' }}</p>
|
||||
<p>
|
||||
<p class="min-w-0">
|
||||
<a
|
||||
v-if="item.receiptPath"
|
||||
:href="getReceiptUrl(props.apiBase, item.id)"
|
||||
:href="getKmReceiptUrl(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"/>
|
||||
<span>{{ item.receiptName ?? 'Télécharger' }}</span>
|
||||
<Icon name="mdi:file-download-outline" size="20" class="shrink-0"/>
|
||||
<span class="truncate">{{ item.receiptName ?? 'Télécharger' }}</span>
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
<p class="min-w-0">
|
||||
<a
|
||||
v-if="item.amountReceiptPath"
|
||||
:href="getAmountReceiptUrl(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.amountReceiptName ?? 'Télécharger' }}</span>
|
||||
</a>
|
||||
<span v-else>-</span>
|
||||
</p>
|
||||
@@ -48,7 +64,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<AppDrawer v-model="isDrawerOpen" title="Frais Kms">
|
||||
<AppDrawer v-model="isDrawerOpen" title="Frais">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-month">
|
||||
@@ -64,7 +80,7 @@
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-kilometers">
|
||||
Nombre de Km <span class="text-red-600">*</span>
|
||||
Nombre de Km
|
||||
</label>
|
||||
<input
|
||||
id="mileage-kilometers"
|
||||
@@ -77,20 +93,53 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-receipt">
|
||||
Justificatif
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-amount">
|
||||
Montant (€)
|
||||
</label>
|
||||
<input
|
||||
id="mileage-amount"
|
||||
v-model.number="form.amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
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 class="mt-1 text-sm text-neutral-500">Au moins un des deux champs doit être rempli</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="mileage-km-receipt">
|
||||
Justificatif Km
|
||||
</label>
|
||||
<div v-if="isEditing && editingItem?.receiptName" class="mt-1 text-sm text-neutral-500">
|
||||
Fichier actuel : {{ editingItem.receiptName }}
|
||||
</div>
|
||||
<input
|
||||
id="mileage-receipt"
|
||||
ref="fileInput"
|
||||
id="mileage-km-receipt"
|
||||
ref="kmFileInput"
|
||||
type="file"
|
||||
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="onFileChange"
|
||||
@change="onKmFileChange"
|
||||
/>
|
||||
<p v-if="fileError" class="mt-1 text-sm text-red-600">{{ fileError }}</p>
|
||||
<p v-if="kmFileError" class="mt-1 text-sm text-red-600">{{ kmFileError }}</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="mileage-amount-receipt">
|
||||
Justificatif Montant
|
||||
</label>
|
||||
<div v-if="isEditing && editingItem?.amountReceiptName" class="mt-1 text-sm text-neutral-500">
|
||||
Fichier actuel : {{ editingItem.amountReceiptName }}
|
||||
</div>
|
||||
<input
|
||||
id="mileage-amount-receipt"
|
||||
ref="amountFileInput"
|
||||
type="file"
|
||||
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="onAmountFileChange"
|
||||
/>
|
||||
<p v-if="amountFileError" class="mt-1 text-sm text-red-600">{{ amountFileError }}</p>
|
||||
<p v-else class="mt-1 text-sm text-neutral-500">Fichier au format pdf</p>
|
||||
</div>
|
||||
|
||||
@@ -139,7 +188,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {MileageAllowance} from '~/services/dto/mileage-allowance'
|
||||
import {getReceiptUrl} from '~/services/mileage-allowances'
|
||||
import {getKmReceiptUrl, getAmountReceiptUrl} from '~/services/mileage-allowances'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -148,17 +197,20 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; kilometers: number; comment?: string }, file?: File): void
|
||||
(event: 'update', id: number, data: { month: string; kilometers: number; comment?: string }, file?: File): void
|
||||
(event: 'create', data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
|
||||
(event: 'update', id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<MileageAllowance | null>(null)
|
||||
const selectedFile = ref<File | undefined>(undefined)
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const fileError = ref('')
|
||||
const selectedKmFile = ref<File | undefined>(undefined)
|
||||
const selectedAmountFile = ref<File | undefined>(undefined)
|
||||
const kmFileInput = ref<HTMLInputElement | null>(null)
|
||||
const amountFileInput = ref<HTMLInputElement | null>(null)
|
||||
const kmFileError = ref('')
|
||||
const amountFileError = ref('')
|
||||
|
||||
const currentYearMonth = () => {
|
||||
const now = new Date()
|
||||
@@ -168,11 +220,12 @@ const currentYearMonth = () => {
|
||||
const form = reactive({
|
||||
month: currentYearMonth(),
|
||||
kilometers: 0,
|
||||
amount: 0,
|
||||
comment: ''
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && form.kilometers > 0 && !fileError.value
|
||||
return form.month && (form.kilometers > 0 || form.amount > 0) && !kmFileError.value && !amountFileError.value
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
@@ -201,11 +254,17 @@ const formatMonth = (dateStr: string): string => {
|
||||
const resetForm = () => {
|
||||
form.month = currentYearMonth()
|
||||
form.kilometers = 0
|
||||
form.amount = 0
|
||||
form.comment = ''
|
||||
selectedFile.value = undefined
|
||||
fileError.value = ''
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
selectedKmFile.value = undefined
|
||||
selectedAmountFile.value = undefined
|
||||
kmFileError.value = ''
|
||||
amountFileError.value = ''
|
||||
if (kmFileInput.value) {
|
||||
kmFileInput.value.value = ''
|
||||
}
|
||||
if (amountFileInput.value) {
|
||||
amountFileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,38 +281,57 @@ const onOpenEditDrawer = (item: MileageAllowance) => {
|
||||
// Extract YYYY-MM from YYYY-MM-DD
|
||||
form.month = item.month.substring(0, 7)
|
||||
form.kilometers = item.kilometers
|
||||
form.amount = item.amount
|
||||
form.comment = item.comment ?? ''
|
||||
selectedFile.value = undefined
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
selectedKmFile.value = undefined
|
||||
selectedAmountFile.value = undefined
|
||||
if (kmFileInput.value) {
|
||||
kmFileInput.value.value = ''
|
||||
}
|
||||
if (amountFileInput.value) {
|
||||
amountFileInput.value.value = ''
|
||||
}
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const onKmFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
fileError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedFile.value = undefined
|
||||
kmFileError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedKmFile.value = undefined
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
fileError.value = ''
|
||||
selectedFile.value = file ?? undefined
|
||||
kmFileError.value = ''
|
||||
selectedKmFile.value = file ?? undefined
|
||||
}
|
||||
|
||||
const onAmountFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (file && file.type !== 'application/pdf') {
|
||||
amountFileError.value = 'Seuls les fichiers PDF sont acceptés.'
|
||||
selectedAmountFile.value = undefined
|
||||
target.value = ''
|
||||
return
|
||||
}
|
||||
amountFileError.value = ''
|
||||
selectedAmountFile.value = file ?? undefined
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
month: `${form.month}-01`,
|
||||
kilometers: form.kilometers,
|
||||
amount: form.amount,
|
||||
comment: form.comment || undefined
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data, selectedFile.value)
|
||||
emit('update', editingItem.value.id, data, selectedKmFile.value, selectedAmountFile.value)
|
||||
} else {
|
||||
emit('create', data, selectedFile.value)
|
||||
emit('create', data, selectedKmFile.value, selectedAmountFile.value)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
187
frontend/components/employees/ObservationTab.vue
Normal file
187
frontend/components/employees/ObservationTab.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-2 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md">
|
||||
<p>Mois</p>
|
||||
<p>Observation</p>
|
||||
</div>
|
||||
<div v-if="observations.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 observation.
|
||||
</div>
|
||||
<div v-else class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<div
|
||||
v-for="item in observations"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-2 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>{{ formatMonth(item.month) }}</p>
|
||||
<p class="truncate">{{ item.content }}</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="isEditing ? 'Modification observation' : 'Nouvelle observation'">
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700" for="observation-month">
|
||||
Mois <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="observation-month"
|
||||
v-model="form.month"
|
||||
type="month"
|
||||
class="capitalize 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="observation-content">
|
||||
Observation <span class="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observation-content"
|
||||
v-model="form.content"
|
||||
rows="5"
|
||||
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="Observation..."
|
||||
/>
|
||||
</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 { Observation } from '~/services/dto/observation'
|
||||
import AppDrawer from '~/components/AppDrawer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
observations: Observation[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; content: string }): void
|
||||
(event: 'update', id: number, data: { month: string; content: string }): void
|
||||
(event: 'delete', id: number): void
|
||||
}>()
|
||||
|
||||
const isDrawerOpen = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editingItem = ref<Observation | null>(null)
|
||||
|
||||
const currentYearMonth = () => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const form = reactive({
|
||||
month: currentYearMonth(),
|
||||
content: ''
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && form.content.trim().length > 0
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
1: 'Janvier',
|
||||
2: 'Février',
|
||||
3: 'Mars',
|
||||
4: 'Avril',
|
||||
5: 'Mai',
|
||||
6: 'Juin',
|
||||
7: 'Juillet',
|
||||
8: 'Août',
|
||||
9: 'Septembre',
|
||||
10: 'Octobre',
|
||||
11: 'Novembre',
|
||||
12: 'Décembre'
|
||||
}
|
||||
|
||||
const formatMonth = (dateStr: string): string => {
|
||||
const date = new Date(dateStr)
|
||||
if (Number.isNaN(date.getTime())) return dateStr
|
||||
const month = date.getMonth() + 1
|
||||
const year = date.getFullYear()
|
||||
return `${monthLabels[month]} ${year}`
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
form.month = currentYearMonth()
|
||||
form.content = ''
|
||||
}
|
||||
|
||||
const onOpenCreateDrawer = () => {
|
||||
isEditing.value = false
|
||||
editingItem.value = null
|
||||
resetForm()
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onOpenEditDrawer = (item: Observation) => {
|
||||
isEditing.value = true
|
||||
editingItem.value = item
|
||||
form.month = item.month.substring(0, 7)
|
||||
form.content = item.content
|
||||
isDrawerOpen.value = true
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
const data = {
|
||||
month: `${form.month}-01`,
|
||||
content: form.content
|
||||
}
|
||||
|
||||
if (isEditing.value && editingItem.value) {
|
||||
emit('update', editingItem.value.id, data)
|
||||
} else {
|
||||
emit('create', data)
|
||||
}
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
|
||||
const onDelete = () => {
|
||||
if (!editingItem.value) return
|
||||
const ok = window.confirm('Supprimer cette observation ?')
|
||||
if (!ok) return
|
||||
emit('delete', editingItem.value.id)
|
||||
isDrawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -22,15 +22,15 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[16px]">
|
||||
<span class="font-bold">RTT À LA DATE DU JOUR :</span>
|
||||
{{ formatMinutes(summary?.availableMinutes ?? 0) }}
|
||||
<span class="font-bold">RTT À LA SEMAINE {{ lastCompleteWeek }} : </span>
|
||||
<span class="font-bold">{{ formatMinutes(summary?.availableMinutes ?? 0) }}</span>
|
||||
</p>
|
||||
<div class="flex justify-center">
|
||||
<button
|
||||
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>
|
||||
@@ -64,29 +64,29 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Report N-1 row (RTT rollover carry, June only) -->
|
||||
<tr v-if="showCarryRow">
|
||||
<tr v-if="showCarryRow" class="bg-tertiary-500">
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase25Minutes + summary!.carryBonus25Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBase50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBonus50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryBase50Minutes + summary!.carryBonus50Minutes) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(summary!.carryFromPreviousYearMinutes) }} <span class="text-neutral-400">/ {{ formatCentiemes(summary!.carryFromPreviousYearMinutes) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Report mois précédent (cumulated balance from previous months, July+) -->
|
||||
<tr v-if="showMonthReportRow">
|
||||
<tr v-if="showMonthReportRow" class="bg-tertiary-500">
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Report</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.base50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.bonus50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(monthReport.total) }}</span></td>
|
||||
</tr>
|
||||
|
||||
<!-- Week rows (always 5) -->
|
||||
@@ -103,11 +103,11 @@
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.base25Minutes) }}</span>
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base25Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.bonus25Minutes) }}</span>
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus25Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
@@ -115,11 +115,11 @@
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.base50Minutes) }}</span>
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.base50Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">
|
||||
<span v-if="week">{{ formatMinutes(week.bonus50Minutes) }}</span>
|
||||
<span v-if="week">{{ formatMinutes(week.totalMinutes >= 0 ? week.bonus50Minutes : 0) }}</span>
|
||||
<span v-else>0 h</span>
|
||||
</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">
|
||||
@@ -162,13 +162,13 @@
|
||||
<tr>
|
||||
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Reste</td>
|
||||
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base25 + totals.base25 - (currentPayment?.paidBase25Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus25 + totals.bonus25 - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total25 + totals.total25 - (currentPayment?.paidBase25Minutes ?? 0) - (currentPayment?.paidBonus25Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.base50 + totals.base50 - (currentPayment?.paidBase50Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(monthReport.bonus50 + totals.bonus50 - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(monthReport.total50 + totals.total50 - (currentPayment?.paidBase50Minutes ?? 0) - (currentPayment?.paidBonus50Minutes ?? 0)) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(resteTotal) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total25) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.base50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.bonus50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total50) }}</span></td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }} <span class="text-neutral-400">/ {{ formatCentiemes(reste.total) }}</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -258,6 +258,17 @@ const emit = defineEmits<{
|
||||
(event: 'submit-rtt-payment', month: number, base25Minutes: number, bonus25Minutes: number, base50Minutes: number, bonus50Minutes: number): void
|
||||
}>()
|
||||
|
||||
// --- Last complete week number ---
|
||||
|
||||
const lastCompleteWeek = computed(() => {
|
||||
const now = new Date()
|
||||
const startOfYear = new Date(now.getFullYear(), 0, 1)
|
||||
const dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000) + 1
|
||||
const dayOfWeek = now.getDay() || 7 // Monday = 1, Sunday = 7
|
||||
const currentWeek = Math.ceil((dayOfYear - dayOfWeek + 10) / 7)
|
||||
return currentWeek - 1
|
||||
})
|
||||
|
||||
// --- Month navigation ---
|
||||
|
||||
const orderedMonths = [6, 7, 8, 9, 10, 11, 12, 1, 2, 3, 4, 5] as const
|
||||
@@ -419,13 +430,14 @@ const showMonthReportRow = computed(() => {
|
||||
|
||||
const totals = computed(() => {
|
||||
const weeks = weeksForCurrentMonth.value
|
||||
const positive = weeks.filter((w) => w.totalMinutes >= 0)
|
||||
return {
|
||||
overtime: weeks.reduce((s, w) => s + w.overtimeMinutes, 0),
|
||||
base25: weeks.reduce((s, w) => s + w.base25Minutes, 0),
|
||||
bonus25: weeks.reduce((s, w) => s + w.bonus25Minutes, 0),
|
||||
base25: positive.reduce((s, w) => s + w.base25Minutes, 0),
|
||||
bonus25: positive.reduce((s, w) => s + w.bonus25Minutes, 0),
|
||||
total25: weeks.reduce((s, w) => s + w.base25Minutes + w.bonus25Minutes, 0),
|
||||
base50: weeks.reduce((s, w) => s + w.base50Minutes, 0),
|
||||
bonus50: weeks.reduce((s, w) => s + w.bonus50Minutes, 0),
|
||||
base50: positive.reduce((s, w) => s + w.base50Minutes, 0),
|
||||
bonus50: positive.reduce((s, w) => s + w.bonus50Minutes, 0),
|
||||
total50: weeks.reduce((s, w) => s + w.base50Minutes + w.bonus50Minutes, 0),
|
||||
total: weeks.reduce((s, w) => s + w.totalMinutes, 0),
|
||||
}
|
||||
@@ -442,8 +454,19 @@ const paidTotal = computed(() => {
|
||||
return -(p.paidBase25Minutes + p.paidBonus25Minutes + p.paidBase50Minutes + p.paidBonus50Minutes)
|
||||
})
|
||||
|
||||
const resteTotal = computed(() => {
|
||||
return monthReport.value.total + totals.value.total + paidTotal.value
|
||||
const reste = computed(() => {
|
||||
const total25 = monthReport.value.total25 + totals.value.total25
|
||||
- (currentPayment.value?.paidBase25Minutes ?? 0) - (currentPayment.value?.paidBonus25Minutes ?? 0)
|
||||
const total50 = monthReport.value.total50 + totals.value.total50
|
||||
- (currentPayment.value?.paidBase50Minutes ?? 0) - (currentPayment.value?.paidBonus50Minutes ?? 0)
|
||||
|
||||
const base25 = Math.round(total25 / 1.25)
|
||||
const bonus25 = total25 - base25
|
||||
const base50 = Math.round(total50 / 1.5)
|
||||
const bonus50 = total50 - base50
|
||||
const total = monthReport.value.total + totals.value.total + paidTotal.value
|
||||
|
||||
return { base25, bonus25, total25, base50, bonus50, total50, total }
|
||||
})
|
||||
|
||||
// --- Format ---
|
||||
@@ -458,6 +481,11 @@ const formatMinutes = (minutes: number): string => {
|
||||
return `${sign}${hours} h ${rest} m`
|
||||
}
|
||||
|
||||
const formatCentiemes = (minutes: number): string => {
|
||||
const value = minutes / 60
|
||||
return value.toFixed(2).replace('.', ',')
|
||||
}
|
||||
|
||||
// --- Payment drawer ---
|
||||
|
||||
const isPaymentDrawerOpen = ref(false)
|
||||
|
||||
@@ -69,15 +69,26 @@
|
||||
</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="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)"
|
||||
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'"
|
||||
@@ -231,6 +242,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,6 +71,17 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
return history.find((item) => item.startDate <= today && (!item.endDate || item.endDate >= today)) ?? null
|
||||
})
|
||||
|
||||
const lastEndedContractPeriod = computed(() => {
|
||||
if (currentActiveContractPeriod.value) return null
|
||||
const today = getTodayYmd()
|
||||
const history = employee.value?.contractHistory ?? []
|
||||
const ended = history.filter((item) => item.endDate && item.endDate < today)
|
||||
if (ended.length === 0) return null
|
||||
return ended.reduce((latest, item) => (item.endDate! > latest.endDate! ? item : latest))
|
||||
})
|
||||
|
||||
const editableContractPeriod = computed(() => currentActiveContractPeriod.value ?? lastEndedContractPeriod.value)
|
||||
|
||||
const currentActiveContractPeriodId = computed<number | null>(() => {
|
||||
const period = currentActiveContractPeriod.value
|
||||
return period?.periodId ?? null
|
||||
@@ -78,13 +89,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
|
||||
const canCloseCurrentContract = computed(() => {
|
||||
const active = currentActiveContractPeriod.value
|
||||
if (!active) return false
|
||||
if (!active.endDate) return true
|
||||
return active.endDate > getTodayYmd()
|
||||
if (active) {
|
||||
if (!active.endDate) return true
|
||||
return active.endDate > getTodayYmd()
|
||||
}
|
||||
return !!lastEndedContractPeriod.value
|
||||
})
|
||||
|
||||
const canCreateContract = computed(() => {
|
||||
const active = currentActiveContractPeriod.value
|
||||
const active = editableContractPeriod.value
|
||||
if (!active) return true
|
||||
return !!active.endDate
|
||||
})
|
||||
@@ -135,15 +148,15 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
|
||||
const hydrateContractFormFromCurrent = () => {
|
||||
const current = employee.value
|
||||
const active = currentActiveContractPeriod.value
|
||||
if (!current || !active) return
|
||||
const period = editableContractPeriod.value
|
||||
if (!current || !period) return
|
||||
|
||||
contractForm.contractId = active.contractId ?? current.contract?.id ?? ''
|
||||
contractForm.contractName = active.contractName ?? current.contract?.name ?? ''
|
||||
contractForm.weeklyHours = active.weeklyHours ?? current.contract?.weeklyHours ?? null
|
||||
contractForm.contractNature = active.contractNature
|
||||
contractForm.startDate = active.startDate
|
||||
contractForm.endDate = getTodayYmd()
|
||||
contractForm.contractId = period.contractId ?? current.contract?.id ?? ''
|
||||
contractForm.contractName = period.contractName ?? current.contract?.name ?? ''
|
||||
contractForm.weeklyHours = period.weeklyHours ?? current.contract?.weeklyHours ?? null
|
||||
contractForm.contractNature = period.contractNature
|
||||
contractForm.startDate = period.startDate
|
||||
contractForm.endDate = period.endDate ?? getTodayYmd()
|
||||
contractForm.paidLeaveSettled = false
|
||||
contractForm.comment = ''
|
||||
}
|
||||
@@ -173,8 +186,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
createContractForm.contractNature = 'CDI'
|
||||
createContractForm.endDate = ''
|
||||
createContractForm.isDriver = false
|
||||
createContractForm.startDate = currentActiveContractPeriod.value?.endDate
|
||||
? (shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate)
|
||||
createContractForm.startDate = editableContractPeriod.value?.endDate
|
||||
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
|
||||
: getTodayYmd()
|
||||
resetCreateValidation()
|
||||
isCreateContractDrawerOpen.value = true
|
||||
@@ -185,15 +198,16 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
}
|
||||
|
||||
const submitContractUpdate = async () => {
|
||||
if (!employee.value || isContractSubmitting.value || !currentActiveContractPeriod.value) return
|
||||
const period = editableContractPeriod.value
|
||||
if (!employee.value || isContractSubmitting.value || !period) return
|
||||
|
||||
validationTouched.endDate = true
|
||||
if (!isContractEndDateValid.value) return
|
||||
|
||||
if (contractForm.endDate < currentActiveContractPeriod.value.startDate) {
|
||||
if (contractForm.endDate < period.startDate) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
message: `La date de fin doit être postérieure au ${formatDate(currentActiveContractPeriod.value.startDate)}.`
|
||||
message: `La date de fin doit être postérieure au ${formatDate(period.startDate)}.`
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -226,8 +240,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
||||
createValidationTouched.endDate = true
|
||||
if (!isCreateContractFormValid.value) return
|
||||
|
||||
if (currentActiveContractPeriod.value?.endDate) {
|
||||
const minStartDate = shiftYmd(currentActiveContractPeriod.value.endDate, 1) ?? currentActiveContractPeriod.value.endDate
|
||||
if (editableContractPeriod.value?.endDate) {
|
||||
const minStartDate = shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate
|
||||
if (createContractForm.startDate < minStartDate) {
|
||||
toast.error({
|
||||
title: 'Erreur',
|
||||
|
||||
@@ -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'>('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,7 +39,9 @@ export const useEmployeeDetailPage = () => {
|
||||
leave.resetLoaded()
|
||||
rtt.resetLoaded()
|
||||
mileage.resetLoaded()
|
||||
formation.resetLoaded()
|
||||
bonus.resetLoaded()
|
||||
observation.resetLoaded()
|
||||
|
||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||
await leave.loadLeaveData()
|
||||
@@ -47,8 +49,12 @@ 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') {
|
||||
await observation.loadObservationData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -59,7 +65,9 @@ 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)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||
@@ -68,8 +76,12 @@ 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) {
|
||||
observation.loadObservationData()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +101,8 @@ export const useEmployeeDetailPage = () => {
|
||||
...leave,
|
||||
...rtt,
|
||||
...mileage,
|
||||
...bonus
|
||||
...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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
createMileageAllowance,
|
||||
updateMileageAllowance,
|
||||
deleteMileageAllowance,
|
||||
uploadReceipt
|
||||
uploadKmReceipt,
|
||||
uploadAmountReceipt
|
||||
} from '~/services/mileage-allowances'
|
||||
|
||||
export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
@@ -32,24 +33,33 @@ export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmploye
|
||||
mileageDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateMileage = async (data: { month: string; kilometers: number; comment?: string }, file?: File) => {
|
||||
const submitCreateMileage = async (data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
|
||||
if (!employee.value) return
|
||||
const result = await createMileageAllowance({
|
||||
employeeId: employee.value.id,
|
||||
month: data.month,
|
||||
kilometers: data.kilometers,
|
||||
amount: data.amount,
|
||||
comment: data.comment
|
||||
})
|
||||
if (file && result?.id) {
|
||||
await uploadReceipt(apiBase, result.id, file)
|
||||
if (result?.id) {
|
||||
if (kmFile) {
|
||||
await uploadKmReceipt(apiBase, result.id, kmFile)
|
||||
}
|
||||
if (amountFile) {
|
||||
await uploadAmountReceipt(apiBase, result.id, amountFile)
|
||||
}
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; comment?: string }, file?: File) => {
|
||||
const submitUpdateMileage = async (id: number, data: { month: string; kilometers: number; amount: number; comment?: string }, kmFile?: File, amountFile?: File) => {
|
||||
await updateMileageAllowance(id, data)
|
||||
if (file) {
|
||||
await uploadReceipt(apiBase, id, file)
|
||||
if (kmFile) {
|
||||
await uploadKmReceipt(apiBase, id, kmFile)
|
||||
}
|
||||
if (amountFile) {
|
||||
await uploadAmountReceipt(apiBase, id, amountFile)
|
||||
}
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
61
frontend/composables/useEmployeeObservation.ts
Normal file
61
frontend/composables/useEmployeeObservation.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Ref } from 'vue'
|
||||
import type { Observation } from '~/services/dto/observation'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import {
|
||||
listObservations,
|
||||
createObservation,
|
||||
updateObservation,
|
||||
deleteObservation
|
||||
} from '~/services/observations'
|
||||
|
||||
export const useEmployeeObservation = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||
const observations = ref<Observation[]>([])
|
||||
const isObservationLoading = ref(false)
|
||||
const observationDataLoaded = ref(false)
|
||||
|
||||
const loadObservationData = async () => {
|
||||
if (!employee.value || isObservationLoading.value) return
|
||||
isObservationLoading.value = true
|
||||
try {
|
||||
observations.value = await listObservations(employee.value.id)
|
||||
observationDataLoaded.value = true
|
||||
} finally {
|
||||
isObservationLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetLoaded = () => {
|
||||
observationDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateObservation = async (data: { month: string; content: string }) => {
|
||||
if (!employee.value) return
|
||||
await createObservation({
|
||||
employeeId: employee.value.id,
|
||||
month: data.month,
|
||||
content: data.content
|
||||
})
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitUpdateObservation = async (id: number, data: { month: string; content: string }) => {
|
||||
await updateObservation(id, data)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
const submitDeleteObservation = async (id: number) => {
|
||||
await deleteObservation(id)
|
||||
await reloadEmployee()
|
||||
}
|
||||
|
||||
return {
|
||||
observations,
|
||||
isObservationLoading,
|
||||
observationDataLoaded,
|
||||
loadObservationData,
|
||||
resetLoaded,
|
||||
submitCreateObservation,
|
||||
submitUpdateObservation,
|
||||
submitDeleteObservation
|
||||
}
|
||||
}
|
||||
@@ -476,6 +476,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 ''
|
||||
@@ -1045,7 +1053,7 @@ export const useHoursPage = () => {
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const entries = employees.value
|
||||
.filter((employee) => hasContractAtSelectedDate(employee.id))
|
||||
.filter((employee) => hasContractAtSelectedDate(employee.id) && !isRowLocked(employee.id))
|
||||
.map((employee) => {
|
||||
const employeeId = employee.id
|
||||
const row = rows.value[employeeId] ?? emptyRow()
|
||||
@@ -1154,6 +1162,8 @@ export const useHoursPage = () => {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
579
frontend/data/documentation-content.ts
Normal file
579
frontend/data/documentation-content.ts
Normal file
@@ -0,0 +1,579 @@
|
||||
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: '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,10 +42,20 @@
|
||||
"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.",
|
||||
"delete": "Impossible de supprimer la prime."
|
||||
},
|
||||
"observation": {
|
||||
"create": "Impossible de créer l'observation.",
|
||||
"update": "Impossible de mettre à jour l'observation.",
|
||||
"delete": "Impossible de supprimer l'observation."
|
||||
}
|
||||
},
|
||||
"success": {
|
||||
@@ -83,10 +93,20 @@
|
||||
"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.",
|
||||
"delete": "Prime supprimée."
|
||||
},
|
||||
"observation": {
|
||||
"create": "Observation créée.",
|
||||
"update": "Observation mise à jour.",
|
||||
"delete": "Observation supprimée."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-if="isAdmin || !isDriver"
|
||||
to="/hours"
|
||||
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="[
|
||||
@@ -30,12 +31,13 @@
|
||||
<p>Heures</p>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
v-if="isAdmin || isDriver"
|
||||
to="/driver-hours"
|
||||
class="flex items-center gap-2 py-2 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/driver-hours')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
:class="[
|
||||
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
|
||||
]"
|
||||
>
|
||||
<Icon name="mdi:truck-outline" size="24"/>
|
||||
<p>Heures Conducteurs</p>
|
||||
@@ -82,6 +84,27 @@
|
||||
<p>Utilisateurs</p>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
<NuxtLink
|
||||
v-if="isSuperAdmin"
|
||||
to="/audit-logs"
|
||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500"
|
||||
:class="route.path.startsWith('/audit-logs')
|
||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||
: ''"
|
||||
>
|
||||
<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">
|
||||
@@ -103,5 +126,7 @@
|
||||
const auth = useAuthStore()
|
||||
const {version} = useAppVersion()
|
||||
const isAdmin = computed(() => auth.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||
const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN') ?? false)
|
||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
12
frontend/middleware/super-admin.ts
Normal file
12
frontend/middleware/super-admin.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export default defineNuxtRouteMiddleware(async () => {
|
||||
const auth = useAuthStore()
|
||||
|
||||
if (!auth.checked) {
|
||||
await auth.ensureSession()
|
||||
}
|
||||
|
||||
const isSuperAdmin = auth.user?.roles?.includes('ROLE_SUPER_ADMIN')
|
||||
if (!isSuperAdmin) {
|
||||
return navigateTo('/')
|
||||
}
|
||||
})
|
||||
254
frontend/pages/audit-logs.vue
Normal file
254
frontend/pages/audit-logs.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col overflow-hidden">
|
||||
<h1 class="text-4xl font-bold text-primary-500 pb-6">Journal des actions</h1>
|
||||
|
||||
<div class="flex items-end gap-4 pb-6 flex-wrap">
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Employé</label>
|
||||
<select
|
||||
v-model="filters.employeeId"
|
||||
class="mt-2 h-[42px] 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"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option v-for="emp in employees" :key="emp.id" :value="emp.id">
|
||||
{{ emp.lastName }} {{ emp.firstName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-md font-semibold text-neutral-700">Du</label>
|
||||
<input
|
||||
v-model="filters.from"
|
||||
type="date"
|
||||
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>
|
||||
<label class="text-md font-semibold text-neutral-700">Au</label>
|
||||
<input
|
||||
v-model="filters.to"
|
||||
type="date"
|
||||
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>
|
||||
<label class="text-md font-semibold text-neutral-700">Type</label>
|
||||
<select
|
||||
v-model="filters.entityType"
|
||||
class="mt-2 h-[42px] 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"
|
||||
>
|
||||
<option :value="undefined">Tous</option>
|
||||
<option value="work_hour">Heures</option>
|
||||
<option value="absence">Absences</option>
|
||||
<option value="employee">Employé</option>
|
||||
<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
|
||||
type="button"
|
||||
class="h-[42px] rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
|
||||
@click="search"
|
||||
>
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
|
||||
<div v-else-if="logs.length === 0" class="rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Aucune entrée trouvée.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="min-h-0 flex-1 overflow-auto rounded-md bg-white">
|
||||
<div class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span>Date action</span>
|
||||
<span>Utilisateur</span>
|
||||
<span>Action</span>
|
||||
<span>Type</span>
|
||||
<span>Employé</span>
|
||||
<span>Description</span>
|
||||
<span>Date affectée</span>
|
||||
</div>
|
||||
<div class="border-x border-b border-primary-500 rounded-b-md">
|
||||
<template v-for="log in logs" :key="log.id">
|
||||
<div
|
||||
class="grid grid-cols-[140px_110px_90px_100px_150px_1fr_130px] items-center gap-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="toggleExpand(log.id)"
|
||||
>
|
||||
<span>{{ formatDateTime(log.createdAt) }}</span>
|
||||
<span>{{ log.username }}</span>
|
||||
<span>
|
||||
<span class="rounded px-2 py-0.5 text-xs font-bold text-white" :class="actionClass(log.action)">
|
||||
{{ actionLabel(log.action) }}
|
||||
</span>
|
||||
</span>
|
||||
<span>{{ entityTypeLabel(log.entityType) }}</span>
|
||||
<span>{{ log.employeeName ?? '-' }}</span>
|
||||
<span class="truncate font-normal" :title="log.description">{{ log.description }}</span>
|
||||
<span>{{ log.affectedDate ? formatDate(log.affectedDate) : '-' }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="expandedIds.has(log.id)"
|
||||
class="border-b border-primary-500 px-6 py-4 bg-neutral-50"
|
||||
>
|
||||
<div v-if="log.changes" class="grid grid-cols-2 gap-6 text-sm font-mono">
|
||||
<div v-if="log.changes.old">
|
||||
<p class="font-bold text-red-600 mb-2">Ancien</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.old, null, 2) }}</pre>
|
||||
</div>
|
||||
<div v-if="log.changes.new">
|
||||
<p class="font-bold text-green-600 mb-2">Nouveau</p>
|
||||
<pre class="whitespace-pre-wrap text-neutral-700">{{ JSON.stringify(log.changes.new, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-md text-neutral-400">Pas de détail disponible.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<p class="text-md text-neutral-500">
|
||||
{{ total }} résultat{{ total > 1 ? 's' : '' }} — page {{ currentPage }}/{{ totalPages }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage <= 1"
|
||||
class="rounded-lg border border-primary-500 px-4 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
Précédent
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="currentPage >= totalPages"
|
||||
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
Suivant
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import type { AuditLog } from '~/services/dto/audit-log'
|
||||
import type { Employee } from '~/services/dto/employee'
|
||||
import { fetchAuditLogs } from '~/services/audit-logs'
|
||||
import { listEmployees } from '~/services/employees'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'super-admin'
|
||||
})
|
||||
|
||||
useHead({ title: 'Journal des actions' })
|
||||
|
||||
const logs = ref<AuditLog[]>([])
|
||||
const employees = ref<Employee[]>([])
|
||||
const isLoading = ref(false)
|
||||
const expandedIds = ref(new Set<number>())
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const perPage = ref(50)
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / perPage.value)))
|
||||
|
||||
const filters = reactive<{
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
}>({})
|
||||
|
||||
const loadLogs = async (page = 1) => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const result = await fetchAuditLogs({ ...filters, page })
|
||||
logs.value = result.items
|
||||
total.value = result.total
|
||||
currentPage.value = result.page
|
||||
perPage.value = result.perPage
|
||||
expandedIds.value.clear()
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
loadLogs(1)
|
||||
}
|
||||
|
||||
const goToPage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value) {
|
||||
loadLogs(page)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (id: number) => {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const formatDateTime = (dt: string) => {
|
||||
const d = new Date(dt)
|
||||
return d.toLocaleDateString('fr-FR') + ' ' + d.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const formatDate = (d: string) => {
|
||||
return d.split('-').reverse().join('/')
|
||||
}
|
||||
|
||||
const actionLabel = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'Créer',
|
||||
update: 'Modifier',
|
||||
delete: 'Suppr.',
|
||||
validate: 'Valid.',
|
||||
site_validate: 'Valid. site',
|
||||
}
|
||||
return map[action] ?? action
|
||||
}
|
||||
|
||||
const actionClass = (action: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
create: 'bg-green-500',
|
||||
update: 'bg-blue-500',
|
||||
delete: 'bg-red-500',
|
||||
validate: 'bg-purple-500',
|
||||
site_validate: 'bg-indigo-500',
|
||||
}
|
||||
return map[action] ?? 'bg-neutral-500'
|
||||
}
|
||||
|
||||
const entityTypeLabel = (type: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
work_hour: 'Heures',
|
||||
absence: 'Absence',
|
||||
employee: 'Employé',
|
||||
contract_suspension: 'Suspension',
|
||||
rtt_payment: 'RTT',
|
||||
fractioned_days: 'Fract.',
|
||||
paid_leave_days: 'Congés payés',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
employees.value = await listEmployees()
|
||||
await loadLogs()
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
@@ -13,7 +13,16 @@
|
||||
<div v-else class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<h1 class="text-[32px] font-bold">{{ employee.firstName }} {{ employee.lastName }}</h1>
|
||||
<button
|
||||
class="inline-flex items-center justify-center rounded-md p-1 transition-colors duration-150 focus:outline-none focus-visible:ring-2 bg-primary-500 hover:bg-secondary-500 active:bg-primary-500 text-white cursor-pointer"
|
||||
title="Export heures annuelles"
|
||||
@click="isYearlyHoursDrawerOpen = true"
|
||||
>
|
||||
<Icon name="mdi:printer" size="24" />
|
||||
</button>
|
||||
</div>
|
||||
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
@@ -62,8 +71,18 @@
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="activeTab = 'mileage'"
|
||||
>
|
||||
<Icon name="mdi:car-outline" size="24" class="align-self"/>
|
||||
Frais Kms
|
||||
<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"
|
||||
@@ -75,6 +94,16 @@
|
||||
<Icon name="mdi:money-100" size="24" class="align-self"/>
|
||||
Prime
|
||||
</button>
|
||||
<button
|
||||
class="pb-2 border-b-2 flex items-center gap-3"
|
||||
:class="activeTab === 'observation'
|
||||
? 'border-primary-500 text-primary-500'
|
||||
: 'border-transparent text-primary-500/50 hover:text-primary-500'"
|
||||
@click="activeTab = 'observation'"
|
||||
>
|
||||
<Icon name="mdi:note-text-outline" size="24" class="align-self"/>
|
||||
Observation
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1">
|
||||
@@ -129,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">
|
||||
@@ -151,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...
|
||||
@@ -164,12 +208,39 @@
|
||||
@delete="submitDeleteBonus"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="activeTab === 'observation'" class="h-full">
|
||||
<div v-if="isObservationLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||
Chargement...
|
||||
</div>
|
||||
<EmployeesObservationTab
|
||||
v-else
|
||||
class="h-full"
|
||||
:observations="observations"
|
||||
@create="submitCreateObservation"
|
||||
@update="submitUpdateObservation"
|
||||
@delete="submitDeleteObservation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EmployeeYearlyHoursDrawer
|
||||
v-if="employee"
|
||||
v-model="isYearlyHoursDrawerOpen"
|
||||
:employee-id="employee.id"
|
||||
@submit="handleYearlyHoursPrint"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import EmployeeYearlyHoursDrawer from '~/components/EmployeeYearlyHoursDrawer.vue'
|
||||
import { usePdfPrinter } from '~/composables/usePdfPrinter'
|
||||
|
||||
const { printPdf } = usePdfPrinter()
|
||||
const isYearlyHoursDrawerOpen = ref(false)
|
||||
|
||||
const {
|
||||
employee,
|
||||
isLoading,
|
||||
@@ -213,6 +284,7 @@ const {
|
||||
submitContractUpdate,
|
||||
submitCreateContract,
|
||||
submitFractionedDays,
|
||||
submitPaidLeaveDays,
|
||||
submitRttPayment,
|
||||
suspensionForms,
|
||||
isSuspensionSubmitting,
|
||||
@@ -227,13 +299,30 @@ const {
|
||||
submitCreateMileage,
|
||||
submitUpdateMileage,
|
||||
submitDeleteMileage,
|
||||
formations,
|
||||
isFormationLoading,
|
||||
formationApiBase,
|
||||
submitCreateFormation,
|
||||
submitUpdateFormation,
|
||||
submitDeleteFormation,
|
||||
bonuses,
|
||||
isBonusLoading,
|
||||
submitCreateBonus,
|
||||
submitUpdateBonus,
|
||||
submitDeleteBonus
|
||||
submitDeleteBonus,
|
||||
observations,
|
||||
isObservationLoading,
|
||||
submitCreateObservation,
|
||||
submitUpdateObservation,
|
||||
submitDeleteObservation
|
||||
} = useEmployeeDetailPage()
|
||||
|
||||
const handleYearlyHoursPrint = async (year: number) => {
|
||||
if (!employee.value) return
|
||||
await printPdf(`/yearly-hours/print?employeeId=${employee.value.id}&year=${year}`)
|
||||
isYearlyHoursDrawerOpen.value = false
|
||||
}
|
||||
|
||||
useHead(() => ({
|
||||
title: employee.value
|
||||
? `${employee.value.firstName} ${employee.value.lastName}`
|
||||
|
||||
@@ -67,6 +67,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"
|
||||
@@ -177,6 +179,8 @@ const {
|
||||
getRowMetrics,
|
||||
getRowAbsenceLabel,
|
||||
getRowAbsenceStyle,
|
||||
hasRowFormation,
|
||||
getRowFormationLabel,
|
||||
getRowUpdatedAt,
|
||||
getPresenceDayValue,
|
||||
openAbsenceDrawer,
|
||||
|
||||
@@ -69,7 +69,8 @@ const handleSubmit = async () => {
|
||||
await auth.login(username.value, password.value)
|
||||
|
||||
const isAdmin = auth.user?.roles?.includes('ROLE_ADMIN')
|
||||
await router.push(isAdmin ? '/calendar' : '/hours')
|
||||
const isDriver = auth.user?.isDriver
|
||||
await router.push(isAdmin ? '/calendar' : isDriver ? '/driver-hours' : '/hours')
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
||||
<div class="grid grid-cols-4 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||
<span class="text-left">Utilisateur</span>
|
||||
<span class="text-left">Employé</span>
|
||||
<span class="text-left">Accès</span>
|
||||
<span class="text-left">Sites</span>
|
||||
<span class="text-left">Statut</span>
|
||||
</div>
|
||||
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
|
||||
Chargement...
|
||||
@@ -32,7 +33,7 @@
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
class="grid grid-cols-4 items-center gap-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"
|
||||
class="grid grid-cols-5 items-center gap-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="openEdit(user)"
|
||||
>
|
||||
<span>{{ user.username }}</span>
|
||||
@@ -41,6 +42,16 @@
|
||||
</span>
|
||||
<span>{{ getAccessLabel(user) }}</span>
|
||||
<span>{{ getSiteLabels(user) }}</span>
|
||||
<span>
|
||||
<span
|
||||
v-if="user.isLocked"
|
||||
class="inline-block rounded-full bg-red-100 px-3 py-1 text-sm font-semibold text-red-700"
|
||||
>Verrouillé</span>
|
||||
<span
|
||||
v-else
|
||||
class="inline-block rounded-full bg-green-100 px-3 py-1 text-sm font-semibold text-green-700"
|
||||
>Actif</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,6 +175,20 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="form.isLocked"
|
||||
type="checkbox"
|
||||
class="cursor-pointer"
|
||||
/>
|
||||
<span class="text-md font-semibold text-neutral-700">Verrouiller le compte</span>
|
||||
</label>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
Un compte verrouillé ne peut plus se connecter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
@@ -207,7 +232,8 @@ const form = reactive({
|
||||
password: '',
|
||||
accessMode: 'admin' as 'admin' | 'self' | 'sites',
|
||||
employeeId: '' as number | '',
|
||||
siteIds: [] as number[]
|
||||
siteIds: [] as number[],
|
||||
isLocked: false
|
||||
})
|
||||
|
||||
const validationTouched = reactive({
|
||||
@@ -318,6 +344,7 @@ const resetForm = () => {
|
||||
form.employeeId = ''
|
||||
form.accessMode = 'admin'
|
||||
form.siteIds = []
|
||||
form.isLocked = false
|
||||
editingUser.value = null
|
||||
validationTouched.username = false
|
||||
validationTouched.password = false
|
||||
@@ -345,6 +372,7 @@ const openEdit = (user: User) => {
|
||||
}
|
||||
|
||||
form.employeeId = user.employee?.id ?? ''
|
||||
form.isLocked = user.isLocked
|
||||
|
||||
const siteRoles = userAccessById.value.get(user.id) ?? []
|
||||
form.siteIds = siteRoles.map((role) => role.site?.id).filter((id): id is number => typeof id === 'number')
|
||||
@@ -398,7 +426,8 @@ const handleSubmit = async () => {
|
||||
username: form.username,
|
||||
plainPassword: form.password.trim() ? form.password : undefined,
|
||||
roles,
|
||||
employeeId
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
})
|
||||
|
||||
const existingSiteRoles = userAccessById.value.get(editingUser.value.id) ?? []
|
||||
@@ -422,7 +451,8 @@ const handleSubmit = async () => {
|
||||
username: form.username,
|
||||
plainPassword: form.password,
|
||||
roles,
|
||||
employeeId
|
||||
employeeId,
|
||||
isLocked: form.isLocked
|
||||
})
|
||||
|
||||
if (form.accessMode === 'sites' && form.siteIds.length > 0) {
|
||||
|
||||
33
frontend/services/audit-logs.ts
Normal file
33
frontend/services/audit-logs.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { AuditLog } from './dto/audit-log'
|
||||
|
||||
export type AuditLogFilters = {
|
||||
employeeId?: number
|
||||
from?: string
|
||||
to?: string
|
||||
entityType?: string
|
||||
page?: number
|
||||
}
|
||||
|
||||
export type AuditLogPage = {
|
||||
items: AuditLog[]
|
||||
total: number
|
||||
page: number
|
||||
perPage: number
|
||||
}
|
||||
|
||||
export const fetchAuditLogs = async (filters: AuditLogFilters = {}): Promise<AuditLogPage> => {
|
||||
const api = useApi()
|
||||
const params: Record<string, string> = {}
|
||||
|
||||
if (filters.employeeId) params.employeeId = String(filters.employeeId)
|
||||
if (filters.from) params.from = filters.from
|
||||
if (filters.to) params.to = filters.to
|
||||
if (filters.entityType) params.entityType = filters.entityType
|
||||
if (filters.page) params.page = String(filters.page)
|
||||
|
||||
return api.get<AuditLogPage>(
|
||||
'/audit-logs',
|
||||
params,
|
||||
{ toast: false }
|
||||
)
|
||||
}
|
||||
12
frontend/services/dto/audit-log.ts
Normal file
12
frontend/services/dto/audit-log.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export type AuditLog = {
|
||||
id: number
|
||||
employeeName: string | null
|
||||
employeeId: number | null
|
||||
username: string
|
||||
action: string
|
||||
entityType: string
|
||||
description: string
|
||||
changes: { old?: Record<string, unknown>; new?: Record<string, unknown> } | null
|
||||
affectedDate: string | null
|
||||
createdAt: string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,8 +2,11 @@ export type MileageAllowance = {
|
||||
id: number
|
||||
month: string
|
||||
kilometers: number
|
||||
amount: number
|
||||
comment: string | null
|
||||
receiptPath: string | null
|
||||
receiptName: string | null
|
||||
amountReceiptPath: string | null
|
||||
amountReceiptName: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
6
frontend/services/dto/observation.ts
Normal file
6
frontend/services/dto/observation.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type Observation = {
|
||||
id: number
|
||||
month: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -2,4 +2,5 @@ export type UserData = {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
isDriver: boolean
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ export type User = {
|
||||
id: number
|
||||
username: string
|
||||
roles: string[]
|
||||
isLocked: boolean
|
||||
employee?: Employee | null
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const createMileageAllowance = async (data: {
|
||||
employeeId: number
|
||||
month: string
|
||||
kilometers: number
|
||||
amount: number
|
||||
comment?: string
|
||||
}) => {
|
||||
const api = useApi()
|
||||
@@ -23,6 +24,7 @@ export const createMileageAllowance = async (data: {
|
||||
employee: `/api/employees/${data.employeeId}`,
|
||||
month: data.month,
|
||||
kilometers: data.kilometers,
|
||||
amount: data.amount,
|
||||
comment: data.comment
|
||||
}, {
|
||||
toastSuccessKey: 'success.mileage.create',
|
||||
@@ -33,12 +35,14 @@ export const createMileageAllowance = async (data: {
|
||||
export const updateMileageAllowance = async (id: number, data: {
|
||||
month: string
|
||||
kilometers: number
|
||||
amount: number
|
||||
comment?: string
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.patch<MileageAllowance>(`/mileage_allowances/${id}`, {
|
||||
month: data.month,
|
||||
kilometers: data.kilometers,
|
||||
amount: data.amount,
|
||||
comment: data.comment
|
||||
}, {
|
||||
toastSuccessKey: 'success.mileage.update',
|
||||
@@ -54,7 +58,7 @@ export const deleteMileageAllowance = async (id: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadReceipt = async (baseURL: string, id: number, file: File) => {
|
||||
export const uploadKmReceipt = async (baseURL: string, id: number, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return $fetch(`${baseURL}/mileage_allowances/${id}/receipt`, {
|
||||
@@ -64,6 +68,20 @@ export const uploadReceipt = async (baseURL: string, id: number, file: File) =>
|
||||
})
|
||||
}
|
||||
|
||||
export const getReceiptUrl = (baseURL: string, id: number): string => {
|
||||
export const uploadAmountReceipt = async (baseURL: string, id: number, file: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
return $fetch(`${baseURL}/mileage_allowances/${id}/amount-receipt`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include'
|
||||
})
|
||||
}
|
||||
|
||||
export const getKmReceiptUrl = (baseURL: string, id: number): string => {
|
||||
return `${baseURL}/mileage_allowances/${id}/receipt`
|
||||
}
|
||||
|
||||
export const getAmountReceiptUrl = (baseURL: string, id: number): string => {
|
||||
return `${baseURL}/mileage_allowances/${id}/amount-receipt`
|
||||
}
|
||||
|
||||
50
frontend/services/observations.ts
Normal file
50
frontend/services/observations.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Observation } from './dto/observation'
|
||||
import { extractItems } from '~/utils/api'
|
||||
|
||||
export const listObservations = async (employeeId: number) => {
|
||||
const api = useApi()
|
||||
const data = await api.get<Observation[] | { 'hydra:member'?: Observation[] }>(
|
||||
'/observations',
|
||||
{ employee: `/api/employees/${employeeId}` },
|
||||
{ toast: false }
|
||||
)
|
||||
return extractItems<Observation>(data)
|
||||
}
|
||||
|
||||
export const createObservation = async (data: {
|
||||
employeeId: number
|
||||
month: string
|
||||
content: string
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<Observation>('/observations', {
|
||||
employee: `/api/employees/${data.employeeId}`,
|
||||
month: data.month,
|
||||
content: data.content
|
||||
}, {
|
||||
toastSuccessKey: 'success.observation.create',
|
||||
toastErrorKey: 'errors.observation.create'
|
||||
})
|
||||
}
|
||||
|
||||
export const updateObservation = async (id: number, data: {
|
||||
month: string
|
||||
content: string
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.patch<Observation>(`/observations/${id}`, {
|
||||
month: data.month,
|
||||
content: data.content
|
||||
}, {
|
||||
toastSuccessKey: 'success.observation.update',
|
||||
toastErrorKey: 'errors.observation.update'
|
||||
})
|
||||
}
|
||||
|
||||
export const deleteObservation = async (id: number) => {
|
||||
const api = useApi()
|
||||
return api.delete(`/observations/${id}`, {}, {
|
||||
toastSuccessKey: 'success.observation.delete',
|
||||
toastErrorKey: 'errors.observation.delete'
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,7 @@ export const createUser = async (payload: {
|
||||
plainPassword: string
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
return api.post<User>(
|
||||
@@ -24,7 +25,8 @@ export const createUser = async (payload: {
|
||||
username: payload.username,
|
||||
plainPassword: payload.plainPassword,
|
||||
roles: payload.roles,
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||
isLocked: payload.isLocked ?? false
|
||||
},
|
||||
{
|
||||
toastSuccessKey: 'success.user.create',
|
||||
@@ -38,12 +40,14 @@ export const updateUser = async (id: number, payload: {
|
||||
plainPassword?: string
|
||||
roles: string[]
|
||||
employeeId?: number | null
|
||||
isLocked?: boolean
|
||||
}) => {
|
||||
const api = useApi()
|
||||
const body: Record<string, unknown> = {
|
||||
username: payload.username,
|
||||
roles: payload.roles,
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null
|
||||
employee: payload.employeeId ? `/api/employees/${payload.employeeId}` : null,
|
||||
isLocked: payload.isLocked ?? false
|
||||
}
|
||||
|
||||
if (payload.plainPassword) {
|
||||
|
||||
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[]
|
||||
}
|
||||
26
migrations/Version20260318143503.php
Normal file
26
migrations/Version20260318143503.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260318143503 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add amount column to mileage_allowances';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE mileage_allowances ADD COLUMN amount DOUBLE PRECISION DEFAULT 0 NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount');
|
||||
}
|
||||
}
|
||||
28
migrations/Version20260319100000.php
Normal file
28
migrations/Version20260319100000.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260319100000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add amount receipt fields to mileage_allowances';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_path VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE mileage_allowances ADD amount_receipt_name VARCHAR(255) DEFAULT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_path');
|
||||
$this->addSql('ALTER TABLE mileage_allowances DROP COLUMN amount_receipt_name');
|
||||
}
|
||||
}
|
||||
32
migrations/Version20260325081258.php
Normal file
32
migrations/Version20260325081258.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260325081258 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create observations table with unique constraint on (employee_id, month)';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE observations (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, employee_id INT NOT NULL, month DATE NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY (id))');
|
||||
$this->addSql('CREATE INDEX IDX_BBC15BA88C03F15C ON observations (employee_id)');
|
||||
$this->addSql('CREATE UNIQUE INDEX uniq_observation_employee_month ON observations (employee_id, month)');
|
||||
$this->addSql('ALTER TABLE observations ADD CONSTRAINT FK_BBC15BA88C03F15C FOREIGN KEY (employee_id) REFERENCES employees (id) NOT DEFERRABLE');
|
||||
$this->addSql("COMMENT ON COLUMN observations.month IS '(DC2Type:date_immutable)'");
|
||||
$this->addSql("COMMENT ON COLUMN observations.created_at IS '(DC2Type:datetime_immutable)'");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE observations DROP CONSTRAINT FK_BBC15BA88C03F15C');
|
||||
$this->addSql('DROP TABLE observations');
|
||||
}
|
||||
}
|
||||
26
migrations/Version20260325084215.php
Normal file
26
migrations/Version20260325084215.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260325084215 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add is_locked column to users table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users ADD is_locked BOOLEAN DEFAULT false NOT NULL');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('ALTER TABLE users DROP is_locked');
|
||||
}
|
||||
}
|
||||
43
migrations/Version20260330120000.php
Normal file
43
migrations/Version20260330120000.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260330120000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create audit_logs table for tracking user actions.';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql('CREATE TABLE audit_logs (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER DEFAULT NULL,
|
||||
username VARCHAR(180) NOT NULL,
|
||||
action VARCHAR(30) NOT NULL,
|
||||
entity_type VARCHAR(50) NOT NULL,
|
||||
entity_id INTEGER DEFAULT NULL,
|
||||
description TEXT NOT NULL,
|
||||
changes JSON DEFAULT NULL,
|
||||
affected_date DATE DEFAULT NULL,
|
||||
created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL,
|
||||
CONSTRAINT fk_audit_employee FOREIGN KEY (employee_id) REFERENCES employees (id) ON DELETE SET NULL
|
||||
)');
|
||||
|
||||
$this->addSql('CREATE INDEX idx_audit_employee_created ON audit_logs (employee_id, created_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_entity ON audit_logs (entity_type, entity_id)');
|
||||
$this->addSql('CREATE INDEX idx_audit_created ON audit_logs (created_at)');
|
||||
$this->addSql('CREATE INDEX idx_audit_affected_date ON audit_logs (affected_date)');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql('DROP TABLE audit_logs');
|
||||
}
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
27
src/ApiResource/AuditLogResource.php
Normal file
27
src/ApiResource/AuditLogResource.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\QueryParameter;
|
||||
use App\State\AuditLogProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new GetCollection(
|
||||
uriTemplate: '/audit-logs',
|
||||
provider: AuditLogProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'employeeId'),
|
||||
new QueryParameter(key: 'from'),
|
||||
new QueryParameter(key: 'to'),
|
||||
new QueryParameter(key: 'entityType'),
|
||||
],
|
||||
security: "is_granted('ROLE_SUPER_ADMIN')"
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class AuditLogResource {}
|
||||
@@ -12,7 +12,7 @@ use App\State\EmployeeLeaveSummaryProvider;
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/employees/{id}/leave-summary',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: EmployeeLeaveSummaryProvider::class
|
||||
),
|
||||
],
|
||||
@@ -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;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\State\EmployeeRttSummaryProvider;
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/employees/{id}/rtt-summary',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: EmployeeRttSummaryProvider::class
|
||||
),
|
||||
],
|
||||
|
||||
25
src/ApiResource/EmployeeYearlyHoursPrint.php
Normal file
25
src/ApiResource/EmployeeYearlyHoursPrint.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\ApiResource;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\QueryParameter;
|
||||
use App\State\EmployeeYearlyHoursPrintProvider;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
uriTemplate: '/yearly-hours/print',
|
||||
provider: EmployeeYearlyHoursPrintProvider::class,
|
||||
parameters: [
|
||||
new QueryParameter(key: 'employeeId', required: true),
|
||||
new QueryParameter(key: 'year', required: true),
|
||||
],
|
||||
security: "is_granted('ROLE_USER')"
|
||||
),
|
||||
]
|
||||
)]
|
||||
final class EmployeeYearlyHoursPrint {}
|
||||
21
src/Dto/AuditLogOutput.php
Normal file
21
src/Dto/AuditLogOutput.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Dto;
|
||||
|
||||
final class AuditLogOutput
|
||||
{
|
||||
public function __construct(
|
||||
public int $id,
|
||||
public ?string $employeeName,
|
||||
public ?int $employeeId,
|
||||
public string $username,
|
||||
public string $action,
|
||||
public string $entityType,
|
||||
public string $description,
|
||||
public ?array $changes,
|
||||
public ?string $affectedDate,
|
||||
public string $createdAt,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
169
src/Entity/AuditLog.php
Normal file
169
src/Entity/AuditLog.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
|
||||
#[ORM\Entity(repositoryClass: AuditLogRepository::class)]
|
||||
#[ORM\Table(name: 'audit_logs')]
|
||||
#[ORM\Index(name: 'idx_audit_employee_created', columns: ['employee_id', 'created_at'])]
|
||||
#[ORM\Index(name: 'idx_audit_entity', columns: ['entity_type', 'entity_id'])]
|
||||
#[ORM\Index(name: 'idx_audit_created', columns: ['created_at'])]
|
||||
#[ORM\Index(name: 'idx_audit_affected_date', columns: ['affected_date'])]
|
||||
class AuditLog
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
private ?Employee $employee = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 180)]
|
||||
private string $username = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 30)]
|
||||
private string $action = '';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 50)]
|
||||
private string $entityType = '';
|
||||
|
||||
#[ORM\Column(type: 'integer', nullable: true)]
|
||||
private ?int $entityId = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
private string $description = '';
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $changes = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable', nullable: true)]
|
||||
private ?DateTimeImmutable $affectedDate = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
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 getUsername(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function setUsername(string $username): self
|
||||
{
|
||||
$this->username = $username;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAction(): string
|
||||
{
|
||||
return $this->action;
|
||||
}
|
||||
|
||||
public function setAction(string $action): self
|
||||
{
|
||||
$this->action = $action;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityType(): string
|
||||
{
|
||||
return $this->entityType;
|
||||
}
|
||||
|
||||
public function setEntityType(string $entityType): self
|
||||
{
|
||||
$this->entityType = $entityType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getEntityId(): ?int
|
||||
{
|
||||
return $this->entityId;
|
||||
}
|
||||
|
||||
public function setEntityId(?int $entityId): self
|
||||
{
|
||||
$this->entityId = $entityId;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDescription(): string
|
||||
{
|
||||
return $this->description;
|
||||
}
|
||||
|
||||
public function setDescription(string $description): self
|
||||
{
|
||||
$this->description = $description;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getChanges(): ?array
|
||||
{
|
||||
return $this->changes;
|
||||
}
|
||||
|
||||
public function setChanges(?array $changes): self
|
||||
{
|
||||
$this->changes = $changes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAffectedDate(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->affectedDate;
|
||||
}
|
||||
|
||||
public function setAffectedDate(?DateTimeImmutable $affectedDate): self
|
||||
{
|
||||
$this->affectedDate = $affectedDate;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function setCreatedAt(DateTimeImmutable $createdAt): self
|
||||
{
|
||||
$this->createdAt = $createdAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use App\Repository\MileageAllowanceRepository;
|
||||
use App\State\MileageAllowanceAmountReceiptDownloadProvider;
|
||||
use App\State\MileageAllowanceAmountReceiptUploadProcessor;
|
||||
use App\State\MileageAllowanceDeleteProcessor;
|
||||
use App\State\MileageAllowanceReceiptDownloadProvider;
|
||||
use App\State\MileageAllowanceReceiptUploadProcessor;
|
||||
@@ -24,10 +26,10 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new GetCollection(
|
||||
security: "is_granted('ROLE_USER')"
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
),
|
||||
new Post(
|
||||
security: "is_granted('ROLE_ADMIN')"
|
||||
@@ -47,9 +49,20 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/mileage_allowances/{id}/receipt',
|
||||
security: "is_granted('ROLE_USER')",
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: MileageAllowanceReceiptDownloadProvider::class,
|
||||
),
|
||||
new Post(
|
||||
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
deserialize: false,
|
||||
processor: MileageAllowanceAmountReceiptUploadProcessor::class,
|
||||
),
|
||||
new Get(
|
||||
uriTemplate: '/mileage_allowances/{id}/amount-receipt',
|
||||
security: "is_granted('ROLE_ADMIN')",
|
||||
provider: MileageAllowanceAmountReceiptDownloadProvider::class,
|
||||
),
|
||||
],
|
||||
normalizationContext: [
|
||||
'groups' => ['mileage_allowance:read', 'employee:read'],
|
||||
@@ -87,6 +100,10 @@ class MileageAllowance
|
||||
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
|
||||
private float $kilometers = 0;
|
||||
|
||||
#[ORM\Column(type: 'float', options: ['default' => 0])]
|
||||
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
|
||||
private float $amount = 0;
|
||||
|
||||
#[ORM\Column(type: 'text', nullable: true)]
|
||||
#[Groups(['mileage_allowance:read', 'mileage_allowance:write'])]
|
||||
private ?string $comment = null;
|
||||
@@ -99,6 +116,14 @@ class MileageAllowance
|
||||
#[Groups(['mileage_allowance:read'])]
|
||||
private ?string $receiptName = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
#[Groups(['mileage_allowance:read'])]
|
||||
private ?string $amountReceiptPath = null;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
#[Groups(['mileage_allowance:read'])]
|
||||
private ?string $amountReceiptName = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
#[Groups(['mileage_allowance:read'])]
|
||||
private DateTimeImmutable $createdAt;
|
||||
@@ -149,6 +174,18 @@ class MileageAllowance
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAmount(): float
|
||||
{
|
||||
return $this->amount;
|
||||
}
|
||||
|
||||
public function setAmount(float $amount): self
|
||||
{
|
||||
$this->amount = $amount;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getComment(): ?string
|
||||
{
|
||||
return $this->comment;
|
||||
@@ -185,6 +222,30 @@ class MileageAllowance
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAmountReceiptPath(): ?string
|
||||
{
|
||||
return $this->amountReceiptPath;
|
||||
}
|
||||
|
||||
public function setAmountReceiptPath(?string $amountReceiptPath): self
|
||||
{
|
||||
$this->amountReceiptPath = $amountReceiptPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getAmountReceiptName(): ?string
|
||||
{
|
||||
return $this->amountReceiptName;
|
||||
}
|
||||
|
||||
public function setAmountReceiptName(?string $amountReceiptName): self
|
||||
{
|
||||
$this->amountReceiptName = $amountReceiptName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
|
||||
130
src/Entity/Observation.php
Normal file
130
src/Entity/Observation.php
Normal file
@@ -0,0 +1,130 @@
|
||||
<?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\ObservationRepository;
|
||||
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')"
|
||||
),
|
||||
],
|
||||
normalizationContext: [
|
||||
'groups' => ['observation:read', 'employee:read'],
|
||||
'datetime_format' => 'Y-m-d',
|
||||
],
|
||||
denormalizationContext: [
|
||||
'groups' => ['observation:write'],
|
||||
'datetime_format' => 'Y-m-d',
|
||||
],
|
||||
order: ['month' => 'DESC'],
|
||||
paginationEnabled: false,
|
||||
)]
|
||||
#[ApiFilter(DateFilter::class, properties: ['month'])]
|
||||
#[ApiFilter(SearchFilter::class, properties: ['employee' => 'exact'])]
|
||||
#[ORM\Entity(repositoryClass: ObservationRepository::class)]
|
||||
#[ORM\Table(name: 'observations')]
|
||||
#[ORM\UniqueConstraint(name: 'uniq_observation_employee_month', columns: ['employee_id', 'month'])]
|
||||
class Observation
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
#[Groups(['observation:read'])]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: Employee::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
#[Groups(['observation:read', 'observation:write'])]
|
||||
private ?Employee $employee = null;
|
||||
|
||||
#[ORM\Column(type: 'date_immutable')]
|
||||
#[Groups(['observation:read', 'observation:write'])]
|
||||
private ?DateTimeImmutable $month = null;
|
||||
|
||||
#[ORM\Column(type: 'text')]
|
||||
#[Groups(['observation:read', 'observation:write'])]
|
||||
private string $content = '';
|
||||
|
||||
#[ORM\Column(type: 'datetime_immutable')]
|
||||
#[Groups(['observation: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 getMonth(): ?DateTimeImmutable
|
||||
{
|
||||
return $this->month;
|
||||
}
|
||||
|
||||
public function setMonth(?DateTimeImmutable $month): self
|
||||
{
|
||||
$this->month = $month;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->content;
|
||||
}
|
||||
|
||||
public function setContent(string $content): self
|
||||
{
|
||||
$this->content = $content;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): DateTimeImmutable
|
||||
{
|
||||
return $this->createdAt;
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Attribute\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
@@ -84,6 +85,11 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
private ?Employee $employee = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean', options: ['default' => false])]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
#[SerializedName('isLocked')]
|
||||
private bool $isLocked = false;
|
||||
|
||||
/**
|
||||
* @var Collection<int, UserSiteRole>
|
||||
*/
|
||||
@@ -204,5 +210,25 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['user:read'])]
|
||||
#[SerializedName('isLocked')]
|
||||
public function isLocked(): bool
|
||||
{
|
||||
return $this->isLocked;
|
||||
}
|
||||
|
||||
public function setIsLocked(bool $isLocked): self
|
||||
{
|
||||
$this->isLocked = $isLocked;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
#[Groups(['user:read'])]
|
||||
public function getIsDriver(): bool
|
||||
{
|
||||
return $this->employee?->getIsDriver() ?? false;
|
||||
}
|
||||
|
||||
public function eraseCredentials(): void {}
|
||||
}
|
||||
|
||||
@@ -100,6 +100,38 @@ final class AbsenceRepository extends ServiceEntityRepository implements Absence
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<DateTimeImmutable> sorted maladie dates
|
||||
*/
|
||||
public function findMaladieDatesByEmployee(
|
||||
Employee $employee,
|
||||
DateTimeImmutable $from,
|
||||
DateTimeImmutable $to
|
||||
): array {
|
||||
$results = $this->createQueryBuilder('a')
|
||||
->select('a.startDate')
|
||||
->join('a.type', 't')
|
||||
->andWhere('a.employee = :employee')
|
||||
->andWhere('t.code = :code')
|
||||
->andWhere('a.startDate >= :from')
|
||||
->andWhere('a.startDate <= :to')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('code', 'M')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->orderBy('a.startDate', 'ASC')
|
||||
->getQuery()
|
||||
->getArrayResult()
|
||||
;
|
||||
|
||||
return array_map(
|
||||
static fn (array $row): DateTimeImmutable => $row['startDate'] instanceof DateTimeImmutable
|
||||
? $row['startDate']
|
||||
: DateTimeImmutable::createFromInterface($row['startDate']),
|
||||
$results
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<Absence>
|
||||
*/
|
||||
|
||||
102
src/Repository/AuditLogRepository.php
Normal file
102
src/Repository/AuditLogRepository.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<AuditLog>
|
||||
*/
|
||||
final class AuditLogRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, AuditLog::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<AuditLog>
|
||||
*/
|
||||
public function findByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
int $limit = 50,
|
||||
int $offset = 0,
|
||||
): array {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->orderBy('a.createdAt', 'DESC')
|
||||
->setMaxResults($limit)
|
||||
->setFirstResult($offset)
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
public function countByFilters(
|
||||
?int $employeeId = null,
|
||||
?DateTimeImmutable $from = null,
|
||||
?DateTimeImmutable $to = null,
|
||||
?string $entityType = null,
|
||||
): int {
|
||||
$qb = $this->createQueryBuilder('a')
|
||||
->select('COUNT(a.id)')
|
||||
;
|
||||
|
||||
if (null !== $employeeId) {
|
||||
$qb->andWhere('a.employee = :employeeId')
|
||||
->setParameter('employeeId', $employeeId)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $from) {
|
||||
$qb->andWhere('a.affectedDate >= :from')
|
||||
->setParameter('from', $from)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $to) {
|
||||
$qb->andWhere('a.affectedDate <= :to')
|
||||
->setParameter('to', $to)
|
||||
;
|
||||
}
|
||||
|
||||
if (null !== $entityType) {
|
||||
$qb->andWhere('a.entityType = :entityType')
|
||||
->setParameter('entityType', $entityType)
|
||||
;
|
||||
}
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,6 @@ use DateTimeImmutable;
|
||||
interface EmployeeContractPeriodReadRepositoryInterface
|
||||
{
|
||||
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
|
||||
|
||||
public function findLatestPeriod(Employee $employee): ?EmployeeContractPeriod;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -60,6 +60,18 @@ final class EmployeeContractPeriodRepository extends ServiceEntityRepository imp
|
||||
;
|
||||
}
|
||||
|
||||
public function findLatestPeriod(Employee $employee): ?EmployeeContractPeriod
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
->andWhere('p.employee = :employee')
|
||||
->setParameter('employee', $employee)
|
||||
->orderBy('p.startDate', 'DESC')
|
||||
->setMaxResults(1)
|
||||
->getQuery()
|
||||
->getOneOrNullResult()
|
||||
;
|
||||
}
|
||||
|
||||
public function closeOpenPeriods(Employee $employee, DateTimeImmutable $endDate): int
|
||||
{
|
||||
return $this->createQueryBuilder('p')
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
38
src/Repository/ObservationRepository.php
Normal file
38
src/Repository/ObservationRepository.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Observation;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<Observation>
|
||||
*/
|
||||
final class ObservationRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, Observation::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Observation[]
|
||||
*/
|
||||
public function findByMonth(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
return $this->createQueryBuilder('o')
|
||||
->andWhere('o.month >= :from')
|
||||
->andWhere('o.month <= :to')
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->innerJoin('o.employee', 'e')
|
||||
->addSelect('e')
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -191,6 +191,57 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count weekend and public holiday worked days for forfait bonus leave (PRESENCE mode only).
|
||||
* Morning + afternoon = 1.0 day, one only = 0.5 day.
|
||||
*
|
||||
* @param list<string> $publicHolidayDates Y-m-d formatted weekday public holiday dates
|
||||
*/
|
||||
public function countWeekendAndHolidayWorkedDays(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to, array $publicHolidayDates = []): float
|
||||
{
|
||||
$targetDates = [];
|
||||
|
||||
// Collect weekend dates in range
|
||||
for ($cursor = $from; $cursor <= $to; $cursor = $cursor->modify('+1 day')) {
|
||||
if ((int) $cursor->format('N') >= 6) {
|
||||
$targetDates[] = $cursor;
|
||||
}
|
||||
}
|
||||
|
||||
// Add weekday public holidays
|
||||
foreach ($publicHolidayDates as $date) {
|
||||
$targetDates[] = new DateTimeImmutable($date);
|
||||
}
|
||||
|
||||
if ([] === $targetDates) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$dateStrings = array_map(static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'), $targetDates);
|
||||
|
||||
/** @var list<WorkHour> $rows */
|
||||
$rows = $this->createQueryBuilder('w')
|
||||
->andWhere('w.employee = :employee')
|
||||
->andWhere('w.workDate IN (:dates)')
|
||||
->andWhere('w.isPresentMorning = true OR w.isPresentAfternoon = true')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('dates', $dateStrings)
|
||||
->getQuery()
|
||||
->getResult()
|
||||
;
|
||||
|
||||
$total = 0.0;
|
||||
foreach ($rows as $row) {
|
||||
if ($row->isPresentMorning() && $row->isPresentAfternoon()) {
|
||||
$total += 1.0;
|
||||
} else {
|
||||
$total += 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the set of Y-m-d dates where the employee has worked hours on the given dates.
|
||||
*
|
||||
@@ -228,6 +279,55 @@ final class WorkHourRepository extends ServiceEntityRepository implements WorkHo
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function isWeekFullyValidated(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): bool
|
||||
{
|
||||
// Count weekdays (Mon-Fri) in range
|
||||
$expectedWeekdays = 0;
|
||||
for ($d = $from; $d <= $to; $d = $d->modify('+1 day')) {
|
||||
if ((int) $d->format('N') <= 5) {
|
||||
++$expectedWeekdays;
|
||||
}
|
||||
}
|
||||
|
||||
if (0 === $expectedWeekdays) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Every weekday must have a work_hour row
|
||||
$totalCount = (int) $this->createQueryBuilder('w')
|
||||
->select('COUNT(w.id)')
|
||||
->andWhere('w.employee = :employee')
|
||||
->andWhere('w.workDate >= :from')
|
||||
->andWhere('w.workDate <= :to')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
if ($totalCount < $expectedWeekdays) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All rows must be validated
|
||||
$nonValidatedCount = (int) $this->createQueryBuilder('w')
|
||||
->select('COUNT(w.id)')
|
||||
->andWhere('w.employee = :employee')
|
||||
->andWhere('w.workDate >= :from')
|
||||
->andWhere('w.workDate <= :to')
|
||||
->andWhere('w.isValid = :isValid')
|
||||
->setParameter('employee', $employee)
|
||||
->setParameter('from', $from)
|
||||
->setParameter('to', $to)
|
||||
->setParameter('isValid', false)
|
||||
->getQuery()
|
||||
->getSingleScalarResult()
|
||||
;
|
||||
|
||||
return 0 === $nonValidatedCount;
|
||||
}
|
||||
|
||||
public function hasPendingSiteValidationForSiteAndDate(int $siteId, DateTimeInterface $date): bool
|
||||
{
|
||||
$workDate = DateTimeImmutable::createFromInterface($date);
|
||||
|
||||
27
src/Security/UserChecker.php
Normal file
27
src/Security/UserChecker.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAccountStatusException;
|
||||
use Symfony\Component\Security\Core\User\UserCheckerInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
final class UserChecker implements UserCheckerInterface
|
||||
{
|
||||
public function checkPreAuth(UserInterface $user): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->isLocked()) {
|
||||
throw new CustomUserMessageAccountStatusException('Ce compte est verrouillé.');
|
||||
}
|
||||
}
|
||||
|
||||
public function checkPostAuth(UserInterface $user, ?TokenInterface $token = null): void {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user