Compare commits

..

4 Commits

Author SHA1 Message Date
6df37d15c1 feat : version mobile écran Utilisateurs
Tableau desktop masqué sous 1024px, remplacé par des cards
cliquables avec username, badge statut, employé lié, accès et sites.
Titre et bouton ajouter adaptés pour mobile.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:11:11 +02:00
08d05a9a52 feat : version mobile écran Heures (jour + semaine)
- HoursDayView : cards empilées en mobile avec inputs par paire,
  pills absence/férié/formation, statut validation explicite
- HoursWeekView : cards par employé avec jours verticaux et totaux
- HoursToolbar : recherche + bouton filtre drawer en mobile,
  navigation date pleine largeur, filtres sites/vue dans drawer
- AppDrawer : ajout bouton fermer (croix) sur tous les drawers
- TimeSelect mobile : pas de dropdown, clavier numérique direct,
  arrondi au quart d'heure, clamp à 23:45 max

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 12:09:26 +02:00
269f660ddf feat : version mobile écran récap congés + croix sidebar bleue
Récap congés : tableau desktop masqué sous 1024px, remplacé par
des cards empilées (nom, site, contrat, grille 2x2 des compteurs).
Titre responsive (text-2xl mobile, text-4xl desktop).
Croix de fermeture sidebar en bleu primary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:03:20 +02:00
09a0d46320 feat : sidebar et navbar responsive mobile
Sidebar en overlay slide-in sous 1024px avec hamburger menu,
overlay semi-transparent, et fermeture auto au clic sur un lien.
Navbar adaptée avec padding réduit et username masqué sur petit écran.
Dropdown notifications responsive (largeur relative au viewport).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:50:09 +02:00
37 changed files with 738 additions and 680 deletions

View File

@@ -15,7 +15,6 @@
## Stack ## Stack
- Backend: Symfony + API Platform + Doctrine ORM - Backend: Symfony + API Platform + Doctrine ORM
- Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS - Frontend: Nuxt 4 + Vue 3 + TypeScript + Tailwind CSS
- UI library: `@malio/layer-ui` (Nuxt layer, `extends: ['@malio/layer-ui']` dans `nuxt.config.ts`). Composants auto-importés avec préfixe `Malio*` (ex. `MalioSelectCheckbox`, `MalioInputText`…). Doc d'usage dans `node_modules/@malio/layer-ui/COMPONENTS.md`. Tokens Tailwind `m-*` (primary/muted/danger/success/…) et variables CSS `--m-*` fournies par la couche.
## Project Structure ## Project Structure
- `src/` — Symfony domain, API resources, state providers/processors, services - `src/` — Symfony domain, API resources, state providers/processors, services
@@ -33,8 +32,6 @@
- Contract nature (per period): CDI, CDD, INTERIM - Contract nature (per period): CDI, CDD, INTERIM
- **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat. - **Agence d'intérim** (`InterimAgency` entity, table `interim_agencies`): optionnelle sur `EmployeeContractPeriod` quand nature = INTERIM. Pas de CRUD UI — gérée en BDD. API lecture seule `GET /interim_agencies`. Affichée "Intérim (NomAgence)" sur la liste employés et l'historique contrat.
- Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver` - Employee contract history: `employee_contract_periods`, resolved by `EmployeeContractResolver`
- **Écrans Heures / Heures Conducteurs (vue jour)** : le libellé nature (CDI/CDD/Intérim) sous le nom de l'employé est résolu **à la date filtrée** via `WorkHourDayContext.contractNature` (alimenté par `EmployeeContractResolver::resolveNatureForEmployeeAndDate`), pas via `Employee.currentContractNature` (qui est résolu à aujourd'hui).
- **Écran Calendrier** : un employé est affiché uniquement si au moins une de ses périodes de contrat (`employee.contractHistory`) intersecte le mois affiché (`[1er ; dernier jour]`). Filtre côté frontend dans `visibleEmployees` (`pages/calendar.vue`).
- **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`. - **Planning jours travaillés** (`EmployeeContractPeriod.workDaysHours` : JSON `{iso_day: minutes}`) : obligatoire pour tout contrat TIME **hors 35h/39h/INTERIM** (ex. 4h, 25h, 28h). Somme = `weeklyHours × 60`. Utilisé par `HolidayVirtualHoursResolver` (crédit férié) et `WorkedHoursCreditPolicy` (crédit absence) pour ne créditer que les jours effectivement travaillés. Validation : `EmployeeContractPeriodValidator::assertWorkDaysHours`.
- Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots - Absences: stored per day (auto-split), AM/PM/full day, clear corresponding hour slots
- Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE) - Absences with `countAsWorkedHours=true`: credit minutes (TIME) or nothing (PRESENCE)
@@ -61,7 +58,6 @@
- INTERIM: no overtime bonuses, no recovery time - INTERIM: no overtime bonuses, no recovery time
- Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges - Driver contracts: RTT uses `dayHoursMinutes + nightHoursMinutes + workshopHoursMinutes` instead of morning/afternoon/evening time ranges
- FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only. - FORFAIT weekend/holiday bonus: each weekend or public holiday day worked gives bonus leave (full day if morning+afternoon, 0.5 if only one). Added to acquired days, no cap. PRESENCE mode only.
- **FORFAIT — jours de présence et N-1** : les congés posés et imputés sur le stock N-1 ne décrémentent **pas** les jours de présence affichés (`presenceDaysByMonth` et `presenceDaysToToday`). Implémenté dans `EmployeeLeaveSummaryProvider::computePresenceDaysByMonth` via un budget N-1 (= `previousYearTakenDays`) consommé chronologiquement avant comptage des absences. Pour les non-forfait, ce budget vaut toujours 0 → comportement inchangé.
## Récap. congés (écran) ## Récap. congés (écran)
- Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin. - Accès via sidebar `Récap. congés`, conditionné au flag `User.hasLeaveRecapAccess` (défaut `false`) — activé au create/edit user. Le flag s'applique à tous les profils, y compris admin.

View File

@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.1.96' app.version: '0.1.93'

View File

