Compare commits

...

13 Commits

Author SHA1 Message Date
gitea-actions
54354c4435 chore: bump version to v0.1.67
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m33s
2026-03-30 13:06:35 +00:00
3dcdf0fb81 [#SIRH-18] Fix connexion conducteur (#10)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #10
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 13:06:27 +00:00
gitea-actions
1a71ff6834 chore: bump version to v0.1.66
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m30s
2026-03-30 07:52:57 +00:00
057d6bf06f [#SIRH-17] Ajouter un système de log des actions utilisateurs (#9)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #9
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-30 07:52:49 +00:00
gitea-actions
e74a264b37 chore: bump version to v0.1.65
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 2m24s
2026-03-25 09:49:32 +00:00
60bb3cf8c4 fix : verrouillage utilisateur + modification de contrat terminé
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-03-25 10:49:20 +01:00
gitea-actions
1a485e8780 chore: bump version to v0.1.64
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m14s
2026-03-25 09:19:21 +00:00
5c6d42c729 [#SIRH-14] Ajouter un onglet Observation sur la fiche employé (#8)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #8
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 09:19:16 +00:00
gitea-actions
3c434d20b2 chore: bump version to v0.1.63
All checks were successful
Auto Tag Develop / tag (push) Successful in 4s
Build Release Artefact / build (push) Successful in 1m16s
2026-03-25 07:51:33 +00:00
bbb020025a [#SIRH-12] Export des heures d'un employé sur une année (#7)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

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

Reviewed-on: #7
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-03-25 07:51:26 +00:00
gitea-actions
640bb42d3a chore: bump version to v0.1.62
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build Release Artefact / build (push) Successful in 1m38s
2026-03-23 15:40:37 +00:00
50712ccb00 Merge remote-tracking branch 'origin/develop' into develop
All checks were successful
Auto Tag Develop / tag (push) Successful in 8s
2026-03-23 16:40:24 +01:00
265b19a9d0 fix : calcule des RTT pour les chauffeurs dans le récap des congés 2026-03-23 16:40:06 +01:00
62 changed files with 2653 additions and 94 deletions

View File

@@ -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
View File

@@ -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>

View File

@@ -72,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

View File

@@ -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';
```

View File

@@ -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

View File

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

57
doc/audit-logging.md Normal file
View 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.

View File

@@ -194,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
@@ -371,7 +372,26 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
- 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
@@ -384,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`

View 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>

View 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>

View File

@@ -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',

View File

@@ -6,7 +6,7 @@ export const useEmployeeDetailPage = () => {
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
const activeTab = ref<'contract' | 'leave' | 'rtt' | 'mileage' | 'bonus'>('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
}
}

View 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
}
}

View File

@@ -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."
}
}
}

View File

@@ -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>

View 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('/')
}
})

View 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>

View File

@@ -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}`

View File

@@ -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
}

View File

@@ -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) {

View 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 }
)
}

View 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
}

View File

@@ -0,0 +1,6 @@
export type Observation = {
id: number
month: string
content: string
createdAt: string
}

View File

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

View File

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

View 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'
})
}

View File

@@ -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) {

View 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');
}
}

View 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');
}
}

View 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');
}
}

View 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 {}

View 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 {}

View 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
View 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;
}
}

130
src/Entity/Observation.php Normal file
View 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;
}
}

View File

@@ -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 {}
}

View 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();
}
}

View File

@@ -11,4 +11,6 @@ use DateTimeImmutable;
interface EmployeeContractPeriodReadRepositoryInterface
{
public function findOneCoveringDate(Employee $employee, DateTimeImmutable $date): ?EmployeeContractPeriod;
public function findLatestPeriod(Employee $employee): ?EmployeeContractPeriod;
}

View File

@@ -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')

View 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()
;
}
}

View 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 {}
}

View 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);
}
}

View File

@@ -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);

View File

@@ -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(

View File

@@ -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;

View 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,
]);
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
);

View 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;
}
}

View File

@@ -171,7 +171,8 @@ 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;

View File

@@ -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,
];
}

View File

@@ -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);
}

View File

@@ -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,

View File

@@ -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;
}
}

View File

@@ -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.

View 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>

View File

@@ -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>

View File

@@ -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);

View File

@@ -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());

View File

@@ -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());