Compare commits

...

6 Commits

Author SHA1 Message Date
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
32 changed files with 1523 additions and 65 deletions

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

@@ -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.62'
app.version: '0.1.65'

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

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

@@ -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,6 @@
export type Observation = {
id: number
month: string
content: string
createdAt: string
}

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

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,19 @@ 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;
}
public function eraseCredentials(): void {}
}

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

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

@@ -80,7 +80,8 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
}
$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 +92,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

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

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