Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f2a84f2c | |||
| 5aac94ad0e | |||
| 36fe9ae54c | |||
| 6395ffbe1c | |||
| b5e7395760 | |||
| 380c72c242 | |||
| ea06059c0b | |||
| 107417a571 | |||
| 5ff7e356be |
@@ -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
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
parameters:
|
||||
app.version: '0.1.14'
|
||||
app.version: '0.1.17'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div class="relative inline-flex h-10 items-center overflow-hidden rounded-md border border-primary-500 bg-white" :class="widthClass">
|
||||
<input
|
||||
ref="nativeInput"
|
||||
:value="pickerValue"
|
||||
:type="pickerType"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@input="onPickerInput"
|
||||
@change="onPickerInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||
:aria-label="prevAriaLabel"
|
||||
@click="emit('prev')"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500"
|
||||
@click="openPicker"
|
||||
>
|
||||
{{ label }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96]"
|
||||
:aria-label="nextAriaLabel"
|
||||
@click="emit('next')"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
pickerType: 'date' | 'week' | 'month'
|
||||
pickerValue: string
|
||||
widthClass?: string
|
||||
prevAriaLabel?: string
|
||||
nextAriaLabel?: string
|
||||
}>(), {
|
||||
widthClass: 'w-[320px]',
|
||||
prevAriaLabel: 'Précédent',
|
||||
nextAriaLabel: 'Suivant'
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'prev'): void
|
||||
(e: 'next'): void
|
||||
(e: 'pick', value: string): void
|
||||
}>()
|
||||
|
||||
const nativeInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const openPicker = () => {
|
||||
const input = nativeInput.value
|
||||
if (!input) return
|
||||
if (typeof input.showPicker === 'function') {
|
||||
input.showPicker()
|
||||
return
|
||||
}
|
||||
input.focus()
|
||||
input.click()
|
||||
}
|
||||
|
||||
const onPickerInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
if (!value) return
|
||||
emit('pick', value)
|
||||
}
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -64,41 +64,17 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative inline-flex h-10 w-[320px] items-center overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
<input
|
||||
ref="nativeDateInput"
|
||||
:value="pickerValue"
|
||||
:type="viewMode === 'week' ? 'week' : 'date'"
|
||||
class="pointer-events-none absolute inset-0 h-full w-full opacity-0"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
@input="onPickerInput"
|
||||
@change="onPickerInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
aria-label="Période précédente"
|
||||
@click="emit('shift-date', -1)"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 flex-1 border-x border-primary-500 px-4 text-sm font-semibold text-primary-500 text-center hover:bg-tertiary-500 active:bg-tertiary-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
@click="openDatePicker"
|
||||
>
|
||||
{{ formattedSelectedDate }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="h-10 px-3 text-lg font-semibold text-primary-500 hover:bg-tertiary-500 active:bg-tertiary-500 active:scale-[0.96] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||
aria-label="Période suivante"
|
||||
@click="emit('shift-date', 1)"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[320px]"
|
||||
:label="formattedSelectedDate"
|
||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||
:picker-value="pickerValue"
|
||||
prev-aria-label="Période précédente"
|
||||
next-aria-label="Période suivante"
|
||||
@prev="emit('shift-date', -1)"
|
||||
@next="emit('shift-date', 1)"
|
||||
@pick="onPickerValue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||
@@ -145,6 +121,7 @@ import type { Site } from '~/services/dto/site'
|
||||
import type { AbsenceType } from '~/services/dto/absence-type'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||
|
||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||
@@ -172,7 +149,6 @@ const emit = defineEmits<{
|
||||
(e: 'shift-date', value: number): void
|
||||
}>()
|
||||
|
||||
const nativeDateInput = ref<HTMLInputElement | null>(null)
|
||||
const pickerValue = computed(() => {
|
||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||
return selectedDate.value
|
||||
@@ -186,19 +162,7 @@ const viewModeButtonClass = (mode: 'day' | 'week') => {
|
||||
return 'bg-white text-primary-500 hover:bg-tertiary-500'
|
||||
}
|
||||
|
||||
const openDatePicker = () => {
|
||||
const input = nativeDateInput.value
|
||||
if (!input) return
|
||||
if (typeof input.showPicker === 'function') {
|
||||
input.showPicker()
|
||||
return
|
||||
}
|
||||
input.focus()
|
||||
input.click()
|
||||
}
|
||||
|
||||
const onPickerInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
const onPickerValue = (value: string) => {
|
||||
if (!value) return
|
||||
|
||||
if (viewMode.value === 'week') {
|
||||
|
||||
@@ -427,8 +427,11 @@ export const useHoursPage = () => {
|
||||
|
||||
const getPresenceDayValue = (employeeId: number) => {
|
||||
const row = rows.value[employeeId]
|
||||
const basePresence = (row?.isPresentMorning ? 0.5 : 0) + (row?.isPresentAfternoon ? 0.5 : 0)
|
||||
const creditedPresence = dayContextByEmployeeId.value.get(employeeId)?.creditedPresenceUnits ?? 0
|
||||
const dayRow = dayContextByEmployeeId.value.get(employeeId)
|
||||
const absentMorning = dayRow?.absentMorning ?? false
|
||||
const absentAfternoon = dayRow?.absentAfternoon ?? false
|
||||
const basePresence = ((row?.isPresentMorning && !absentMorning) ? 0.5 : 0) + ((row?.isPresentAfternoon && !absentAfternoon) ? 0.5 : 0)
|
||||
const creditedPresence = dayRow?.creditedPresenceUnits ?? 0
|
||||
const total = Math.min(1, basePresence + creditedPresence)
|
||||
return Number.isInteger(total) ? String(total) : total.toFixed(1)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+30
-18
@@ -30,22 +30,17 @@
|
||||
<div class="w-80">
|
||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
||||
</div>
|
||||
<select
|
||||
v-model="selectedMonth"
|
||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="month in months" :key="month.value" :value="month.value">
|
||||
{{ month.label }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-model="selectedYear"
|
||||
class="h-10 rounded-md border border-neutral-300 bg-white px-3 text-md text-neutral-900"
|
||||
>
|
||||
<option v-for="year in years" :key="year" :value="year">
|
||||
{{ year }}
|
||||
</option>
|
||||
</select>
|
||||
<PeriodStepperPicker
|
||||
width-class="w-[260px]"
|
||||
:label="selectedMonthLabel"
|
||||
picker-type="month"
|
||||
:picker-value="monthPickerValue"
|
||||
prev-aria-label="Mois précédent"
|
||||
next-aria-label="Mois suivant"
|
||||
@prev="shiftMonth(-1)"
|
||||
@next="shiftMonth(1)"
|
||||
@pick="onMonthPickerValue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-6 py-2">
|
||||
@@ -111,6 +106,7 @@ import CalendarGrid from '~/components/CalendarGrid.vue'
|
||||
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
|
||||
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
|
||||
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
|
||||
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
|
||||
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
||||
|
||||
useHead({
|
||||
@@ -195,8 +191,8 @@ const months = [
|
||||
{value: 11, label: 'Décembre'}
|
||||
]
|
||||
|
||||
const years = Array.from({length: 5}, (unusedValue, index) => now.getFullYear() - 2 + index)
|
||||
|
||||
const selectedMonthLabel = computed(() => `${months[selectedMonth.value]?.label ?? ''}`)
|
||||
const monthPickerValue = computed(() => `${selectedYear.value}-${String(selectedMonth.value + 1).padStart(2, '0')}`)
|
||||
|
||||
// Infos de calendrier calculées.
|
||||
const daysInMonth = computed(() => getDaysInMonth(selectedYear.value, selectedMonth.value))
|
||||
@@ -316,6 +312,22 @@ const addMonths = (date: Date, months: number) => {
|
||||
return next
|
||||
}
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const next = new Date(selectedYear.value, selectedMonth.value + delta, 1)
|
||||
selectedYear.value = next.getFullYear()
|
||||
selectedMonth.value = next.getMonth()
|
||||
}
|
||||
|
||||
const onMonthPickerValue = (value: string) => {
|
||||
if (!value) return
|
||||
const [yearStr, monthStr] = value.split('-')
|
||||
const year = Number(yearStr)
|
||||
const month = Number(monthStr)
|
||||
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) return
|
||||
selectedYear.value = year
|
||||
selectedMonth.value = month - 1
|
||||
}
|
||||
|
||||
// Limite l'intervalle d'impression à 2 mois max.
|
||||
const enforcePrintRange = () => {
|
||||
if (!printForm.from) return
|
||||
|
||||
@@ -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>
|
||||
@@ -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 gap-8 [grid-template-columns:repeat(auto-fill,minmax(260px,1fr))]">
|
||||
<NuxtLink
|
||||
v-for="employee in filteredEmployees"
|
||||
:key="employee.id"
|
||||
:to="`/employees/${employee.id}`"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="group relative min-h-[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">
|
||||
@@ -244,7 +218,7 @@ const contracts = ref<Contract[]>([])
|
||||
const employeeFilter = ref('')
|
||||
const selectedSiteIds = ref<number[]>([])
|
||||
|
||||
const filteredEmployees = computed(() => {
|
||||
const filteredEmployees = computed<Employee[]>(() => {
|
||||
if (selectedSiteIds.value.length === 0) return []
|
||||
|
||||
const filter = employeeFilter.value.trim().toLowerCase()
|
||||
@@ -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
|
||||
|
||||
@@ -60,11 +60,6 @@ final readonly class WorkedHoursCreditPolicy
|
||||
bool $absentMorning,
|
||||
bool $absentAfternoon
|
||||
): float {
|
||||
$type = $absence->getType();
|
||||
if (!$type?->getCountAsWorkedHours()) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$employee = $absence->getEmployee();
|
||||
if (null === $employee) {
|
||||
return 0.0;
|
||||
@@ -74,9 +69,14 @@ final readonly class WorkedHoursCreditPolicy
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$halfUnits = ($absentMorning ? 1 : 0) + ($absentAfternoon ? 1 : 0);
|
||||
// Règle forfait:
|
||||
// - demi-journée d'absence => 0.5 travaillé
|
||||
// - journée complète d'absence => 0 travaillé
|
||||
if ($absentMorning xor $absentAfternoon) {
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
return $halfUnits * 0.5;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
public function resolveContractDayMinutes(?int $weeklyHours, int $isoWeekDay): int
|
||||
|
||||
@@ -125,6 +125,14 @@ final readonly class WorkHourBulkUpsertProcessor implements ProcessorInterface
|
||||
continue;
|
||||
}
|
||||
|
||||
// Si aucune donnée n'a changé, on ne touche pas la ligne:
|
||||
// cela évite de perdre les validations existantes (site/RH) sur un simple enregistrement.
|
||||
if (null !== $existing && $this->isSameAsExisting($existing, $normalized)) {
|
||||
++$result->processed;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isEntryEmpty($normalized)) {
|
||||
// Convention choisie: une ligne vide supprime l'enregistrement existant.
|
||||
if ($existing) {
|
||||
|
||||
@@ -135,6 +135,8 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$creditedByEmployeeDate = [];
|
||||
$creditedPresenceByEmployeeDate = [];
|
||||
$absenceByEmployeeDate = [];
|
||||
$absentMorningByEmployeeDate = [];
|
||||
$absentAfternoonByEmployeeDate = [];
|
||||
$absenceLabelByEmployeeDate = [];
|
||||
$absenceColorByEmployeeDate = [];
|
||||
foreach ($absences as $absence) {
|
||||
@@ -153,7 +155,9 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
|
||||
[$absentMorning, $absentAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||
if ($absentMorning || $absentAfternoon) {
|
||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||
$absenceByEmployeeDate[$employeeId][$date] = true;
|
||||
$absentMorningByEmployeeDate[$employeeId][$date] = ($absentMorningByEmployeeDate[$employeeId][$date] ?? false) || $absentMorning;
|
||||
$absentAfternoonByEmployeeDate[$employeeId][$date] = ($absentAfternoonByEmployeeDate[$employeeId][$date] ?? false) || $absentAfternoon;
|
||||
if (!isset($absenceLabelByEmployeeDate[$employeeId][$date])) {
|
||||
$absenceLabelByEmployeeDate[$employeeId][$date] = $absence->getType()?->getLabel();
|
||||
}
|
||||
@@ -202,8 +206,10 @@ final readonly class WorkHourWeeklySummaryProvider implements ProviderInterface
|
||||
$metrics->addCreditedMinutes($creditedMinutes);
|
||||
$present = null;
|
||||
if ($isPresenceTracking) {
|
||||
$morning = ($entry['isPresentMorning'] ?? false) ? 0.5 : 0.0;
|
||||
$afternoon = ($entry['isPresentAfternoon'] ?? false) ? 0.5 : 0.0;
|
||||
$absentMorning = $absentMorningByEmployeeDate[$employeeId][$date] ?? false;
|
||||
$absentAfternoon = $absentAfternoonByEmployeeDate[$employeeId][$date] ?? false;
|
||||
$morning = (($entry['isPresentMorning'] ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||
$afternoon = (($entry['isPresentAfternoon'] ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||
$creditedPresence = $creditedPresenceByEmployeeDate[$employeeId][$date] ?? 0.0;
|
||||
$present = min(1.0, $morning + $afternoon + $creditedPresence);
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ final class WorkHourWeeklySummaryProviderTest extends TestCase
|
||||
self::assertSame(210, $result->rows[0]->weeklyOvertime50Minutes);
|
||||
self::assertSame(1230, $result->rows[0]->weeklyRecoveryMinutes);
|
||||
|
||||
self::assertSame(1.0, $result->rows[1]->weeklyPresenceCount);
|
||||
self::assertSame(0.0, $result->rows[1]->weeklyPresenceCount);
|
||||
self::assertTrue($result->rows[1]->daily[0]->hasAbsence);
|
||||
self::assertSame('Congé', $result->rows[1]->daily[0]->absenceLabel);
|
||||
self::assertSame('#000', $result->rows[1]->daily[0]->absenceColor);
|
||||
|
||||
Reference in New Issue
Block a user