Compare commits

..

4 Commits

Author SHA1 Message Date
gitea-actions
973de2d094 chore: bump version to v0.1.97
All checks were successful
Auto Tag Develop / tag (push) Successful in 5s
Build & Push Docker Image / build (push) Successful in 56s
2026-04-27 13:02:01 +00:00
74c109713c fix : malio UI
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
2026-04-27 15:01:51 +02:00
gitea-actions
06173e7225 chore: bump version to v0.1.96
All checks were successful
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 2m59s
2026-04-27 12:08:31 +00:00
cc868a1e82 feat: ajout malio UI + décompte des jours de présence forfait (#17)
Some checks failed
Auto Tag Develop / tag (push) Has been cancelled
| Numéro du ticket | Titre du ticket |
|------------------|-----------------|
|                  |                 |

## Description de la PR

## Modification du .env

## Check list

- [ ] Pas de régression
- [ ] TU/TI/TF rédigée
- [ ] TU/TI/TF OK
- [ ] CHANGELOG modifié

Reviewed-on: #17
Co-authored-by: tristan <tristan@yuno.malio.fr>
Co-committed-by: tristan <tristan@yuno.malio.fr>
2026-04-27 12:08:24 +00:00
18 changed files with 435 additions and 542 deletions

View File

@@ -61,6 +61,7 @@
- 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.95' app.version: '0.1.97'

View File

@@ -335,7 +335,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: jours acquis année civile. Non-forfait: en cours d'acquisition | | 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 |
| 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

@@ -6,6 +6,7 @@
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="selectedSiteIds" v-model="selectedSiteIds"
:options="siteOptions" :options="siteOptions"
groupClass="w-80"
label="Sites" label="Sites"
display-select-all display-select-all
/> />
@@ -46,6 +47,7 @@
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="selectedSiteIds" v-model="selectedSiteIds"
:options="siteOptions" :options="siteOptions"
groupClass="w-80"
label="Sites" label="Sites"
display-select-all display-select-all
/> />

View File

@@ -10,10 +10,11 @@ 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' if (contract.type === CONTRACT_TYPES.FORFAIT) return 'Forfait - 218 jours'
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 || '-'
}) })
@@ -55,6 +56,9 @@ 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
@@ -63,6 +67,13 @@ 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)
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
showLeaveTab, showLeaveTab,
showRttTab, showRttTab,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
...contract, ...contract,
...leave, ...leave,
...rtt, ...rtt,

View File

