Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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>
|
||||
6
.idea/sqldialects.xml
generated
6
.idea/sqldialects.xml
generated
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/sirh.sql" dialect="GenericSQL" />
|
||||
</component>
|
||||
</project>
|
||||
15
CLAUDE.md
15
CLAUDE.md
@@ -43,8 +43,15 @@
|
||||
## 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
|
||||
|
||||
## Frontend Patterns
|
||||
|
||||
@@ -65,6 +72,12 @@
|
||||
- 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
|
||||
|
||||
@@ -17,3 +17,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
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.55'
|
||||
app.version: '0.1.69'
|
||||
|
||||
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/"
|
||||
77
deploy/docker/Dockerfile.prod
Normal file
77
deploy/docker/Dockerfile.prod
Normal file
@@ -0,0 +1,77 @@
|
||||
# --- 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
|
||||
|
||||
# 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"]
|
||||
28
deploy/docker/deploy.sh
Executable file
28
deploy/docker/deploy.sh
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/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 app php bin/console doctrine:migrations:migrate --no-interaction
|
||||
|
||||
echo "==> Clearing cache..."
|
||||
docker compose exec -T app php bin/console cache:clear --env=prod
|
||||
docker compose exec -T app php bin/console cache:warmup --env=prod
|
||||
|
||||
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
|
||||
12
deploy/nginx/sirh-docker.conf
Normal file
12
deploy/nginx/sirh-docker.conf
Normal file
@@ -0,0 +1,12 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name sirh.malio-dev.fr;
|
||||
|
||||
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.
|
||||
135
doc/deployment-docker.md
Normal file
135
doc/deployment-docker.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Deploiement Docker — SIRH
|
||||
|
||||
## Pre-requis
|
||||
|
||||
Installer Docker et Docker Compose sur la machine :
|
||||
|
||||
```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.
|
||||
|
||||
## Premier deploiement
|
||||
|
||||
### 1. Creer le dossier de deploiement
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/sirh
|
||||
sudo chown -R $(whoami):$(whoami) /var/www/sirh
|
||||
```
|
||||
|
||||
### 2. Copier les fichiers depuis le repo
|
||||
|
||||
```bash
|
||||
cp deploy/docker/docker-compose.prod.yml /var/www/sirh/docker-compose.yml
|
||||
cp deploy/docker/deploy.sh /var/www/sirh/deploy.sh
|
||||
cp deploy/docker/.env.example /var/www/sirh/.env
|
||||
chmod +x /var/www/sirh/deploy.sh
|
||||
```
|
||||
|
||||
### 3. Configurer l'environnement
|
||||
|
||||
Editer `/var/www/sirh/.env` avec les vraies valeurs :
|
||||
- `APP_SECRET` : generer avec `openssl rand -hex 32`
|
||||
- `DATABASE_URL` : `postgresql://user:pass@host.docker.internal:5432/sirh?serverVersion=16&charset=utf8`
|
||||
- `JWT_PASSPHRASE` : generer avec `openssl rand -hex 32`
|
||||
|
||||
### 4. Generer les cles JWT
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
mkdir -p config/jwt
|
||||
docker run --rm -v $(pwd)/config/jwt:/jwt php:8.4-cli bash -c \
|
||||
"apt-get update -qq && apt-get install -y -qq openssl > /dev/null && \
|
||||
openssl genpkey -algorithm RSA -out /jwt/private.pem -pkeyopt rsa_keygen_bits:4096 && \
|
||||
openssl rsa -pubout -in /jwt/private.pem -out /jwt/public.pem"
|
||||
```
|
||||
|
||||
### 5. Creer le dossier uploads
|
||||
|
||||
```bash
|
||||
mkdir -p /var/www/sirh/uploads
|
||||
```
|
||||
|
||||
### 6. Configurer le login au registry Gitea
|
||||
|
||||
```bash
|
||||
docker login gitea.malio.fr
|
||||
# Username: ton user Gitea
|
||||
# Password: ton token Gitea
|
||||
```
|
||||
|
||||
### 7. Configurer Nginx systeme
|
||||
|
||||
```bash
|
||||
sudo cp deploy/nginx/sirh-docker.conf /etc/nginx/sites-available/sirh.conf
|
||||
sudo ln -sf /etc/nginx/sites-available/sirh.conf /etc/nginx/sites-enabled/sirh.conf
|
||||
sudo nginx -t && sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
### 8. Deployer
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
## Deployer une release
|
||||
|
||||
```bash
|
||||
cd /var/www/sirh
|
||||
./deploy.sh # deploie la derniere version (latest)
|
||||
./deploy.sh v0.1.61 # deploie une version specifique
|
||||
```
|
||||
|
||||
## 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 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
|
||||
```
|
||||
|
||||
## 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 :
|
||||
```bash
|
||||
cp /var/www/sirh/.env /var/www/sirh-docker/.env
|
||||
cp -a /var/www/sirh/config/jwt /var/www/sirh-docker/config/jwt
|
||||
cp -a /var/www/sirh/var/uploads /var/www/sirh-docker/uploads
|
||||
```
|
||||
4. Editer `/var/www/sirh-docker/.env` : changer `DATABASE_URL` pour utiliser `host.docker.internal` au lieu de `127.0.0.1`
|
||||
5. Mettre a jour Nginx systeme : remplacer la conf par `deploy/nginx/sirh-docker.conf`
|
||||
6. Arreter l'ancien PHP-FPM : `sudo systemctl stop php8.4-fpm`
|
||||
7. Deployer : `cd /var/www/sirh-docker && ./deploy.sh`
|
||||
8. Verifier que tout marche, puis supprimer l'ancien dossier
|
||||
@@ -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%
|
||||
@@ -150,7 +154,7 @@ Documents complementaires:
|
||||
- jour/nuit/atelier par jour + indicateurs repas/dîner/nuitée
|
||||
- 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
|
||||
@@ -227,6 +232,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- 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`)
|
||||
@@ -274,6 +280,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- 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:
|
||||
@@ -353,15 +360,38 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
|
||||
- `kilometers` (nombre de km, optionnel)
|
||||
- `amount` (montant en €, optionnel)
|
||||
- `comment` (commentaire, optionnel)
|
||||
- `receiptPath` / `receiptName` (justificatif PDF)
|
||||
- `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, Justificatif
|
||||
- Justificatif: upload PDF uniquement, téléchargement via endpoint dédié
|
||||
- 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) Notifications
|
||||
## 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
|
||||
@@ -374,3 +404,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`
|
||||
|
||||
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>
|
||||
@@ -2,12 +2,13 @@
|
||||
<section class="mt-8">
|
||||
<div class="overflow-hidden bg-white">
|
||||
<div
|
||||
class="grid grid-cols-5 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.
|
||||
@@ -16,23 +17,36 @@
|
||||
<div
|
||||
v-for="item in allowances"
|
||||
:key="item.id"
|
||||
class="grid grid-cols-5 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>
|
||||
@@ -94,20 +108,38 @@
|
||||
</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-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>
|
||||
|
||||
@@ -156,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<{
|
||||
@@ -165,17 +197,20 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'create', data: { month: string; kilometers: number; amount: number; comment?: string }, file?: File): void
|
||||
(event: 'update', id: number, data: { month: string; kilometers: number; amount: 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()
|
||||
@@ -190,7 +225,7 @@ const form = reactive({
|
||||
})
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return form.month && (form.kilometers > 0 || form.amount > 0) && !fileError.value
|
||||
return form.month && (form.kilometers > 0 || form.amount > 0) && !kmFileError.value && !amountFileError.value
|
||||
})
|
||||
|
||||
const monthLabels: Record<number, string> = {
|
||||
@@ -221,10 +256,15 @@ const resetForm = () => {
|
||||
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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,24 +283,41 @@ const onOpenEditDrawer = (item: MileageAllowance) => {
|
||||
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 = () => {
|
||||
@@ -272,9 +329,9 @@ const onSubmit = () => {
|
||||
}
|
||||
|
||||
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>
|
||||
@@ -67,26 +67,26 @@
|
||||
<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" 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) -->
|
||||
@@ -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(reste.base25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total25) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.base50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.bonus50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ formatMinutes(reste.total50) }}</td>
|
||||
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(reste.total) }}</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>
|
||||
@@ -481,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)
|
||||
|
||||
@@ -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' | 'bonus' | 'observation'>('contract')
|
||||
|
||||
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
|
||||
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
|
||||
@@ -40,6 +40,7 @@ export const useEmployeeDetailPage = () => {
|
||||
rtt.resetLoaded()
|
||||
mileage.resetLoaded()
|
||||
bonus.resetLoaded()
|
||||
observation.resetLoaded()
|
||||
|
||||
if (activeTab.value === 'leave' && showLeaveTab.value) {
|
||||
await leave.loadLeaveData()
|
||||
@@ -49,6 +50,8 @@ export const useEmployeeDetailPage = () => {
|
||||
await mileage.loadMileageData()
|
||||
} else if (activeTab.value === 'bonus') {
|
||||
await bonus.loadBonusData()
|
||||
} else if (activeTab.value === 'observation') {
|
||||
await observation.loadObservationData()
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
@@ -60,6 +63,7 @@ export const useEmployeeDetailPage = () => {
|
||||
const rtt = useEmployeeRtt(employee, loadEmployee)
|
||||
const mileage = useEmployeeMileage(employee, loadEmployee)
|
||||
const bonus = useEmployeeBonus(employee, loadEmployee)
|
||||
const observation = useEmployeeObservation(employee, loadEmployee)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'leave' && !leave.leaveDataLoaded.value && showLeaveTab.value) {
|
||||
@@ -70,6 +74,8 @@ export const useEmployeeDetailPage = () => {
|
||||
mileage.loadMileageData()
|
||||
} else if (tab === 'bonus' && !bonus.bonusDataLoaded.value) {
|
||||
bonus.loadBonusData()
|
||||
} else if (tab === 'observation' && !observation.observationDataLoaded.value) {
|
||||
observation.loadObservationData()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -89,6 +95,7 @@ export const useEmployeeDetailPage = () => {
|
||||
...leave,
|
||||
...rtt,
|
||||
...mileage,
|
||||
...bonus
|
||||
...bonus,
|
||||
...observation
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,7 +33,7 @@ export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmploye
|
||||
mileageDataLoaded.value = false
|
||||
}
|
||||
|
||||
const submitCreateMileage = async (data: { month: string; kilometers: number; amount: 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,
|
||||
@@ -41,16 +42,24 @@ export const useEmployeeMileage = (employee: Ref<Employee | null>, reloadEmploye
|
||||
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; amount: 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
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@
|
||||
"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": {
|
||||
@@ -87,6 +92,11 @@
|
||||
"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,17 @@
|
||||
<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>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-col gap-2 items-center p-4">
|
||||
@@ -103,5 +116,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('/')
|
||||
}
|
||||
})
|
||||
252
frontend/pages/audit-logs.vue
Normal file
252
frontend/pages/audit-logs.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<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>
|
||||
</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.',
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
employees.value = await listEmployees()
|
||||
await loadLogs()
|
||||
})
|
||||
</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">
|
||||
@@ -75,6 +84,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">
|
||||
@@ -164,12 +183,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,
|
||||
@@ -231,9 +277,20 @@ const {
|
||||
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}`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,5 +6,7 @@ export type MileageAllowance = {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -58,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`, {
|
||||
@@ -68,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) {
|
||||
|
||||
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
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 {}
|
||||
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,
|
||||
) {}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -50,6 +52,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
||||
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'],
|
||||
@@ -103,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;
|
||||
@@ -201,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 {}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
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 {}
|
||||
}
|
||||
47
src/Service/AuditLogger.php
Normal file
47
src/Service/AuditLogger.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\AuditLog;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\User;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
|
||||
readonly class AuditLogger
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private Security $security,
|
||||
) {}
|
||||
|
||||
public function log(
|
||||
?Employee $employee,
|
||||
string $action,
|
||||
string $entityType,
|
||||
?int $entityId,
|
||||
string $description,
|
||||
?array $changes = null,
|
||||
?DateTimeImmutable $affectedDate = null,
|
||||
): void {
|
||||
$user = $this->security->getUser();
|
||||
$username = $user instanceof User ? $user->getUsername() : 'system';
|
||||
|
||||
$auditLog = new AuditLog();
|
||||
$auditLog
|
||||
->setEmployee($employee)
|
||||
->setUsername($username)
|
||||
->setAction($action)
|
||||
->setEntityType($entityType)
|
||||
->setEntityId($entityId)
|
||||
->setDescription($description)
|
||||
->setChanges($changes)
|
||||
->setAffectedDate($affectedDate)
|
||||
;
|
||||
|
||||
$this->entityManager->persist($auditLog);
|
||||
}
|
||||
}
|
||||
@@ -45,18 +45,21 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
DateTimeImmutable $requestedEndDate,
|
||||
bool $paidLeaveSettled,
|
||||
?string $comment = null
|
||||
?string $comment = null,
|
||||
bool $isAlreadyEnded = false
|
||||
): void {
|
||||
if (null === $todayPeriod) {
|
||||
throw new UnprocessableEntityHttpException('No active contract period to close.');
|
||||
}
|
||||
|
||||
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
||||
$todayPeriod->getStartDate(),
|
||||
$todayPeriod->getEndDate(),
|
||||
$requestedEndDate,
|
||||
$todayPeriod->getContractNatureEnum()
|
||||
);
|
||||
if (!$isAlreadyEnded) {
|
||||
$this->periodValidator->assertCloseEndDateCanBeApplied(
|
||||
$todayPeriod->getStartDate(),
|
||||
$todayPeriod->getEndDate(),
|
||||
$requestedEndDate,
|
||||
$todayPeriod->getContractNatureEnum()
|
||||
);
|
||||
}
|
||||
|
||||
$todayPeriod->setEndDate($requestedEndDate);
|
||||
$todayPeriod->setPaidLeaveSettled($paidLeaveSettled);
|
||||
|
||||
@@ -25,7 +25,8 @@ interface EmployeeContractPeriodManagerInterface
|
||||
?EmployeeContractPeriod $todayPeriod,
|
||||
DateTimeImmutable $requestedEndDate,
|
||||
bool $paidLeaveSettled,
|
||||
?string $comment = null
|
||||
?string $comment = null,
|
||||
bool $isAlreadyEnded = false
|
||||
): void;
|
||||
|
||||
public function createNextPeriod(
|
||||
|
||||
@@ -206,20 +206,36 @@ final readonly class RttRecoveryComputationService
|
||||
continue;
|
||||
}
|
||||
|
||||
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
||||
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
||||
$overtimeReferenceMinutes = $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||||
$weekAnchorNature = $naturesByDate[$employeeId][$weekDays[0]] ?? ContractNature::CDI;
|
||||
$weekAnchorContract = $employeeContractsByDate[$weekDays[0]] ?? null;
|
||||
$isWeekPresenceTracking = TrackingMode::PRESENCE->value === $weekAnchorContract?->getTrackingMode();
|
||||
$disableOvertimeBonuses = $this->hasDisabledOvertimeBonuses($weekAnchorContract, $weekAnchorNature);
|
||||
$weekContractType = ContractType::resolve(
|
||||
$weekAnchorContract?->getName(),
|
||||
$weekAnchorContract?->getTrackingMode(),
|
||||
$weekAnchorContract?->getWeeklyHours()
|
||||
);
|
||||
$isCustomContract = ContractType::CUSTOM === $weekContractType;
|
||||
$overtimeReferenceMinutes = $isCustomContract
|
||||
? $this->computeWeeklyCustomReferenceMinutes($weekDays, $employeeContractsByDate)
|
||||
: $this->computeWeeklyOvertimeReferenceMinutes($weekDays, $employeeContractsByDate);
|
||||
$overtime25StartMinutes = $this->computeWeeklyOvertime25StartMinutes($weekDays, $employeeContractsByDate);
|
||||
$weeklyOvertimeTotalMinutes = $isWeekPresenceTracking
|
||||
? 0
|
||||
: $weeklyTotalMinutes - $overtimeReferenceMinutes;
|
||||
|
||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses) ? 0 : (int) round($base50 * 0.5);
|
||||
$base25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, min($weeklyTotalMinutes, 43 * 60) - $overtime25StartMinutes);
|
||||
$bonus25 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base25 * 0.25);
|
||||
$base50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : max(0, $weeklyTotalMinutes - 43 * 60);
|
||||
$bonus50 = ($isWeekPresenceTracking || $disableOvertimeBonuses || $isCustomContract) ? 0 : (int) round($base50 * 0.5);
|
||||
|
||||
if ($isWeekPresenceTracking || $disableOvertimeBonuses) {
|
||||
$totalMinutes = 0;
|
||||
} elseif ($isCustomContract) {
|
||||
$totalMinutes = max(0, $weeklyOvertimeTotalMinutes);
|
||||
} else {
|
||||
$totalMinutes = $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50;
|
||||
}
|
||||
|
||||
$results[$weekKey] = new WeekRecoveryDetail(
|
||||
overtimeMinutes: $weeklyOvertimeTotalMinutes,
|
||||
@@ -227,9 +243,7 @@ final readonly class RttRecoveryComputationService
|
||||
bonus25Minutes: $bonus25,
|
||||
base50Minutes: $base50,
|
||||
bonus50Minutes: $bonus50,
|
||||
totalMinutes: ($isWeekPresenceTracking || $disableOvertimeBonuses)
|
||||
? 0
|
||||
: $weeklyOvertimeTotalMinutes + $bonus25 + $bonus50,
|
||||
totalMinutes: $totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,6 +252,20 @@ final readonly class RttRecoveryComputationService
|
||||
|
||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||
{
|
||||
$driverDay = $workHour->getDayHoursMinutes() ?? 0;
|
||||
$driverNight = $workHour->getNightHoursMinutes() ?? 0;
|
||||
$driverWorkshop = $workHour->getWorkshopHoursMinutes() ?? 0;
|
||||
|
||||
if ($driverDay > 0 || $driverNight > 0 || $driverWorkshop > 0) {
|
||||
$totalMinutes = $driverDay + $driverNight + $driverWorkshop;
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $driverDay + $driverWorkshop,
|
||||
nightMinutes: $driverNight,
|
||||
totalMinutes: $totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
$ranges = [
|
||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||
@@ -326,6 +354,23 @@ final readonly class RttRecoveryComputationService
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
*/
|
||||
private function computeWeeklyCustomReferenceMinutes(array $days, array $contractsByDate): int
|
||||
{
|
||||
$total = 0;
|
||||
foreach ($days as $date) {
|
||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$hours = $contract?->getWeeklyHours();
|
||||
$total += $this->resolveDailyReferenceMinutes($hours, $isoDay);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $days
|
||||
* @param array<string, ?Contract> $contractsByDate
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Entity\User;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use DateInterval;
|
||||
use DatePeriod;
|
||||
@@ -33,6 +34,7 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
private WorkHourReadRepositoryInterface $workHourRepository,
|
||||
private Security $security,
|
||||
private PublicHolidayServiceInterface $publicHolidayService,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
@@ -54,6 +56,21 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
throw new ConflictHttpException('Impossible de modifier une absence sur une période validée.');
|
||||
}
|
||||
|
||||
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
|
||||
$startDate = $data->getStartDate()->format('d/m/Y');
|
||||
$endDate = $data->getEndDate()->format('d/m/Y');
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'delete',
|
||||
'absence',
|
||||
$data->getId(),
|
||||
sprintf('Absence %s supprimée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
|
||||
['old' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
|
||||
DateTimeImmutable::createFromInterface($data->getStartDate()),
|
||||
);
|
||||
|
||||
$this->entityManager->remove($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
@@ -110,6 +127,21 @@ final readonly class AbsenceWriteProcessor implements ProcessorInterface
|
||||
$this->entityManager->persist($absence);
|
||||
}
|
||||
|
||||
$typeName = $data->getType()?->getLabel() ?? 'inconnu';
|
||||
$startDate = $data->getStartDate()->format('d/m/Y');
|
||||
$endDate = $data->getEndDate()->format('d/m/Y');
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'create',
|
||||
'absence',
|
||||
null,
|
||||
sprintf('Absence %s créée pour %s du %s au %s', $typeName, $empName, $startDate, $endDate),
|
||||
['new' => ['type' => $typeName, 'start' => $startDate, 'end' => $endDate, 'startHalf' => $data->getStartHalf()->value, 'endHalf' => $data->getEndHalf()->value, 'comment' => $data->getComment()]],
|
||||
DateTimeImmutable::createFromInterface($data->getStartDate()),
|
||||
);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $data;
|
||||
|
||||
73
src/State/AuditLogProvider.php
Normal file
73
src/State/AuditLogProvider.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
|
||||
class AuditLogProvider implements ProviderInterface
|
||||
{
|
||||
private const PER_PAGE = 50;
|
||||
|
||||
public function __construct(
|
||||
private readonly RequestStack $requestStack,
|
||||
private readonly AuditLogRepository $auditLogRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (!$request) {
|
||||
return new JsonResponse(['items' => [], 'total' => 0]);
|
||||
}
|
||||
|
||||
$employeeId = $request->query->get('employeeId');
|
||||
$from = $request->query->get('from');
|
||||
$to = $request->query->get('to');
|
||||
$entityType = $request->query->get('entityType');
|
||||
$page = max(1, (int) $request->query->get('page', '1'));
|
||||
|
||||
$empId = $employeeId ? (int) $employeeId : null;
|
||||
$fromDt = $from ? new DateTimeImmutable($from) : null;
|
||||
$toDt = $to ? new DateTimeImmutable($to) : null;
|
||||
$type = $entityType ?: null;
|
||||
$offset = ($page - 1) * self::PER_PAGE;
|
||||
|
||||
$total = $this->auditLogRepository->countByFilters($empId, $fromDt, $toDt, $type);
|
||||
$logs = $this->auditLogRepository->findByFilters($empId, $fromDt, $toDt, $type, self::PER_PAGE, $offset);
|
||||
|
||||
$items = [];
|
||||
foreach ($logs as $log) {
|
||||
$employee = $log->getEmployee();
|
||||
$employeeName = $employee
|
||||
? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''))
|
||||
: null;
|
||||
|
||||
$items[] = [
|
||||
'id' => $log->getId(),
|
||||
'employeeName' => $employeeName,
|
||||
'employeeId' => $employee?->getId(),
|
||||
'username' => $log->getUsername(),
|
||||
'action' => $log->getAction(),
|
||||
'entityType' => $log->getEntityType(),
|
||||
'description' => $log->getDescription(),
|
||||
'changes' => $log->getChanges(),
|
||||
'affectedDate' => $log->getAffectedDate()?->format('Y-m-d'),
|
||||
'createdAt' => $log->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
];
|
||||
}
|
||||
|
||||
return new JsonResponse([
|
||||
'items' => $items,
|
||||
'total' => $total,
|
||||
'page' => $page,
|
||||
'perPage' => self::PER_PAGE,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\ContractSuspension;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Service\AuditLogger;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
@@ -19,6 +20,7 @@ final readonly class ContractSuspensionWriteProcessor implements ProcessorInterf
|
||||
#[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')]
|
||||
private ProcessorInterface $persistProcessor,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -46,7 +48,26 @@ final readonly class ContractSuspensionWriteProcessor implements ProcessorInterf
|
||||
|
||||
$this->validate($data, $period);
|
||||
|
||||
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
$isNew = null === $data->getId();
|
||||
$employee = $period->getEmployee();
|
||||
$empName = $employee ? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')) : '';
|
||||
$start = $data->getStartDate()->format('d/m/Y');
|
||||
$end = $data->getEndDate()?->format('d/m/Y') ?? 'indéfinie';
|
||||
|
||||
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
|
||||
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
$isNew ? 'create' : 'update',
|
||||
'contract_suspension',
|
||||
$data->getId(),
|
||||
sprintf('Suspension %s pour %s du %s au %s', $isNew ? 'créée' : 'modifiée', $empName, $start, $end),
|
||||
['new' => ['start' => $start, 'end' => $end]],
|
||||
DateTimeImmutable::createFromInterface($data->getStartDate()),
|
||||
);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function validate(ContractSuspension $suspension, EmployeeContractPeriod $period): void
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Enum\ContractType;
|
||||
use App\Enum\LeaveRuleCode;
|
||||
use App\Repository\EmployeeLeaveBalanceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -24,6 +25,7 @@ final readonly class EmployeeFractionedDaysProcessor implements ProcessorInterfa
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeLeaveBalanceRepository $leaveBalanceRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeFractionedDaysInput
|
||||
@@ -57,6 +59,17 @@ final readonly class EmployeeFractionedDaysProcessor implements ProcessorInterfa
|
||||
|
||||
$balance->setFractionedDays($data->fractionedDays);
|
||||
$balance->touch();
|
||||
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'update',
|
||||
'fractioned_days',
|
||||
$balance->getId(),
|
||||
sprintf('Jours fractionnés modifiés pour %s (année %d) : %s', $empName, $year, (string) $data->fractionedDays),
|
||||
['new' => ['fractionedDays' => $data->fractionedDays, 'year' => $year]],
|
||||
);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$data->year = $year;
|
||||
|
||||
@@ -535,10 +535,21 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
|
||||
$type = $employee->getContract()?->getType();
|
||||
if (ContractType::FORFAIT === $type) {
|
||||
$businessDaysInPeriod = $this->countBusinessDays($from, $to);
|
||||
$publicHolidays = $this->buildPublicHolidayMap($from, $to);
|
||||
$weekdayHolidays = array_filter(
|
||||
array_keys($publicHolidays),
|
||||
static fn (string $date): bool => (int) new DateTimeImmutable($date)->format('N') <= 5
|
||||
);
|
||||
$bonusDays = $this->workHourRepository->countWeekendAndHolidayWorkedDays(
|
||||
$employee,
|
||||
$from,
|
||||
$to,
|
||||
array_values($weekdayHolidays)
|
||||
);
|
||||
|
||||
return [
|
||||
'ruleCode' => LeaveRuleCode::FORFAIT_218->value,
|
||||
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS),
|
||||
'acquiredDays' => (float) max(0, $businessDaysInPeriod - self::FORFAIT_TARGET_WORKED_DAYS) + $bonusDays,
|
||||
'acquiredSaturdays' => 0.0,
|
||||
'accrualPerMonth' => 0.0,
|
||||
'saturdayAccrualPerMonth' => 0.0,
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Entity\Employee;
|
||||
use App\Entity\EmployeeRttPayment;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
@@ -22,6 +23,7 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): EmployeeRttPaymentInput
|
||||
@@ -61,6 +63,17 @@ final readonly class EmployeeRttPaymentProcessor implements ProcessorInterface
|
||||
$payment->setBase50Minutes($data->base50Minutes);
|
||||
$payment->setBonus50Minutes($data->bonus50Minutes);
|
||||
$payment->touch();
|
||||
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'update',
|
||||
'rtt_payment',
|
||||
$payment->getId(),
|
||||
sprintf('Paiement RTT modifié pour %s (%02d/%d)', $empName, $data->month, $year),
|
||||
['new' => ['month' => $data->month, 'year' => $year, 'base25' => $data->base25Minutes, 'bonus25' => $data->bonus25Minutes, 'base50' => $data->base50Minutes, 'bonus50' => $data->bonus50Minutes]],
|
||||
);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
$data->year = $year;
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Entity\User;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use DateTimeImmutable;
|
||||
@@ -36,6 +37,7 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private RttRecoveryComputationService $rttRecoveryService,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
string $rttStartDate = '',
|
||||
) {
|
||||
$this->rttStartDate = '' !== $rttStartDate ? $rttStartDate : null;
|
||||
@@ -83,6 +85,16 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
// Exclude the current (incomplete) week: limit to last Sunday
|
||||
$isoDay = (int) $today->format('N'); // 1=Monday .. 7=Sunday
|
||||
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
|
||||
|
||||
// Include the current week if all existing days are admin-validated
|
||||
if (7 !== $isoDay) {
|
||||
$currentWeekStart = $today->modify('monday this week');
|
||||
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
||||
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
|
||||
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
||||
$limitDate = $currentWeekEnd;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$currentByWeekStart = $this->rttRecoveryService->computeRecoveryByWeek($employee, $weekRanges, $periodFrom, $periodTo, $limitDate);
|
||||
@@ -236,4 +248,25 @@ final readonly class EmployeeRttSummaryProvider implements ProviderInterface
|
||||
|
||||
return $month >= 6 ? $year + 1 : $year;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the employee's contract ends within the current week, cap the check range to that end date.
|
||||
*/
|
||||
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
||||
{
|
||||
foreach ($employee->getContractPeriods() as $period) {
|
||||
if ($period->getStartDate() > $today) {
|
||||
continue;
|
||||
}
|
||||
$endDate = $period->getEndDate();
|
||||
if (null === $endDate) {
|
||||
continue;
|
||||
}
|
||||
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
||||
return $endDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $weekEnd;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Entity\Contract;
|
||||
use App\Entity\Employee;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
|
||||
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
|
||||
use DateTimeImmutable;
|
||||
@@ -29,6 +30,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
private EmployeeContractPeriodReadRepositoryInterface $periodRepository,
|
||||
private EmployeeContractChangeRequestFactory $changeRequestFactory,
|
||||
private EmployeeContractPeriodManagerInterface $periodManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -72,6 +74,17 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
$data->setEntryDate($startDate);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$empName = trim(($data->getLastName() ?? '').' '.($data->getFirstName() ?? ''));
|
||||
$this->auditLogger->log(
|
||||
$data,
|
||||
'create',
|
||||
'employee',
|
||||
$data->getId(),
|
||||
sprintf('Employé %s créé (contrat: %s)', $empName, $currentContract->getName() ?? ''),
|
||||
['new' => ['name' => $empName, 'contract' => $currentContract->getName(), 'nature' => $nature->value, 'startDate' => $startDate->format('d/m/Y')]],
|
||||
);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
@@ -79,8 +92,20 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
return $result;
|
||||
}
|
||||
|
||||
$empName = trim(($data->getLastName() ?? '').' '.($data->getFirstName() ?? ''));
|
||||
$this->auditLogger->log(
|
||||
$data,
|
||||
'update',
|
||||
'employee',
|
||||
$data->getId(),
|
||||
sprintf('Contrat modifié pour %s : %s → %s', $empName, $previousContract?->getName() ?? 'aucun', $currentContract->getName() ?? ''),
|
||||
['old' => ['contract' => $previousContract?->getName()], 'new' => ['contract' => $currentContract->getName()]],
|
||||
);
|
||||
$this->entityManager->flush();
|
||||
|
||||
$todayPeriod = $this->periodRepository->findOneCoveringDate($data, $today);
|
||||
$currentPeriodContract = $todayPeriod?->getContract();
|
||||
$effectivePeriod = $todayPeriod ?? $this->periodRepository->findLatestPeriod($data);
|
||||
$currentPeriodContract = $effectivePeriod?->getContract();
|
||||
$contractChanged = $currentPeriodContract instanceof Contract
|
||||
? $currentPeriodContract->getId() !== $currentContract->getId()
|
||||
: true;
|
||||
@@ -91,25 +116,27 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
||||
if (null === $requestedEndDate) {
|
||||
throw new UnprocessableEntityHttpException('contractEndDate is required for close-only request.');
|
||||
}
|
||||
$isAlreadyEnded = null === $todayPeriod;
|
||||
$this->periodManager->closeCurrentPeriod(
|
||||
$todayPeriod,
|
||||
$effectivePeriod,
|
||||
$requestedEndDate,
|
||||
$changeRequest->contractPaidLeaveSettled ?? false,
|
||||
$changeRequest->contractComment
|
||||
$changeRequest->contractComment,
|
||||
$isAlreadyEnded
|
||||
);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
$startDate = $changeRequest->contractStartDate ?? $today;
|
||||
$nature = $changeRequest->contractNature ?? $todayPeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
$nature = $changeRequest->contractNature ?? $effectivePeriod?->getContractNatureEnum() ?? ContractNature::CDI;
|
||||
$this->periodManager->createNextPeriod(
|
||||
employee: $data,
|
||||
contract: $currentContract,
|
||||
startDate: $startDate,
|
||||
endDate: $changeRequest->contractEndDate,
|
||||
nature: $nature,
|
||||
todayPeriod: $todayPeriod,
|
||||
todayPeriod: $effectivePeriod,
|
||||
isDriver: $changeRequest->isDriver ?? false,
|
||||
);
|
||||
|
||||
|
||||
420
src/State/EmployeeYearlyHoursPrintProvider.php
Normal file
420
src/State/EmployeeYearlyHoursPrintProvider.php
Normal file
@@ -0,0 +1,420 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Dto\WorkHours\WorkMetrics;
|
||||
use App\Entity\Employee;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Enum\TrackingMode;
|
||||
use App\Repository\AbsenceRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
use Dompdf\Dompdf;
|
||||
use Dompdf\Options;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||
use Twig\Environment;
|
||||
|
||||
class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private readonly RequestStack $requestStack,
|
||||
private EmployeeRepository $employeeRepository,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private AbsenceRepository $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
{
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
if (!$request) {
|
||||
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
||||
}
|
||||
|
||||
$employeeId = (int) $request->query->get('employeeId', '0');
|
||||
if ($employeeId <= 0) {
|
||||
throw new UnprocessableEntityHttpException('employeeId must be a positive integer.');
|
||||
}
|
||||
|
||||
$employee = $this->employeeRepository->find($employeeId);
|
||||
if (!$employee instanceof Employee) {
|
||||
throw new NotFoundHttpException('Employee not found.');
|
||||
}
|
||||
|
||||
$yearRaw = (string) $request->query->get('year');
|
||||
if (!preg_match('/^\d{4}$/', $yearRaw)) {
|
||||
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||||
}
|
||||
$year = (int) $yearRaw;
|
||||
|
||||
$from = new DateTimeImmutable("{$year}-01-01");
|
||||
$to = new DateTimeImmutable("{$year}-12-31");
|
||||
$days = $this->buildDays($from, $to);
|
||||
|
||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
||||
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
|
||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
|
||||
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
|
||||
|
||||
$segments = $this->buildSegments(
|
||||
$employee,
|
||||
$days,
|
||||
$contractMap[$employee->getId()] ?? [],
|
||||
$driverMap[$employee->getId()] ?? [],
|
||||
$workHourMap[$employee->getId()] ?? [],
|
||||
$absenceData,
|
||||
);
|
||||
|
||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
|
||||
$dompdf = new Dompdf($options);
|
||||
$html = $this->twig->render('employee-yearly-hours/print.html.twig', [
|
||||
'employeeName' => $employeeName,
|
||||
'year' => $year,
|
||||
'segments' => $segments,
|
||||
]);
|
||||
|
||||
$dompdf->loadHtml($html);
|
||||
$dompdf->setPaper('A4', 'portrait');
|
||||
$dompdf->render();
|
||||
|
||||
$filename = sprintf(
|
||||
'%s_%s_%d.pdf',
|
||||
$this->sanitizeFilename($employee->getLastName() ?? ''),
|
||||
$this->sanitizeFilename($employee->getFirstName() ?? ''),
|
||||
$year,
|
||||
);
|
||||
|
||||
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||
'Content-Type' => 'application/pdf',
|
||||
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||
{
|
||||
$days = [];
|
||||
$current = $from;
|
||||
|
||||
while ($current <= $to) {
|
||||
$days[] = $current->format('Y-m-d');
|
||||
$current = $current->add(new DateInterval('P1D'));
|
||||
}
|
||||
|
||||
return $days;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, WorkHour>>
|
||||
*/
|
||||
private function buildWorkHourMap(array $workHours): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($workHours as $wh) {
|
||||
$employeeId = $wh->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
$date = $wh->getWorkDate()->format('Y-m-d');
|
||||
$map[$employeeId][$date] = $wh;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
||||
*/
|
||||
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
|
||||
{
|
||||
$credited = [];
|
||||
$labels = [];
|
||||
$absentMorning = [];
|
||||
$absentAfternoon = [];
|
||||
$hasDayAbsence = [];
|
||||
|
||||
foreach ($absences as $absence) {
|
||||
$absEmployeeId = $absence->getEmployee()?->getId();
|
||||
if ($absEmployeeId !== $employee->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$start = $absence->getStartDate()->format('Y-m-d');
|
||||
$end = $absence->getEndDate()->format('Y-m-d');
|
||||
|
||||
foreach ($days as $date) {
|
||||
if ($date < $start || $date > $end) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||
if ($isMorning || $isAfternoon) {
|
||||
$hasDayAbsence[$date] = true;
|
||||
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
||||
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
||||
if (!isset($labels[$date])) {
|
||||
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
$credited[$date] = ($credited[$date] ?? 0)
|
||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'credited' => $credited,
|
||||
'labels' => $labels,
|
||||
'absentMorning' => $absentMorning,
|
||||
'absentAfternoon' => $absentAfternoon,
|
||||
'hasDayAbsence' => $hasDayAbsence,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
||||
*/
|
||||
private function buildSegments(
|
||||
Employee $employee,
|
||||
array $days,
|
||||
array $contractsByDate,
|
||||
array $driverByDate,
|
||||
array $workHoursByDate,
|
||||
array $absenceData,
|
||||
): array {
|
||||
$segments = [];
|
||||
$currentMode = null;
|
||||
$currentRows = [];
|
||||
$currentName = null;
|
||||
|
||||
foreach ($days as $date) {
|
||||
$contract = $contractsByDate[$date] ?? null;
|
||||
$isDriver = $driverByDate[$date] ?? false;
|
||||
$wh = $workHoursByDate[$date] ?? null;
|
||||
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||
|
||||
if (!$hasData) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
||||
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
||||
$contractName = $contract?->getName();
|
||||
|
||||
if ($mode !== $currentMode) {
|
||||
if (null !== $currentMode && [] !== $currentRows) {
|
||||
$segments[] = [
|
||||
'mode' => $currentMode,
|
||||
'contractName' => $currentName,
|
||||
'rows' => $currentRows,
|
||||
];
|
||||
}
|
||||
$currentMode = $mode;
|
||||
$currentRows = [];
|
||||
$currentName = $contractName;
|
||||
}
|
||||
|
||||
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
||||
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
||||
|
||||
$row = [
|
||||
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
||||
'absenceLabel' => $absenceLabel,
|
||||
];
|
||||
|
||||
if ('presence' === $mode) {
|
||||
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
||||
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
||||
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||
$total = $morning + $afternoon;
|
||||
|
||||
$row['presentMorning'] = $morning > 0;
|
||||
$row['presentAfternoon'] = $afternoon > 0;
|
||||
$row['total'] = $total > 0 ? (string) $total : '';
|
||||
} elseif ('driver' === $mode) {
|
||||
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
||||
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
||||
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
||||
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
||||
|
||||
$row['dayHours'] = $this->formatMinutes($dayMin);
|
||||
$row['nightHours'] = $this->formatMinutes($nightMin);
|
||||
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
||||
$row['total'] = $this->formatMinutes($totalMin);
|
||||
} else {
|
||||
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
||||
$metrics->addCreditedMinutes($creditedMinutes);
|
||||
|
||||
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
||||
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
||||
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
||||
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
||||
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
||||
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
||||
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
|
||||
}
|
||||
|
||||
$currentRows[] = $row;
|
||||
}
|
||||
|
||||
if (null !== $currentMode && [] !== $currentRows) {
|
||||
$segments[] = [
|
||||
'mode' => $currentMode,
|
||||
'contractName' => $currentName,
|
||||
'rows' => $currentRows,
|
||||
];
|
||||
}
|
||||
|
||||
return $segments;
|
||||
}
|
||||
|
||||
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
||||
{
|
||||
if ($isDriver) {
|
||||
return 'driver';
|
||||
}
|
||||
|
||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||
return 'presence';
|
||||
}
|
||||
|
||||
return 'time';
|
||||
}
|
||||
|
||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||
{
|
||||
$ranges = [
|
||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||
];
|
||||
|
||||
$totalMinutes = 0;
|
||||
$nightMinutes = 0;
|
||||
|
||||
foreach ($ranges as [$from, $to]) {
|
||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||
}
|
||||
|
||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||
|
||||
return new WorkMetrics(
|
||||
dayMinutes: $dayMinutes,
|
||||
nightMinutes: $nightMinutes,
|
||||
totalMinutes: $totalMinutes,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|array{int, int}
|
||||
*/
|
||||
private function resolveInterval(?string $from, ?string $to): ?array
|
||||
{
|
||||
$fromMinutes = $this->toMinutes($from);
|
||||
$toMinutes = $this->toMinutes($to);
|
||||
if (null === $fromMinutes || null === $toMinutes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||
|
||||
return [$fromMinutes, $end];
|
||||
}
|
||||
|
||||
private function toMinutes(?string $time): ?int
|
||||
{
|
||||
if (null === $time || '' === $time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||
|
||||
return ($hours * 60) + $minutes;
|
||||
}
|
||||
|
||||
private function intervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||
{
|
||||
$interval = $this->resolveInterval($from, $to);
|
||||
if (null === $interval) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
[$start, $end] = $interval;
|
||||
$windows = [[0, 360], [1260, 1440]];
|
||||
$total = 0;
|
||||
|
||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||
$shift = $dayOffset * 1440;
|
||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||
}
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||
{
|
||||
$start = max($startA, $startB);
|
||||
$end = min($endA, $endB);
|
||||
|
||||
return max(0, $end - $start);
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
if (0 === $minutes) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$h = intdiv($minutes, 60);
|
||||
$m = $minutes % 60;
|
||||
|
||||
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
||||
}
|
||||
|
||||
private function sanitizeFilename(string $name): string
|
||||
{
|
||||
$name = str_replace(' ', '_', $name);
|
||||
|
||||
return preg_replace('/[^a-zA-Z0-9_\-]/', '', $name) ?? $name;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ use App\Enum\TrackingMode;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttBalanceRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Rtt\RttRecoveryComputationService;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -32,6 +33,7 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
||||
private EmployeeRttBalanceRepository $rttBalanceRepository,
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private WorkHourRepository $workHourRepository,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||
@@ -142,6 +144,16 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
||||
$isoDay = (int) $today->format('N');
|
||||
$limitDate = 7 === $isoDay ? $today : $today->modify('last sunday');
|
||||
|
||||
// Include the current week if all existing days are admin-validated
|
||||
if (7 !== $isoDay) {
|
||||
$currentWeekStart = $today->modify('monday this week');
|
||||
$currentWeekEnd = $currentWeekStart->modify('+6 days');
|
||||
$checkEnd = $this->resolveWeekEndForEmployee($employee, $currentWeekStart, $currentWeekEnd, $today);
|
||||
if ($this->workHourRepository->isWeekFullyValidated($employee, $currentWeekStart, $checkEnd)) {
|
||||
$limitDate = $currentWeekEnd;
|
||||
}
|
||||
}
|
||||
|
||||
// Carry from previous exercise
|
||||
$carry = 0;
|
||||
$balance = $this->rttBalanceRepository->findOneByEmployeeAndYear($employee, $exerciseYear);
|
||||
@@ -159,12 +171,31 @@ class LeaveRecapPrintProvider implements ProviderInterface
|
||||
$paid = 0;
|
||||
$payments = $this->rttPaymentRepository->findByEmployeeAndYear($employee, $exerciseYear);
|
||||
foreach ($payments as $payment) {
|
||||
$paid += $payment->getBase25Minutes() + $payment->getBase50Minutes();
|
||||
$paid += $payment->getBase25Minutes() + $payment->getBonus25Minutes()
|
||||
+ $payment->getBase50Minutes() + $payment->getBonus50Minutes();
|
||||
}
|
||||
|
||||
return $carry + $current->totalMinutes - $paid;
|
||||
}
|
||||
|
||||
private function resolveWeekEndForEmployee(Employee $employee, DateTimeImmutable $weekStart, DateTimeImmutable $weekEnd, DateTimeImmutable $today): DateTimeImmutable
|
||||
{
|
||||
foreach ($employee->getContractPeriods() as $period) {
|
||||
if ($period->getStartDate() > $today) {
|
||||
continue;
|
||||
}
|
||||
$endDate = $period->getEndDate();
|
||||
if (null === $endDate) {
|
||||
continue;
|
||||
}
|
||||
if ($endDate >= $weekStart && $endDate <= $weekEnd) {
|
||||
return $endDate;
|
||||
}
|
||||
}
|
||||
|
||||
return $weekEnd;
|
||||
}
|
||||
|
||||
private function formatMinutes(int $minutes): string
|
||||
{
|
||||
if (0 === $minutes) {
|
||||
|
||||
53
src/State/MileageAllowanceAmountReceiptDownloadProvider.php
Normal file
53
src/State/MileageAllowanceAmountReceiptDownloadProvider.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProviderInterface;
|
||||
use App\Entity\MileageAllowance;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\HeaderUtils;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
final readonly class MileageAllowanceAmountReceiptDownloadProvider implements ProviderInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private string $uploadDir,
|
||||
) {}
|
||||
|
||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): BinaryFileResponse
|
||||
{
|
||||
$mileageAllowance = $this->entityManager->find(MileageAllowance::class, $uriVariables['id']);
|
||||
|
||||
if (null === $mileageAllowance) {
|
||||
throw new NotFoundHttpException('Mileage allowance not found.');
|
||||
}
|
||||
|
||||
$receiptPath = $mileageAllowance->getAmountReceiptPath();
|
||||
|
||||
if (null === $receiptPath) {
|
||||
throw new NotFoundHttpException('No amount receipt found for this mileage allowance.');
|
||||
}
|
||||
|
||||
$absolutePath = sprintf('%s/%s', $this->uploadDir, $receiptPath);
|
||||
|
||||
if (!file_exists($absolutePath)) {
|
||||
throw new NotFoundHttpException('Amount receipt file not found.');
|
||||
}
|
||||
|
||||
$response = new BinaryFileResponse($absolutePath);
|
||||
$disposition = HeaderUtils::makeDisposition(
|
||||
HeaderUtils::DISPOSITION_ATTACHMENT,
|
||||
$mileageAllowance->getAmountReceiptName() ?? 'justificatif.pdf'
|
||||
);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
66
src/State/MileageAllowanceAmountReceiptUploadProcessor.php
Normal file
66
src/State/MileageAllowanceAmountReceiptUploadProcessor.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\MileageAllowance;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
final readonly class MileageAllowanceAmountReceiptUploadProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private RequestStack $requestStack,
|
||||
#[Autowire('%kernel.project_dir%/var/uploads')]
|
||||
private string $uploadDir,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): JsonResponse
|
||||
{
|
||||
if (!$data instanceof MileageAllowance) {
|
||||
throw new BadRequestHttpException('Invalid entity.');
|
||||
}
|
||||
|
||||
$request = $this->requestStack->getCurrentRequest();
|
||||
$file = $request?->files->get('file');
|
||||
|
||||
if (null === $file) {
|
||||
throw new BadRequestHttpException('No file uploaded.');
|
||||
}
|
||||
|
||||
if ('application/pdf' !== $file->getMimeType()) {
|
||||
throw new BadRequestHttpException('Only PDF files are accepted.');
|
||||
}
|
||||
|
||||
$month = $data->getMonth();
|
||||
$year = $month?->format('Y') ?? date('Y');
|
||||
$monthNumber = $month?->format('m') ?? date('m');
|
||||
$relativePath = sprintf('mileage-receipts/%s/%s', $year, $monthNumber);
|
||||
$absoluteDir = sprintf('%s/%s', $this->uploadDir, $relativePath);
|
||||
|
||||
if (!is_dir($absoluteDir)) {
|
||||
mkdir($absoluteDir, 0o755, true);
|
||||
}
|
||||
|
||||
$filename = Uuid::v4()->toRfc4122().'.pdf';
|
||||
$fullRelative = sprintf('%s/%s', $relativePath, $filename);
|
||||
$originalName = $file->getClientOriginalName();
|
||||
|
||||
$file->move($absoluteDir, $filename);
|
||||
|
||||
$data->setAmountReceiptPath($fullRelative);
|
||||
$data->setAmountReceiptName($originalName);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['path' => $fullRelative, 'name' => $originalName], Response::HTTP_OK);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,16 @@ final readonly class MileageAllowanceDeleteProcessor implements ProcessorInterfa
|
||||
}
|
||||
}
|
||||
|
||||
$amountReceiptPath = $data->getAmountReceiptPath();
|
||||
|
||||
if (null !== $amountReceiptPath) {
|
||||
$absolutePath = sprintf('%s/%s', $this->uploadDir, $amountReceiptPath);
|
||||
|
||||
if (file_exists($absolutePath)) {
|
||||
unlink($absolutePath);
|
||||
}
|
||||
}
|
||||
|
||||
$this->entityManager->remove($data);
|
||||
$this->entityManager->flush();
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Repository\BonusRepository;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\EmployeeRttPaymentRepository;
|
||||
use App\Repository\MileageAllowanceRepository;
|
||||
use App\Repository\ObservationRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateInterval;
|
||||
@@ -36,6 +37,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
private EmployeeRttPaymentRepository $rttPaymentRepository,
|
||||
private BonusRepository $bonusRepository,
|
||||
private MileageAllowanceRepository $mileageAllowanceRepository,
|
||||
private ObservationRepository $observationRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
) {}
|
||||
|
||||
@@ -62,20 +64,22 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$monthNumber = (int) $from->format('n');
|
||||
$rttPayments = $this->rttPaymentRepository->findByYearAndMonth($year, $monthNumber);
|
||||
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
$bonuses = $this->bonusRepository->findByMonth($from, $to);
|
||||
$mileages = $this->mileageAllowanceRepository->findByMonth($from, $to);
|
||||
$observations = $this->observationRepository->findByMonth($from, $to);
|
||||
|
||||
$days = $this->buildDays($from, $to);
|
||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceMap = $this->buildAbsenceMap($absences);
|
||||
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
||||
$bonusMap = $this->buildBonusMap($bonuses);
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||
$absenceMap = $this->buildAbsenceMap($absences);
|
||||
$rttPaymentMap = $this->buildRttPaymentMap($rttPayments);
|
||||
$bonusMap = $this->buildBonusMap($bonuses);
|
||||
$mileageMap = $this->buildMileageMap($mileages);
|
||||
$observationMap = $this->buildObservationMap($observations);
|
||||
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap);
|
||||
$siteGroups = $this->aggregateBySite($employees, $days, $contractMap, $driverMap, $workHourMap, $absenceMap, $rttPaymentMap, $bonusMap, $mileageMap, $observationMap);
|
||||
|
||||
$options = new Options();
|
||||
$options->set('isRemoteEnabled', true);
|
||||
@@ -204,6 +208,23 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function buildObservationMap(array $observations): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ($observations as $observation) {
|
||||
$employeeId = $observation->getEmployee()?->getId();
|
||||
if (!$employeeId) {
|
||||
continue;
|
||||
}
|
||||
$map[$employeeId] = $observation->getContent();
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
private function aggregateBySite(
|
||||
array $employees,
|
||||
array $days,
|
||||
@@ -214,6 +235,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
array $rttPaymentMap,
|
||||
array $bonusMap,
|
||||
array $mileageMap,
|
||||
array $observationMap,
|
||||
): array {
|
||||
$siteGroups = [];
|
||||
|
||||
@@ -234,6 +256,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
$rttPaymentMap[$employeeId] ?? 0,
|
||||
$bonusMap[$employeeId] ?? 0.0,
|
||||
$mileageMap[$employeeId] ?? 0.0,
|
||||
$observationMap[$employeeId] ?? '',
|
||||
);
|
||||
|
||||
if (!isset($siteGroups[$siteId])) {
|
||||
@@ -261,6 +284,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
int $rttPaidMinutes,
|
||||
float $bonusAmount,
|
||||
float $mileageKm,
|
||||
string $observation,
|
||||
): array {
|
||||
$contractName = null;
|
||||
$presenceDays = 0.0;
|
||||
@@ -373,6 +397,7 @@ class SalaryRecapPrintProvider implements ProviderInterface
|
||||
'driverMeals' => $driverMeals,
|
||||
'driverOvernight' => $driverOvernight,
|
||||
'driverSaturdays' => $driverSaturdays,
|
||||
'observation' => $observation,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\WorkHour;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -30,6 +31,7 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
|
||||
private UserRepository $userRepository,
|
||||
private EmployeeScopeService $employeeScopeService,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -61,6 +63,21 @@ final readonly class WorkHourBulkSiteValidationProcessor implements ProcessorInt
|
||||
}
|
||||
);
|
||||
|
||||
if ($result->updated > 0) {
|
||||
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
|
||||
$action = $data->isSiteValid ? 'validé' : 'dévalidé';
|
||||
|
||||
$this->auditLogger->log(
|
||||
null,
|
||||
'site_validate',
|
||||
'work_hour',
|
||||
null,
|
||||
sprintf('Validation site %s pour %d employé(s) le %s', $action, $result->updated, $data->workDate),
|
||||
['employeeIds' => $data->employeeIds, 'isSiteValid' => $data->isSiteValid],
|
||||
$workDate ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
if ($data->isSiteValid && $result->updated > 0) {
|
||||
$this->createNotificationsIfSiteFullyValidated($user, $data->workDate);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Enum\TrackingMode;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\EmployeeRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractResolver;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -31,6 +32,7 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private AbsenceReadRepositoryInterface $absenceRepository,
|
||||
private EmployeeContractResolver $contractResolver,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -137,9 +139,20 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
|
||||
$is4hContract = 4 === $contract->getWeeklyHours();
|
||||
|
||||
$empName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||
|
||||
if ($this->isEntryEmpty($normalized)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'delete',
|
||||
'work_hour',
|
||||
$existing->getId(),
|
||||
sprintf('Heures supprimées pour %s le %s', $empName, $data->workDate),
|
||||
['old' => $this->snapshotWorkHour($existing)],
|
||||
$workDate,
|
||||
);
|
||||
$this->entityManager->remove($existing);
|
||||
++$result->deleted;
|
||||
} elseif (($absenceByEmployeeId[$employeeId] ?? false) === true || $is4hContract) {
|
||||
@@ -163,9 +176,11 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
}
|
||||
|
||||
if ($existing) {
|
||||
$workHour = $existing;
|
||||
$oldSnapshot = $this->snapshotWorkHour($existing);
|
||||
$workHour = $existing;
|
||||
++$result->updated;
|
||||
} else {
|
||||
$oldSnapshot = null;
|
||||
// Upsert: création si aucune ligne n'existe pour (employé, date).
|
||||
$workHour = new WorkHour()
|
||||
->setEmployee($employee)
|
||||
@@ -179,6 +194,23 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
if (!$isAdmin) {
|
||||
$workHour->setUpdatedAt(new DateTimeImmutable());
|
||||
}
|
||||
|
||||
$newSnapshot = $this->snapshotWorkHour($workHour);
|
||||
$action = null !== $oldSnapshot ? 'update' : 'create';
|
||||
$changes = null !== $oldSnapshot
|
||||
? ['old' => $oldSnapshot, 'new' => $newSnapshot]
|
||||
: ['new' => $newSnapshot];
|
||||
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
$action,
|
||||
'work_hour',
|
||||
$workHour->getId(),
|
||||
sprintf('Heures %s pour %s le %s', null !== $oldSnapshot ? 'modifiées' : 'créées', $empName, $data->workDate),
|
||||
$changes,
|
||||
$workDate,
|
||||
);
|
||||
|
||||
++$result->processed;
|
||||
}
|
||||
|
||||
@@ -446,6 +478,30 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function snapshotWorkHour(WorkHour $wh): array
|
||||
{
|
||||
return [
|
||||
'morningFrom' => $wh->getMorningFrom(),
|
||||
'morningTo' => $wh->getMorningTo(),
|
||||
'afternoonFrom' => $wh->getAfternoonFrom(),
|
||||
'afternoonTo' => $wh->getAfternoonTo(),
|
||||
'eveningFrom' => $wh->getEveningFrom(),
|
||||
'eveningTo' => $wh->getEveningTo(),
|
||||
'isPresentMorning' => $wh->getIsPresentMorning(),
|
||||
'isPresentAfternoon' => $wh->getIsPresentAfternoon(),
|
||||
'dayHoursMinutes' => $wh->getDayHoursMinutes(),
|
||||
'nightHoursMinutes' => $wh->getNightHoursMinutes(),
|
||||
'workshopHoursMinutes' => $wh->getWorkshopHoursMinutes(),
|
||||
'hasBreakfast' => $wh->getHasBreakfast(),
|
||||
'hasLunch' => $wh->getHasLunch(),
|
||||
'hasDinner' => $wh->getHasDinner(),
|
||||
'hasOvernight' => $wh->getHasOvernight(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* morningFrom:?string,
|
||||
|
||||
@@ -10,7 +10,9 @@ use App\ApiResource\WorkHourBulkValidation;
|
||||
use App\ApiResource\WorkHourBulkValidationResult;
|
||||
use App\Entity\User;
|
||||
use App\Entity\WorkHour;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\WorkHours\WorkHourBulkValidationExecutor;
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
@@ -20,6 +22,7 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private WorkHourBulkValidationExecutor $executor,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(
|
||||
@@ -41,7 +44,7 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
|
||||
throw new AccessDeniedHttpException('Only admins can bulk validate work hours.');
|
||||
}
|
||||
|
||||
return $this->executor->execute(
|
||||
$result = $this->executor->execute(
|
||||
user: $user,
|
||||
workDateValue: $data->workDate,
|
||||
employeeIds: $data->employeeIds,
|
||||
@@ -50,5 +53,22 @@ final readonly class WorkHourBulkValidationProcessor implements ProcessorInterfa
|
||||
$workHour->setIsValid($data->isValid);
|
||||
}
|
||||
);
|
||||
|
||||
if ($result->updated > 0) {
|
||||
$workDate = DateTimeImmutable::createFromFormat('Y-m-d', $data->workDate);
|
||||
$action = $data->isValid ? 'validé' : 'dévalidé';
|
||||
|
||||
$this->auditLogger->log(
|
||||
null,
|
||||
'validate',
|
||||
'work_hour',
|
||||
null,
|
||||
sprintf('Validation RH %s pour %d employé(s) le %s', $action, $result->updated, $data->workDate),
|
||||
['employeeIds' => $data->employeeIds, 'isValid' => $data->isValid],
|
||||
$workDate ?: null,
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ use App\Entity\WorkHour;
|
||||
use App\Repository\UserRepository;
|
||||
use App\Repository\WorkHourRepository;
|
||||
use App\Security\EmployeeScopeService;
|
||||
use App\Service\AuditLogger;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
@@ -24,6 +26,7 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
||||
private WorkHourRepository $workHourRepository,
|
||||
private UserRepository $userRepository,
|
||||
private EntityManagerInterface $entityManager,
|
||||
private AuditLogger $auditLogger,
|
||||
) {}
|
||||
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): WorkHour
|
||||
@@ -59,6 +62,23 @@ final readonly class WorkHourSiteValidationProcessor implements ProcessorInterfa
|
||||
&& false === $changeSet['isSiteValid'][0]
|
||||
&& true === $changeSet['isSiteValid'][1];
|
||||
|
||||
if (isset($changeSet['isSiteValid'])) {
|
||||
$employee = $data->getEmployee();
|
||||
$empName = $employee ? trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')) : '';
|
||||
$workDate = $data->getWorkDate();
|
||||
$newVal = $changeSet['isSiteValid'][1] ? 'validé' : 'dévalidé';
|
||||
|
||||
$this->auditLogger->log(
|
||||
$employee,
|
||||
'site_validate',
|
||||
'work_hour',
|
||||
$data->getId(),
|
||||
sprintf('Validation site %s pour %s le %s', $newVal, $empName, $workDate->format('d/m/Y')),
|
||||
['old' => ['isSiteValid' => $changeSet['isSiteValid'][0]], 'new' => ['isSiteValid' => $changeSet['isSiteValid'][1]]],
|
||||
$workDate instanceof DateTimeImmutable ? $workDate : DateTimeImmutable::createFromInterface($workDate),
|
||||
);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Notification uniquement quand la dernière ligne du site est validée pour la date.
|
||||
|
||||
151
templates/employee-yearly-hours/print.html.twig
Normal file
151
templates/employee-yearly-hours/print.html.twig
Normal file
@@ -0,0 +1,151 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ employeeName }} - {{ year }}</title>
|
||||
|
||||
<style>
|
||||
@page { size: A4 portrait; margin: 4mm; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 2mm;
|
||||
font-family: Helvetica, sans-serif;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
margin: 0 0 4mm 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
margin: 4mm 0 2mm 0;
|
||||
padding: 2px 6px;
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: auto;
|
||||
border: 2px solid #0a0a0a;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #0a0a0a;
|
||||
padding: 2px 4px;
|
||||
vertical-align: middle;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead th {
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 9px;
|
||||
background: #d9e2f3;
|
||||
}
|
||||
|
||||
td { font-size: 9px; }
|
||||
td.date { text-align: left; font-weight: bold; }
|
||||
td.absence { text-align: left; color: #c00; }
|
||||
td.time { text-align: center; }
|
||||
td.presence { text-align: center; }
|
||||
td.total { text-align: center; font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>{{ employeeName }} - {{ year }}</h1>
|
||||
|
||||
{% for segment in segments %}
|
||||
{% if segments|length > 1 %}
|
||||
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if segment.mode == 'presence' %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Absence</th>
|
||||
<th>Présence matin</th>
|
||||
<th>Présence après-midi</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
||||
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
|
||||
<td class="total">{{ row.total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% elseif segment.mode == 'driver' %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Absence</th>
|
||||
<th>Heures jour</th>
|
||||
<th>Heures nuit</th>
|
||||
<th>Heures atelier</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="time">{{ row.dayHours }}</td>
|
||||
<td class="time">{{ row.nightHours }}</td>
|
||||
<td class="time">{{ row.workshopHours }}</td>
|
||||
<td class="total">{{ row.total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Absence</th>
|
||||
<th>Début matin</th>
|
||||
<th>Fin matin</th>
|
||||
<th>Début après-midi</th>
|
||||
<th>Fin après-midi</th>
|
||||
<th>Début soir</th>
|
||||
<th>Fin soir</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in segment.rows %}
|
||||
<tr>
|
||||
<td class="date">{{ row.date }}</td>
|
||||
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||
<td class="time">{{ row.morningFrom }}</td>
|
||||
<td class="time">{{ row.morningTo }}</td>
|
||||
<td class="time">{{ row.afternoonFrom }}</td>
|
||||
<td class="time">{{ row.afternoonTo }}</td>
|
||||
<td class="time">{{ row.eveningFrom }}</td>
|
||||
<td class="time">{{ row.eveningTo }}</td>
|
||||
<td class="total">{{ row.total }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -76,7 +76,12 @@
|
||||
word-break: break-word;
|
||||
font-size: 10px;
|
||||
}
|
||||
td.obs { }
|
||||
td.obs {
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
tbody td { font-size: 10px; }
|
||||
</style>
|
||||
@@ -139,7 +144,7 @@
|
||||
<td class="num">{{ row.nightBasketCount > 0 ? row.nightBasketCount : '' }}</td>
|
||||
<td class="num">{{ row.paidHours > 0 ? row.paidHours : '' }}</td>
|
||||
<td class="num">{{ row.sundayHours > 0 ? row.sundayHours : '' }}</td>
|
||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount : '' }}</td>
|
||||
<td class="num">{{ row.bonusAmount > 0 ? row.bonusAmount ~ ' €' : '' }}</td>
|
||||
<td class="num">{{ row.congesCount > 0 ? row.congesCount : '' }}</td>
|
||||
<td class="dates">{{ row.congesDates }}</td>
|
||||
<td class="num">{{ row.maladieCount > 0 ? row.maladieCount : '' }}</td>
|
||||
@@ -148,7 +153,7 @@
|
||||
<td class="num">{{ row.isDriver and row.driverMeals > 0 ? row.driverMeals : '' }}</td>
|
||||
<td class="num">{{ row.isDriver and row.driverOvernight > 0 ? row.driverOvernight : '' }}</td>
|
||||
<td class="num">{{ row.isDriver and row.driverSaturdays > 0 ? row.driverSaturdays : '' }}</td>
|
||||
<td class="obs"></td>
|
||||
<td class="obs">{{ row.observation }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Entity\User;
|
||||
use App\Enum\HalfDay;
|
||||
use App\Repository\Contract\AbsenceReadRepositoryInterface;
|
||||
use App\Repository\Contract\WorkHourReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\PublicHolidayServiceInterface;
|
||||
use App\State\AbsenceWriteProcessor;
|
||||
use DateTime;
|
||||
@@ -36,7 +37,7 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$absenceRepository = $this->createMock(AbsenceReadRepositoryInterface::class);
|
||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||
$security = $this->createAdminSecurityStub();
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
|
||||
|
||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-18', HalfDay::AM, HalfDay::PM);
|
||||
|
||||
@@ -64,7 +65,7 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||
$security = $this->createAdminSecurityStub();
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
|
||||
|
||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
||||
|
||||
@@ -85,7 +86,7 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||
$workHourRepository = $this->createMock(WorkHourReadRepositoryInterface::class);
|
||||
$security = $this->createAdminSecurityStub();
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
|
||||
|
||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::AM, HalfDay::PM);
|
||||
|
||||
@@ -107,7 +108,7 @@ final class AbsenceWriteProcessorTest extends TestCase
|
||||
$absenceRepository = $this->createStub(AbsenceReadRepositoryInterface::class);
|
||||
$workHourRepository = $this->createStub(WorkHourReadRepositoryInterface::class);
|
||||
$security = $this->createAdminSecurityStub();
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub());
|
||||
$this->processor = new AbsenceWriteProcessor($entityManager, $absenceRepository, $workHourRepository, $security, $this->createEmptyHolidayServiceStub(), $this->createStub(AuditLogger::class));
|
||||
|
||||
$absence = $this->buildAbsence('2026-02-16', '2026-02-16', HalfDay::PM, HalfDay::AM);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\ContractSuspension;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Service\AuditLogger;
|
||||
use App\State\ContractSuspensionWriteProcessor;
|
||||
use DateTimeImmutable;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
@@ -35,7 +36,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class));
|
||||
$result = $processor->process($suspension, new Post());
|
||||
|
||||
self::assertSame($suspension, $result);
|
||||
@@ -52,7 +53,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($suspension, new Post());
|
||||
@@ -68,7 +69,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($suspension, new Post());
|
||||
@@ -92,7 +93,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($suspension, new Post());
|
||||
@@ -109,7 +110,7 @@ final class ContractSuspensionWriteProcessorTest extends TestCase
|
||||
$persistProcessor = $this->createStub(ProcessorInterface::class);
|
||||
$entityManager = $this->createStub(EntityManagerInterface::class);
|
||||
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager);
|
||||
$processor = new ContractSuspensionWriteProcessor($persistProcessor, $entityManager, $this->createStub(AuditLogger::class));
|
||||
|
||||
$this->expectException(UnprocessableEntityHttpException::class);
|
||||
$processor->process($suspension, new Post());
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Entity\Employee;
|
||||
use App\Entity\EmployeeContractPeriod;
|
||||
use App\Enum\ContractNature;
|
||||
use App\Repository\Contract\EmployeeContractPeriodReadRepositoryInterface;
|
||||
use App\Service\AuditLogger;
|
||||
use App\Service\Contracts\EmployeeContractChangeRequestFactory;
|
||||
use App\Service\Contracts\EmployeeContractPeriodManagerInterface;
|
||||
use App\State\EmployeeWriteProcessor;
|
||||
@@ -83,7 +84,8 @@ final class EmployeeWriteProcessorTest extends TestCase
|
||||
$entityManager,
|
||||
$periodRepository,
|
||||
$changeRequestFactory,
|
||||
$periodManager
|
||||
$periodManager,
|
||||
$this->createStub(AuditLogger::class)
|
||||
);
|
||||
|
||||
$result = $processor->process($employee, new Patch());
|
||||
@@ -149,7 +151,8 @@ final class EmployeeWriteProcessorTest extends TestCase
|
||||
$entityManager,
|
||||
$periodRepository,
|
||||
$changeRequestFactory,
|
||||
$periodManager
|
||||
$periodManager,
|
||||
$this->createStub(AuditLogger::class)
|
||||
);
|
||||
|
||||
$result = $processor->process($employee, new Patch());
|
||||
@@ -187,7 +190,8 @@ final class EmployeeWriteProcessorTest extends TestCase
|
||||
$entityManager,
|
||||
$periodRepository,
|
||||
$changeRequestFactory,
|
||||
$periodManager
|
||||
$periodManager,
|
||||
$this->createStub(AuditLogger::class)
|
||||
);
|
||||
|
||||
$result = $processor->process($employee, new Patch());
|
||||
@@ -234,7 +238,8 @@ final class EmployeeWriteProcessorTest extends TestCase
|
||||
$entityManager,
|
||||
$periodRepository,
|
||||
$changeRequestFactory,
|
||||
$periodManager
|
||||
$periodManager,
|
||||
$this->createStub(AuditLogger::class)
|
||||
);
|
||||
|
||||
$processor->process($employee, new Post());
|
||||
@@ -268,7 +273,8 @@ final class EmployeeWriteProcessorTest extends TestCase
|
||||
$entityManager,
|
||||
$periodRepository,
|
||||
$changeRequestFactory,
|
||||
$periodManager
|
||||
$periodManager,
|
||||
$this->createStub(AuditLogger::class)
|
||||
);
|
||||
|
||||
$result = $processor->process($employee, new Delete());
|
||||
|
||||
Reference in New Issue
Block a user