feat : modification de la page employé WIP + ajout d'une navbar

This commit is contained in:
2026-03-02 09:50:09 +01:00
parent 107417a571
commit ea06059c0b
9 changed files with 375 additions and 85 deletions

View File

@@ -13,6 +13,13 @@ Arborescence clé:
- `tests/`: TU backend (PHPUnit)
- `frontend/`: app Nuxt (pages, composants, composables, services)
- `migrations/`: migrations Doctrine
- `doc/`: documentation fonctionnelle et règles métier de référence
## 1.1) Référentiel Fonctionnel (obligatoire)
- Référence principale des règles métier: `doc/functional-rules.md`
- Toute intervention doit commencer par une vérification de cohérence avec cette documentation.
- Règle permanente: à chaque développement qui modifie le fonctionnel, la documentation dans `doc/` doit être mise à jour automatiquement dans la même intervention (pas de report).
## 2) Commandes utiles

134
doc/functional-rules.md Normal file
View File

@@ -0,0 +1,134 @@
# Règles Fonctionnelles SIRH
Ce document centralise les règles métier actuellement implémentées dans l'application.
## 1) Utilisateurs et accès
- `ROLE_ADMIN`
- accès complet aux écrans d'administration
- vue semaine des heures
- validation RH des lignes d'heures
- `ROLE_SELF`
- accès limité à son périmètre personnel
- Accès "Sites" (via `user_site_roles` avec rôle `SITE_ACCESS`)
- accès au périmètre des sites autorisés
- validation site des lignes d'heures
## 2) Contrats
- Le profil de temps de travail est porté par `Contract`:
- `trackingMode`: `TIME` ou `PRESENCE`
- `weeklyHours` (ex: 35, 39, 4, etc.)
- La nature RH est portée par période employé:
- `CDI`, `CDD`, `INTERIM`
- Historique des contrats employé:
- table `employee_contract_periods`
- un employé peut avoir plusieurs périodes
### Règles de période
- `CDI`:
- `endDate` doit être vide
- `CDD` / `INTERIM`:
- `endDate` obligatoire
- `endDate` ne peut pas être antérieure à `startDate`
## 3) Heures (vue jour)
- Saisie par salarié et par date:
- matin / après-midi / soir
- pour `PRESENCE`: demi-journées matin/après-midi
- Calculs affichés:
- `Jour`, `Nuit`, `Total`
- Heures de nuit:
- fenêtres `00:00-06:00` et `21:00-24:00`
## 4) Absences
- Les absences sont stockées par jour (découpage lors de l'écriture)
- Une absence peut être:
- journée complète
- demi-journée `AM` ou `PM`
- Colonne absence (vue jour):
- affiche le libellé
- fond coloré selon le type d'absence
- Si plusieurs absences de couleurs différentes sur le même jour:
- fallback rouge
### Effet absence sur les heures
- Absence `AM`:
- efface les heures du matin
- Absence `PM`:
- efface les heures d'après-midi et du soir
- Absence journée:
- efface toutes les plages horaires
### Absences "comptées comme travaillées"
- Si `countAsWorkedHours = true`:
- `TIME`: crédit de minutes selon contrat actif du jour
- `PRESENCE`: crédit d'unités (0.5 / demi-journée)
## 5) Validations des lignes d'heures
- Validation RH (`isValid`)
- action admin
- Validation site (`isSiteValid`)
- action chef de site
### Verrouillage
- Ligne validée RH:
- verrouillée pour modifications heures/absences
- Ligne validée site:
- verrouillée pour non-admin
- admin peut corriger
- Toute vraie modification d'une ligne:
- remet `isSiteValid = false`
- remet `isValid = false`
- Si aucun changement réel à l'enregistrement:
- les validations existantes ne sont pas altérées
## 6) Heures supplémentaires (vue semaine)
- Base de calcul:
- dépend du contrat actif par jour
- Tranche 25%:
- contrats <= 35h: de 35h à 43h
- contrats >= 39h: de 39h à 43h
- Tranche 50%:
- au-delà de 43h
- Nature `INTERIM`:
- pas de bonus 25%
- pas de bonus 50%
- pas de total récup
## 7) Fériés
- Les jours fériés sont identifiés et affichés
- Règle courante:
- absences bloquées sur jour férié
- saisie d'heures autorisée
## 8) Impression absences (PDF)
Filtres disponibles:
- période `from` / `to`
- sites
- nature de contrat (`CDI`, `CDD`, `INTERIM`)
- temps de travail (contrats de type Forfait, 35h, 39h, etc.)
Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
## 9) Employés
- Création employé:
- prénom, nom, site
- type de contrat (nature RH)
- temps de travail
- dates début/fin (selon règles nature)
- Modification employé:
- uniquement prénom, nom, site
- pas de modification de contrat depuis ce drawer

View File

@@ -0,0 +1,43 @@
<template>
<header class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
<div class="flex h-full items-center justify-end">
<div class="flex gap-12 text-xl text-white">
<Icon name="mdi:bell-plus" class="self-center cursor-pointer" size="36" />
<div class="group relative flex gap-4">
<Icon name="mdi:account-circle-outline" class="self-center cursor-pointer" size="36" />
<p class="self-center cursor-pointer">{{ user?.username }}</p>
<div class="invisible absolute right-0 top-full z-20 mt-2 w-44 rounded-md border border-neutral-200 bg-white py-1 text-sm text-neutral-800 opacity-0 shadow-lg transition-all group-hover:visible group-hover:opacity-100">
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
>
Mon profil
</button>
<button
type="button"
class="block w-full px-3 py-2 text-left hover:bg-neutral-100"
@click="handleLogout"
>
Déconnexion
</button>
</div>
</div>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import type { User } from '~/services/dto/user'
defineProps<{
user?: User
}>()
const auth = useAuthStore()
const handleLogout = async () => {
await auth.logout()
await navigateTo('/login')
}
</script>

View File

@@ -1,18 +1,26 @@
<template>
<input
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full max-w-md rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
/>
<div class="relative w-full max-w-[340px]">
<input
id="employee-search"
v-model="model"
type="text"
:placeholder="placeholder"
class="h-10 w-full rounded-md border border-neutral-300 bg-white pl-3 pr-10 text-md text-neutral-900"
/>
<Icon
name="mdi:magnify"
size="18"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500"
/>
</div>
</template>
<script setup lang="ts">
const model = defineModel<string>({ required: true })
const model = defineModel<string>({required: true})
withDefaults(defineProps<{
placeholder?: string
placeholder?: string
}>(), {
placeholder: 'Chercher un employé (nom ou prénom)'
placeholder: "Recherche d'un employé"
})
</script>

View File

@@ -1,25 +1,70 @@
<template>
<div class="inline-flex w-fit max-w-full flex-wrap items-center gap-6 py-2">
<div v-for="site in sites" :key="site.id" class="flex items-center gap-2">
<div :style="{ backgroundColor: site.color }" class="h-4 w-4 rounded" />
<label class="text-md" :for="`site-${site.id}`">{{ site.name }}</label>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[280px] min-h-10 items-center justify-between gap-2 rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 hover:bg-tertiary-500"
@click="isOpen = !isOpen"
>
<span>Sites</span>
<span class="inline-flex items-center gap-2">
<span class="text-sm font-medium text-neutral-600">{{ selectedCount }}/{{ sites.length }}</span>
<Icon :name="isOpen ? 'mdi:chevron-up' : 'mdi:chevron-down'" size="18" />
</span>
</button>
<div
v-if="isOpen"
class="absolute left-0 top-full z-20 mt-2 max-h-80 w-full overflow-auto rounded-md border border-neutral-200 bg-white p-3 shadow-lg"
>
<div class="flex flex-col gap-2">
<label
v-for="site in sites"
:key="site.id"
:for="`site-${site.id}`"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-tertiary-500"
>
<input
:id="`site-${site.id}`"
v-model="selectedSiteIds"
:value="site.id"
type="checkbox"
class="h-4 w-4"
/>
<span :style="{ backgroundColor: site.color }" class="h-3 w-3 rounded" />
<span class="text-md text-neutral-800">{{ site.name }}</span>
</label>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Site } from '~/services/dto/site'
const selectedSiteIds = defineModel<number[]>({ required: true })
const isOpen = ref(false)
const root = ref<HTMLElement | null>(null)
defineProps<{
sites: Site[]
}>()
const selectedCount = computed(() => selectedSiteIds.value.length)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node | null
if (!root.value || !target) return
if (!root.value.contains(target)) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>