@@ -58,9 +58,6 @@ Documents complementaires:
- mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures - mise à jour uniquement quand un employé (`ROLE_SELF`) modifie ses propres heures
- non mise à jour lors de modifications admin ou chef de site - non mise à jour lors de modifications admin ou chef de site
- affichée sous le nom de l'employé (visible admin uniquement) - affichée sous le nom de l'employé (visible admin uniquement)
- Libellé nature de contrat (CDI/CDD/Intérim) affiché sous le nom:
- résolu à la date filtrée (période de contrat couvrant ce jour), pas à aujourd'hui
- masqué si aucun contrat à cette date (cas rarissime en vue jour puisque l'employé est alors déjà filtré)
## 4) Absences ## 4) Absences
@@ -74,10 +71,6 @@ Documents complementaires:
- Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`) - Calendrier congés: fond coloré selon la couleur du type d'absence (`AbsenceType.color`)
- demi-journée: dégradé diagonal - demi-journée: dégradé diagonal
- journée complète: fond plein - journée complète: fond plein
- Visibilité des employés dans le Calendrier:
- un employé est affiché si au moins une de ses périodes de contrat intersecte le mois affiché
- un employé dont toutes les périodes se terminent avant le 1er du mois (ou commencent après la fin du mois) est masqué
- même logique que l'écran Heures : « pas de contrat sur la période → masqué »
### Effet absence sur les heures ### Effet absence sur les heures
@@ -335,7 +328,7 @@ Tous les filtres checkbox sont cochés par défaut à l'ouverture du drawer.
| Contrat | Contract.name | | Contrat | Contract.name |
| CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant | | CP N-1 restant | CDI/CDD: acquis N-1 pris sur N-1. Forfait: report N-1 restant |
| Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` | | Samedi restant | CDI/CDD: samedis acquis N-1 pris. Forfait: `-` |
| CP N | Forfait: restant sur quota année civile (acquis pris depuis N, sans toucher au stock N-1). Non-forfait: en cours d'acquisition | | CP N | Forfait: jours acquis année civile. Non-forfait: en cours d'acquisition |
| RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` | | RTT | Minutes disponibles (report N-1 + acquis N - payés). Format `X h Y m`. Forfait et INTERIM: `-` |
## 10bis) Écran Récap. congés (tableau) ## 10bis) Écran Récap. congés (tableau)

View File

@@ -1 +0,0 @@
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/

View File

@@ -1,26 +1,44 @@
<template> <template>
<AppDrawer v-model="drawerOpen" title="Nouvelle absence"> <AppDrawer v-model="drawerOpen" title="Nouvelle absence">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<MalioSelect <div>
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId" <label class="text-md font-semibold text-neutral-700" for="employee">
:options="employeeOptions" Employé <span class="text-red-600">*</span>
label="Employé *" </label>
empty-option-label="Choisir un employé" <select
min-width="" id="employee"
:disabled="props.lockEmployee" v-model="absenceForm.employeeId"
:error="showEmployeeError ? `L'employé est obligatoire.` : ''" :class="employeeFieldClass"
@update:model-value="onEmployeeChange" :disabled="props.lockEmployee"
/> >
<option value="" disabled>Choisir un employé</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showEmployeeError" class="mt-1 text-sm text-red-600">
L'employé est obligatoire.
</p>
</div>
<MalioSelect <div>
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId" <label class="text-md font-semibold text-neutral-700" for="type">
:options="typeOptions" Type d'absence <span class="text-red-600">*</span>
label="Type d'absence *" </label>
empty-option-label="Choisir un type" <select
min-width="" id="type"
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''" v-model="absenceForm.typeId"
@update:model-value="onTypeChange" :class="typeFieldClass"
/> >
<option value="" disabled>Choisir un type</option>
<option v-for="type in absenceTypes" :key="type.id" :value="type.id">
{{ type.label }} ({{ type.code }})
</option>
</select>
<p v-if="showTypeError" class="mt-1 text-sm text-red-600">
Le type d'absence est obligatoire.
</p>
</div>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
@@ -30,15 +48,17 @@
id="start-date" id="start-date"
v-model="absenceForm.startDate" v-model="absenceForm.startDate"
type="date" type="date"
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates" :disabled="props.lockDates"
/> />
<MalioSelect <select
:model-value="absenceForm.startHalf" v-model="absenceForm.startHalf"
:options="halfDayOptions" class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
min-width="" >
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }" <option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
/> {{ half.label }}
</option>
</select>
</div> </div>
</div> </div>
<div> <div>
@@ -48,15 +68,17 @@
id="end-date" id="end-date"
v-model="absenceForm.endDate" v-model="absenceForm.endDate"
type="date" type="date"
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']" class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
:disabled="props.lockDates" :disabled="props.lockDates"
/> />
<MalioSelect <select
:model-value="absenceForm.endHalf" v-model="absenceForm.endHalf"
:options="halfDayOptions" class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
min-width="" >
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }" <option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
/> {{ half.label }}
</option>
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -88,12 +110,13 @@
</button> </button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<MalioButton <button
type="submit" type="submit"
label="Valider" 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"
button-class="w-[200px]" :class="submitButtonClass"
:disabled="props.isSubmitting || !isFormValid" >
/> + Ajouter
</button>
</div> </div>
</form> </form>
</AppDrawer> </AppDrawer>
@@ -166,23 +189,20 @@ const submitButtonClass = computed(() => {
return '' return ''
}) })
const employeeOptions = computed(() => const baseSelectClass =
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id })) 'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
) const employeeFieldClass = computed(() => {
const typeOptions = computed(() => if (showEmployeeError.value) {
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id })) return `${baseSelectClass} border-red-500`
) }
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value })) return `${baseSelectClass} border-neutral-300`
})
const dateInputBaseClass = const typeFieldClass = computed(() => {
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary' if (showTypeError.value) {
return `${baseSelectClass} border-red-500`
const onEmployeeChange = (value: string | number | null) => { }
absenceForm.value.employeeId = value === null ? '' : Number(value) return `${baseSelectClass} border-neutral-300`
} })
const onTypeChange = (value: string | number | null) => {
absenceForm.value.typeId = value === null ? '' : Number(value)
}
watch( watch(
() => props.modelValue, () => props.modelValue,

View File

@@ -17,7 +17,7 @@
<Icon name="mdi:close" size="24"/> <Icon name="mdi:close" size="24"/>
</button> </button>
</div> </div>
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1"> <div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
<slot /> <slot />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,26 @@
<template>
<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})
withDefaults(defineProps<{
placeholder?: string
}>(), {
placeholder: "Recherche d'un employé"
})
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div ref="root" class="relative inline-block w-fit max-w-full">
<button
type="button"
class="inline-flex w-[320px] 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="z-50 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 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

@@ -43,7 +43,7 @@
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span> </span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -205,7 +205,6 @@ const props = defineProps<{
getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number } getRowMetrics: (employeeId: number) => { dayMinutes: number; nightMinutes: number; workshopMinutes: number; totalMinutes: number; virtualHolidayMinutes: number }
getRowAbsenceLabel: (employeeId: number) => string getRowAbsenceLabel: (employeeId: number) => string
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void
formatMinutes: (minutes: number) => string formatMinutes: (minutes: number) => string

View File