@@ -7,7 +7,7 @@
"name": "frontend", "name": "frontend",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@malio/layer-ui": "^1.4.3", "@malio/layer-ui": "^1.4.6",
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/i18n": "^10.2.1", "@nuxtjs/i18n": "^10.2.1",
"@pinia/nuxt": "^0.11.3", "@pinia/nuxt": "^0.11.3",
@@ -2222,9 +2222,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@malio/layer-ui": { "node_modules/@malio/layer-ui": {
"version": "1.4.3", "version": "1.4.6",
"resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.3/layer-ui-1.4.3.tgz", "resolved": "https://gitea.malio.fr/api/packages/MALIO-DEV/npm/%40malio%2Flayer-ui/-/1.4.6/layer-ui-1.4.6.tgz",
"integrity": "sha512-XGR0VteuRGGizl8ZP2ZRlyWsdSTAwOYR7z5687Gx/SFr5eTg+poOV2NupqOuWCksxEcXA54vzzC0vMG8PbSvxg==", "integrity": "sha512-stHqUAJ8E6a62Ka7QXlE177GhkIsjtmYNa/tNk1TVpbJ099okfLLivrlofEl7CCAqDeMaIepnW4q0vxJT+EFEA==",
"dependencies": { "dependencies": {
"@nuxt/icon": "^2.2.1", "@nuxt/icon": "^2.2.1",
"@nuxtjs/tailwindcss": "^6.14.0", "@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -13,7 +13,7 @@
"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.3", "@malio/layer-ui": "^1.4.6",
"@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,13 +2,12 @@
<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>
<button <MalioButton
type="button" label="Ajouter un type"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" icon-name="mdi:plus"
icon-position="left"
@click="openCreate" @click="openCreate"
> />
+ Ajouter un type
</button>
</div> </div>
<div <div
@@ -56,60 +55,40 @@
</div> </div>
</div> </div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle"> <MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="code"> v-model="form.code"
Code <span class="text-red-600">*</span> label="Code *"
</label> group-class="mt-2"
<input :max-length="10"
id="code" :error="showCodeError ? 'Le code est obligatoire.' : ''"
v-model="form.code" />
type="text" <MalioInputText
maxlength="10" v-model="form.label"
:class="codeFieldClass" label="Libellé *"
/> group-class="mt-2"
<p v-if="showCodeError" class="mt-1 text-sm text-red-600"> :error="showLabelError ? 'Le libellé est obligatoire.' : ''"
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">
<label class="inline-flex items-center gap-2 text-md text-neutral-800"> <MalioRadioButton
<input v-model="form.countAsWorkedHours"
v-model="form.countAsWorkedHours" name="countAsWorkedHours"
type="radio" :value="true"
class="h-4 w-4" label="Oui"
:value="true" group-class="w-auto mt-0"
/> />
Oui <MalioRadioButton
</label> v-model="form.countAsWorkedHours"
<label class="inline-flex items-center gap-2 text-md text-neutral-800"> name="countAsWorkedHours"
<input :value="false"
v-model="form.countAsWorkedHours" label="Non"
type="radio" group-class="w-auto mt-0"
class="h-4 w-4" />
:value="false"
/>
Non
</label>
</div> </div>
</div> </div>
<div> <div>
@@ -130,32 +109,29 @@
</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">
<button <MalioButton
type="button" label="Supprimer"
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" variant="danger"
button-class="w-full"
@click="confirmDelete(editingType)" @click="confirmDelete(editingType)"
> />
Supprimer <MalioButton
</button>
<button
type="submit" type="submit"
class="flex items-center justify-center rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Modifier"
:class="submitButtonClass" button-class="w-full"
> :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">
<button <MalioButton
type="submit" type="submit"
class="flex w-[200px] items-center justify-center gap-2 rounded-md bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Valider"
:class="submitButtonClass" button-class="w-[200px]"
> :disabled="isSubmitting || !isFormValid"
+ Ajouter />
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
</div> </div>
</template> </template>
@@ -202,20 +178,6 @@ 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) {
@@ -224,13 +186,6 @@ 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,16 +5,13 @@
</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">
<div class="flex items-center gap-4"> <MalioSelectCheckbox
<div class="relative z-50 w-80"> v-model="selectedSiteIds"
<MalioSelectCheckbox :options="siteOptions"
v-model="selectedSiteIds" label="Sites"
:options="siteOptions" groupClass="relative z-50 w-80 h-10"
label="Sites" display-select-all
display-select-all />
/>
</div>
</div>
<div class="flex gap-4"> <div class="flex gap-4">
<MalioButton <MalioButton
label="Ajouter une absence" label="Ajouter une absence"

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 }}</p> <p class="font-bold text-[22px]">{{ contractNatureLabel(employee.currentContractNature) }} {{ employeeContractWorkLabel }}{{ forfaitRemainingDaysLabel }}</p>
<p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p> <p class="text-[18px]">{{ employee.site?.name ?? '-' }}</p>
</div> </div>
</div> </div>
@@ -257,6 +257,7 @@ const {
showRttTab, showRttTab,
contractHistory, contractHistory,
employeeContractWorkLabel, employeeContractWorkLabel,
forfaitRemainingDaysLabel,
contractForm, contractForm,
createContractForm, createContractForm,
isContractDrawerOpen, isContractDrawerOpen,

View File

@@ -4,34 +4,19 @@
<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">
<button <MalioButton
type="button" label="Export"
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" variant="secondary"
@click="handleLeaveRecapPrint" icon-name="mdi:download"
> icon-position="left"
Export récap. congés @click="openExportDrawer"
</button> />
<button <MalioButton
type="button" label="Ajouter un employé"
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:plus"
@click="isSalaryRecapOpen = true" icon-position="left"
>
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 items-center gap-3 py-7">
@@ -46,6 +31,7 @@
<MalioSelectCheckbox <MalioSelectCheckbox
v-model="selectedSiteIds" v-model="selectedSiteIds"
:options="siteOptions" :options="siteOptions"
groupClass="w-80"
label="Sites" label="Sites"
display-select-all display-select-all
/> />
@@ -98,105 +84,53 @@
</NuxtLink> </NuxtLink>
</div> </div>
<AppDrawer v-model="isDrawerOpen" :title="drawerTitle"> <MalioDrawer v-model="isDrawerOpen" :title="drawerTitle">
<form class="space-y-4" @submit.prevent="handleSubmit"> <form class="space-y-4" @submit.prevent="handleSubmit">
<div> <MalioInputText
<label class="text-md font-semibold text-neutral-700" for="first-name"> v-model="form.firstName"
Prénom <span class="text-red-600">*</span> label="Prénom *"
</label> group-class="mt-2"
<input :error="showFirstNameError ? 'Le prénom est obligatoire.' : ''"
id="first-name" />
v-model="form.firstName" <MalioInputText
type="text" v-model="form.lastName"
:class="firstNameFieldClass" label="Nom *"
/> group-class="mt-2"
<p v-if="showFirstNameError" class="mt-1 text-sm text-red-600"> :error="showLastNameError ? 'Le nom est obligatoire.' : ''"
Le prénom est obligatoire. />
</p> <MalioSelect
</div> :model-value="form.siteId === '' ? null : form.siteId"
<div> :options="formSiteOptions"
<label class="text-md font-semibold text-neutral-700" for="last-name"> label="Site *"
Nom <span class="text-red-600">*</span> min-width=""
</label> :error="showSiteError ? 'Le site est obligatoire.' : ''"
<input @update:model-value="(v) => { form.siteId = v === null ? '' : Number(v) }"
id="last-name" />
v-model="form.lastName"
type="text"
:class="lastNameFieldClass"
/>
<p v-if="showLastNameError" class="mt-1 text-sm text-red-600">
Le nom est obligatoire.
</p>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="site">
Site <span class="text-red-600">*</span>
</label>
<select
id="site"
v-model="form.siteId"
:class="siteFieldClass"
>
<option value="">Aucun site</option>
<option v-for="site in sites" :key="site.id" :value="site.id">
{{ site.name }}
</option>
</select>
<p v-if="showSiteError" class="mt-1 text-sm text-red-600">
Le site est obligatoire.
</p>
</div>
<template v-if="!editingEmployee"> <template v-if="!editingEmployee">
<div> <MalioSelect
<label class="text-md font-semibold text-neutral-700" for="contract-nature"> :model-value="form.contractNature"
Type de contrat <span class="text-red-600">*</span> :options="contractNatureFormOptions"
</label> label="Type de contrat *"
<select min-width=""
id="contract-nature" :error="showContractNatureError ? 'Le type de contrat est obligatoire.' : ''"
v-model="form.contractNature" @update:model-value="(v) => { if (v !== null) form.contractNature = v as 'CDI' | 'CDD' | 'INTERIM' }"
:class="contractNatureFieldClass" />
> <MalioSelect
<option value="CDI">CDI</option> v-if="form.contractNature === 'INTERIM'"
<option value="CDD">CDD</option> :model-value="form.interimAgencyId === '' ? null : form.interimAgencyId"
<option value="INTERIM">Intérim</option> :options="interimAgencyOptions"
</select> label="Agence d'intérim"
<p v-if="showContractNatureError" class="mt-1 text-sm text-red-600"> min-width=""
Le type de contrat est obligatoire. @update:model-value="(v) => { form.interimAgencyId = v === null ? '' : Number(v) }"
</p> />
</div> <MalioSelect
<div v-if="form.contractNature === 'INTERIM'"> :model-value="form.contractId === '' ? null : form.contractId"
<label class="text-md font-semibold text-neutral-700" for="interim-agency"> :options="contractFormOptions"
Agence d'intérim label="Temps de travail *"
</label> min-width=""
<select :error="showContractError ? 'Le temps de travail est obligatoire.' : ''"
id="interim-agency" @update:model-value="(v) => { form.contractId = v === null ? '' : Number(v) }"
v-model="form.interimAgencyId" />
class="mt-2 w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
>
<option value="">Aucune</option>
<option v-for="agency in interimAgencies" :key="agency.id" :value="agency.id">
{{ agency.name }}
</option>
</select>
</div>
<div>
<label class="text-md font-semibold text-neutral-700" for="contract">
Temps de travail <span class="text-red-600">*</span>
</label>
<select
id="contract"
v-model="form.contractId"
:class="contractFieldClass"
>
<option value="">Sélectionner un contrat</option>
<option v-for="contract in contracts" :key="contract.id" :value="contract.id">
{{ contract.name }}
</option>
</select>
<p v-if="showContractError" class="mt-1 text-sm text-red-600">
Le temps de travail est obligatoire.
</p>
</div>
<div> <div>
<label class="text-md font-semibold text-neutral-700" for="contract-start-date"> <label class="text-md font-semibold text-neutral-700" for="contract-start-date">
Début contrat <span class="text-red-600">*</span> Début contrat <span class="text-red-600">*</span>
@@ -205,7 +139,7 @@
id="contract-start-date" id="contract-start-date"
v-model="form.contractStartDate" v-model="form.contractStartDate"
type="date" type="date"
:class="contractStartDateFieldClass" :class="[dateInputBaseClass, form.contractStartDate ? 'border-black' : 'border-m-muted', showContractStartDateError ? '!border-m-danger' : '']"
/> />
<p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600"> <p v-if="showContractStartDateError" class="mt-1 text-sm text-red-600">
La date de début est obligatoire. La date de début est obligatoire.
@@ -220,22 +154,18 @@
id="contract-end-date" id="contract-end-date"
v-model="form.contractEndDate" v-model="form.contractEndDate"
type="date" type="date"
:class="contractEndDateFieldClass" :class="[dateInputBaseClass, form.contractEndDate ? 'border-black' : 'border-m-muted', showContractEndDateError ? '!border-m-danger' : '']"
/> />
<p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600"> <p v-if="showContractEndDateError" class="mt-1 text-sm text-red-600">
La date de fin est obligatoire pour un CDD ou un Intérim. La date de fin est obligatoire pour un CDD ou un Intérim.
</p> </p>
</div> </div>
<div class="rounded-md border border-neutral-200 bg-neutral-50 p-3"> <div class="flex h-10 items-center rounded-md border border-neutral-200 bg-neutral-50 px-3">
<label class="inline-flex items-center gap-2 text-md font-semibold text-neutral-700" for="is-driver"> <MalioCheckbox
<input v-model="form.isDriver"
id="is-driver" label="Chauffeur"
v-model="form.isDriver" group-class="flex items-center"
type="checkbox" />
class="h-4 w-4 rounded border-neutral-300 text-primary-500 focus:ring-primary-500"
/>
Chauffeur
</label>
</div> </div>
<WorkDaysHoursInput <WorkDaysHoursInput
v-if="requiresSchedule" v-if="requiresSchedule"
@@ -244,34 +174,72 @@
/> />
</template> </template>
<div class="flex justify-end gap-3 pt-2"> <div class="flex justify-end gap-3 pt-2">
<button <MalioButton
type="button" label="Annuler"
class="rounded-lg border border-neutral-200 px-4 py-2 text-md font-semibold text-neutral-700 hover:bg-neutral-100" variant="tertiary"
@click="isDrawerOpen = false" @click="isDrawerOpen = false"
> />
Annuler <MalioButton
</button>
<button
type="submit" type="submit"
class="rounded-lg bg-primary-500 px-4 py-2 text-md font-semibold text-white hover:bg-secondary-500" label="Enregistrer"
:class="submitButtonClass" :disabled="isSubmitting || !isFormValid"
> />
Enregistrer
</button>
</div> </div>
</form> </form>
</AppDrawer> </MalioDrawer>
<SalaryRecapDrawer <MalioDrawer v-model="isExportDrawerOpen" title="Export">
v-model="isSalaryRecapOpen" <div class="space-y-4">
@submit="handleSalaryRecapPrint" <MalioSelect
/> :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"
/>
<BulkYearlyHoursDrawer <div v-if="exportChoice === 'salary-recap'">
v-model="isYearlyHoursBulkOpen" <label class="text-md font-semibold text-neutral-700" for="export-salary-month">
:is-loading="isYearlyHoursBulkLoading" Mois <span class="text-red-600">*</span>
@submit="handleBulkYearlyHoursPrint" </label>
/> <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>
@@ -285,8 +253,6 @@ 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 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'
@@ -297,9 +263,50 @@ 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 isSalaryRecapOpen = ref(false) const isExportDrawerOpen = ref(false)
const isYearlyHoursBulkOpen = ref(false) const exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
const isYearlyHoursBulkLoading = ref(false) const exportYear = ref<number>(new Date().getFullYear())
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)
@@ -425,63 +432,23 @@ const showContractEndDateError = computed(
() => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value () => !editingEmployee.value && validationTouched.contractEndDate && !isContractEndDateValid.value
) )
const baseInputClass = const dateInputBaseClass =
'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' 'mt-2 h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
const firstNameFieldClass = computed(() => {
if (showFirstNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const lastNameFieldClass = computed(() => {
if (showLastNameError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const siteFieldClass = computed(() => {
const baseSelectClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showSiteError.value) {
return `${baseSelectClass} border-red-500`
}
return `${baseSelectClass} border-neutral-300`
})
const contractFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractNatureFieldClass = computed(() => {
const baseClass =
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
if (showContractNatureError.value) {
return `${baseClass} border-red-500`
}
return `${baseClass} border-neutral-300`
})
const contractStartDateFieldClass = computed(() => {
if (showContractStartDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const contractEndDateFieldClass = computed(() => {
if (showContractEndDateError.value) {
return `${baseInputClass} border-red-500`
}
return `${baseInputClass} border-neutral-300`
})
const submitButtonClass = computed(() => { const formSiteOptions = computed(() =>
if (isSubmitting.value || !isFormValid.value) { sites.value.map((site) => ({ label: site.name, value: site.id }))
return 'opacity-50 cursor-not-allowed' )
} const interimAgencyOptions = computed(() =>
return '' interimAgencies.value.map((agency) => ({ label: agency.name, value: agency.id }))
}) )
const contractFormOptions = computed(() =>
contracts.value.map((contract) => ({ label: contract.name, value: contract.id }))
)
const contractNatureFormOptions = [
{ label: 'CDI', value: 'CDI' },
{ label: 'CDD', value: 'CDD' },
{ label: 'Intérim', value: 'INTERIM' }
]
const loadEmployees = async () => { const loadEmployees = async () => {
isLoading.value = true isLoading.value = true
@@ -632,26 +599,29 @@ const openCreate = () => {
isDrawerOpen.value = true isDrawerOpen.value = true
} }
const handleLeaveRecapPrint = async () => { const openExportDrawer = () => {
await printPdf('/leave-recap/print') exportChoice.value = ''
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 handleSalaryRecapPrint = async (month: string) => { const handleExportValidate = async () => {
await printPdf(`/salary-recap/print?month=${month}`) if (!isExportValid.value) return
isSalaryRecapOpen.value = false const choice = exportChoice.value
} isExportDrawerOpen.value = false
if (choice === 'leave-recap') {
const handleBulkYearlyHoursPrint = async (payload: { year: number; month: number | null }) => { await printPdf('/leave-recap/print')
isYearlyHoursBulkLoading.value = true } else if (choice === 'salary-recap') {
try { await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
const monthParam = null !== payload.month ? `&month=${payload.month}` : '' } else if (choice === 'yearly-hours') {
await printPdf(`/yearly-hours/print-all?year=${payload.year}${monthParam}`) await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
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

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

View File

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

View File

@@ -38,4 +38,7 @@ 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

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

View File

@@ -119,8 +119,29 @@ 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);
$summary->presenceDaysByMonth = $this->computePresenceDaysByMonth($employee, $periodFrom, $periodTo); // Forfait-only: leaves taken from N-1 stock do NOT decrement presence days.
// 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;
} }
@@ -686,8 +707,12 @@ 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(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array private function computePresenceDaysByMonth(
{ 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);
@@ -697,10 +722,20 @@ 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 ($absences as $absence) { foreach ($sortedAbsences 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);
@@ -718,6 +753,17 @@ 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;
} }
} }