View File

@@ -73,9 +73,12 @@
</div>
</aside>
<main class="h-full flex-1 overflow-y-auto px-8 py-8">
<slot/>
</main>
<div class="h-full flex-1 overflow-hidden flex flex-col">
<AppTopNav :user="auth.user" />
<main class="flex-1 overflow-y-auto px-8 py-12">
<slot/>
</main>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,71 @@
<template>
<div class="h-full overflow-auto">
<div v-if="isLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Chargement...
</div>
<div v-else-if="!employee"
class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
Employé introuvable.
</div>
<div v-else>
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">{{ employee.firstName }} {{ employee.lastName }}</h1>
<NuxtLink
to="/employees"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
>
Retour
</NuxtLink>
</div>
<div class="mt-6 rounded-lg border border-neutral-200 bg-white p-6">
<p class="text-xl font-semibold text-neutral-900">{{ employee.firstName }} {{ employee.lastName }}</p>
<p class="mt-2 text-md text-neutral-700">Site: {{ employee.site?.name ?? '-' }}</p>
<p class="text-md text-neutral-700">Type de contrat: {{
contractNatureLabel(employee.currentContractNature)
}}</p>
<p class="text-md text-neutral-700">Temps de travail: {{ employee.contract?.name ?? '-' }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {Employee} from '~/services/dto/employee'
import {getEmployee} from '~/services/employees'
const route = useRoute()
const employee = ref<Employee | null>(null)
const isLoading = ref(false)
useHead(() => ({
title: employee.value
? `${employee.value.firstName} ${employee.value.lastName}`
: 'Détail employé'
}))
const contractNatureLabel = (value?: 'CDI' | 'CDD' | 'INTERIM') => {
if (value === 'CDD') return 'CDD'
if (value === 'INTERIM') return 'Intérim'
return 'CDI'
}
onMounted(async () => {
const idParam = Array.isArray(route.params.id) ? route.params.id[0] : route.params.id
const employeeId = Number(idParam)
if (!Number.isInteger(employeeId) || employeeId <= 0) {
return
}
isLoading.value = true
try {
employee.value = await getEmployee(employeeId)
} finally {
isLoading.value = false
}
})
</script>

View File

@@ -1,24 +1,22 @@
<template>
<div class="h-full overflow-hidden flex flex-col">
<div class="flex-col">
<div class="shrink-0">
<div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<button
type="button"
class="flex items-center gap-2 rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
<Icon name="mdi:plus-thick" size="16" class="text-white"/>
Ajouter un employé
</button>
</div>
<div class="flex flex-col gap-3 py-6">
<div class="flex justify-between">
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
<button
type="button"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
@click="openCreate"
>
Ajouter un employé
</button>
</div>
<div class="flex gap-10 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter" />
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites" />
</div>
</div>
@@ -29,57 +27,33 @@
Aucun employé pour le moment.
</div>
<div v-else class="flex-1 min-h-0 rounded-lg border border-neutral-200 bg-white overflow-hidden">
<div class="h-full overflow-auto">
<div class="min-w-[900px]">
<div class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] gap-4 border-b border-neutral-200 bg-tertiary-500 px-6 py-3 text-md font-semibold text-neutral-700 sticky top-0 z-10">
<span class="text-left">Prénom</span>
<span class="text-left">Nom</span>
<span class="text-left">Site</span>
<span class="text-left">Nature</span>
<span class="text-left">Contrat</span>
<span class="text-right">Actions</span>
</div>
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500">
Chargement...
</div>
<div v-else>
<div
v-for="employee in filteredEmployees"
:key="employee.id"
class="grid grid-cols-[120px_1fr_1fr_180px_180px_200px] items-center gap-4 border-b border-neutral-100 px-6 py-3 text-md text-neutral-800 last:border-b-0"
>
<span>{{ employee.firstName }}</span>
<span>{{ employee.lastName }}</span>
<span
class="inline-flex w-fit max-w-full rounded-md px-2 py-1 text-sm font-semibold"
:style="employee.site ? { backgroundColor: employee.site.color, color: '#0f172a' } : {}"
:class="employee.site ? '' : 'bg-neutral-100 text-neutral-600'"
>
{{ employee.site?.name ?? '-' }}
</span>
<span>{{ contractNatureLabel(employee.currentContractNature) }}</span>
<span>{{ employee.contract?.name ?? '-' }}</span>
<div class="flex items-center justify-end gap-2">
<button
type="button"
class="rounded-md border border-neutral-200 px-2 py-1 text-md font-semibold text-neutral-700 hover:bg-neutral-100"
@click="openEdit(employee)"
>
Modifier
</button>
<button
type="button"
class="rounded-md border border-red-200 px-2 py-1 text-md font-semibold text-red-600 hover:bg-red-50"
@click="confirmDelete(employee)"
>
Supprimer
</button>
</div>
</div>
<div v-else class="grid grid-cols-4 gap-8 lg:[grid-template-columns:repeat(auto-fit,328px)]">
<NuxtLink
v-for="employee in filteredEmployees"
:key="employee.id"
:to="`/employees/${employee.id}`"
target="_blank"
rel="noopener noreferrer"
class="group relative h-[328px] w-[328px] overflow-hidden rounded-lg bg-tertiary-500 p-4 transition-all duration-200 hover:shadow-md"
>
<div class="flex flex-col items-center gap-7 transition-opacity duration-200 group-hover:opacity-0">
<div class="rounded-full bg-neutral-300 h-[175px] w-[175px]"></div>
<div class="text-center text-[20px]">
<p class="text-primary-500 font-bold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p>Nom du poste occupé</p>
<p>Site ({{ employee.site?.name ?? '-' }})</p>
</div>
</div>
</div>
<div class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
<p>Type: {{ contractNatureLabel(employee.currentContractNature) }}</p>
<p>Temps de travail: {{ employee.contract?.name ?? '-' }}</p>
<p>Site: {{ employee.site?.name ?? '-' }}</p>
</div>
</div>
</NuxtLink>
</div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle">

View File

@@ -21,6 +21,11 @@ export const listScopedEmployees = async () => {
return extractItems<Employee>(data)
}
export const getEmployee = async (id: number) => {
const api = useApi()
return api.get<Employee>(`/employees/${id}`, {}, { toast: false })
}
export const createEmployee = async (payload: {
firstName: string
lastName: string