@@ -149,13 +149,13 @@
<tr> <tr>
<td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td> <td class="px-5 py-[10px] font-bold text-primary-500 border border-primary-500">Payé</td>
<td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td> <td class="px-4 py-[10px] text-center text-neutral-500 border border-primary-500 border-r-2">-</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase25Minutes : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus25Minutes : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus25Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes) : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase25Minutes + currentPayment.paidBonus25Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBase50Minutes : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBase50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -currentPayment.paidBonus50Minutes : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ currentPayment ? formatMinutes(-currentPayment.paidBonus50Minutes) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }} <span class="text-neutral-400">/ {{ formatCentiemes(currentPayment ? -(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes) : 0) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500 border-r-2">{{ currentPayment ? formatMinutes(-(currentPayment.paidBase50Minutes + currentPayment.paidBonus50Minutes)) : '0 h' }}</td>
<td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }} <span class="text-neutral-400">/ {{ formatCentiemes(paidTotal) }}</span></td> <td class="px-4 py-[10px] text-center font-bold text-primary-500 border border-primary-500">{{ formatMinutes(paidTotal) }}</td>
</tr> </tr>
<!-- Reste row --> <!-- Reste row -->
@@ -187,41 +187,41 @@
</select> </select>
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label> <label class="block text-sm font-medium text-neutral-700">Base 25% (heures)</label>
<input <input
v-model.number="paymentForm.base25Hours" v-model.number="paymentForm.base25Hours"
type="number" type="number"
step="0.01" step="0.5"
min="0" min="0"
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" 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"
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label> <label class="block text-sm font-medium text-neutral-700">Heures 25% (heures)</label>
<input <input
v-model.number="paymentForm.bonus25Hours" v-model.number="paymentForm.bonus25Hours"
type="number" type="number"
step="0.01" step="0.5"
min="0" min="0"
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" 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"
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label> <label class="block text-sm font-medium text-neutral-700">Base 50% (heures)</label>
<input <input
v-model.number="paymentForm.base50Hours" v-model.number="paymentForm.base50Hours"
type="number" type="number"
step="0.01" step="0.5"
min="0" min="0"
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" 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"
/> />
</div> </div>
<div class="mb-6"> <div class="mb-6">
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label> <label class="block text-sm font-medium text-neutral-700">Heures 50% (heures)</label>
<input <input
v-model.number="paymentForm.bonus50Hours" v-model.number="paymentForm.bonus50Hours"
type="number" type="number"
step="0.01" step="0.5"
min="0" min="0"
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" 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"
/> />
@@ -500,10 +500,10 @@ const paymentForm = reactive({
const prefillFromExistingPayment = (month: number) => { const prefillFromExistingPayment = (month: number) => {
const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null const existing = props.summary?.monthPayments.find((p) => p.month === month) ?? null
if (existing) { if (existing) {
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100 paymentForm.base25Hours = existing.paidBase25Minutes / 60
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100 paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100 paymentForm.base50Hours = existing.paidBase50Minutes / 60
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100 paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
} else { } else {
paymentForm.base25Hours = 0 paymentForm.base25Hours = 0
paymentForm.bonus25Hours = 0 paymentForm.bonus25Hours = 0
@@ -516,14 +516,6 @@ watch(() => paymentForm.month, (newMonth) => {
prefillFromExistingPayment(newMonth) prefillFromExistingPayment(newMonth)
}) })
watch(() => paymentForm.base25Hours, (value) => {
paymentForm.bonus25Hours = Math.round(value * 0.25 * 100) / 100
})
watch(() => paymentForm.base50Hours, (value) => {
paymentForm.bonus50Hours = Math.round(value * 0.50 * 100) / 100
})
const openPaymentDrawer = () => { const openPaymentDrawer = () => {
paymentForm.month = currentMonth.value paymentForm.month = currentMonth.value
prefillFromExistingPayment(currentMonth.value) prefillFromExistingPayment(currentMonth.value)

View File

@@ -14,7 +14,7 @@
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span> <span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
</p> </p>
<p class="text-sm text-neutral-500 truncate"> <p class="text-sm text-neutral-500 truncate">
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</p> </p>
</div> </div>
@@ -212,7 +212,7 @@
</p> </p>
<p class="text-neutral-500 truncate inline-flex items-center gap-2"> <p class="text-neutral-500 truncate inline-flex items-center gap-2">
<span> <span>
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span> {{ employee.site?.name ?? 'Sans site' }}<span v-if="employee.currentContractNature"> {{ contractNatureLabel(employee.currentContractNature) }}</span>
</span> </span>
<span <span
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)" v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
@@ -405,7 +405,6 @@ const props = defineProps<{
getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined getRowAbsenceStyle: (employeeId: number) => { backgroundColor: string } | undefined
hasRowFormation: (employeeId: number) => boolean hasRowFormation: (employeeId: number) => boolean
getRowFormationLabel: (employeeId: number) => string getRowFormationLabel: (employeeId: number) => string
getRowContractNature: (employeeId: number) => 'CDI' | 'CDD' | 'INTERIM' | null
getRowUpdatedAt: (employeeId: number) => string getRowUpdatedAt: (employeeId: number) => string
getPresenceDayValue: (employeeId: number) => string getPresenceDayValue: (employeeId: number) => string
onAbsenceClick: (employeeId: number) => void onAbsenceClick: (employeeId: number) => void

View File

@@ -1,33 +1,17 @@
<template> <template>
<div class="py-4 flex flex-col gap-3 lg:py-6"> <div class="py-4 flex flex-col gap-3 lg:py-6">
<!-- Desktop: filters row --> <!-- Desktop: filters row -->
<div class="hidden lg:flex lg:items-center lg:gap-4"> <div class="hidden lg:flex lg:gap-4">
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80"> <SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<div v-if="isAdmin" class="w-80"> <div v-if="isAdmin" class="w-80">
<MalioInputText <EmployeeNameFilterInput v-model="employeeFilter" />
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
</div> </div>
<!-- Mobile: search + filter button --> <!-- Mobile: search + filter button -->
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden"> <div v-if="isAdmin" class="flex gap-2 lg:hidden">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<MalioInputText <EmployeeNameFilterInput v-model="employeeFilter" />
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
<button <button
type="button" type="button"
@@ -44,13 +28,7 @@
<div v-if="sites.length > 0 && isAdmin"> <div v-if="sites.length > 0 && isAdmin">
<label class="text-md font-semibold text-neutral-700">Sites</label> <label class="text-md font-semibold text-neutral-700">Sites</label>
<div class="mt-2"> <div class="mt-2">
<MalioSelectCheckbox <SiteFilterSelector v-model="selectedSiteIds" :sites="sites" />
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div> </div>
</div> </div>
<div v-if="isAdmin"> <div v-if="isAdmin">
@@ -194,6 +172,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Site } from '~/services/dto/site' import type { Site } from '~/services/dto/site'
import type { AbsenceType } from '~/services/dto/absence-type' 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 PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import AppDrawer from '~/components/AppDrawer.vue' import AppDrawer from '~/components/AppDrawer.vue'
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date' import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
@@ -203,7 +183,7 @@ const viewMode = defineModel<'day' | 'week'>('viewMode', { required: true })
const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true }) const selectedSiteIds = defineModel<number[]>('selectedSiteIds', { required: true })
const employeeFilter = defineModel<string>('employeeFilter', { required: true }) const employeeFilter = defineModel<string>('employeeFilter', { required: true })
const props = defineProps<{ defineProps<{
isAdmin: boolean isAdmin: boolean
sites: Site[] sites: Site[]
absenceTypes: AbsenceType[] absenceTypes: AbsenceType[]
@@ -213,8 +193,6 @@ const props = defineProps<{
getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string getWeekShortcutLabel: (target: 'previousWeek' | 'thisWeek' | 'nextWeek') => string
}>() }>()
const siteOptions = computed(() => props.sites.map((site) => ({ label: site.name, value: site.id })))
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'set-yesterday'): void (e: 'set-yesterday'): void
(e: 'set-today'): void (e: 'set-today'): void

View File

@@ -417,10 +417,6 @@ export const useDriverHoursPage = () => {
return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) return date.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' })
} }
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const hasContractAtSelectedDate = (employeeId: number) => { const hasContractAtSelectedDate = (employeeId: number) => {
const dayRow = dayContextByEmployeeId.value.get(employeeId) const dayRow = dayContextByEmployeeId.value.get(employeeId)
if (!dayRow) return true if (!dayRow) return true
@@ -986,7 +982,6 @@ export const useDriverHoursPage = () => {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -10,11 +10,10 @@ export const useEmployeeDetailPage = () => {
const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM') const showLeaveTab = computed(() => employee.value?.currentContractNature !== 'INTERIM')
const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT) const showRttTab = computed(() => employee.value?.contract?.type !== CONTRACT_TYPES.FORFAIT)
const isForfait = computed(() => employee.value?.contract?.type === CONTRACT_TYPES.FORFAIT)
const employeeContractWorkLabel = computed(() => { const employeeContractWorkLabel = computed(() => {
const contract = employee.value?.contract const contract = employee.value?.contract
if (!contract) return '-' if (!contract) return '-'
if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours' if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait'
if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures` if (contract.weeklyHours !== null && contract.weeklyHours !== undefined) return `${contract.weeklyHours} heures`
return contract.name || '-' return contract.name || '-'
}) })
@@ -56,9 +55,6 @@ export const useEmployeeDetailPage = () => {
await bonus.loadBonusData() await bonus.loadBonusData()
} else if (activeTab.value === 'observation') { } else if (activeTab.value === 'observation') {
await observation.loadObservationData() await observation.loadObservationData()
} else if (isForfait.value && showLeaveTab.value) {
// Eager load: needed for the "X jours restants" header label on forfait employees.
await leave.loadLeaveData()
} }
} finally { } finally {
isLoading.value = false isLoading.value = false
@@ -67,13 +63,6 @@ export const useEmployeeDetailPage = () => {
const contract = useEmployeeContract(employee, loadEmployee) const contract = useEmployeeContract(employee, loadEmployee)
const leave = useEmployeeLeave(employee, loadEmployee) const leave = useEmployeeLeave(employee, loadEmployee)
const forfaitRemainingDaysLabel = computed(() => {
if (!isForfait.value) return ''
const presence = leave.leaveSummary.value?.presenceDaysToToday
if (presence === undefined || presence === null) return ''
const remaining = 218 - presence
return ` (${remaining} restants)`
})
const rtt = useEmployeeRtt(employee, loadEmployee) const rtt = useEmployeeRtt(employee, loadEmployee)
const mileage = useEmployeeMileage(employee, loadEmployee) const mileage = useEmployeeMileage(employee, loadEmployee)
const formation = useEmployeeFormation(employee, loadEmployee) const formation = useEmployeeFormation(employee, loadEmployee)
@@ -108,7 +97,6 @@ export const useEmployeeDetailPage = () => {
showLeaveTab, showLeaveTab,
showRttTab, showRttTab,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
...contract, ...contract,
...leave, ...leave,
...rtt, ...rtt,

View File

@@ -494,10 +494,6 @@ export const useHoursPage = () => {
return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? '' return dayContextByEmployeeId.value.get(employeeId)?.formationLabel ?? ''
} }
const getRowContractNature = (employeeId: number): 'CDI' | 'CDD' | 'INTERIM' | null => {
return dayContextByEmployeeId.value.get(employeeId)?.contractNature ?? null
}
const getRowUpdatedAt = (employeeId: number): string => { const getRowUpdatedAt = (employeeId: number): string => {
const raw = rows.value[employeeId]?.updatedAt const raw = rows.value[employeeId]?.updatedAt
if (!raw) return '' if (!raw) return ''
@@ -1178,7 +1174,6 @@ export const useHoursPage = () => {
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,

View File

@@ -28,7 +28,6 @@ export const documentationSections: DocSection[] = [
{ type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' }, { type: 'paragraph', content: 'La vue jour est votre écran principal. Elle affiche les heures de travail pour une date donnée.' },
{ type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' }, { type: 'list', content: 'Boutons "Hier" / "Aujourd\'hui" / "Demain" pour naviguer rapidement\nSélecteur de date pour choisir une date spécifique\nFiltrage par site si vous avez accès à plusieurs sites' },
{ type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' }, { type: 'paragraph', content: 'Seuls les employés ayant un contrat actif à la date sélectionnée sont affichés.' },
{ type: 'note', content: 'Sous le nom de l\'employé, la nature du contrat (CDI / CDD / Intérim) affichée correspond à la période couvrant la date filtrée, et non à aujourd\'hui.' },
], ],
}, },
{ {
@@ -389,7 +388,6 @@ export const documentationSections: DocSection[] = [
blocks: [ blocks: [
{ type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' }, { type: 'paragraph', content: 'Le calendrier offre une vue d\'ensemble mensuelle des absences de tous les employés.' },
{ type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' }, { type: 'list', content: 'Code couleur par type d\'absence\nDemi-journée : affichage en dégradé diagonal\nJournée complète : fond plein\nJours fériés : fond bleu clair (cliquable pour créer une absence)\nFiltres par site et par employé\nNavigation par mois (précédent / suivant)' },
{ type: 'note', content: 'Seuls les employés ayant au moins un jour de contrat sur le mois affiché apparaissent. Un employé dont le contrat s\'est terminé avant le 1er du mois (ou qui commence après la fin du mois) est masqué.' },
{ type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' }, { type: 'note', content: 'Les absences peuvent être créées sur les jours fériés. Quand une absence est posée sur un férié, elle remplace l\'affichage « Férié » dans la cellule.' },
], ],
}, },

View File

@@ -174,7 +174,7 @@
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0"> <div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" /> <AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
<main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12"> <main class="flex-1 overflow-y-auto px-4 py-6 lg:px-8 lg:py-12">
<slot/> <slot/>
</main> </main>
</div> </div>

View File

@@ -2,7 +2,6 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: {enabled: false}, devtools: {enabled: false},
ssr: false, ssr: false,
extends: ['@malio/layer-ui'],
app: { app: {
baseURL: process.env.NODE_ENV === 'production' baseURL: process.env.NODE_ENV === 'production'
? (process.env.NUXT_PUBLIC_APP_BASE || '/') ? (process.env.NUXT_PUBLIC_APP_BASE || '/')

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1", "@nuxtjs/i18n": "^10.2.1",
"@malio/layer-ui": "^1.4.5",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
"nuxt": "^4.3.0", "nuxt": "^4.3.0",
"nuxt-toast": "^1.4.0", "nuxt-toast": "^1.4.0",

View File

@@ -2,12 +2,13 @@
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Types de statut</h1> <h1 class="text-4xl font-bold text-primary-500">Types de statut</h1>
<MalioButton <button
label="Ajouter un type" type="button"
icon-name="mdi:plus" class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
icon-position="left"
@click="openCreate" @click="openCreate"
/> >
+ Ajouter un type
</button>
</div> </div>
<div <div
@@ -55,40 +56,60 @@
</div> </div>
</div> </div>
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle"> <AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<MalioInputText <div>
v-model="form.code" <label class="text-md font-semibold text-neutral-700" for="code">
label="Code *" Code <span class="text-red-600">*</span>
group-class="mt-2" </label>
:max-length="10" <input
:error="showCodeError ? 'Le code est obligatoire.' : ''" id="code"
/> v-model="form.code"
<MalioInputText type="text"
v-model="form.label" maxlength="10"
label="Libellé *" :class="codeFieldClass"
group-class="mt-2" />
:error="showLabelError ? 'Le libellé est obligatoire.' : ''" <p v-if="showCodeError" class="mt-1 text-sm text-red-600">
/> Le code est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="label">
Libellé <span class="text-red-600">*</span>
</label>
<input
id="label"
v-model="form.label"
type="text"
:class="labelFieldClass"
/>
<p v-if="showLabelError" class="mt-1 text-sm text-red-600">
Le libellé est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700"> <label class="text-md font-semibold text-neutral-700">
Compté comme travaillé Compté comme travaillé
</label> </label>
<div class="mt-2 flex items-center gap-6"> <div class="mt-2 flex items-center gap-6">
<MalioRadioButton <label class="inline-flex items-center gap-2 text-md text-neutral-800">
v-model="form.countAsWorkedHours" <input
name="countAsWorkedHours" v-model="form.countAsWorkedHours"
:value="true" type="radio"
label="Oui" class="h-4 w-4"
group-class="w-auto mt-0" :value="true"
/> />
<MalioRadioButton Oui
v-model="form.countAsWorkedHours" </label>
name="countAsWorkedHours" <label class="inline-flex items-center gap-2 text-md text-neutral-800">
:value="false" <input
label="Non" v-model="form.countAsWorkedHours"
group-class="w-auto mt-0" type="radio"
/> class="h-4 w-4"
:value="false"
/>
Non
</label>
</div> </div>
</div> </div>
<div> <div>
@@ -109,29 +130,32 @@
</p> </p>
</div> </div>
<div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2"> <div v-if="editingType" class="grid grid-cols-2 gap-3 pt-2">
<MalioButton <button
label="Supprimer" type="button"
variant="danger" 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"
button-class="w-full"
@click="confirmDelete(editingType)" @click="confirmDelete(editingType)"
/> >
<MalioButton Supprimer
</button>
<button
type="submit" type="submit"
label="Modifier" 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"
button-class="w-full" :class="submitButtonClass"
:disabled="isSubmitting || !isFormValid" >
/> Modifier
</button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<MalioButton <button
type="submit" type="submit"
label="Valider" 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"
button-class="w-[200px]" :class="submitButtonClass"
:disabled="isSubmitting || !isFormValid" >
/> + Ajouter
</button>
</div> </div>
</form> </form>
</MalioDrawer> </AppDrawer>
</div> </div>
</template> </template>
@@ -178,6 +202,20 @@ const showCodeError = computed(() => validationTouched.code && !isCodeValid.valu
const showLabelError = computed(() => validationTouched.label && !isLabelValid.value) const showLabelError = computed(() => validationTouched.label && !isLabelValid.value)
const showColorError = computed(() => validationTouched.color && !isColorValid.value) const showColorError = computed(() => validationTouched.color && !isColorValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const codeFieldClass = computed(() => {
if (showCodeError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const labelFieldClass = computed(() => {
if (showLabelError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const colorFieldClass = computed(() => { const colorFieldClass = computed(() => {
const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1' const baseColorClass = 'h-10 w-16 cursor-pointer rounded-md border bg-white p-1'
if (showColorError.value) { if (showColorError.value) {
@@ -186,6 +224,13 @@ const colorFieldClass = computed(() => {
return `${baseColorClass} border-neutral-300` return `${baseColorClass} border-neutral-300`
}) })
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadAbsenceTypes = async () => { const loadAbsenceTypes = async () => {
isLoading.value = true isLoading.value = true
try { try {

View File

@@ -5,37 +5,30 @@
</div> </div>
<div class="flex flex-col gap-3 py-6"> <div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<MalioSelectCheckbox <div class="flex items-center gap-4">
v-model="selectedSiteIds" <SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
:options="siteOptions" </div>
label="Sites"
groupClass="relative z-50 w-80 h-10"
display-select-all
/>
<div class="flex gap-4"> <div class="flex gap-4">
<MalioButton <button
label="Ajouter une absence" type="button"
icon-name="mdi:plus" class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
icon-position="left"
@click="openCreateFromToday" @click="openCreateFromToday"
/> >
<MalioButton + Ajouter une absence
label="Imprimer" </button>
variant="secondary" <button
icon-name="mdi:printer" type="button"
icon-position="left" class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
@click="openPrint" @click="openPrint"
/> >
Imprimer
</button>
</div> </div>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="w-80"> <div class="w-80">
<MalioInputText <EmployeeNameFilterInput v-model="employeeFilter"/>
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
<PeriodStepperPicker <PeriodStepperPicker
width-class="w-[260px]" width-class="w-[260px]"
@@ -118,7 +111,9 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
import CalendarGrid from '~/components/CalendarGrid.vue' import CalendarGrid from '~/components/CalendarGrid.vue'
import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue' import AbsenceFormDrawer from '~/components/AbsenceFormDrawer.vue'
import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue' import AbsencePrintDrawer from '~/components/AbsencePrintDrawer.vue'
import EmployeeNameFilterInput from '~/components/EmployeeNameFilterInput.vue'
import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue' import PeriodStepperPicker from '~/components/PeriodStepperPicker.vue'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
useHead({ useHead({
title: 'Calendrier' title: 'Calendrier'
@@ -141,8 +136,6 @@ const sites = computed(() => {
}) })
}) })
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
// Filtres de sites (par défaut: tous sélectionnés à l'init). // Filtres de sites (par défaut: tous sélectionnés à l'init).
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false) const sitesInitialized = ref(false)
@@ -161,27 +154,12 @@ const sortedEmployees = computed(() => {
// Employés visibles selon le filtre de sites. // Employés visibles selon le filtre de sites.
const employeeFilter = ref('') const employeeFilter = ref('')
// Un employé est considéré "présent" sur le mois affiché si au moins une de ses
// périodes de contrat intersecte [début du mois ; fin du mois]. Sinon il est masqué.
const hasContractInSelectedMonth = (employee: Employee): boolean => {
const monthStart = toYmd(selectedYear.value, selectedMonth.value, 1)
const monthEnd = toYmd(selectedYear.value, selectedMonth.value + 1, 0)
const history = employee.contractHistory ?? []
if (history.length === 0) return false
return history.some((period) => {
const start = period.startDate
const end = period.endDate ?? '9999-12-31'
return start <= monthEnd && end >= monthStart
})
}
const visibleEmployees = computed(() => { const visibleEmployees = computed(() => {
if (selectedSiteIds.value.length === 0) return [] if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase() const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => { return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id) const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? '' const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? '' const last = employee.lastName?.toLowerCase() ?? ''

View File

@@ -64,7 +64,6 @@
:get-row-metrics="getRowMetrics" :get-row-metrics="getRowMetrics"
:get-row-absence-label="getRowAbsenceLabel" :get-row-absence-label="getRowAbsenceLabel"
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
:format-minutes="formatMinutes" :format-minutes="formatMinutes"
@@ -170,7 +169,6 @@ const {
getRowMetrics, getRowMetrics,
getRowAbsenceLabel, getRowAbsenceLabel,
getRowAbsenceStyle, getRowAbsenceStyle,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
openAbsenceDrawer, openAbsenceDrawer,
submitAbsence, submitAbsence,

View File

@@ -26,7 +26,7 @@
<p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p> <p>Date d'entrée : {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p> <p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p> <p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -257,7 +257,6 @@ const {
showRttTab, showRttTab,
contractHistory, contractHistory,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
contractForm, contractForm,
createContractForm, createContractForm,
isContractDrawerOpen, isContractDrawerOpen,

View File

@@ -4,45 +4,49 @@
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h1 class="text-4xl font-bold text-primary-500">Employés</h1> <h1 class="text-4xl font-bold text-primary-500">Employés</h1>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<MalioButton <button
label="Export" type="button"
variant="secondary" 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"
icon-name="mdi:download" @click="handleLeaveRecapPrint"
icon-position="left" >
@click="openExportDrawer" Export récap. congés
/> </button>
<MalioButton <button
label="Ajouter un employé" type="button"
icon-name="mdi:plus" 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"
icon-position="left" @click="isSalaryRecapOpen = true"
>
Export récap. salaire
</button>
<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="isYearlyHoursBulkOpen = true"
>
Export heures
</button>
<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" @click="openCreate"
/> >
+ Ajouter un employé
</button>
</div> </div>
</div> </div>
<div class="flex items-center gap-3 py-7"> <div class="flex gap-3 py-7">
<div class="w-80"> <div class="w-80">
<MalioInputText <EmployeeNameFilterInput v-model="employeeFilter"/>
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div> </div>
<div v-if="sites.length > 0" class="relative z-50 w-80"> <SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<MalioSelectCheckbox <select
v-model="selectedSiteIds"
:options="siteOptions"
groupClass="w-80"
label="Sites"
display-select-all
/>
</div>
<MalioSelect
v-model="contractStatusFilter" v-model="contractStatusFilter"
label="Statut contrat" class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
:options="contractStatusOptions" >
group-class="w-40" <option value="active">Avec contrat</option>
/> <option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
</div> </div>
</div> </div>
@@ -248,58 +252,16 @@
</form> </form>
</AppDrawer> </AppDrawer>
<MalioDrawer v-model="isExportDrawerOpen" title="Export"> <SalaryRecapDrawer
<div class="space-y-4"> v-model="isSalaryRecapOpen"
<MalioSelect @submit="handleSalaryRecapPrint"
:model-value="exportChoice === '' ? null : exportChoice" />
:options="exportTypeOptions"
label="Type d'export"
empty-option-label="Choisir un export"
group-class="mt-2"
min-width=""
@update:model-value="onExportChoiceChange"
/>
<div v-if="exportChoice === 'salary-recap'"> <BulkYearlyHoursDrawer
<label class="text-md font-semibold text-neutral-700" for="export-salary-month"> v-model="isYearlyHoursBulkOpen"
Mois <span class="text-red-600">*</span> :is-loading="isYearlyHoursBulkLoading"
</label> @submit="handleBulkYearlyHoursPrint"
<input />
id="export-salary-month"
v-model="exportSalaryMonth"
type="month"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
/>
</div>
<template v-else-if="exportChoice === 'yearly-hours'">
<MalioSelect
:model-value="exportYear"
:options="exportYearOptions"
label="Année *"
min-width=""
@update:model-value="(v) => { if (v !== null) exportYear = Number(v) }"
/>
<MalioSelect
:model-value="exportMonth === '' ? null : exportMonth"
:options="exportMonthOptions"
label="Mois *"
empty-option-label="Choisir un mois"
min-width=""
@update:model-value="(v) => { exportMonth = v === null ? '' : Number(v) }"
/>
</template>
<div class="flex justify-center pt-2">
<MalioButton
label="Valider"
button-class="w-[200px]"
:disabled="!isExportValid"
@click="handleExportValidate"
/>
</div>
</div>
</MalioDrawer>
</div> </div>
</template> </template>
@@ -313,6 +275,9 @@ import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees' import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites' import {listSites} from '~/services/sites'
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies' import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
import SiteFilterSelector from '~/components/SiteFilterSelector.vue'
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.vue'
import BulkYearlyHoursDrawer from '~/components/BulkYearlyHoursDrawer.vue'
import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract' import {contractNatureLabel, isContractNature, requiresContractEndDate, showsContractEndDate} from '~/utils/contract'
import {usePdfPrinter} from '~/composables/usePdfPrinter' import {usePdfPrinter} from '~/composables/usePdfPrinter'
@@ -323,50 +288,9 @@ useHead({
const isDrawerOpen = ref(false) const isDrawerOpen = ref(false)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const isExportDrawerOpen = ref(false) const isSalaryRecapOpen = ref(false)
const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('') const isYearlyHoursBulkOpen = ref(false)
const exportYear = ref<number>(new Date().getFullYear()) const isYearlyHoursBulkLoading = ref(false)
const exportMonth = ref<number | ''>(new Date().getMonth() + 1)
const exportSalaryMonth = ref<string>(`${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`)
const exportTypeOptions = [
{ label: 'Récap. congés', value: 'leave-recap' },
{ label: 'Récap. salaire', value: 'salary-recap' },
{ label: 'Heures annuelles', value: 'yearly-hours' }
]
const exportYearOptions = computed(() => {
const current = new Date().getFullYear()
return Array.from({ length: 6 }, (_, i) => ({ label: String(current - i), value: current - i }))
})
const exportMonthOptions = [
{ label: 'Janvier', value: 1 },
{ label: 'Février', value: 2 },
{ label: 'Mars', value: 3 },
{ label: 'Avril', value: 4 },
{ label: 'Mai', value: 5 },
{ label: 'Juin', value: 6 },
{ label: 'Juillet', value: 7 },
{ label: 'Août', value: 8 },
{ label: 'Septembre', value: 9 },
{ label: 'Octobre', value: 10 },
{ label: 'Novembre', value: 11 },
{ label: 'Décembre', value: 12 }
]
const isExportValid = computed(() => {
if (!exportChoice.value) return false
if (exportChoice.value === 'salary-recap') {
return exportSalaryMonth.value.trim() !== ''
}
if (exportChoice.value === 'yearly-hours') {
return exportYear.value > 0 && exportMonth.value !== ''
}
return true
})
const onExportChoiceChange = (value: string | number | null) => {
exportChoice.value = (value === null ? '' : String(value)) as 'leave-recap' | 'salary-recap' | 'yearly-hours' | ''
}
const { printPdf } = usePdfPrinter() const { printPdf } = usePdfPrinter()
const sitesInitialized = ref(false) const sitesInitialized = ref(false)
const editingEmployee = ref<Employee | null>(null) const editingEmployee = ref<Employee | null>(null)
@@ -380,13 +304,7 @@ const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([]) const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('') const employeeFilter = ref('')
const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active') const contractStatusFilter = ref<'active' | 'inactive' | 'all'>('active')
const contractStatusOptions = [
{ label: 'Avec contrat', value: 'active' },
{ label: 'Sans contrat', value: 'inactive' },
{ label: 'Tous', value: 'all' }
]
const selectedSiteIds = ref<number[]>([]) const selectedSiteIds = ref<number[]>([])
const siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
const filteredEmployees = computed<Employee[]>(() => { const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return [] if (selectedSiteIds.value.length === 0) return []
@@ -699,29 +617,26 @@ const openCreate = () => {
isDrawerOpen.value = true isDrawerOpen.value = true
} }
const openExportDrawer = () => { const handleLeaveRecapPrint = async () => {
exportChoice.value = '' await printPdf('/leave-recap/print')
const now = new Date()
exportYear.value = now.getFullYear()
exportMonth.value = now.getMonth() + 1
exportSalaryMonth.value = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
isExportDrawerOpen.value = true
} }
const handleExportValidate = async () => { const handleSalaryRecapPrint = async (month: string) => {
if (!isExportValid.value) return await printPdf(`/salary-recap/print?month=${month}`)
const choice = exportChoice.value isSalaryRecapOpen.value = false
isExportDrawerOpen.value = false }
if (choice === 'leave-recap') {
await printPdf('/leave-recap/print') const handleBulkYearlyHoursPrint = async (payload: { year: number; month: number | null }) => {
} else if (choice === 'salary-recap') { isYearlyHoursBulkLoading.value = true
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`) try {
} else if (choice === 'yearly-hours') { const monthParam = null !== payload.month ? `&month=${payload.month}` : ''
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`) await printPdf(`/yearly-hours/print-all?year=${payload.year}${monthParam}`)
isYearlyHoursBulkOpen.value = false
} finally {
isYearlyHoursBulkLoading.value = false
} }
} }
const confirmDelete = async (employee: Employee) => { const confirmDelete = async (employee: Employee) => {
const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`) const ok = window.confirm(`Supprimer ${employee.firstName} ${employee.lastName} ?`)
if (!ok) return if (!ok) return

View File

@@ -70,7 +70,6 @@
:get-row-absence-style="getRowAbsenceStyle" :get-row-absence-style="getRowAbsenceStyle"
:has-row-formation="hasRowFormation" :has-row-formation="hasRowFormation"
:get-row-formation-label="getRowFormationLabel" :get-row-formation-label="getRowFormationLabel"
:get-row-contract-nature="getRowContractNature"
:get-row-updated-at="getRowUpdatedAt" :get-row-updated-at="getRowUpdatedAt"
:get-presence-day-value="getPresenceDayValue" :get-presence-day-value="getPresenceDayValue"
:on-absence-click="openAbsenceDrawer" :on-absence-click="openAbsenceDrawer"
@@ -185,7 +184,6 @@ const {
getRowAbsenceStyle, getRowAbsenceStyle,
hasRowFormation, hasRowFormation,
getRowFormationLabel, getRowFormationLabel,
getRowContractNature,
getRowUpdatedAt, getRowUpdatedAt,
getPresenceDayValue, getPresenceDayValue,
openAbsenceDrawer, openAbsenceDrawer,

View File

@@ -9,18 +9,31 @@
class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm" class="mt-8 space-y-6 rounded-lg border border-neutral-200 bg-white p-6 shadow-sm"
@submit.prevent="handleSubmit" @submit.prevent="handleSubmit"
> >
<MalioInputText <div>
v-model="username" <label class="text-sm font-semibold text-neutral-700" for="username">
label="Nom d'utilisateur" Nom d'utilisateur
autocomplete="username" </label>
group-class="mt-2" <input
/> id="username"
v-model="username"
type="text"
autocomplete="username"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white 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>
<MalioInputPassword <div>
v-model="password" <label class="text-sm font-semibold text-neutral-700" for="password">
label="Mot de passe" Mot de passe
autocomplete="current-password" </label>
/> <input
id="password"
v-model="password"
type="password"
autocomplete="current-password"
class="mt-2 w-full rounded-md border border-neutral-300 bg-white 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>
<button <button
type="submit" type="submit"

View File

@@ -2,12 +2,13 @@
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-4xl font-bold text-primary-500">Sites</h1> <h1 class="text-4xl font-bold text-primary-500">Sites</h1>
<MalioButton <button
label="Ajouter un site" type="button"
icon-name="mdi:plus" class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500"
icon-position="left"
@click="openCreate" @click="openCreate"
/> >
+ Ajouter un site
</button>
</div> </div>
<div <div
@@ -51,14 +52,22 @@
</div> </div>
</div> </div>
<MalioDrawer v-model="isDrawerOpen" :title="drawerTitle"> <AppDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<MalioInputText <div>
v-model="form.name" <label class="text-md font-semibold text-neutral-700" for="name">
label="Nom *" Nom <span class="text-red-600">*</span>
group-class="mt-2" </label>
:error="showNameError ? 'Le nom du site est obligatoire.' : ''" <input
/> id="name"
v-model="form.name"
type="text"
:class="nameFieldClass"
/>
<p v-if="showNameError" class="mt-1 text-sm text-red-600">
Le nom du site est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="color"> <label class="text-md font-semibold text-neutral-700" for="color">
Couleur <span class="text-red-600">*</span> Couleur <span class="text-red-600">*</span>
@@ -74,29 +83,32 @@
</div> </div>
</div> </div>
<div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2"> <div v-if="editingSite" class="grid grid-cols-2 gap-3 pt-2">
<MalioButton <button
label="Supprimer" type="button"
variant="danger" 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"
button-class="w-full"
@click="confirmDelete(editingSite)" @click="confirmDelete(editingSite)"
/> >
<MalioButton Supprimer
</button>
<button
type="submit" type="submit"
label="Modifier" 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"
button-class="w-full" :class="submitButtonClass"
:disabled="isSubmitting || !isFormValid" >
/> Modifier
</button>
</div> </div>
<div v-else class="flex justify-center pt-2"> <div v-else class="flex justify-center pt-2">
<MalioButton <button
type="submit" type="submit"
label="Valider" 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"
button-class="w-[200px]" :class="submitButtonClass"
:disabled="isSubmitting || !isFormValid" >
/> + Ajouter
</button>
</div> </div>
</form> </form>
</MalioDrawer> </AppDrawer>
</div> </div>
</template> </template>
@@ -134,6 +146,22 @@ const isFormValid = computed(() => isNameValid.value)
const showNameError = computed(() => validationTouched.name && !isNameValid.value) const showNameError = computed(() => validationTouched.name && !isNameValid.value)
const baseInputClass =
'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
const nameFieldClass = computed(() => {
if (showNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => {
if (isSubmitting.value || !isFormValid.value) {
return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadSites = async () => { const loadSites = async () => {
isLoading.value = true isLoading.value = true
try { try {

View File

@@ -2,12 +2,13 @@
<div class="h-full flex flex-col overflow-hidden"> <div class="h-full flex flex-col overflow-hidden">
<div class="flex items-center justify-between pb-6"> <div class="flex items-center justify-between pb-6">
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1> <h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Utilisateurs</h1>
<MalioButton <button
label="Ajouter" type="button"
icon-name="mdi:plus" class="rounded-lg bg-primary-500 px-3 py-2 text-sm font-semibold text-white hover:bg-secondary-500 lg:px-4 lg:text-md"
icon-position="left"
@click="openCreate" @click="openCreate"
/> >
+ Ajouter
</button>
</div> </div>
<div <div
@@ -92,25 +93,43 @@
</div> </div>
</div> </div>
<MalioDrawer <AppDrawer
v-model="isDrawerOpen" v-model="isDrawerOpen"
:title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'" :title="editingUser ? 'Modifier un utilisateur' : 'Ajouter un utilisateur'"
> >
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<MalioInputText <div>
v-model="form.username" <label class="text-md font-semibold text-neutral-700" for="username">
:label="editingUser ? `Nom d'utilisateur` : `Nom d'utilisateur *`" Nom d'utilisateur <span class="text-red-600">*</span>
group-class="mt-2" </label>
:error="showUsernameError ? `Le nom d'utilisateur est obligatoire.` : ''" <input
/> id="username"
v-model="form.username"
type="text"
:class="usernameFieldClass"
/>
<p v-if="showUsernameError" class="mt-1 text-sm text-red-600">
Le nom d'utilisateur est obligatoire.
</p>
</div>
<div> <div>
<MalioInputPassword <label class="text-md font-semibold text-neutral-700" for="password">
Mot de passe
<span v-if="!editingUser" class="text-red-600">*</span>
</label>
<input
id="password"
v-model="form.password" v-model="form.password"
:label="editingUser ? 'Mot de passe' : 'Mot de passe *'" type="password"
:hint="editingUser ? 'Laisse vide pour ne pas changer le mot de passe.' : ''" :class="passwordFieldClass"
:error="!editingUser && showPasswordError ? 'Le mot de passe est obligatoire.' : ''"
/> />
<p v-if="editingUser" class="mt-1 text-sm text-neutral-500">
Laisse vide pour ne pas changer le mot de passe.
</p>
<p v-else-if="showPasswordError" class="mt-1 text-sm text-red-600">
Le mot de passe est obligatoire.
</p>
</div> </div>
<div> <div>
@@ -153,32 +172,40 @@
</div> </div>
<div v-if="form.accessMode === 'self'"> <div v-if="form.accessMode === 'self'">
<MalioSelect <label class="text-md font-semibold text-neutral-700" for="employee">
:model-value="form.employeeId === '' ? null : form.employeeId" Employé lié
:options="employeeOptions" </label>
label="Employé lié" <select
empty-option-label="Aucun" id="employee"
min-width="" v-model="form.employeeId"
:error="showSelfEmployeeError ? 'Sélectionne un employé.' : ''" class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
@update:model-value="onEmployeeChange" >
/> <option value="">Aucun</option>
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.firstName }} {{ employee.lastName }}
</option>
</select>
<p v-if="showSelfEmployeeError" class="mt-1 text-sm text-red-600">
Sélectionne un employé.
</p>
</div> </div>
<div v-if="form.accessMode === 'sites'"> <div v-if="form.accessMode === 'sites'">
<p class="text-md font-semibold text-neutral-700">Sites autorisés</p> <p class="text-md font-semibold text-neutral-700">Sites autorisés</p>
<div class="mt-2 grid gap-2 sm:grid-cols-2"> <div class="mt-2 grid gap-2 sm:grid-cols-2">
<div <label
v-for="site in sites" v-for="site in sites"
:key="site.id" :key="site.id"
class="flex h-10 items-center rounded-md border border-neutral-200 px-3" class="flex items-center gap-2 rounded-md border border-neutral-200 px-3 py-2 text-sm text-neutral-700 cursor-pointer"
> >
<MalioCheckbox <input
:model-value="form.siteIds.includes(site.id)" type="checkbox"
:label="site.name" class="cursor-pointer"
group-class="flex items-center" :checked="form.siteIds.includes(site.id)"
@update:model-value="toggleSite(site.id)" @change="toggleSite(site.id)"
/> />
</div> <span>{{ site.name }}</span>
</label>
</div> </div>
<p v-if="showSitesError" class="mt-1 text-sm text-red-600"> <p v-if="showSitesError" class="mt-1 text-sm text-red-600">
Sélectionne au moins un site. Sélectionne au moins un site.
@@ -186,31 +213,44 @@
</div> </div>
<div> <div>
<MalioCheckbox <label class="flex items-center gap-2 cursor-pointer">
v-model="form.isLocked" <input
label="Verrouiller le compte" v-model="form.isLocked"
hint="Un compte verrouillé ne peut plus se connecter." 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>
<div> <div>
<MalioCheckbox <label class="flex items-center gap-2 cursor-pointer">
v-model="form.hasLeaveRecapAccess" <input
label="Accès à l'écran Récap. congés" v-model="form.hasLeaveRecapAccess"
hint="Affiche l'onglet dans la sidebar et donne accès au tableau récap." type="checkbox"
/> class="cursor-pointer"
/>
<span class="text-md font-semibold text-neutral-700">Accès à l'écran Récap. congés</span>
</label>
<p class="mt-1 text-sm text-neutral-500">
Affiche l'onglet dans la sidebar et donne accès au tableau récap.
</p>
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <button
type="submit" type="submit"
:label="editingUser ? 'Modifier' : 'Valider'" 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"
button-class="w-[200px]" :class="submitButtonClass"
:disabled="isSubmitting || !isFormValid" >
/> {{ editingUser ? 'Modifier' : '+ Ajouter' }}
</button>
</div> </div>
</form> </form>
</MalioDrawer> </AppDrawer>
</div> </div>
</template> </template>
@@ -308,13 +348,27 @@ const getSiteLabels = (user: User) => {
return names.length > 0 ? names.join(', ') : 'Sites sélectionnés' return names.length > 0 ? names.join(', ') : 'Sites sélectionnés'
} }
const employeeOptions = computed(() => const baseInputClass =
employees.value.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id })) 'mt-2 w-full rounded-md border px-3 py-2 text-base text-neutral-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-secondary-500/20'
) const usernameFieldClass = computed(() => {
if (showUsernameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const passwordFieldClass = computed(() => {
if (showPasswordError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const onEmployeeChange = (value: string | number | null) => { const submitButtonClass = computed(() => {
form.employeeId = value === null ? '' : Number(value) if (isSubmitting.value || !isFormValid.value) {
} return 'opacity-50 cursor-not-allowed'
}
return ''
})
const loadData = async () => { const loadData = async () => {
isLoading.value = true isLoading.value = true

View File

@@ -15,6 +15,5 @@ export type EmployeeLeaveSummary = {
previousYearRemainingDays: number previousYearRemainingDays: number
previousYearPaidDays: number previousYearPaidDays: number
presenceDaysByMonth: Record<string, number> presenceDaysByMonth: Record<string, number>
presenceDaysToToday: number
} }

View File

@@ -111,7 +111,6 @@ export type WorkHourDayContextRow = {
hasFormation?: boolean hasFormation?: boolean
formationLabel?: string | null formationLabel?: string | null
virtualHolidayMinutes?: number virtualHolidayMinutes?: number
contractNature?: 'CDI' | 'CDD' | 'INTERIM' | null
} }
export type WorkHourDayContext = { export type WorkHourDayContext = {

View File

@@ -38,7 +38,4 @@ final class EmployeeLeaveSummary
/** @var array<string, float> YYYY-MM => count (0.5 for half-days) */ /** @var array<string, float> YYYY-MM => count (0.5 for half-days) */
public array $presenceDaysByMonth = []; public array $presenceDaysByMonth = [];
/** Cumul des jours de présence depuis le début de l'année de congé jusqu'à aujourd'hui (forfait). */
public float $presenceDaysToToday = 0.0;
} }

View File

@@ -20,7 +20,6 @@ final class DayContextRow
public bool $hasFormation = false, public bool $hasFormation = false,
public ?string $formationLabel = null, public ?string $formationLabel = null,
public int $virtualHolidayMinutes = 0, public int $virtualHolidayMinutes = 0,
public ?string $contractNature = null,
) {} ) {}
public function setFormation(string $label): void public function setFormation(string $label): void
@@ -78,8 +77,7 @@ final class DayContextRow
* isDriverContract:bool, * isDriverContract:bool,
* hasFormation:bool, * hasFormation:bool,
* formationLabel:?string, * formationLabel:?string,
* virtualHolidayMinutes:int, * virtualHolidayMinutes:int
* contractNature:?string
* } * }
*/ */
public function toArray(): array public function toArray(): array
@@ -98,7 +96,6 @@ final class DayContextRow
'hasFormation' => $this->hasFormation, 'hasFormation' => $this->hasFormation,
'formationLabel' => $this->formationLabel, 'formationLabel' => $this->formationLabel,
'virtualHolidayMinutes' => $this->virtualHolidayMinutes, 'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
'contractNature' => $this->contractNature,
]; ];
} }

View File

@@ -73,7 +73,7 @@ final readonly class LeaveRecapRowBuilder
} }
} }
$cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2); $cpN1Remaining = round($yearSummary['previousYearRemainingDays'], 2);
$cpN = (string) round($yearSummary['remainingDays'], 2); $cpN = (string) round($yearSummary['acquiredDays'], 2);
$acquiredSaturdays = '-'; $acquiredSaturdays = '-';
} else { } else {
$cpN1Remaining = round($yearSummary['remainingDays'], 2); $cpN1Remaining = round($yearSummary['remainingDays'], 2);

View File

@@ -119,29 +119,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
$summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays']; $summary->previousYearRemainingDays = $yearSummary['previousYearRemainingDays'];
$summary->previousYearPaidDays = $paidLeaveDays; $summary->previousYearPaidDays = $paidLeaveDays;
[$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year); [$periodFrom, $periodTo] = $this->resolvePeriodBounds($employee, $year);
// Forfait-only: leaves taken from N-1 stock do NOT decrement presence days. $summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo);
// For non-forfait, previousYearTakenDays is always 0, so the budget has no effect.
$n1AbsencesBudget = $yearSummary['previousYearTakenDays'];
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth(
$employee,
$periodFrom,
$periodTo,
$n1AbsencesBudget
);
// Same logic as presenceDaysByMonth but bounded at today: number of presence days
// accumulated from leave year start up to today (inclusive).
$today = new DateTimeImmutable('today');
$cappedTo = $today < $periodTo ? $today : $periodTo;
$summary->presenceDaysToToday = $today < $periodFrom
? 0.0
: array_sum($this->computePresenceDaysByMonth(
$employee,
$periodFrom,
$cappedTo,
$n1AbsencesBudget
));
return $summary; return $summary;
} }
@@ -707,12 +686,8 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
* *
* @return array<string, float> YYYY-MM => presence day count * @return array<string, float> YYYY-MM => presence day count
*/ */
private function computePresenceDaysByMonth( private function computePresenceDaysByMonth(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
Employee $employee, {
DateTimeImmutable $from,
DateTimeImmutable $to,
float $n1AbsencesBudget = 0.0
): array {
$publicHolidays = $this->buildPublicHolidayMap($from, $to); $publicHolidays = $this->buildPublicHolidayMap($from, $to);
$weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to); $weekendWorkedDays = $this->workHourRepository->countWeekendWorkedDaysByMonth($employee, $from, $to);
$absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to); $absences = $this->absenceRepository->findByEmployeeAndOverlappingDateRange($employee, $from, $to);
@@ -722,20 +697,10 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays)) ? $this->workHourRepository->findWorkedDatesAmong($employee, array_keys($publicHolidays))
: []; : [];
// Sort absences chronologically so N-1 budget (forfait only) is consumed in date order:
// earliest absences attribute to N-1 first, later ones overflow to N and reduce presence.
$sortedAbsences = $absences;
usort(
$sortedAbsences,
static fn ($a, $b): int => $a->getStartDate() <=> $b->getStartDate()
);
$remainingN1Budget = $n1AbsencesBudget;
// Count absence days per month, iterating day by day to handle multi-day absences // Count absence days per month, iterating day by day to handle multi-day absences
// and properly distribute across months. // and properly distribute across months.
$absenceDaysByMonth = []; $absenceDaysByMonth = [];
foreach ($sortedAbsences as $absence) { foreach ($absences as $absence) {
$start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0); $start = DateTimeImmutable::createFromInterface($absence->getStartDate())->setTime(0, 0);
$end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0); $end = DateTimeImmutable::createFromInterface($absence->getEndDate())->setTime(0, 0);
@@ -753,17 +718,6 @@ final readonly class EmployeeLeaveSummaryProvider implements ProviderInterface
continue; continue;
} }
// Forfait: leaves taken from N-1 stock do NOT decrement presence days.
// We chronologically consume the N-1 budget before counting any absence.
if ($remainingN1Budget > 0.0) {
$consumed = min($remainingN1Budget, $dayAmount);
$remainingN1Budget -= $consumed;
$dayAmount -= $consumed;
if ($dayAmount <= 0.0) {
continue;
}
}
$absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount; $absenceDaysByMonth[$monthKey] = ($absenceDaysByMonth[$monthKey] ?? 0.0) + $dayAmount;
} }
} }

