feat : migrate filter/form UI to @malio/layer-ui + fix hours/calendar contract scoping

- Add @malio/layer-ui as Nuxt layer (extends in nuxt.config.ts)
- Migrate site/employee/contract filters to MalioSelectCheckbox / MalioInputText / MalioSelect on employees, calendar and hours screens
- Migrate absence drawer selects + submit button to Malio (MalioSelect + MalioButton)
- Migrate calendar "Ajouter une absence" / "Imprimer" actions to MalioButton
- Drop now-unused EmployeeNameFilterInput and SiteFilterSelector components
- Hours day view: resolve contract nature at selected date (WorkHourDayContext.contractNature) instead of employee.currentContractNature (today-based); fixes interim contracts showing as CDI when mission ended
- Calendar: hide employees whose contract periods do not intersect the displayed month
- Layout: scrollbar-gutter:stable on <main> to avoid horizontal shift when dropdowns open
- Update functional-rules.md, in-app documentation, CLAUDE.md to match

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-24 16:54:38 +02:00
parent 90843dd997
commit bd93c52197
25 changed files with 309 additions and 368 deletions

View File

@@ -6,29 +6,39 @@
<div class="flex flex-col gap-3 py-6">
<div class="flex items-center justify-between gap-4">
<div class="flex items-center gap-4">
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
<div class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
display-select-all
/>
</div>
</div>
<div class="flex gap-4">
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
<MalioButton
label="Ajouter une absence"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateFromToday"
>
+ Ajouter une absence
</button>
<button
type="button"
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
/>
<MalioButton
label="Imprimer"
variant="secondary"
icon-name="mdi:printer"
icon-position="left"
@click="openPrint"
>
Imprimer
</button>
/>
</div>
</div>
<div class="flex justify-between">
<div class="flex items-center gap-4">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<PeriodStepperPicker
width-class="w-[260px]"
@@ -111,9 +121,7 @@ import {compareEmployeesInSite, sortEmployeesBySiteAndOrder} from '~/utils/emplo
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({
title: 'Calendrier'
@@ -136,6 +144,8 @@ 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).
const selectedSiteIds = ref<number[]>([])
const sitesInitialized = ref(false)
@@ -154,12 +164,27 @@ const sortedEmployees = computed(() => {
// Employés visibles selon le filtre de sites.
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(() => {
if (selectedSiteIds.value.length === 0) return []
const filter = employeeFilter.value.trim().toLowerCase()
return sortedEmployees.value.filter((employee) => {
const siteOk = employee.site?.id && selectedSiteIds.value.includes(employee.site.id)
if (!siteOk) return false
if (!hasContractInSelectedMonth(employee)) return false
if (!filter) return true
const first = employee.firstName?.toLowerCase() ?? ''
const last = employee.lastName?.toLowerCase() ?? ''

View File

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

View File

@@ -34,19 +34,29 @@
</button>
</div>
</div>
<div class="flex gap-3 py-7">
<div class="flex items-center gap-3 py-7">
<div class="w-80">
<EmployeeNameFilterInput v-model="employeeFilter"/>
<MalioInputText
v-model="employeeFilter"
label="Recherche d'un employé"
icon-name="mdi:magnify"
/>
</div>
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
<select
<div v-if="sites.length > 0" class="relative z-50 w-80">
<MalioSelectCheckbox
v-model="selectedSiteIds"
:options="siteOptions"
label="Sites"
display-select-all
/>
</div>
<MalioSelect
v-model="contractStatusFilter"
class="rounded-md border border-primary-500 bg-white px-3 py-2 text-md font-semibold text-primary-500 cursor-pointer"
>
<option value="active">Avec contrat</option>
<option value="inactive">Sans contrat</option>
<option value="all">Tous</option>
</select>
label="Statut contrat"
:options="contractStatusOptions"
group-class="w-40"
/>
</div>
</div>
@@ -275,7 +285,6 @@ import {listContracts} from '~/services/contracts'
import {createEmployee, deleteEmployee, listEmployees, updateEmployee} from '~/services/employees'
import {listSites} from '~/services/sites'
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'
@@ -304,7 +313,13 @@ const contracts = ref<Contract[]>([])
const interimAgencies = ref<InterimAgency[]>([])
const employeeFilter = ref('')
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 siteOptions = computed(() => sites.value.map((site) => ({ label: site.name, value: site.id })))
const filteredEmployees = computed<Employee[]>(() => {
if (selectedSiteIds.value.length === 0) return []

View File

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