View File

@@ -57,17 +57,13 @@ final readonly class WorkHourDayContextProvider implements ProviderInterface
} }
// On initialise toutes les lignes, même sans absence ce jour-là. // On initialise toutes les lignes, même sans absence ce jour-là.
$contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate); $contract = $this->contractResolver->resolveForEmployeeAndDate($employee, $workDate);
$workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate); $workDaysMinutes = $this->contractResolver->resolveWorkDaysMinutesForEmployeeAndDate($employee, $workDate);
$contractNature = null !== $contract
? $this->contractResolver->resolveNatureForEmployeeAndDate($employee, $workDate)->value
: null;
$rowsByEmployeeId[$employeeId] = new DayContextRow( $rowsByEmployeeId[$employeeId] = new DayContextRow(
employeeId: $employeeId, employeeId: $employeeId,
hasContractAtDate: null !== $contract, hasContractAtDate: null !== $contract,
isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate), isDriverContract: $this->contractResolver->resolveIsDriverForEmployeeAndDate($employee, $workDate),
virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes), virtualHolidayMinutes: $this->holidayVirtualHoursResolver->resolveVirtualCredit($contract, $workDate, false, $workDaysMinutes),
contractNature: $contractNature,
); );
} }

View File

@@ -10,7 +10,6 @@ use App\Entity\AbsenceType;
use App\Entity\Contract; use App\Entity\Contract;
use App\Entity\Employee; use App\Entity\Employee;
use App\Entity\User; use App\Entity\User;
use App\Enum\ContractNature;
use App\Enum\HalfDay; use App\Enum\HalfDay;
use App\Repository\Contract\AbsenceReadRepositoryInterface; use App\Repository\Contract\AbsenceReadRepositoryInterface;
use App\Repository\Contract\EmployeeScopedRepositoryInterface; use App\Repository\Contract\EmployeeScopedRepositoryInterface;
@@ -177,10 +176,6 @@ final class WorkHourDayContextProviderTest extends TestCase
->method('resolveForEmployeeAndDate') ->method('resolveForEmployeeAndDate')
->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract()) ->willReturnCallback(static fn (Employee $employee): ?Contract => $employee->getContract())
; ;
$resolver
->method('resolveNatureForEmployeeAndDate')
->willReturn(ContractNature::CDI)
;
return $resolver; return $resolver;
} }