Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06173e7225 | ||
| cc868a1e82 | |||
|
|
90843dd997 | ||
| 8a449cf81b | |||
|
|
3926946a5f | ||
| b9c3a8a84f | |||
|
|
b2f6fdf222 | ||
| 0fe82c63c5 | |||
| 849d19f124 | |||
|
|
d230a252b6 | ||
| d46e7c04d5 | |||
|
|
fe0910a661 | ||
| ff7566d4cd | |||
|
|
2f25a3cd52 | ||
| 1fe7f2cdde |
@@ -3,6 +3,7 @@
|
|||||||
.env.local
|
.env.local
|
||||||
.env.test
|
.env.test
|
||||||
docker/
|
docker/
|
||||||
|
!docker/php/config/php.ini
|
||||||
deploy/docker/docker-compose.prod.yml
|
deploy/docker/docker-compose.prod.yml
|
||||||
deploy/docker/deploy.sh
|
deploy/docker/deploy.sh
|
||||||
deploy/docker/.env.example
|
deploy/docker/.env.example
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
## 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
|
||||||
@@ -30,7 +31,10 @@
|
|||||||
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
|
- Contracts: `trackingMode` (TIME=hours, PRESENCE=half-days), `weeklyHours`
|
||||||
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
|
- Contract types: FORFAIT, THIRTY_FIVE_HOURS, THIRTY_NINE_HOURS, INTERIM, CUSTOM
|
||||||
- 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.
|
||||||
- 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)
|
||||||
@@ -57,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.
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.89'
|
app.version: '0.1.96'
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ RUN apt-get update && apt-get install -y \
|
|||||||
|
|
||||||
# PHP production config
|
# PHP production config
|
||||||
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
|
||||||
|
COPY docker/php/config/php.ini "$PHP_INI_DIR/conf.d/99-app.ini"
|
||||||
|
|
||||||
# PHP-FPM: forward worker output to stderr for docker logs
|
# PHP-FPM: forward worker output to stderr for docker logs
|
||||||
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
RUN echo "catch_workers_output = yes" >> /usr/local/etc/php-fpm.d/www.conf \
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ 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
|
||||||
|
|
||||||
@@ -71,6 +74,10 @@ 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
|
||||||
|
|
||||||
@@ -130,6 +137,7 @@ Documents complementaires:
|
|||||||
- pas de bonus 25%
|
- pas de bonus 25%
|
||||||
- pas de bonus 50%
|
- pas de bonus 50%
|
||||||
- pas de total récup
|
- pas de total récup
|
||||||
|
- agence d'intérim optionnelle (table `interim_agencies`): affichée sur la fiche employé et le détail contrat sous la forme "Intérim (NomAgence)"
|
||||||
|
|
||||||
## 6bis) Heures Conducteurs
|
## 6bis) Heures Conducteurs
|
||||||
|
|
||||||
@@ -327,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)
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
[Date]
|
[Date]
|
||||||
; Defines the default timezone used by the date functions
|
; Defines the default timezone used by the date functions
|
||||||
; http://php.net/date.timezone
|
; http://php.net/date.timezone
|
||||||
date.timezone = Europe/Paris
|
date.timezone = Europe/Paris
|
||||||
|
|
||||||
|
[PHP]
|
||||||
|
memory_limit = 256M
|
||||||
|
|||||||
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@malio:registry=https://gitea.malio.fr/api/packages/MALIO-DEV/npm/
|
||||||
@@ -1,44 +1,26 @@
|
|||||||
<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">
|
||||||
<div>
|
<MalioSelect
|
||||||
<label class="text-md font-semibold text-neutral-700" for="employee">
|
:model-value="absenceForm.employeeId === '' ? null : absenceForm.employeeId"
|
||||||
Employé <span class="text-red-600">*</span>
|
:options="employeeOptions"
|
||||||
</label>
|
label="Employé *"
|
||||||
<select
|
empty-option-label="Choisir un employé"
|
||||||
id="employee"
|
min-width=""
|
||||||
v-model="absenceForm.employeeId"
|
:disabled="props.lockEmployee"
|
||||||
:class="employeeFieldClass"
|
:error="showEmployeeError ? `L'employé est obligatoire.` : ''"
|
||||||
:disabled="props.lockEmployee"
|
@update:model-value="onEmployeeChange"
|
||||||
>
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<MalioSelect
|
||||||
<label class="text-md font-semibold text-neutral-700" for="type">
|
:model-value="absenceForm.typeId === '' ? null : absenceForm.typeId"
|
||||||
Type d'absence <span class="text-red-600">*</span>
|
:options="typeOptions"
|
||||||
</label>
|
label="Type d'absence *"
|
||||||
<select
|
empty-option-label="Choisir un type"
|
||||||
id="type"
|
min-width=""
|
||||||
v-model="absenceForm.typeId"
|
:error="showTypeError ? `Le type d'absence est obligatoire.` : ''"
|
||||||
:class="typeFieldClass"
|
@update:model-value="onTypeChange"
|
||||||
>
|
/>
|
||||||
<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>
|
||||||
@@ -48,17 +30,15 @@
|
|||||||
id="start-date"
|
id="start-date"
|
||||||
v-model="absenceForm.startDate"
|
v-model="absenceForm.startDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
:class="[dateInputBaseClass, absenceForm.startDate ? 'border-black' : 'border-m-muted']"
|
||||||
:disabled="props.lockDates"
|
:disabled="props.lockDates"
|
||||||
/>
|
/>
|
||||||
<select
|
<MalioSelect
|
||||||
v-model="absenceForm.startHalf"
|
:model-value="absenceForm.startHalf"
|
||||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
:options="halfDayOptions"
|
||||||
>
|
min-width=""
|
||||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
@update:model-value="(v) => { if (v !== null) absenceForm.startHalf = v as HalfDay }"
|
||||||
{{ half.label }}
|
/>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -68,17 +48,15 @@
|
|||||||
id="end-date"
|
id="end-date"
|
||||||
v-model="absenceForm.endDate"
|
v-model="absenceForm.endDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="w-full rounded-md border border-neutral-300 px-3 py-2 text-md text-neutral-900"
|
:class="[dateInputBaseClass, absenceForm.endDate ? 'border-black' : 'border-m-muted']"
|
||||||
:disabled="props.lockDates"
|
:disabled="props.lockDates"
|
||||||
/>
|
/>
|
||||||
<select
|
<MalioSelect
|
||||||
v-model="absenceForm.endHalf"
|
:model-value="absenceForm.endHalf"
|
||||||
class="w-full rounded-md border border-neutral-300 bg-white px-3 py-2 text-md text-neutral-900"
|
:options="halfDayOptions"
|
||||||
>
|
min-width=""
|
||||||
<option v-for="half in HALF_DAYS" :key="half.value" :value="half.value">
|
@update:model-value="(v) => { if (v !== null) absenceForm.endHalf = v as HalfDay }"
|
||||||
{{ half.label }}
|
/>
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,13 +88,12 @@
|
|||||||
</button>
|
</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="props.isSubmitting || !isFormValid"
|
||||||
+ Ajouter
|
/>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
@@ -189,20 +166,23 @@ const submitButtonClass = computed(() => {
|
|||||||
return ''
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const baseSelectClass =
|
const employeeOptions = computed(() =>
|
||||||
'mt-2 w-full rounded-md border bg-white px-3 py-2 text-md text-neutral-900'
|
props.employees.map((e) => ({ label: `${e.firstName} ${e.lastName}`, value: e.id }))
|
||||||
const employeeFieldClass = computed(() => {
|
)
|
||||||
if (showEmployeeError.value) {
|
const typeOptions = computed(() =>
|
||||||
return `${baseSelectClass} border-red-500`
|
props.absenceTypes.map((t) => ({ label: `${t.label} (${t.code})`, value: t.id }))
|
||||||
}
|
)
|
||||||
return `${baseSelectClass} border-neutral-300`
|
const halfDayOptions = HALF_DAYS.map((h) => ({ label: h.label, value: h.value }))
|
||||||
})
|
|
||||||
const typeFieldClass = computed(() => {
|
const dateInputBaseClass =
|
||||||
if (showTypeError.value) {
|
'h-10 w-full rounded-md border px-3 text-md text-black outline-none focus:border-2 focus:border-m-primary'
|
||||||
return `${baseSelectClass} border-red-500`
|
|
||||||
}
|
const onEmployeeChange = (value: string | number | null) => {
|
||||||
return `${baseSelectClass} border-neutral-300`
|
absenceForm.value.employeeId = value === null ? '' : Number(value)
|
||||||
})
|
}
|
||||||
|
const onTypeChange = (value: string | number | null) => {
|
||||||
|
absenceForm.value.typeId = value === null ? '' : Number(value)
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.modelValue,
|
() => props.modelValue,
|
||||||
|
|||||||
@@ -9,8 +9,15 @@
|
|||||||
<h2 class="text-[32px] font-semibold text-primary-500">
|
<h2 class="text-[32px] font-semibold text-primary-500">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h2>
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1 text-primary-500 hover:text-secondary-500"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="24"/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4">
|
<div class="min-h-0 flex-1 overflow-y-auto px-[20px] pb-4 pt-1">
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 p-5 text-white">
|
<header ref="headerRef" class="border-b border-neutral-200 bg-primary-500 px-4 py-3 text-white lg:p-5">
|
||||||
<div class="flex h-full items-center justify-end">
|
<div class="flex h-full items-center justify-between lg:justify-end">
|
||||||
<div class="flex gap-6 text-xl text-white">
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md p-1 text-white hover:text-neutral-200 lg:hidden"
|
||||||
|
@click="$emit('toggleSidebar')"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:menu" size="28"/>
|
||||||
|
</button>
|
||||||
|
<div class="flex gap-4 text-xl text-white lg:gap-6">
|
||||||
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
<div v-if="isAdmin" ref="bellRoot" class="relative">
|
||||||
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
|
<button type="button" class="relative self-center cursor-pointer flex items-center" @click="toggleNotifications">
|
||||||
<Icon name="mdi:bell-plus" size="36"/>
|
<Icon name="mdi:bell-plus" size="36"/>
|
||||||
@@ -15,8 +22,8 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isNotificationsOpen"
|
v-if="isNotificationsOpen"
|
||||||
class="fixed right-[20px] z-30 w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg"
|
class="fixed right-2 z-30 w-[calc(100vw-1rem)] max-w-[400px] rounded-md border border-neutral-200 bg-white text-neutral-800 shadow-lg lg:right-[20px]"
|
||||||
:style="{ top: `${navbarBottom + 20}px` }"
|
:style="{ top: `${navbarBottom + 10}px` }"
|
||||||
>
|
>
|
||||||
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
<div class="px-3 pt-3 pb-6 text-xl font-semibold">
|
||||||
Notifications
|
Notifications
|
||||||
@@ -66,7 +73,7 @@
|
|||||||
<div ref="userMenuRoot" class="relative flex gap-4">
|
<div ref="userMenuRoot" class="relative flex gap-4">
|
||||||
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
|
<button type="button" class="flex items-center gap-4 cursor-pointer" @click="toggleUserMenu">
|
||||||
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
<Icon name="mdi:account-circle-outline" class="self-center" size="36"/>
|
||||||
<p class="self-center">{{ user?.username }}</p>
|
<p class="hidden self-center sm:block">{{ user?.username }}</p>
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-if="isUserMenuOpen"
|
v-if="isUserMenuOpen"
|
||||||
@@ -103,6 +110,10 @@ defineProps<{
|
|||||||
user?: User
|
user?: User
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(event: 'toggleSidebar'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const formatTimeAgo = (dateString: string): string => {
|
const formatTimeAgo = (dateString: string): string => {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|||||||
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal file
108
frontend/components/BulkYearlyHoursDrawer.vue
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<template>
|
||||||
|
<AppDrawer v-model="drawerOpen" title="Export heures (tous les employés)">
|
||||||
|
<form class="space-y-4" @submit.prevent="handleSubmit">
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-year">
|
||||||
|
Année <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bulk-yearly-hours-year"
|
||||||
|
v-model="selectedYear"
|
||||||
|
:class="selectFieldClass"
|
||||||
|
>
|
||||||
|
<option v-for="y in years" :key="y" :value="y">{{ y }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="bulk-yearly-hours-month">
|
||||||
|
Mois <span class="text-red-600">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="bulk-yearly-hours-month"
|
||||||
|
v-model="selectedMonth"
|
||||||
|
:class="selectFieldClass"
|
||||||
|
>
|
||||||
|
<option value="" disabled>Sélectionner un mois</option>
|
||||||
|
<option v-for="m in months" :key="m.value" :value="m.value">{{ m.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center pt-2">
|
||||||
|
<button
|
||||||
|
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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
:disabled="isLoading || selectedMonth === ''"
|
||||||
|
>
|
||||||
|
<template v-if="isLoading">
|
||||||
|
Génération en cours...
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Imprimer
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</AppDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import AppDrawer from '~/components/AppDrawer.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
isLoading?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'update:modelValue', value: boolean): void
|
||||||
|
(event: 'submit', payload: { year: number; month: number | null }): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const drawerOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentYear = new Date().getFullYear()
|
||||||
|
const years = Array.from({ length: 6 }, (_, i) => currentYear - i)
|
||||||
|
const months = [
|
||||||
|
{ value: 1, label: 'Janvier' },
|
||||||
|
{ value: 2, label: 'Février' },
|
||||||
|
{ value: 3, label: 'Mars' },
|
||||||
|
{ value: 4, label: 'Avril' },
|
||||||
|
{ value: 5, label: 'Mai' },
|
||||||
|
{ value: 6, label: 'Juin' },
|
||||||
|
{ value: 7, label: 'Juillet' },
|
||||||
|
{ value: 8, label: 'Août' },
|
||||||
|
{ value: 9, label: 'Septembre' },
|
||||||
|
{ value: 10, label: 'Octobre' },
|
||||||
|
{ value: 11, label: 'Novembre' },
|
||||||
|
{ value: 12, label: 'Décembre' }
|
||||||
|
]
|
||||||
|
const selectedYear = ref(currentYear)
|
||||||
|
const currentMonth = new Date().getMonth() + 1
|
||||||
|
const selectedMonth = ref<number | ''>(currentMonth)
|
||||||
|
|
||||||
|
const baseInputClass = 'mt-2 w-full rounded-md border px-3 py-2 text-md text-neutral-900'
|
||||||
|
const selectFieldClass = computed(() => `${baseInputClass} border-neutral-300`)
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selectedMonth.value === '') return
|
||||||
|
emit('submit', {
|
||||||
|
year: selectedYear.value,
|
||||||
|
month: selectedMonth.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(isOpen) => {
|
||||||
|
if (!isOpen) {
|
||||||
|
selectedYear.value = currentYear
|
||||||
|
selectedMonth.value = currentMonth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<span class="pl-2">Absence</span>
|
<span class="pl-2">Statut</span>
|
||||||
<span class="pl-4">Heure de jour</span>
|
<span class="pl-4">Heure de jour</span>
|
||||||
<span class="pl-2">Heure de nuit</span>
|
<span class="pl-2">Heure de nuit</span>
|
||||||
<span class="pl-2">Heure atelier</span>
|
<span class="pl-2">Heure atelier</span>
|
||||||
@@ -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="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
@@ -205,6 +205,7 @@ 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
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
:key="`${item.startDate}-${item.endDate ?? 'open'}-${item.contractId ?? item.contractName}`"
|
||||||
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
class="grid grid-cols-4 border-b border-primary-500 px-6 py-3 text-md font-bold text-primary-500 last:border-b-0 hover:bg-tertiary-500"
|
||||||
>
|
>
|
||||||
<p>{{ contractNatureLabel(item.contractNature) }}</p>
|
<p>{{ item.interimAgencyName ? `${contractNatureLabel(item.contractNature)} (${item.interimAgencyName})` : contractNatureLabel(item.contractNature) }}</p>
|
||||||
<p>{{ contractHistoryLabel(item) }}</p>
|
<p>{{ contractHistoryLabel(item) }}</p>
|
||||||
<p>{{ formatDate(item.startDate) }}</p>
|
<p>{{ formatDate(item.startDate) }}</p>
|
||||||
<p>{{ formatDate(item.endDate) }}</p>
|
<p>{{ formatDate(item.endDate) }}</p>
|
||||||
@@ -221,6 +221,22 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="createContractForm.contractNature === 'INTERIM'">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="create-interim-agency">
|
||||||
|
Agence d'intérim
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="create-interim-agency"
|
||||||
|
v-model="createContractForm.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>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
<label class="text-md font-semibold text-neutral-700" for="create-contract-id">
|
||||||
Temps de travail <span class="text-red-600">*</span>
|
Temps de travail <span class="text-red-600">*</span>
|
||||||
@@ -282,6 +298,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Contract } from '~/services/dto/contract'
|
import type { Contract } from '~/services/dto/contract'
|
||||||
import type { ContractHistoryItem } from '~/services/dto/employee'
|
import type { ContractHistoryItem } from '~/services/dto/employee'
|
||||||
|
import type { InterimAgency } from '~/services/interim-agencies'
|
||||||
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
import WorkDaysHoursInput from '~/components/employees/WorkDaysHoursInput.vue'
|
||||||
|
|
||||||
type SuspensionForm = {
|
type SuspensionForm = {
|
||||||
@@ -310,6 +327,7 @@ type CreateContractForm = {
|
|||||||
endDate: string
|
endDate: string
|
||||||
isDriver: boolean
|
isDriver: boolean
|
||||||
workDaysHours: Record<number, number> | null
|
workDaysHours: Record<number, number> | null
|
||||||
|
interimAgencyId: number | ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -351,6 +369,7 @@ const props = defineProps<{
|
|||||||
onSubmitSuspension: (index: number) => void
|
onSubmitSuspension: (index: number) => void
|
||||||
onAddSuspensionForm: () => void
|
onAddSuspensionForm: () => void
|
||||||
currentContractPeriodId?: number | null
|
currentContractPeriodId?: number | null
|
||||||
|
interimAgencies: InterimAgency[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const drawerTab = ref<'close' | 'suspend'>('close')
|
const drawerTab = ref<'close' | 'suspend'>('close')
|
||||||
|
|||||||
@@ -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' }}</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.paidBonus25Minutes) : '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 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 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">{{ 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.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.paidBonus50Minutes) : '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 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 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">{{ formatMinutes(paidTotal) }}</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>
|
||||||
</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% (heures)</label>
|
<label class="block text-sm font-medium text-neutral-700">Base 25% (centièmes)</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="paymentForm.base25Hours"
|
v-model.number="paymentForm.base25Hours"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.01"
|
||||||
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% (heures)</label>
|
<label class="block text-sm font-medium text-neutral-700">Heures 25% (centièmes)</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="paymentForm.bonus25Hours"
|
v-model.number="paymentForm.bonus25Hours"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.01"
|
||||||
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% (heures)</label>
|
<label class="block text-sm font-medium text-neutral-700">Base 50% (centièmes)</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="paymentForm.base50Hours"
|
v-model.number="paymentForm.base50Hours"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.01"
|
||||||
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% (heures)</label>
|
<label class="block text-sm font-medium text-neutral-700">Heures 50% (centièmes)</label>
|
||||||
<input
|
<input
|
||||||
v-model.number="paymentForm.bonus50Hours"
|
v-model.number="paymentForm.bonus50Hours"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.01"
|
||||||
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 = existing.paidBase25Minutes / 60
|
paymentForm.base25Hours = Math.round(existing.paidBase25Minutes / 60 * 100) / 100
|
||||||
paymentForm.bonus25Hours = existing.paidBonus25Minutes / 60
|
paymentForm.bonus25Hours = Math.round(existing.paidBonus25Minutes / 60 * 100) / 100
|
||||||
paymentForm.base50Hours = existing.paidBase50Minutes / 60
|
paymentForm.base50Hours = Math.round(existing.paidBase50Minutes / 60 * 100) / 100
|
||||||
paymentForm.bonus50Hours = existing.paidBonus50Minutes / 60
|
paymentForm.bonus50Hours = Math.round(existing.paidBonus50Minutes / 60 * 100) / 100
|
||||||
} else {
|
} else {
|
||||||
paymentForm.base25Hours = 0
|
paymentForm.base25Hours = 0
|
||||||
paymentForm.bonus25Hours = 0
|
paymentForm.bonus25Hours = 0
|
||||||
@@ -516,6 +516,14 @@ 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)
|
||||||
|
|||||||
@@ -1,12 +1,180 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||||
<div class="overflow-y-auto min-h-0">
|
<!-- Mobile card layout -->
|
||||||
|
<div class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||||
|
<div
|
||||||
|
v-for="employee in employees"
|
||||||
|
:key="'m-' + employee.id"
|
||||||
|
class="rounded-md border border-primary-500 bg-white p-4"
|
||||||
|
>
|
||||||
|
<!-- Employee name + site -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-md font-bold text-primary-500 truncate">
|
||||||
|
{{ employee.firstName }} {{ employee.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600 text-sm">({{ contractLabel(employee) }})</span>
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Absence / Holiday / Formation pills -->
|
||||||
|
<div class="mb-3 flex flex-col gap-1">
|
||||||
|
<p
|
||||||
|
v-if="getRowAbsenceLabel(employee.id)"
|
||||||
|
class="rounded-md px-2 py-1 text-xs text-white truncate"
|
||||||
|
:style="getRowAbsenceStyle(employee.id)"
|
||||||
|
>
|
||||||
|
{{ getRowAbsenceLabel(employee.id) }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-else
|
||||||
|
class="text-xs text-neutral-400"
|
||||||
|
>
|
||||||
|
Aucune absence
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="isHoliday"
|
||||||
|
class="rounded-md px-2 py-1 text-xs text-sky-900 inline-flex items-center gap-1"
|
||||||
|
style="background-color: #b3e5fc"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-star" size="14" class="shrink-0"/>
|
||||||
|
<span class="truncate">{{ holidayLabel || 'Férié' }}</span>
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="hasRowFormation(employee.id)"
|
||||||
|
class="rounded-md px-2 py-1 text-xs text-white bg-indigo-500 inline-flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:school-outline" size="14" class="shrink-0"/>
|
||||||
|
<span class="truncate">{{ getRowFormationLabel(employee.id) }}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
v-if="!hasRowFormation(employee.id)"
|
||||||
|
type="button"
|
||||||
|
class="self-start text-xs font-semibold underline"
|
||||||
|
:class="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id) ? 'text-neutral-400 cursor-not-allowed' : 'text-primary-500 cursor-pointer'"
|
||||||
|
:disabled="isRowLocked(employee.id) || !hasContractAtSelectedDate(employee.id)"
|
||||||
|
@click="onAbsenceClick(employee.id)"
|
||||||
|
>
|
||||||
|
Modifier
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time inputs (TIME tracking) -->
|
||||||
|
<div v-if="isTimeTracking(employee)" class="space-y-2">
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Début matin</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].morningFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Fin matin</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].morningTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Début après-midi</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].afternoonFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Fin après-midi</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].afternoonTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Début soir</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].eveningFrom"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-neutral-500">Fin soir</label>
|
||||||
|
<TimeSelect
|
||||||
|
v-model="rows[employee.id].eveningTo"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isEveningLockedByAbsence(employee.id))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-4 pt-1 text-sm font-semibold text-primary-500">
|
||||||
|
<span>Jour : {{ formatMinutes(getRowMetrics(employee.id).dayMinutes) }}</span>
|
||||||
|
<span>Nuit : {{ formatMinutes(getRowMetrics(employee.id).nightMinutes) }}</span>
|
||||||
|
<span>Total : {{ formatMinutes(getRowMetrics(employee.id).totalMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Presence tracking -->
|
||||||
|
<div v-else-if="isPresenceTracking(employee)" class="space-y-2">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].isPresentMorning"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'AM'))"
|
||||||
|
/>
|
||||||
|
Matin
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
v-model="rows[employee.id].isPresentAfternoon"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
:disabled="!hasContractAtSelectedDate(employee.id) || isRowLocked(employee.id) || (!isAdmin && isHalfLockedByAbsence(employee.id, 'PM'))"
|
||||||
|
/>
|
||||||
|
Après-midi
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-semibold text-primary-500">Total : {{ getPresenceDayValue(employee.id) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation status (non-admin) -->
|
||||||
|
<div v-if="!isAdmin" class="mt-3 flex gap-4 text-xs border-t border-neutral-200 pt-2">
|
||||||
|
<span v-if="!isSiteManager" class="flex items-center gap-1">
|
||||||
|
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isSiteValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||||
|
Validation site : <span :class="rows[employee.id]?.isSiteValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isSiteValid ? 'Validé' : 'En attente' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<Icon name="mdi:check-circle-outline" size="14" :class="rows[employee.id]?.isValid ? 'text-green-600' : 'text-neutral-400'"/>
|
||||||
|
Validation RH : <span :class="rows[employee.id]?.isValid ? 'font-semibold text-green-600' : 'text-neutral-500'">{{ rows[employee.id]?.isValid ? 'Validé' : 'En attente' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Validation checkbox (admin) -->
|
||||||
|
<div v-if="isAdmin" class="mt-3 flex items-center gap-2 text-sm">
|
||||||
|
<input
|
||||||
|
:checked="rows[employee.id]?.isValid ?? false"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 cursor-pointer"
|
||||||
|
@change="onToggleValidation(employee.id, ($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
<span class="text-neutral-700 font-semibold">Valider</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop table layout -->
|
||||||
|
<div class="overflow-y-auto min-h-0 hidden lg:block">
|
||||||
<div
|
<div
|
||||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||||
:style="{ gridTemplateColumns: dayGridCols }"
|
:style="{ gridTemplateColumns: dayGridCols }"
|
||||||
>
|
>
|
||||||
<span>Nom</span>
|
<span>Nom</span>
|
||||||
<span class="pl-2">Absence</span>
|
<span class="pl-2">Statut</span>
|
||||||
<span class="pl-4">Début matin</span>
|
<span class="pl-4">Début matin</span>
|
||||||
<span class="pr-2">Fin matin</span>
|
<span class="pr-2">Fin matin</span>
|
||||||
<span class="pl-2">Début après-midi</span>
|
<span class="pl-2">Début après-midi</span>
|
||||||
@@ -44,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="employee.currentContractNature"> — {{ contractNatureLabel(employee.currentContractNature) }}</span>
|
{{ employee.site?.name ?? 'Sans site' }}<span v-if="getRowContractNature(employee.id)"> — {{ contractNatureLabel(getRowContractNature(employee.id) ?? undefined) }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
v-if="isAdmin && (rows[employee.id]?.isSiteValid ?? false)"
|
||||||
@@ -237,6 +405,7 @@ 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
|
||||||
|
|||||||
@@ -1,17 +1,90 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="py-6 flex flex-col gap-3">
|
<div class="py-4 flex flex-col gap-3 lg:py-6">
|
||||||
<div class="flex gap-4">
|
<!-- Desktop: filters row -->
|
||||||
<SiteFilterSelector v-if="sites.length > 0 && isAdmin" v-model="selectedSiteIds" :sites="sites" />
|
<div class="hidden lg:flex lg:items-center lg:gap-4">
|
||||||
<div v-if="isAdmin" class="w-80 max-w-full">
|
<div v-if="sites.length > 0 && isAdmin" class="relative z-50 w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter" />
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:options="siteOptions"
|
||||||
|
groupClass="w-80"
|
||||||
|
label="Sites"
|
||||||
|
display-select-all
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin" class="w-80">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="employeeFilter"
|
||||||
|
label="Recherche d'un employé"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-between items-center gap-4">
|
<!-- Mobile: search + filter button -->
|
||||||
<div class="flex gap-4 flex-wrap">
|
<div v-if="isAdmin" class="flex items-center gap-2 lg:hidden">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="employeeFilter"
|
||||||
|
label="Recherche d'un employé"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-md border border-primary-500 bg-white text-primary-500"
|
||||||
|
@click="filtersDrawerOpen = true"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:filter-variant" size="22"/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile filters drawer -->
|
||||||
|
<AppDrawer v-model="filtersDrawerOpen" title="Filtres">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div v-if="sites.length > 0 && isAdmin">
|
||||||
|
<label class="text-md font-semibold text-neutral-700">Sites</label>
|
||||||
|
<div class="mt-2">
|
||||||
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:options="siteOptions"
|
||||||
|
groupClass="w-80"
|
||||||
|
label="Sites"
|
||||||
|
display-select-all
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin">
|
||||||
|
<label class="text-md font-semibold text-neutral-700">Vue</label>
|
||||||
|
<div class="mt-2 inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-semibold"
|
||||||
|
:class="viewModeButtonClass('day')"
|
||||||
|
@click="viewMode = 'day'; filtersDrawerOpen = false"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-clock" />
|
||||||
|
Jour
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-1 inline-flex items-center justify-center gap-2 border-l border-primary-500 px-4 py-2 text-sm font-semibold"
|
||||||
|
:class="viewModeButtonClass('week')"
|
||||||
|
@click="viewMode = 'week'; filtersDrawerOpen = false"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:calendar-week" />
|
||||||
|
Semaine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AppDrawer>
|
||||||
|
|
||||||
|
<!-- Date navigation -->
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:justify-between lg:items-center lg:gap-4">
|
||||||
|
<div class="flex flex-col gap-3 lg:flex-row lg:flex-wrap lg:gap-4">
|
||||||
<div
|
<div
|
||||||
v-if="viewMode === 'day'"
|
v-if="viewMode === 'day'"
|
||||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -41,7 +114,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="inline-flex h-10 w-[320px] overflow-hidden rounded-md border border-primary-500 bg-white"
|
class="inline-flex h-10 w-full overflow-hidden rounded-md border border-primary-500 bg-white lg:w-[320px]"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -70,7 +143,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PeriodStepperPicker
|
<PeriodStepperPicker
|
||||||
width-class="w-[320px]"
|
width-class="w-full lg:w-[320px]"
|
||||||
:label="formattedSelectedDate"
|
:label="formattedSelectedDate"
|
||||||
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
:picker-type="viewMode === 'week' ? 'week' : 'date'"
|
||||||
:picker-value="pickerValue"
|
:picker-value="pickerValue"
|
||||||
@@ -82,7 +155,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
<!-- Desktop: view mode toggle -->
|
||||||
|
<div v-if="isAdmin" class="hidden lg:inline-flex h-10 overflow-hidden rounded-md border border-primary-500 bg-white">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
class="inline-flex items-center gap-2 px-4 py-2 text-sm font-semibold active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500/40"
|
||||||
@@ -106,7 +180,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
v-if="isAdmin && viewMode === 'week' && absenceTypes.length > 0"
|
||||||
class="flex flex-wrap items-center gap-6"
|
class="hidden lg:flex flex-wrap items-center gap-6"
|
||||||
>
|
>
|
||||||
<p class="font-bold">Légende :</p>
|
<p class="font-bold">Légende :</p>
|
||||||
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
<div v-for="type in absenceTypes" :key="type.id" class="flex items-center gap-2">
|
||||||
@@ -120,9 +194,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 { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
import { weekInputValueToYmd, ymdToWeekInputValue } from '~/utils/date'
|
||||||
|
|
||||||
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
const selectedDate = defineModel<string>('selectedDate', { required: true })
|
||||||
@@ -130,7 +203,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 })
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
sites: Site[]
|
sites: Site[]
|
||||||
absenceTypes: AbsenceType[]
|
absenceTypes: AbsenceType[]
|
||||||
@@ -140,6 +213,8 @@ 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
|
||||||
@@ -150,6 +225,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'shift-date', value: number): void
|
(e: 'shift-date', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const filtersDrawerOpen = ref(false)
|
||||||
|
|
||||||
const pickerValue = computed(() => {
|
const pickerValue = computed(() => {
|
||||||
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
if (viewMode.value === 'week') return ymdToWeekInputValue(selectedDate.value)
|
||||||
return selectedDate.value
|
return selectedDate.value
|
||||||
|
|||||||
@@ -1,7 +1,71 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
<div class="bg-white overflow-hidden flex min-h-0 flex-col">
|
||||||
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
<div v-if="isWeekLoading" class="p-6 text-md text-neutral-600">Chargement de la semaine...</div>
|
||||||
<div v-else class="overflow-y-auto min-h-0">
|
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div v-else class="overflow-y-auto min-h-0 space-y-3 lg:hidden">
|
||||||
|
<div
|
||||||
|
v-for="row in weeklySummary?.rows ?? []"
|
||||||
|
:key="'m-' + row.employeeId"
|
||||||
|
class="rounded-md border border-primary-500 bg-white p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3">
|
||||||
|
<p class="text-md font-bold text-primary-500 truncate">
|
||||||
|
{{ row.firstName }} {{ row.lastName }}
|
||||||
|
<span class="font-normal text-neutral-600 text-sm">({{ row.contractName ?? '-' }})</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-neutral-500 truncate">
|
||||||
|
{{ row.siteName ?? 'Sans site' }}<span v-if="row.contractNature"> — {{ contractNatureLabel(row.contractNature) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Daily breakdown -->
|
||||||
|
<div class="mb-3 space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="(daily, i) in row.daily"
|
||||||
|
:key="daily.date"
|
||||||
|
class="flex items-center justify-between rounded-md px-2 py-1 text-xs"
|
||||||
|
:class="daily.hasAbsence ? 'text-white' : 'text-primary-500'"
|
||||||
|
:style="getDailyCellStyle(daily)"
|
||||||
|
>
|
||||||
|
<span class="font-semibold">{{ weekDayHeaders[i]?.label ?? '' }}</span>
|
||||||
|
<span v-if="row.trackingMode === 'PRESENCE'">{{ daily.present ?? 0 }}</span>
|
||||||
|
<span v-else>J {{ formatMinutes(daily.dayMinutes) }} / N {{ formatMinutes(daily.nightMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly totals -->
|
||||||
|
<div class="border-t border-neutral-200 pt-2 grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">Total sem.</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? (row.weeklyPresenceCount ?? 0) : formatMinutes(row.weeklyTotalMinutes) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">H. supp.</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ row.trackingMode === 'PRESENCE' ? '-' : formatMinutes(row.weeklyOvertimeTotalMinutes ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">+25%</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime25Minutes ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">+50%</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyOvertime50Minutes ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="row.trackingMode !== 'PRESENCE' && !isInterimContract(row.contractType)" class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">Récup.</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ formatMinutes(row.weeklyRecoveryMinutes ?? 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="(row.weeklyNightBasketCount ?? 0) > 0" class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">Panier nuit</span>
|
||||||
|
<span class="font-bold text-primary-500">{{ row.weeklyNightBasketCount }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop table -->
|
||||||
|
<div v-if="!isWeekLoading" class="overflow-y-auto min-h-0 hidden lg:block">
|
||||||
<div
|
<div
|
||||||
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
class="grid w-full min-w-0 gap-1 border border-black bg-tertiary-500 px-4 py-3 text-sm font-semibold text-black rounded-t-md sticky top-0 z-10"
|
||||||
:style="{ gridTemplateColumns: weekGridCols }"
|
:style="{ gridTemplateColumns: weekGridCols }"
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
class="inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
class="hidden lg:inline-flex h-8 w-8 items-center justify-center rounded text-neutral-600 hover:bg-tertiary-500 disabled:cursor-not-allowed disabled:bg-neutral-200 disabled:text-neutral-500"
|
||||||
:disabled="props.disabled"
|
:disabled="props.disabled"
|
||||||
@mousedown.prevent
|
@mousedown.prevent
|
||||||
@click="toggleOpen"
|
@click="toggleOpen"
|
||||||
@@ -149,8 +149,11 @@ const toggleOpen = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = () => window.innerWidth < 1024
|
||||||
|
|
||||||
const openMenu = () => {
|
const openMenu = () => {
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
|
if (isMobile()) return
|
||||||
if (!isOpen.value) {
|
if (!isOpen.value) {
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
nextTick(updateMenuPosition)
|
nextTick(updateMenuPosition)
|
||||||
@@ -165,8 +168,28 @@ const closeMenu = () => {
|
|||||||
isOpen.value = false
|
isOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const snapToNearest15 = (time: string): string => {
|
||||||
|
const [h, m] = time.split(':').map(Number)
|
||||||
|
const snapped = Math.round(m / 15) * 15
|
||||||
|
if (snapped === 60) {
|
||||||
|
const newH = h + 1
|
||||||
|
if (newH > 23) return '23:45'
|
||||||
|
return `${String(newH).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
return `${String(h).padStart(2, '0')}:${String(snapped).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
const commitInput = () => {
|
const commitInput = () => {
|
||||||
const normalized = normalizeTypedTime(inputValue.value)
|
let value = inputValue.value
|
||||||
|
if (isMobile()) {
|
||||||
|
value = clampTime(value)
|
||||||
|
const normalized = normalizeTypedTime(value)
|
||||||
|
if (normalized !== null && normalized !== '') {
|
||||||
|
value = snapToNearest15(normalized)
|
||||||
|
}
|
||||||
|
inputValue.value = value
|
||||||
|
}
|
||||||
|
const normalized = normalizeTypedTime(value)
|
||||||
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
if (normalized === null || (normalized !== '' && !timeSlots.value.includes(normalized))) {
|
||||||
emit('update:modelValue', '')
|
emit('update:modelValue', '')
|
||||||
inputValue.value = ''
|
inputValue.value = ''
|
||||||
@@ -184,13 +207,26 @@ const onInput = (event: Event) => {
|
|||||||
if (masked !== inputValue.value) {
|
if (masked !== inputValue.value) {
|
||||||
inputValue.value = masked
|
inputValue.value = masked
|
||||||
}
|
}
|
||||||
openMenu()
|
if (!isMobile()) {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampTime = (value: string): string => {
|
||||||
|
const normalized = normalizeTypedTime(value)
|
||||||
|
if (normalized === null || normalized === '') return value
|
||||||
|
const [h, m] = normalized.split(':').map(Number)
|
||||||
|
if (h > 23 || (h === 23 && m > 45)) return '23:45'
|
||||||
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
const onInputBlur = () => {
|
const onInputBlur = () => {
|
||||||
// Laisse le temps au click menu de passer avant fermeture.
|
// Laisse le temps au click menu de passer avant fermeture.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (menu.value?.contains(document.activeElement)) return
|
if (menu.value?.contains(document.activeElement)) return
|
||||||
|
if (isMobile()) {
|
||||||
|
inputValue.value = clampTime(inputValue.value)
|
||||||
|
}
|
||||||
commitInput()
|
commitInput()
|
||||||
}, 50)
|
}, 50)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -417,6 +417,10 @@ 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
|
||||||
@@ -982,6 +986,7 @@ export const useDriverHoursPage = () => {
|
|||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
submitAbsence,
|
submitAbsence,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ContractHistoryItem, Employee } from '~/services/dto/employee'
|
|||||||
import { listContracts } from '~/services/contracts'
|
import { listContracts } from '~/services/contracts'
|
||||||
import { updateEmployee } from '~/services/employees'
|
import { updateEmployee } from '~/services/employees'
|
||||||
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
import { createSuspension, updateSuspension } from '~/services/contractSuspensions'
|
||||||
|
import { listInterimAgencies, type InterimAgency } from '~/services/interim-agencies'
|
||||||
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
import { formatNullableYmdToFr, getTodayYmd, shiftYmd } from '~/utils/date'
|
||||||
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
import { contractNatureLabel, formatWorkDaysHoursSummary, isContractNature, requiresContractEndDate, requiresWorkDaysHours, showsContractEndDate } from '~/utils/contract'
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ type SuspensionForm = {
|
|||||||
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmployee: () => Promise<void>) => {
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const contracts = ref<Contract[]>([])
|
const contracts = ref<Contract[]>([])
|
||||||
|
const interimAgencies = ref<InterimAgency[]>([])
|
||||||
const isContractDrawerOpen = ref(false)
|
const isContractDrawerOpen = ref(false)
|
||||||
const isContractSubmitting = ref(false)
|
const isContractSubmitting = ref(false)
|
||||||
const isCreateContractDrawerOpen = ref(false)
|
const isCreateContractDrawerOpen = ref(false)
|
||||||
@@ -46,7 +48,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
startDate: '',
|
startDate: '',
|
||||||
endDate: '',
|
endDate: '',
|
||||||
isDriver: false,
|
isDriver: false,
|
||||||
workDaysHours: null as Record<number, number> | null
|
workDaysHours: null as Record<number, number> | null,
|
||||||
|
interimAgencyId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const createValidationTouched = reactive({
|
const createValidationTouched = reactive({
|
||||||
@@ -207,6 +210,7 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
createContractForm.endDate = ''
|
createContractForm.endDate = ''
|
||||||
createContractForm.isDriver = false
|
createContractForm.isDriver = false
|
||||||
createContractForm.workDaysHours = null
|
createContractForm.workDaysHours = null
|
||||||
|
createContractForm.interimAgencyId = ''
|
||||||
createContractForm.startDate = editableContractPeriod.value?.endDate
|
createContractForm.startDate = editableContractPeriod.value?.endDate
|
||||||
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
|
? (shiftYmd(editableContractPeriod.value.endDate, 1) ?? editableContractPeriod.value.endDate)
|
||||||
: getTodayYmd()
|
: getTodayYmd()
|
||||||
@@ -283,7 +287,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
contractStartDate: createContractForm.startDate,
|
contractStartDate: createContractForm.startDate,
|
||||||
contractEndDate: createContractForm.endDate || null,
|
contractEndDate: createContractForm.endDate || null,
|
||||||
isDriverInput: createContractForm.isDriver,
|
isDriverInput: createContractForm.isDriver,
|
||||||
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null
|
workDaysHoursInput: requiresCreateWorkDaysHours.value ? createContractForm.workDaysHours : null,
|
||||||
|
interimAgencyId: createContractForm.contractNature === 'INTERIM' && createContractForm.interimAgencyId !== '' ? Number(createContractForm.interimAgencyId) : null
|
||||||
})
|
})
|
||||||
isCreateContractDrawerOpen.value = false
|
isCreateContractDrawerOpen.value = false
|
||||||
await reloadEmployee()
|
await reloadEmployee()
|
||||||
@@ -335,6 +340,16 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
contracts.value = await listContracts()
|
contracts.value = await listContracts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadInterimAgencies = async () => {
|
||||||
|
interimAgencies.value = await listInterimAgencies()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => createContractForm.contractNature, (nature) => {
|
||||||
|
if (nature !== 'INTERIM') {
|
||||||
|
createContractForm.interimAgencyId = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(showsCreateContractEndDate, (shows) => {
|
watch(showsCreateContractEndDate, (shows) => {
|
||||||
if (!shows) {
|
if (!shows) {
|
||||||
createContractForm.endDate = ''
|
createContractForm.endDate = ''
|
||||||
@@ -386,6 +401,8 @@ export const useEmployeeContract = (employee: Ref<Employee | null>, reloadEmploy
|
|||||||
submitSuspension,
|
submitSuspension,
|
||||||
addSuspensionForm,
|
addSuspensionForm,
|
||||||
currentActiveContractPeriodId,
|
currentActiveContractPeriodId,
|
||||||
loadContracts
|
interimAgencies,
|
||||||
|
loadContracts,
|
||||||
|
loadInterimAgencies
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -86,7 +97,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await contract.loadContracts()
|
await Promise.all([contract.loadContracts(), contract.loadInterimAgencies()])
|
||||||
await loadEmployee()
|
await loadEmployee()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -97,6 +108,7 @@ export const useEmployeeDetailPage = () => {
|
|||||||
showLeaveTab,
|
showLeaveTab,
|
||||||
showRttTab,
|
showRttTab,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
|
forfaitRemainingDaysLabel,
|
||||||
...contract,
|
...contract,
|
||||||
...leave,
|
...leave,
|
||||||
...rtt,
|
...rtt,
|
||||||
|
|||||||
@@ -494,6 +494,10 @@ 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 ''
|
||||||
@@ -1174,6 +1178,7 @@ export const useHoursPage = () => {
|
|||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ 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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -207,10 +208,10 @@ export const documentationSections: DocSection[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'gestion-types-absence',
|
id: 'gestion-types-absence',
|
||||||
title: 'Gestion des types d\'absence',
|
title: 'Gestion des types de statut',
|
||||||
requiredLevel: 'admin',
|
requiredLevel: 'admin',
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'Les types d\'absence définissent les catégories disponibles lors de la pose d\'une absence.' },
|
{ type: 'paragraph', content: 'Les types de statut définissent les catégories disponibles lors de la pose d\'une absence.' },
|
||||||
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
|
{ type: 'list', content: 'Code : identifiant court (max 10 caractères), ex: C, M, AT\nLibellé : nom affiché, ex: Congé, Maladie, Accident du travail\nCouleur : code couleur pour le calendrier et la vue jour\nOption "Compté comme travaillé" : si activé, l\'absence crédite des heures en mode TIME' },
|
||||||
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
|
{ type: 'note', content: 'L\'option "Compté comme travaillé" impacte le calcul des heures supplémentaires. En mode TIME, les minutes sont créditées selon le contrat. En mode PRESENCE, aucun crédit n\'est appliqué.' },
|
||||||
],
|
],
|
||||||
@@ -258,7 +259,7 @@ export const documentationSections: DocSection[] = [
|
|||||||
requiredLevel: 'admin',
|
requiredLevel: 'admin',
|
||||||
blocks: [
|
blocks: [
|
||||||
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
|
{ type: 'paragraph', content: 'La création d\'un employé se fait via le drawer d\'ajout.' },
|
||||||
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
|
{ type: 'list', content: 'Champs : prénom, nom, site\nNature du contrat : CDI, CDD ou INTERIM\nAgence d\'intérim (visible uniquement pour INTERIM, optionnel)\nType de contrat / temps de travail (Forfait, 35h, 39h, etc.)\nDate de début (obligatoire)\nDate de fin (obligatoire pour CDD et INTERIM)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -388,6 +389,7 @@ 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.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,11 +1,40 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-screen overflow-hidden">
|
<div class="h-screen overflow-hidden">
|
||||||
<div class="flex h-full">
|
<div class="flex h-full">
|
||||||
<aside class="flex h-full w-64 flex-shrink-0 flex-col border-r border-neutral-200 bg-tertiary-500">
|
<!-- Mobile overlay -->
|
||||||
<div class="h-[75px]">
|
<Transition
|
||||||
|
enter-active-class="transition-opacity duration-300"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition-opacity duration-300"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="sidebarOpen"
|
||||||
|
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside
|
||||||
|
:class="[
|
||||||
|
'fixed inset-y-0 left-0 z-50 flex w-64 flex-col border-r border-neutral-200 bg-tertiary-500 transition-transform duration-300 lg:static lg:translate-x-0 lg:flex-shrink-0',
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex h-[75px] items-center justify-between">
|
||||||
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
<img src="/malio.png" alt="Logo" class="w-auto"/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mr-3 rounded-md p-1 text-primary-500 hover:text-secondary-500 lg:hidden"
|
||||||
|
@click="sidebarOpen = false"
|
||||||
|
>
|
||||||
|
<Icon name="mdi:close" size="24"/>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav class="flex-1 px-4 pb-6">
|
<nav class="flex-1 overflow-y-auto px-4 pb-6">
|
||||||
<template v-if="isAdmin">
|
<template v-if="isAdmin">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/calendar"
|
to="/calendar"
|
||||||
@@ -13,6 +42,7 @@
|
|||||||
:class="route.path.startsWith('/calendar')
|
:class="route.path.startsWith('/calendar')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:calendar-blank" size="24"/>
|
<Icon name="mdi:calendar-blank" size="24"/>
|
||||||
<p>Calendrier</p>
|
<p>Calendrier</p>
|
||||||
@@ -26,6 +56,7 @@
|
|||||||
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
route.path.startsWith('/hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||||
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
|
!isAdmin ? 'border-t border-secondary-500 pt-3' : ''
|
||||||
]"
|
]"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
<Icon name="mdi:clock-time-four-outline" size="24"/>
|
||||||
<p>Heures</p>
|
<p>Heures</p>
|
||||||
@@ -38,6 +69,7 @@
|
|||||||
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
route.path.startsWith('/driver-hours') ? 'bg-tertiary-500 text-primary-500 font-bold' : '',
|
||||||
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
|
!isAdmin && isDriver ? 'border-t border-secondary-500 pt-3' : ''
|
||||||
]"
|
]"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:truck-outline" size="24"/>
|
<Icon name="mdi:truck-outline" size="24"/>
|
||||||
<p>Heures Conducteurs</p>
|
<p>Heures Conducteurs</p>
|
||||||
@@ -49,6 +81,7 @@
|
|||||||
:class="route.path.startsWith('/employees')
|
:class="route.path.startsWith('/employees')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:account-group-outline" size="24"/>
|
<Icon name="mdi:account-group-outline" size="24"/>
|
||||||
<p>Employés</p>
|
<p>Employés</p>
|
||||||
@@ -60,6 +93,7 @@
|
|||||||
:class="route.path.startsWith('/leave-recap')
|
:class="route.path.startsWith('/leave-recap')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:beach" size="24"/>
|
<Icon name="mdi:beach" size="24"/>
|
||||||
<p>Récap. congés</p>
|
<p>Récap. congés</p>
|
||||||
@@ -70,6 +104,7 @@
|
|||||||
:class="route.path.startsWith('/sites')
|
:class="route.path.startsWith('/sites')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:business" size="24"/>
|
<Icon name="mdi:business" size="24"/>
|
||||||
<p>Sites</p>
|
<p>Sites</p>
|
||||||
@@ -80,9 +115,10 @@
|
|||||||
:class="route.path.startsWith('/absence-types')
|
:class="route.path.startsWith('/absence-types')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:umbrella-beach-outline" size="24"/>
|
<Icon name="mdi:umbrella-beach-outline" size="24"/>
|
||||||
<p>Types d'absence</p>
|
<p>Types de statut</p>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/users"
|
to="/users"
|
||||||
@@ -90,6 +126,7 @@
|
|||||||
:class="route.path.startsWith('/users')
|
:class="route.path.startsWith('/users')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:account-outline" size="24"/>
|
<Icon name="mdi:account-outline" size="24"/>
|
||||||
<p>Utilisateurs</p>
|
<p>Utilisateurs</p>
|
||||||
@@ -100,6 +137,7 @@
|
|||||||
to="/leave-recap"
|
to="/leave-recap"
|
||||||
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
|
class="flex items-center gap-2 py-3 text-md text-black hover:bg-tertiary-500 hover:text-primary-500 pt-3"
|
||||||
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
|
:class="route.path.startsWith('/leave-recap') ? 'bg-tertiary-500 text-primary-500 font-bold' : ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:beach" size="24"/>
|
<Icon name="mdi:beach" size="24"/>
|
||||||
<p>Récap. congés</p>
|
<p>Récap. congés</p>
|
||||||
@@ -111,6 +149,7 @@
|
|||||||
:class="route.path.startsWith('/audit-logs')
|
:class="route.path.startsWith('/audit-logs')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
|
<Icon name="mdi:clipboard-text-clock-outline" size="24"/>
|
||||||
<p>Journal</p>
|
<p>Journal</p>
|
||||||
@@ -121,6 +160,7 @@
|
|||||||
:class="route.path.startsWith('/documentation')
|
:class="route.path.startsWith('/documentation')
|
||||||
? 'bg-tertiary-500 text-primary-500 font-bold'
|
? 'bg-tertiary-500 text-primary-500 font-bold'
|
||||||
: ''"
|
: ''"
|
||||||
|
@click="closeSidebarOnMobile"
|
||||||
>
|
>
|
||||||
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
|
<Icon name="mdi:book-open-page-variant-outline" size="24"/>
|
||||||
<p>Documentation</p>
|
<p>Documentation</p>
|
||||||
@@ -132,9 +172,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="h-full flex-1 overflow-hidden flex flex-col">
|
<div class="h-full flex-1 overflow-hidden flex flex-col min-w-0">
|
||||||
<AppTopNav :user="auth.user" />
|
<AppTopNav :user="auth.user" @toggle-sidebar="sidebarOpen = !sidebarOpen" />
|
||||||
<main class="flex-1 overflow-y-auto px-8 py-12">
|
<main class="flex-1 overflow-y-auto [scrollbar-gutter:stable] px-4 py-6 lg:px-8 lg:py-12">
|
||||||
<slot/>
|
<slot/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,4 +190,9 @@ const isSuperAdmin = computed(() => auth.user?.roles?.includes('ROLE_SUPER_ADMIN
|
|||||||
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
const isDriver = computed(() => auth.user?.isDriver ?? false)
|
||||||
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
|
const hasLeaveRecapAccess = computed(() => auth.user?.hasLeaveRecapAccess ?? false)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const sidebarOpen = ref(false)
|
||||||
|
|
||||||
|
const closeSidebarOnMobile = () => {
|
||||||
|
sidebarOpen.value = false
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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 || '/')
|
||||||
|
|||||||
260
frontend/package-lock.json
generated
260
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,6 +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.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",
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<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 d'absence</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>
|
||||||
|
|
||||||
@@ -164,7 +140,7 @@ import type { AbsenceType } from '~/services/dto/absence-type'
|
|||||||
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
import { createAbsenceType, deleteAbsenceType, listAbsenceTypes, updateAbsenceType } from '~/services/absence-types'
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Types d\'absences'
|
title: 'Types de statut'
|
||||||
})
|
})
|
||||||
|
|
||||||
const isDrawerOpen = ref(false)
|
const isDrawerOpen = ref(false)
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -5,30 +5,37 @@
|
|||||||
</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
|
||||||
<SiteFilterSelector v-model="selectedSiteIds" :sites="sites"/>
|
v-model="selectedSiteIds"
|
||||||
</div>
|
:options="siteOptions"
|
||||||
|
label="Sites"
|
||||||
|
groupClass="relative z-50 w-80 h-10"
|
||||||
|
display-select-all
|
||||||
|
/>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
label="Ajouter une absence"
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
@click="openCreateFromToday"
|
@click="openCreateFromToday"
|
||||||
>
|
/>
|
||||||
+ Ajouter une absence
|
<MalioButton
|
||||||
</button>
|
label="Imprimer"
|
||||||
<button
|
variant="secondary"
|
||||||
type="button"
|
icon-name="mdi:printer"
|
||||||
class="h-10 rounded-lg bg-primary-500 px-4 text-md font-semibold text-white hover:bg-secondary-500"
|
icon-position="left"
|
||||||
@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">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<MalioInputText
|
||||||
|
v-model="employeeFilter"
|
||||||
|
label="Recherche d'un employé"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<PeriodStepperPicker
|
<PeriodStepperPicker
|
||||||
width-class="w-[260px]"
|
width-class="w-[260px]"
|
||||||
@@ -111,9 +118,7 @@ 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'
|
||||||
@@ -136,6 +141,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).
|
// 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)
|
||||||
@@ -154,12 +161,27 @@ 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() ?? ''
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
: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"
|
||||||
@@ -169,6 +170,7 @@ const {
|
|||||||
getRowMetrics,
|
getRowMetrics,
|
||||||
getRowAbsenceLabel,
|
getRowAbsenceLabel,
|
||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
submitAbsence,
|
submitAbsence,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -148,6 +148,7 @@
|
|||||||
:on-submit-suspension="submitSuspension"
|
:on-submit-suspension="submitSuspension"
|
||||||
:on-add-suspension-form="addSuspensionForm"
|
:on-add-suspension-form="addSuspensionForm"
|
||||||
:current-contract-period-id="currentActiveContractPeriodId"
|
:current-contract-period-id="currentActiveContractPeriodId"
|
||||||
|
:interim-agencies="interimAgencies"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
|
<div v-else-if="showLeaveTab && activeTab === 'leave'" class="h-full">
|
||||||
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
<div v-if="isLeaveLoading" class="mt-6 rounded-lg border border-neutral-200 bg-white p-6 text-md text-neutral-600">
|
||||||
@@ -256,6 +257,7 @@ const {
|
|||||||
showRttTab,
|
showRttTab,
|
||||||
contractHistory,
|
contractHistory,
|
||||||
employeeContractWorkLabel,
|
employeeContractWorkLabel,
|
||||||
|
forfaitRemainingDaysLabel,
|
||||||
contractForm,
|
contractForm,
|
||||||
createContractForm,
|
createContractForm,
|
||||||
isContractDrawerOpen,
|
isContractDrawerOpen,
|
||||||
@@ -295,6 +297,7 @@ const {
|
|||||||
submitSuspension,
|
submitSuspension,
|
||||||
addSuspensionForm,
|
addSuspensionForm,
|
||||||
currentActiveContractPeriodId,
|
currentActiveContractPeriodId,
|
||||||
|
interimAgencies,
|
||||||
isLeaveLoading,
|
isLeaveLoading,
|
||||||
isRttLoading,
|
isRttLoading,
|
||||||
mileageAllowances,
|
mileageAllowances,
|
||||||
|
|||||||
@@ -4,42 +4,45 @@
|
|||||||
<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="openCreate"
|
@click="openCreate"
|
||||||
>
|
/>
|
||||||
+ Ajouter un employé
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 py-7">
|
<div class="flex items-center gap-3 py-7">
|
||||||
<div class="w-80">
|
<div class="w-80">
|
||||||
<EmployeeNameFilterInput v-model="employeeFilter"/>
|
<MalioInputText
|
||||||
|
v-model="employeeFilter"
|
||||||
|
label="Recherche d'un employé"
|
||||||
|
icon-name="mdi:magnify"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SiteFilterSelector v-if="sites.length > 0" v-model="selectedSiteIds" :sites="sites"/>
|
<div v-if="sites.length > 0" class="relative z-50 w-80">
|
||||||
<select
|
<MalioSelectCheckbox
|
||||||
|
v-model="selectedSiteIds"
|
||||||
|
:options="siteOptions"
|
||||||
|
groupClass="w-80"
|
||||||
|
label="Sites"
|
||||||
|
display-select-all
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioSelect
|
||||||
v-model="contractStatusFilter"
|
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"
|
label="Statut contrat"
|
||||||
>
|
:options="contractStatusOptions"
|
||||||
<option value="active">Avec contrat</option>
|
group-class="w-40"
|
||||||
<option value="inactive">Sans contrat</option>
|
/>
|
||||||
<option value="all">Tous</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -72,7 +75,7 @@
|
|||||||
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
class="absolute inset-0 flex items-center justify-center bg-primary-500 p-4 text-white opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||||
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
<div class="w-full rounded-md bg-white/15 p-4 text-sm">
|
||||||
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
<p class="text-base font-semibold">{{ employee.lastName }} {{ employee.firstName }}</p>
|
||||||
<p><strong>Type:</strong> {{ contractNatureLabel(employee.currentContractNature) }}</p>
|
<p><strong>Type:</strong> {{ employee.currentInterimAgencyName ? `${contractNatureLabel(employee.currentContractNature)} (${employee.currentInterimAgencyName})` : contractNatureLabel(employee.currentContractNature) }}</p>
|
||||||
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
<p><strong>Temps de travail:</strong> {{ employee.contract?.name ?? '-' }}</p>
|
||||||
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
<p><strong>Site:</strong> {{ employee.site?.name ?? '-' }}</p>
|
||||||
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
<p><strong>Date d'entrée :</strong> {{ employee.entryDate ? employee.entryDate.split('-').reverse().join('/') : '-' }}</p>
|
||||||
@@ -147,6 +150,21 @@
|
|||||||
Le type de contrat est obligatoire.
|
Le type de contrat est obligatoire.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="form.contractNature === 'INTERIM'">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="interim-agency">
|
||||||
|
Agence d'intérim
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="interim-agency"
|
||||||
|
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>
|
<div>
|
||||||
<label class="text-md font-semibold text-neutral-700" for="contract">
|
<label class="text-md font-semibold text-neutral-700" for="contract">
|
||||||
Temps de travail <span class="text-red-600">*</span>
|
Temps de travail <span class="text-red-600">*</span>
|
||||||
@@ -191,7 +209,7 @@
|
|||||||
:class="contractEndDateFieldClass"
|
:class="contractEndDateFieldClass"
|
||||||
/>
|
/>
|
||||||
<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.
|
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="rounded-md border border-neutral-200 bg-neutral-50 p-3">
|
||||||
@@ -230,10 +248,58 @@
|
|||||||
</form>
|
</form>
|
||||||
</AppDrawer>
|
</AppDrawer>
|
||||||
|
|
||||||
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="exportChoice === 'salary-recap'">
|
||||||
|
<label class="text-md font-semibold text-neutral-700" for="export-salary-month">
|
||||||
|
Mois <span class="text-red-600">*</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
@@ -246,8 +312,7 @@ import type {Site} from '~/services/dto/site'
|
|||||||
import {listContracts} from '~/services/contracts'
|
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 SiteFilterSelector from '~/components/SiteFilterSelector.vue'
|
import {listInterimAgencies, type InterimAgency} from '~/services/interim-agencies'
|
||||||
import SalaryRecapDrawer from '~/components/SalaryRecapDrawer.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'
|
||||||
|
|
||||||
@@ -258,7 +323,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 exportChoice = ref<'leave-recap' | 'salary-recap' | 'yearly-hours' | ''>('')
|
||||||
|
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)
|
||||||
@@ -269,9 +377,16 @@ const drawerTitle = computed(() =>
|
|||||||
const employees = ref<Employee[]>([])
|
const employees = ref<Employee[]>([])
|
||||||
const sites = ref<Site[]>([])
|
const sites = ref<Site[]>([])
|
||||||
const contracts = ref<Contract[]>([])
|
const contracts = ref<Contract[]>([])
|
||||||
|
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 []
|
||||||
@@ -300,7 +415,8 @@ const form = reactive({
|
|||||||
contractStartDate: '',
|
contractStartDate: '',
|
||||||
contractEndDate: '',
|
contractEndDate: '',
|
||||||
isDriver: false,
|
isDriver: false,
|
||||||
workDaysHours: null as Record<number, number> | null
|
workDaysHours: null as Record<number, number> | null,
|
||||||
|
interimAgencyId: '' as number | ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const validationTouched = reactive({
|
const validationTouched = reactive({
|
||||||
@@ -451,8 +567,12 @@ const loadContracts = async () => {
|
|||||||
contracts.value = await listContracts()
|
contracts.value = await listContracts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadInterimAgencies = async () => {
|
||||||
|
interimAgencies.value = await listInterimAgencies()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadEmployees(), loadSites(), loadContracts()])
|
await Promise.all([loadEmployees(), loadSites(), loadContracts(), loadInterimAgencies()])
|
||||||
if (form.contractStartDate === '') {
|
if (form.contractStartDate === '') {
|
||||||
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
form.contractStartDate = new Date().toISOString().slice(0, 10)
|
||||||
}
|
}
|
||||||
@@ -503,7 +623,8 @@ const handleSubmit = async () => {
|
|||||||
contractStartDate: form.contractStartDate,
|
contractStartDate: form.contractStartDate,
|
||||||
contractEndDate: form.contractEndDate || null,
|
contractEndDate: form.contractEndDate || null,
|
||||||
isDriverInput: form.isDriver,
|
isDriverInput: form.isDriver,
|
||||||
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null
|
workDaysHoursInput: requiresSchedule.value ? form.workDaysHours : null,
|
||||||
|
interimAgencyId: form.contractNature === 'INTERIM' && form.interimAgencyId !== '' ? Number(form.interimAgencyId) : null
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,6 +637,7 @@ const handleSubmit = async () => {
|
|||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
form.isDriver = false
|
form.isDriver = false
|
||||||
form.workDaysHours = null
|
form.workDaysHours = null
|
||||||
|
form.interimAgencyId = ''
|
||||||
editingEmployee.value = null
|
editingEmployee.value = null
|
||||||
isDrawerOpen.value = false
|
isDrawerOpen.value = false
|
||||||
await loadEmployees()
|
await loadEmployees()
|
||||||
@@ -542,6 +664,12 @@ watch(showsContractEndDateComputed, (shows) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(() => form.contractNature, (nature) => {
|
||||||
|
if (nature !== 'INTERIM') {
|
||||||
|
form.interimAgencyId = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
watch(requiresSchedule, (required) => {
|
watch(requiresSchedule, (required) => {
|
||||||
if (!required) {
|
if (!required) {
|
||||||
form.workDaysHours = null
|
form.workDaysHours = null
|
||||||
@@ -567,18 +695,33 @@ const openCreate = () => {
|
|||||||
form.contractEndDate = ''
|
form.contractEndDate = ''
|
||||||
form.isDriver = false
|
form.isDriver = false
|
||||||
form.workDaysHours = null
|
form.workDaysHours = null
|
||||||
|
form.interimAgencyId = ''
|
||||||
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') {
|
||||||
|
await printPdf('/leave-recap/print')
|
||||||
|
} else if (choice === 'salary-recap') {
|
||||||
|
await printPdf(`/salary-recap/print?month=${exportSalaryMonth.value}`)
|
||||||
|
} else if (choice === 'yearly-hours') {
|
||||||
|
await printPdf(`/yearly-hours/print-all?year=${exportYear.value}&month=${exportMonth.value}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full overflow-hidden flex flex-col">
|
<div class="h-full overflow-hidden flex flex-col">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Heures</h1>
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Heures</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<HoursToolbar
|
<HoursToolbar
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
: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"
|
||||||
@@ -184,6 +185,7 @@ const {
|
|||||||
getRowAbsenceStyle,
|
getRowAbsenceStyle,
|
||||||
hasRowFormation,
|
hasRowFormation,
|
||||||
getRowFormationLabel,
|
getRowFormationLabel,
|
||||||
|
getRowContractNature,
|
||||||
getRowUpdatedAt,
|
getRowUpdatedAt,
|
||||||
getPresenceDayValue,
|
getPresenceDayValue,
|
||||||
openAbsenceDrawer,
|
openAbsenceDrawer,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="h-full flex flex-col overflow-hidden">
|
<div class="h-full flex flex-col overflow-hidden">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
<div class="flex flex-wrap items-center justify-between gap-4 pb-8">
|
||||||
<h1 class="text-4xl font-bold text-primary-500">Récap. congés</h1>
|
<h1 class="text-2xl font-bold text-primary-500 lg:text-4xl">Récap. congés</h1>
|
||||||
<span
|
<span
|
||||||
v-if="cutoffLabel"
|
v-if="cutoffLabel"
|
||||||
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
|
class="inline-flex items-center gap-2 rounded-full bg-tertiary-500 px-4 py-1 text-sm font-semibold text-primary-500"
|
||||||
@@ -25,7 +25,8 @@
|
|||||||
Aucun employé à afficher.
|
Aucun employé à afficher.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
<!-- Desktop table -->
|
||||||
|
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
|
||||||
<div
|
<div
|
||||||
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
|
:class="`grid ${gridColsClass} gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10`"
|
||||||
>
|
>
|
||||||
@@ -64,6 +65,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile cards -->
|
||||||
|
<div v-if="!isLoading && rows.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
|
||||||
|
<div
|
||||||
|
v-for="row in rows"
|
||||||
|
:key="'m-' + row.employeeId"
|
||||||
|
class="rounded-md border border-primary-500 bg-white p-4"
|
||||||
|
>
|
||||||
|
<div class="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<p class="text-md font-bold text-primary-500 truncate">
|
||||||
|
{{ row.lastName }} {{ row.firstName }}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
v-if="showSiteColumn && row.siteName"
|
||||||
|
class="inline-block shrink-0 rounded-full px-3 py-1 text-sm"
|
||||||
|
:style="{ backgroundColor: row.siteColor || '#ffd7d7', color: '#1a1a1a' }"
|
||||||
|
>
|
||||||
|
{{ row.siteName }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="row.contractName" class="mb-3 text-sm text-neutral-600">{{ row.contractName }}</p>
|
||||||
|
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">CP N-1</span>
|
||||||
|
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN1Remaining) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">Samedis</span>
|
||||||
|
<span class="font-bold text-primary-500 tabular-nums">{{ row.acquiredSaturdays }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">CP N</span>
|
||||||
|
<span class="font-bold text-primary-500 tabular-nums">{{ formatNumber(row.cpN) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-neutral-500">RTT</span>
|
||||||
|
<span class="font-bold text-primary-500 tabular-nums">{{ row.rtt }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<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">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-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 utilisateur
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -18,7 +17,8 @@
|
|||||||
Aucun utilisateur pour le moment.
|
Aucun utilisateur pour le moment.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="min-h-0 overflow-auto rounded-md bg-white">
|
<!-- Desktop table -->
|
||||||
|
<div v-else class="min-h-0 overflow-auto rounded-md bg-white hidden lg:block">
|
||||||
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
<div class="grid grid-cols-5 gap-4 border border-black bg-tertiary-500 px-6 py-3 text-[20px] font-semibold text-black rounded-t-md sticky top-0 z-10">
|
||||||
<span class="text-left">Utilisateur</span>
|
<span class="text-left">Utilisateur</span>
|
||||||
<span class="text-left">Employé</span>
|
<span class="text-left">Employé</span>
|
||||||
@@ -56,43 +56,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AppDrawer
|
<!-- Mobile cards -->
|
||||||
|
<div v-if="isLoading" class="px-6 py-4 text-md text-neutral-500 lg:hidden">
|
||||||
|
Chargement...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="users.length > 0" class="min-h-0 overflow-auto space-y-3 lg:hidden">
|
||||||
|
<div
|
||||||
|
v-for="user in users"
|
||||||
|
:key="'m-' + user.id"
|
||||||
|
class="rounded-md border border-primary-500 bg-white p-4 cursor-pointer active:bg-tertiary-500"
|
||||||
|
@click="openEdit(user)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2 mb-2">
|
||||||
|
<p class="text-md font-bold text-primary-500 truncate">{{ user.username }}</p>
|
||||||
|
<span
|
||||||
|
v-if="user.isLocked"
|
||||||
|
class="shrink-0 inline-block rounded-full bg-red-100 px-3 py-1 text-xs font-semibold text-red-700"
|
||||||
|
>Verrouillé</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="shrink-0 inline-block rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-700"
|
||||||
|
>Actif</span>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 text-sm">
|
||||||
|
<p v-if="user.employee" class="text-neutral-600">
|
||||||
|
{{ user.employee.firstName }} {{ user.employee.lastName }}
|
||||||
|
</p>
|
||||||
|
<p class="text-neutral-500">
|
||||||
|
Accès : <span class="font-semibold text-primary-500">{{ getAccessLabel(user) }}</span>
|
||||||
|
</p>
|
||||||
|
<p v-if="getSiteLabels(user) !== '-'" class="text-neutral-500 truncate">
|
||||||
|
Sites : <span class="font-semibold text-primary-500">{{ getSiteLabels(user) }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
@@ -135,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.
|
||||||
@@ -176,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>
|
||||||
|
|
||||||
@@ -311,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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type ContractHistoryItem = {
|
|||||||
suspensions?: ContractSuspension[]
|
suspensions?: ContractSuspension[]
|
||||||
isDriver?: boolean
|
isDriver?: boolean
|
||||||
workDaysHours?: Record<number, number> | null
|
workDaysHours?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
|
interimAgencyName?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Employee = {
|
export type Employee = {
|
||||||
@@ -37,4 +39,6 @@ export type Employee = {
|
|||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
entryDate?: string | null
|
entryDate?: string | null
|
||||||
currentSuspensions?: ContractSuspension[]
|
currentSuspensions?: ContractSuspension[]
|
||||||
|
currentInterimAgencyId?: number | null
|
||||||
|
currentInterimAgencyName?: string | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ 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 = {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export const createEmployee = async (payload: {
|
|||||||
contractEndDate?: string | null
|
contractEndDate?: string | null
|
||||||
isDriverInput?: boolean
|
isDriverInput?: boolean
|
||||||
workDaysHoursInput?: Record<number, number> | null
|
workDaysHoursInput?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
}) => {
|
}) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
return api.post<Employee>('/employees', {
|
return api.post<Employee>('/employees', {
|
||||||
@@ -47,7 +48,8 @@ export const createEmployee = async (payload: {
|
|||||||
contractStartDate: payload.contractStartDate,
|
contractStartDate: payload.contractStartDate,
|
||||||
contractEndDate: payload.contractEndDate ?? null,
|
contractEndDate: payload.contractEndDate ?? null,
|
||||||
isDriverInput: payload.isDriverInput ?? false,
|
isDriverInput: payload.isDriverInput ?? false,
|
||||||
workDaysHoursInput: payload.workDaysHoursInput ?? null
|
workDaysHoursInput: payload.workDaysHoursInput ?? null,
|
||||||
|
interimAgencyId: payload.interimAgencyId ?? null
|
||||||
}, {
|
}, {
|
||||||
toastSuccessKey: 'success.employee.create',
|
toastSuccessKey: 'success.employee.create',
|
||||||
toastErrorKey: 'errors.employee.create'
|
toastErrorKey: 'errors.employee.create'
|
||||||
@@ -69,6 +71,7 @@ export const updateEmployee = async (
|
|||||||
displayOrder?: number
|
displayOrder?: number
|
||||||
isDriverInput?: boolean
|
isDriverInput?: boolean
|
||||||
workDaysHoursInput?: Record<number, number> | null
|
workDaysHoursInput?: Record<number, number> | null
|
||||||
|
interimAgencyId?: number | null
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
@@ -103,6 +106,9 @@ export const updateEmployee = async (
|
|||||||
if (payload.workDaysHoursInput !== undefined) {
|
if (payload.workDaysHoursInput !== undefined) {
|
||||||
body.workDaysHoursInput = payload.workDaysHoursInput
|
body.workDaysHoursInput = payload.workDaysHoursInput
|
||||||
}
|
}
|
||||||
|
if (payload.interimAgencyId !== undefined) {
|
||||||
|
body.interimAgencyId = payload.interimAgencyId
|
||||||
|
}
|
||||||
|
|
||||||
return api.patch<Employee>(`/employees/${id}`, body, {
|
return api.patch<Employee>(`/employees/${id}`, body, {
|
||||||
toastSuccessKey: 'success.employee.update',
|
toastSuccessKey: 'success.employee.update',
|
||||||
|
|||||||
16
frontend/services/interim-agencies.ts
Normal file
16
frontend/services/interim-agencies.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { extractItems } from '~/utils/api'
|
||||||
|
|
||||||
|
export type InterimAgency = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const listInterimAgencies = async (): Promise<InterimAgency[]> => {
|
||||||
|
const api = useApi()
|
||||||
|
const data = await api.get<InterimAgency[] | { 'hydra:member'?: InterimAgency[] }>(
|
||||||
|
'/interim_agencies',
|
||||||
|
{},
|
||||||
|
{ toast: false }
|
||||||
|
)
|
||||||
|
return extractItems<InterimAgency>(data)
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const showsContractEndDate = (nature: ContractNature) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const requiresContractEndDate = (nature: ContractNature) => {
|
export const requiresContractEndDate = (nature: ContractNature) => {
|
||||||
return nature === 'CDD'
|
return nature === 'CDD' || nature === 'INTERIM'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isContractNature = (value: string): value is ContractNature => {
|
export const isContractNature = (value: string): value is ContractNature => {
|
||||||
|
|||||||
32
migrations/Version20260417120000.php
Normal file
32
migrations/Version20260417120000.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260417120000 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Create interim_agencies table and add interim_agency_id to employee_contract_periods';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('CREATE TABLE interim_agencies (id SERIAL PRIMARY KEY, name VARCHAR(150) NOT NULL UNIQUE)');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD interim_agency_id INT DEFAULT NULL');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods ADD CONSTRAINT fk_ecp_interim_agency FOREIGN KEY (interim_agency_id) REFERENCES interim_agencies (id) ON DELETE SET NULL');
|
||||||
|
$this->addSql('CREATE INDEX idx_ecp_interim_agency ON employee_contract_periods (interim_agency_id)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP CONSTRAINT IF EXISTS fk_ecp_interim_agency');
|
||||||
|
$this->addSql('DROP INDEX IF EXISTS idx_ecp_interim_agency');
|
||||||
|
$this->addSql('ALTER TABLE employee_contract_periods DROP COLUMN interim_agency_id');
|
||||||
|
$this->addSql('DROP TABLE interim_agencies');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal file
24
src/ApiResource/EmployeeYearlyHoursBulkPrint.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\ApiResource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use ApiPlatform\Metadata\QueryParameter;
|
||||||
|
use App\State\EmployeeYearlyHoursBulkPrintProvider;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/yearly-hours/print-all',
|
||||||
|
provider: EmployeeYearlyHoursBulkPrintProvider::class,
|
||||||
|
parameters: [
|
||||||
|
new QueryParameter(key: 'year', required: true),
|
||||||
|
],
|
||||||
|
security: "is_granted('ROLE_ADMIN')"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
final class EmployeeYearlyHoursBulkPrint {}
|
||||||
@@ -34,5 +34,9 @@ final class ContractHistoryItem
|
|||||||
*/
|
*/
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public ?array $workDaysHours = null,
|
public ?array $workDaysHours = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?int $interimAgencyId = null,
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public ?string $interimAgencyName = null,
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ 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
|
||||||
@@ -77,7 +78,8 @@ 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
|
||||||
@@ -96,6 +98,7 @@ final class DayContextRow
|
|||||||
'hasFormation' => $this->hasFormation,
|
'hasFormation' => $this->hasFormation,
|
||||||
'formationLabel' => $this->formationLabel,
|
'formationLabel' => $this->formationLabel,
|
||||||
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
'virtualHolidayMinutes' => $this->virtualHolidayMinutes,
|
||||||
|
'contractNature' => $this->contractNature,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ class Employee
|
|||||||
#[Groups(['employee:write'])]
|
#[Groups(['employee:write'])]
|
||||||
private ?array $workDaysHoursInput = null;
|
private ?array $workDaysHoursInput = null;
|
||||||
|
|
||||||
|
#[Groups(['employee:write'])]
|
||||||
|
private ?int $interimAgencyId = null;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new DateTimeImmutable();
|
$this->createdAt = new DateTimeImmutable();
|
||||||
@@ -295,6 +298,30 @@ class Employee
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getInterimAgencyId(): ?int
|
||||||
|
{
|
||||||
|
return $this->interimAgencyId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInterimAgencyId(?int $interimAgencyId): self
|
||||||
|
{
|
||||||
|
$this->interimAgencyId = $interimAgencyId;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getCurrentInterimAgencyId(): ?int
|
||||||
|
{
|
||||||
|
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Groups(['employee:read'])]
|
||||||
|
public function getCurrentInterimAgencyName(): ?string
|
||||||
|
{
|
||||||
|
return $this->resolveCurrentContractPeriod()?->getInterimAgency()?->getName();
|
||||||
|
}
|
||||||
|
|
||||||
#[Groups(['employee:read'])]
|
#[Groups(['employee:read'])]
|
||||||
public function getHasActiveContract(): bool
|
public function getHasActiveContract(): bool
|
||||||
{
|
{
|
||||||
@@ -393,6 +420,8 @@ class Employee
|
|||||||
suspensions: $suspensionData,
|
suspensions: $suspensionData,
|
||||||
isDriver: $period->getIsDriver(),
|
isDriver: $period->getIsDriver(),
|
||||||
workDaysHours: $period->getWorkDaysHours(),
|
workDaysHours: $period->getWorkDaysHours(),
|
||||||
|
interimAgencyId: $period->getInterimAgency()?->getId(),
|
||||||
|
interimAgencyName: $period->getInterimAgency()?->getName(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
$periods
|
$periods
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ class EmployeeContractPeriod
|
|||||||
#[ORM\Column(type: 'json', nullable: true)]
|
#[ORM\Column(type: 'json', nullable: true)]
|
||||||
private ?array $workDaysHours = null;
|
private ?array $workDaysHours = null;
|
||||||
|
|
||||||
|
#[ORM\ManyToOne(targetEntity: InterimAgency::class)]
|
||||||
|
#[ORM\JoinColumn(nullable: true)]
|
||||||
|
private ?InterimAgency $interimAgency = null;
|
||||||
|
|
||||||
#[ORM\Column(type: 'text', nullable: true)]
|
#[ORM\Column(type: 'text', nullable: true)]
|
||||||
private ?string $comment = null;
|
private ?string $comment = null;
|
||||||
|
|
||||||
@@ -204,6 +208,18 @@ class EmployeeContractPeriod
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getInterimAgency(): ?InterimAgency
|
||||||
|
{
|
||||||
|
return $this->interimAgency;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setInterimAgency(?InterimAgency $interimAgency): self
|
||||||
|
{
|
||||||
|
$this->interimAgency = $interimAgency;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, ContractSuspension>
|
* @return Collection<int, ContractSuspension>
|
||||||
*/
|
*/
|
||||||
|
|||||||
51
src/Entity/InterimAgency.php
Normal file
51
src/Entity/InterimAgency.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Entity;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
|
|
||||||
|
#[ApiResource(
|
||||||
|
operations: [
|
||||||
|
new GetCollection(),
|
||||||
|
],
|
||||||
|
normalizationContext: ['groups' => ['interim_agency:read']],
|
||||||
|
paginationEnabled: false,
|
||||||
|
security: "is_granted('ROLE_USER')",
|
||||||
|
order: ['name' => 'ASC'],
|
||||||
|
)]
|
||||||
|
#[ORM\Entity]
|
||||||
|
#[ORM\Table(name: 'interim_agencies')]
|
||||||
|
class InterimAgency
|
||||||
|
{
|
||||||
|
#[ORM\Id]
|
||||||
|
#[ORM\GeneratedValue]
|
||||||
|
#[ORM\Column(type: 'integer')]
|
||||||
|
#[Groups(['interim_agency:read', 'employee:read'])]
|
||||||
|
private ?int $id = null;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 150, unique: true)]
|
||||||
|
#[Groups(['interim_agency:read', 'employee:read'])]
|
||||||
|
private string $name = '';
|
||||||
|
|
||||||
|
public function getId(): ?int
|
||||||
|
{
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setName(string $name): self
|
||||||
|
{
|
||||||
|
$this->name = $name;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ final readonly class EmployeeContractChangeRequest
|
|||||||
public ?string $contractComment,
|
public ?string $contractComment,
|
||||||
public ?bool $isDriver = null,
|
public ?bool $isDriver = null,
|
||||||
public ?array $workDaysHours = null,
|
public ?array $workDaysHours = null,
|
||||||
|
public ?int $interimAgencyId = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function hasPeriodChangeRequest(): bool
|
public function hasPeriodChangeRequest(): bool
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ final class EmployeeContractChangeRequestFactory
|
|||||||
contractComment: $employee->getContractComment(),
|
contractComment: $employee->getContractComment(),
|
||||||
isDriver: $employee->getIsDriverInput(),
|
isDriver: $employee->getIsDriverInput(),
|
||||||
workDaysHours: $employee->getWorkDaysHoursInput(),
|
workDaysHours: $employee->getWorkDaysHoursInput(),
|
||||||
|
interimAgencyId: $employee->getInterimAgencyId(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
|
|||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\EmployeeContractPeriod;
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Entity\InterimAgency;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?InterimAgency $interimAgency = null,
|
||||||
): EmployeeContractPeriod {
|
): EmployeeContractPeriod {
|
||||||
return new EmployeeContractPeriod()
|
return new EmployeeContractPeriod()
|
||||||
->setEmployee($employee)
|
->setEmployee($employee)
|
||||||
@@ -32,6 +34,7 @@ final class EmployeeContractPeriodBuilder
|
|||||||
->setContractNature($nature)
|
->setContractNature($nature)
|
||||||
->setIsDriver($isDriver)
|
->setIsDriver($isDriver)
|
||||||
->setWorkDaysHours($workDaysHours)
|
->setWorkDaysHours($workDaysHours)
|
||||||
|
->setInterimAgency($interimAgency)
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Service\Contracts;
|
|||||||
use App\Entity\Contract;
|
use App\Entity\Contract;
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\EmployeeContractPeriod;
|
use App\Entity\EmployeeContractPeriod;
|
||||||
|
use App\Entity\InterimAgency;
|
||||||
use App\Enum\ContractNature;
|
use App\Enum\ContractNature;
|
||||||
use App\Repository\EmployeeContractPeriodRepository;
|
use App\Repository\EmployeeContractPeriodRepository;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
@@ -30,6 +31,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||||
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||||
@@ -39,7 +41,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
|
||||||
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +81,7 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void {
|
): void {
|
||||||
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
$this->periodValidator->assertPeriodDates($startDate, $endDate, $nature);
|
||||||
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
$this->periodValidator->assertWorkDaysHours($contract, $nature, $workDaysHours);
|
||||||
@@ -90,7 +94,8 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$interimAgency = $this->resolveInterimAgency($interimAgencyId);
|
||||||
|
$this->persistNewPeriod($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
|
||||||
$this->entityManager->flush();
|
$this->entityManager->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,8 +110,23 @@ final readonly class EmployeeContractPeriodManager implements EmployeeContractPe
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?InterimAgency $interimAgency = null,
|
||||||
): void {
|
): void {
|
||||||
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours);
|
$period = $this->periodBuilder->build($employee, $contract, $startDate, $endDate, $nature, $isDriver, $workDaysHours, $interimAgency);
|
||||||
$this->entityManager->persist($period);
|
$this->entityManager->persist($period);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveInterimAgency(?int $id): ?InterimAgency
|
||||||
|
{
|
||||||
|
if (null === $id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$agency = $this->entityManager->find(InterimAgency::class, $id);
|
||||||
|
if (null === $agency) {
|
||||||
|
throw new UnprocessableEntityHttpException(sprintf('Interim agency with id %d not found.', $id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $agency;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
ContractNature $nature,
|
ContractNature $nature,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
public function closeCurrentPeriod(
|
public function closeCurrentPeriod(
|
||||||
@@ -45,5 +46,6 @@ interface EmployeeContractPeriodManagerInterface
|
|||||||
?EmployeeContractPeriod $todayPeriod,
|
?EmployeeContractPeriod $todayPeriod,
|
||||||
bool $isDriver = false,
|
bool $isDriver = false,
|
||||||
?array $workDaysHours = null,
|
?array $workDaysHours = null,
|
||||||
|
?int $interimAgencyId = null,
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
449
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal file
449
src/Service/WorkHours/YearlyHoursExportBuilder.php
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service\WorkHours;
|
||||||
|
|
||||||
|
use App\Dto\WorkHours\WorkMetrics;
|
||||||
|
use App\Entity\Absence;
|
||||||
|
use App\Entity\Employee;
|
||||||
|
use App\Entity\WorkHour;
|
||||||
|
use App\Enum\ContractNature;
|
||||||
|
use App\Enum\ContractType;
|
||||||
|
use App\Enum\TrackingMode;
|
||||||
|
use App\Repository\AbsenceRepository;
|
||||||
|
use App\Repository\WorkHourRepository;
|
||||||
|
use App\Service\Contracts\EmployeeContractResolver;
|
||||||
|
use DateInterval;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
class YearlyHoursExportBuilder
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private WorkHourRepository $workHourRepository,
|
||||||
|
private AbsenceRepository $absenceRepository,
|
||||||
|
private EmployeeContractResolver $contractResolver,
|
||||||
|
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
||||||
|
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$days = [];
|
||||||
|
$current = $from;
|
||||||
|
|
||||||
|
while ($current <= $to) {
|
||||||
|
$days[] = $current->format('Y-m-d');
|
||||||
|
$current = $current->add(new DateInterval('P1D'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $days;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Employee> $employees
|
||||||
|
*
|
||||||
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
||||||
|
*/
|
||||||
|
public function buildForEmployees(array $employees, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
$days = $this->buildDays($from, $to);
|
||||||
|
|
||||||
|
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, $employees);
|
||||||
|
$absences = $this->absenceRepository->findForPrint($from, $to, $employees);
|
||||||
|
$contractMap = $this->contractResolver->resolveForEmployeesAndDays($employees, $days);
|
||||||
|
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays($employees, $days);
|
||||||
|
|
||||||
|
$workHourMap = $this->buildWorkHourMap($workHours);
|
||||||
|
$absenceMap = $this->buildAbsenceMap($absences, $days);
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
foreach ($employees as $employee) {
|
||||||
|
$employeeId = $employee->getId();
|
||||||
|
$absenceData = $this->resolveAbsenceDataForEmployee($absenceMap[$employeeId] ?? [], $days, $employee);
|
||||||
|
|
||||||
|
$segments = $this->buildSegments(
|
||||||
|
$days,
|
||||||
|
$contractMap[$employeeId] ?? [],
|
||||||
|
$driverMap[$employeeId] ?? [],
|
||||||
|
$workHourMap[$employeeId] ?? [],
|
||||||
|
$absenceData,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ([] === $segments) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results[] = [
|
||||||
|
'employeeName' => trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? '')),
|
||||||
|
'contractLabel' => $this->buildContractLabel($employee),
|
||||||
|
'segments' => $segments,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{employeeName: string, contractLabel: ?string, segments: list<array>}>
|
||||||
|
*/
|
||||||
|
public function buildForEmployee(Employee $employee, DateTimeImmutable $from, DateTimeImmutable $to): array
|
||||||
|
{
|
||||||
|
return $this->buildForEmployees([$employee], $from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildContractLabel(Employee $employee): ?string
|
||||||
|
{
|
||||||
|
$contract = $employee->getContract();
|
||||||
|
if (null === $contract) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$natureRaw = $employee->getCurrentContractNature();
|
||||||
|
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
||||||
|
$natureLabel = match ($nature) {
|
||||||
|
ContractNature::CDI => 'CDI',
|
||||||
|
ContractNature::CDD => 'CDD',
|
||||||
|
ContractNature::INTERIM => 'Intérim',
|
||||||
|
};
|
||||||
|
|
||||||
|
$contractType = $contract->getType();
|
||||||
|
if (ContractType::FORFAIT === $contractType) {
|
||||||
|
return $natureLabel.' Forfait';
|
||||||
|
}
|
||||||
|
|
||||||
|
$weeklyHours = $contract->getWeeklyHours();
|
||||||
|
if (null !== $weeklyHours && $weeklyHours > 0) {
|
||||||
|
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $contract->getName();
|
||||||
|
|
||||||
|
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array<string, WorkHour>>
|
||||||
|
*/
|
||||||
|
private function buildWorkHourMap(array $workHours): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($workHours as $wh) {
|
||||||
|
$employeeId = $wh->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$date = $wh->getWorkDate()->format('Y-m-d');
|
||||||
|
$map[$employeeId][$date] = $wh;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, list<Absence>>
|
||||||
|
*/
|
||||||
|
private function buildAbsenceMap(array $absences, array $days): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($absences as $absence) {
|
||||||
|
$employeeId = $absence->getEmployee()?->getId();
|
||||||
|
if (!$employeeId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$map[$employeeId][] = $absence;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
||||||
|
*/
|
||||||
|
private function resolveAbsenceDataForEmployee(array $absences, array $days, Employee $employee): array
|
||||||
|
{
|
||||||
|
$credited = [];
|
||||||
|
$labels = [];
|
||||||
|
$absentMorning = [];
|
||||||
|
$absentAfternoon = [];
|
||||||
|
$hasDayAbsence = [];
|
||||||
|
|
||||||
|
foreach ($absences as $absence) {
|
||||||
|
$start = $absence->getStartDate()->format('Y-m-d');
|
||||||
|
$end = $absence->getEndDate()->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($days as $date) {
|
||||||
|
if ($date < $start || $date > $end) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
||||||
|
if ($isMorning || $isAfternoon) {
|
||||||
|
$hasDayAbsence[$date] = true;
|
||||||
|
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
||||||
|
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
||||||
|
if (!isset($labels[$date])) {
|
||||||
|
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$credited[$date] = ($credited[$date] ?? 0)
|
||||||
|
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'credited' => $credited,
|
||||||
|
'labels' => $labels,
|
||||||
|
'absentMorning' => $absentMorning,
|
||||||
|
'absentAfternoon' => $absentAfternoon,
|
||||||
|
'hasDayAbsence' => $hasDayAbsence,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
||||||
|
*/
|
||||||
|
private function buildSegments(
|
||||||
|
array $days,
|
||||||
|
array $contractsByDate,
|
||||||
|
array $driverByDate,
|
||||||
|
array $workHoursByDate,
|
||||||
|
array $absenceData,
|
||||||
|
): array {
|
||||||
|
$segments = [];
|
||||||
|
$currentMode = null;
|
||||||
|
$currentRows = [];
|
||||||
|
$currentName = null;
|
||||||
|
|
||||||
|
$firstDataDate = null;
|
||||||
|
foreach ($days as $date) {
|
||||||
|
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
||||||
|
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||||
|
if ($hasRow) {
|
||||||
|
$firstDataDate = $date;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null === $firstDataDate) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
||||||
|
|
||||||
|
foreach ($days as $date) {
|
||||||
|
if ($date < $firstDataDate || $date > $todayYmd) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$contract = $contractsByDate[$date] ?? null;
|
||||||
|
$isDriver = $driverByDate[$date] ?? false;
|
||||||
|
$wh = $workHoursByDate[$date] ?? null;
|
||||||
|
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
||||||
|
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
||||||
|
$isWeekend = $isoDay >= 6;
|
||||||
|
|
||||||
|
if (!$hasData && !$isWeekend) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasData && null === $contract) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
||||||
|
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
||||||
|
$contractName = $contract?->getName();
|
||||||
|
|
||||||
|
if ($mode !== $currentMode) {
|
||||||
|
if (null !== $currentMode && [] !== $currentRows) {
|
||||||
|
$segments[] = [
|
||||||
|
'mode' => $currentMode,
|
||||||
|
'contractName' => $currentName,
|
||||||
|
'rows' => $currentRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$currentMode = $mode;
|
||||||
|
$currentRows = [];
|
||||||
|
$currentName = $contractName;
|
||||||
|
}
|
||||||
|
|
||||||
|
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
||||||
|
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
||||||
|
|
||||||
|
$row = [
|
||||||
|
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
||||||
|
'absenceLabel' => $absenceLabel,
|
||||||
|
'isWeekend' => $isWeekend,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ('presence' === $mode) {
|
||||||
|
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
||||||
|
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
||||||
|
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
||||||
|
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
||||||
|
$total = $morning + $afternoon;
|
||||||
|
|
||||||
|
$row['presentMorning'] = $morning > 0;
|
||||||
|
$row['presentAfternoon'] = $afternoon > 0;
|
||||||
|
$row['total'] = $total > 0 ? (string) $total : '';
|
||||||
|
} elseif ('driver' === $mode) {
|
||||||
|
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
||||||
|
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
||||||
|
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
||||||
|
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
||||||
|
|
||||||
|
$row['dayHours'] = $this->formatMinutes($dayMin);
|
||||||
|
$row['nightHours'] = $this->formatMinutes($nightMin);
|
||||||
|
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
||||||
|
$row['total'] = $this->formatMinutes($totalMin);
|
||||||
|
} else {
|
||||||
|
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
||||||
|
$metrics->addCreditedMinutes($creditedMinutes);
|
||||||
|
|
||||||
|
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
||||||
|
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
||||||
|
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
||||||
|
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
||||||
|
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
||||||
|
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
||||||
|
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentRows[] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $currentMode && [] !== $currentRows) {
|
||||||
|
$segments[] = [
|
||||||
|
'mode' => $currentMode,
|
||||||
|
'contractName' => $currentName,
|
||||||
|
'rows' => $currentRows,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
||||||
|
{
|
||||||
|
if ($isDriver) {
|
||||||
|
return 'driver';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
||||||
|
return 'presence';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'time';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
||||||
|
{
|
||||||
|
$ranges = [
|
||||||
|
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
||||||
|
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
||||||
|
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$totalMinutes = 0;
|
||||||
|
$nightMinutes = 0;
|
||||||
|
|
||||||
|
foreach ($ranges as [$from, $to]) {
|
||||||
|
$totalMinutes += $this->intervalMinutes($from, $to);
|
||||||
|
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
||||||
|
|
||||||
|
return new WorkMetrics(
|
||||||
|
dayMinutes: $dayMinutes,
|
||||||
|
nightMinutes: $nightMinutes,
|
||||||
|
totalMinutes: $totalMinutes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null|array{int, int}
|
||||||
|
*/
|
||||||
|
private function resolveInterval(?string $from, ?string $to): ?array
|
||||||
|
{
|
||||||
|
$fromMinutes = $this->toMinutes($from);
|
||||||
|
$toMinutes = $this->toMinutes($to);
|
||||||
|
if (null === $fromMinutes || null === $toMinutes) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
||||||
|
|
||||||
|
return [$fromMinutes, $end];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function toMinutes(?string $time): ?int
|
||||||
|
{
|
||||||
|
if (null === $time || '' === $time) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
||||||
|
|
||||||
|
return ($hours * 60) + $minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nightIntervalMinutes(?string $from, ?string $to): int
|
||||||
|
{
|
||||||
|
$interval = $this->resolveInterval($from, $to);
|
||||||
|
if (null === $interval) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$start, $end] = $interval;
|
||||||
|
$windows = [[0, 360], [1260, 1440]];
|
||||||
|
$total = 0;
|
||||||
|
|
||||||
|
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
||||||
|
$shift = $dayOffset * 1440;
|
||||||
|
foreach ($windows as [$windowStart, $windowEnd]) {
|
||||||
|
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
||||||
|
{
|
||||||
|
$start = max($startA, $startB);
|
||||||
|
$end = min($endA, $endB);
|
||||||
|
|
||||||
|
return max(0, $end - $start);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatMinutes(int $minutes): string
|
||||||
|
{
|
||||||
|
if (0 === $minutes) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$h = intdiv($minutes, 60);
|
||||||
|
$m = $minutes % 60;
|
||||||
|
|
||||||
|
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
nature: $nature,
|
nature: $nature,
|
||||||
isDriver: $changeRequest->isDriver ?? false,
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
workDaysHours: $changeRequest->workDaysHours,
|
workDaysHours: $changeRequest->workDaysHours,
|
||||||
|
interimAgencyId: $changeRequest->interimAgencyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
$data->setEntryDate($startDate);
|
$data->setEntryDate($startDate);
|
||||||
@@ -140,6 +141,7 @@ final readonly class EmployeeWriteProcessor implements ProcessorInterface
|
|||||||
todayPeriod: $effectivePeriod,
|
todayPeriod: $effectivePeriod,
|
||||||
isDriver: $changeRequest->isDriver ?? false,
|
isDriver: $changeRequest->isDriver ?? false,
|
||||||
workDaysHours: $changeRequest->workDaysHours,
|
workDaysHours: $changeRequest->workDaysHours,
|
||||||
|
interimAgencyId: $changeRequest->interimAgencyId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $result;
|
return $result;
|
||||||
|
|||||||
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal file
86
src/State/EmployeeYearlyHoursBulkPrintProvider.php
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\State;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Repository\EmployeeRepository;
|
||||||
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Dompdf\Dompdf;
|
||||||
|
use Dompdf\Options;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Twig\Environment;
|
||||||
|
|
||||||
|
class EmployeeYearlyHoursBulkPrintProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private Environment $twig,
|
||||||
|
private readonly RequestStack $requestStack,
|
||||||
|
private EmployeeRepository $employeeRepository,
|
||||||
|
private YearlyHoursExportBuilder $exportBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
|
{
|
||||||
|
$request = $this->requestStack->getCurrentRequest();
|
||||||
|
if (!$request) {
|
||||||
|
return new Response('Missing request.', Response::HTTP_BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
$yearRaw = (string) $request->query->get('year');
|
||||||
|
if (!preg_match('/^\d{4}$/', $yearRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('year must use YYYY format.');
|
||||||
|
}
|
||||||
|
$year = (int) $yearRaw;
|
||||||
|
|
||||||
|
$monthRaw = (string) $request->query->get('month', '');
|
||||||
|
$month = null;
|
||||||
|
if ('' !== $monthRaw) {
|
||||||
|
if (!preg_match('/^(?:0?[1-9]|1[0-2])$/', $monthRaw)) {
|
||||||
|
throw new UnprocessableEntityHttpException('month must be between 1 and 12.');
|
||||||
|
}
|
||||||
|
$month = (int) $monthRaw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (null !== $month) {
|
||||||
|
$from = new DateTimeImmutable(sprintf('%d-%02d-01', $year, $month));
|
||||||
|
$to = $from->modify('last day of this month');
|
||||||
|
} else {
|
||||||
|
$from = new DateTimeImmutable("{$year}-01-01");
|
||||||
|
$to = new DateTimeImmutable("{$year}-12-31");
|
||||||
|
}
|
||||||
|
|
||||||
|
$employees = $this->employeeRepository->findAll();
|
||||||
|
usort($employees, fn ($a, $b) => ($a->getLastName() ?? '') <=> ($b->getLastName() ?? ''));
|
||||||
|
|
||||||
|
$entries = $this->exportBuilder->buildForEmployees($employees, $from, $to);
|
||||||
|
|
||||||
|
$options = new Options();
|
||||||
|
$options->set('isRemoteEnabled', true);
|
||||||
|
|
||||||
|
$dompdf = new Dompdf($options);
|
||||||
|
$html = $this->twig->render('employee-yearly-hours/print-all.html.twig', [
|
||||||
|
'entries' => $entries,
|
||||||
|
'year' => $year,
|
||||||
|
'month' => $month,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dompdf->loadHtml($html);
|
||||||
|
$dompdf->setPaper('A4', 'portrait');
|
||||||
|
$dompdf->render();
|
||||||
|
|
||||||
|
$filename = null !== $month
|
||||||
|
? sprintf('heures_tous_%d-%02d.pdf', $year, $month)
|
||||||
|
: sprintf('heures_tous_%d.pdf', $year);
|
||||||
|
|
||||||
|
return new Response($dompdf->output(), Response::HTTP_OK, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'inline; filename="'.$filename.'"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,19 +6,9 @@ namespace App\State;
|
|||||||
|
|
||||||
use ApiPlatform\Metadata\Operation;
|
use ApiPlatform\Metadata\Operation;
|
||||||
use ApiPlatform\State\ProviderInterface;
|
use ApiPlatform\State\ProviderInterface;
|
||||||
use App\Dto\WorkHours\WorkMetrics;
|
|
||||||
use App\Entity\Employee;
|
use App\Entity\Employee;
|
||||||
use App\Entity\WorkHour;
|
|
||||||
use App\Enum\ContractNature;
|
|
||||||
use App\Enum\ContractType;
|
|
||||||
use App\Enum\TrackingMode;
|
|
||||||
use App\Repository\AbsenceRepository;
|
|
||||||
use App\Repository\EmployeeRepository;
|
use App\Repository\EmployeeRepository;
|
||||||
use App\Repository\WorkHourRepository;
|
use App\Service\WorkHours\YearlyHoursExportBuilder;
|
||||||
use App\Service\Contracts\EmployeeContractResolver;
|
|
||||||
use App\Service\WorkHours\AbsenceSegmentsResolver;
|
|
||||||
use App\Service\WorkHours\WorkedHoursCreditPolicy;
|
|
||||||
use DateInterval;
|
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Dompdf\Dompdf;
|
use Dompdf\Dompdf;
|
||||||
use Dompdf\Options;
|
use Dompdf\Options;
|
||||||
@@ -34,11 +24,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
private Environment $twig,
|
private Environment $twig,
|
||||||
private readonly RequestStack $requestStack,
|
private readonly RequestStack $requestStack,
|
||||||
private EmployeeRepository $employeeRepository,
|
private EmployeeRepository $employeeRepository,
|
||||||
private WorkHourRepository $workHourRepository,
|
private YearlyHoursExportBuilder $exportBuilder,
|
||||||
private AbsenceRepository $absenceRepository,
|
|
||||||
private EmployeeContractResolver $contractResolver,
|
|
||||||
private AbsenceSegmentsResolver $absenceSegmentsResolver,
|
|
||||||
private WorkedHoursCreditPolicy $workedHoursCreditPolicy,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): Response
|
||||||
@@ -80,27 +66,11 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
$from = new DateTimeImmutable("{$year}-01-01");
|
$from = new DateTimeImmutable("{$year}-01-01");
|
||||||
$to = new DateTimeImmutable("{$year}-12-31");
|
$to = new DateTimeImmutable("{$year}-12-31");
|
||||||
}
|
}
|
||||||
$days = $this->buildDays($from, $to);
|
|
||||||
|
|
||||||
$workHours = $this->workHourRepository->findByDateRangeAndEmployees($from, $to, [$employee]);
|
$entries = $this->exportBuilder->buildForEmployee($employee, $from, $to);
|
||||||
$absences = $this->absenceRepository->findForPrint($from, $to, [$employee]);
|
|
||||||
$contractMap = $this->contractResolver->resolveForEmployeesAndDays([$employee], $days);
|
|
||||||
$driverMap = $this->contractResolver->resolveIsDriverForEmployeesAndDays([$employee], $days);
|
|
||||||
|
|
||||||
$workHourMap = $this->buildWorkHourMap($workHours);
|
|
||||||
$absenceData = $this->buildAbsenceData($absences, $days, $employee);
|
|
||||||
|
|
||||||
$segments = $this->buildSegments(
|
|
||||||
$employee,
|
|
||||||
$days,
|
|
||||||
$contractMap[$employee->getId()] ?? [],
|
|
||||||
$driverMap[$employee->getId()] ?? [],
|
|
||||||
$workHourMap[$employee->getId()] ?? [],
|
|
||||||
$absenceData,
|
|
||||||
);
|
|
||||||
|
|
||||||
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
$employeeName = trim(($employee->getLastName() ?? '').' '.($employee->getFirstName() ?? ''));
|
||||||
$contractLabel = $this->buildContractLabel($employee);
|
$contractLabel = $this->exportBuilder->buildContractLabel($employee);
|
||||||
|
|
||||||
$options = new Options();
|
$options = new Options();
|
||||||
$options->set('isRemoteEnabled', true);
|
$options->set('isRemoteEnabled', true);
|
||||||
@@ -111,7 +81,7 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
'contractLabel' => $contractLabel,
|
'contractLabel' => $contractLabel,
|
||||||
'year' => $year,
|
'year' => $year,
|
||||||
'month' => $month,
|
'month' => $month,
|
||||||
'segments' => $segments,
|
'segments' => $entries[0]['segments'] ?? [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$dompdf->loadHtml($html);
|
$dompdf->loadHtml($html);
|
||||||
@@ -139,367 +109,6 @@ class EmployeeYearlyHoursPrintProvider implements ProviderInterface
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildContractLabel(Employee $employee): ?string
|
|
||||||
{
|
|
||||||
$contract = $employee->getContract();
|
|
||||||
if (null === $contract) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$natureRaw = $employee->getCurrentContractNature();
|
|
||||||
$nature = ContractNature::tryFrom($natureRaw) ?? ContractNature::CDI;
|
|
||||||
$natureLabel = match ($nature) {
|
|
||||||
ContractNature::CDI => 'CDI',
|
|
||||||
ContractNature::CDD => 'CDD',
|
|
||||||
ContractNature::INTERIM => 'Intérim',
|
|
||||||
};
|
|
||||||
|
|
||||||
$contractType = $contract->getType();
|
|
||||||
if (ContractType::FORFAIT === $contractType) {
|
|
||||||
return $natureLabel.' Forfait';
|
|
||||||
}
|
|
||||||
|
|
||||||
$weeklyHours = $contract->getWeeklyHours();
|
|
||||||
if (null !== $weeklyHours && $weeklyHours > 0) {
|
|
||||||
return sprintf('%s %d heures', $natureLabel, $weeklyHours);
|
|
||||||
}
|
|
||||||
|
|
||||||
$name = $contract->getName();
|
|
||||||
|
|
||||||
return null !== $name && '' !== $name ? $natureLabel.' '.$name : $natureLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<string>
|
|
||||||
*/
|
|
||||||
private function buildDays(DateTimeImmutable $from, DateTimeImmutable $to): array
|
|
||||||
{
|
|
||||||
$days = [];
|
|
||||||
$current = $from;
|
|
||||||
|
|
||||||
while ($current <= $to) {
|
|
||||||
$days[] = $current->format('Y-m-d');
|
|
||||||
$current = $current->add(new DateInterval('P1D'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $days;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<int, array<string, WorkHour>>
|
|
||||||
*/
|
|
||||||
private function buildWorkHourMap(array $workHours): array
|
|
||||||
{
|
|
||||||
$map = [];
|
|
||||||
foreach ($workHours as $wh) {
|
|
||||||
$employeeId = $wh->getEmployee()?->getId();
|
|
||||||
if (!$employeeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$date = $wh->getWorkDate()->format('Y-m-d');
|
|
||||||
$map[$employeeId][$date] = $wh;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{credited: array<string, int>, labels: array<string, string>, absentMorning: array<string, bool>, absentAfternoon: array<string, bool>, hasDayAbsence: array<string, bool>}
|
|
||||||
*/
|
|
||||||
private function buildAbsenceData(array $absences, array $days, Employee $employee): array
|
|
||||||
{
|
|
||||||
$credited = [];
|
|
||||||
$labels = [];
|
|
||||||
$absentMorning = [];
|
|
||||||
$absentAfternoon = [];
|
|
||||||
$hasDayAbsence = [];
|
|
||||||
|
|
||||||
foreach ($absences as $absence) {
|
|
||||||
$absEmployeeId = $absence->getEmployee()?->getId();
|
|
||||||
if ($absEmployeeId !== $employee->getId()) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$start = $absence->getStartDate()->format('Y-m-d');
|
|
||||||
$end = $absence->getEndDate()->format('Y-m-d');
|
|
||||||
|
|
||||||
foreach ($days as $date) {
|
|
||||||
if ($date < $start || $date > $end) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$isMorning, $isAfternoon] = $this->absenceSegmentsResolver->resolveForDate($absence, $date);
|
|
||||||
if ($isMorning || $isAfternoon) {
|
|
||||||
$hasDayAbsence[$date] = true;
|
|
||||||
$absentMorning[$date] = ($absentMorning[$date] ?? false) || $isMorning;
|
|
||||||
$absentAfternoon[$date] = ($absentAfternoon[$date] ?? false) || $isAfternoon;
|
|
||||||
if (!isset($labels[$date])) {
|
|
||||||
$labels[$date] = $absence->getType()?->getLabel() ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$credited[$date] = ($credited[$date] ?? 0)
|
|
||||||
+ $this->workedHoursCreditPolicy->computeCreditedMinutes($absence, $date, $isMorning, $isAfternoon);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'credited' => $credited,
|
|
||||||
'labels' => $labels,
|
|
||||||
'absentMorning' => $absentMorning,
|
|
||||||
'absentAfternoon' => $absentAfternoon,
|
|
||||||
'hasDayAbsence' => $hasDayAbsence,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{mode: string, contractName: ?string, rows: list<array>}>
|
|
||||||
*/
|
|
||||||
private function buildSegments(
|
|
||||||
Employee $employee,
|
|
||||||
array $days,
|
|
||||||
array $contractsByDate,
|
|
||||||
array $driverByDate,
|
|
||||||
array $workHoursByDate,
|
|
||||||
array $absenceData,
|
|
||||||
): array {
|
|
||||||
$segments = [];
|
|
||||||
$currentMode = null;
|
|
||||||
$currentRows = [];
|
|
||||||
$currentName = null;
|
|
||||||
|
|
||||||
// Crop the output window to [first data day, today] to avoid padding the
|
|
||||||
// export with empty rows (notably weekends before the first saisie or after today).
|
|
||||||
$firstDataDate = null;
|
|
||||||
foreach ($days as $date) {
|
|
||||||
$hasRow = null !== ($workHoursByDate[$date] ?? null)
|
|
||||||
|| ($absenceData['hasDayAbsence'][$date] ?? false);
|
|
||||||
if ($hasRow) {
|
|
||||||
$firstDataDate = $date;
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null === $firstDataDate) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$todayYmd = new DateTimeImmutable('today')->format('Y-m-d');
|
|
||||||
|
|
||||||
foreach ($days as $date) {
|
|
||||||
if ($date < $firstDataDate || $date > $todayYmd) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$contract = $contractsByDate[$date] ?? null;
|
|
||||||
$isDriver = $driverByDate[$date] ?? false;
|
|
||||||
$wh = $workHoursByDate[$date] ?? null;
|
|
||||||
$hasData = null !== $wh || ($absenceData['hasDayAbsence'][$date] ?? false);
|
|
||||||
$isoDay = (int) new DateTimeImmutable($date)->format('N');
|
|
||||||
$isWeekend = $isoDay >= 6;
|
|
||||||
|
|
||||||
// Keep weekend rows even when empty so the reader can distinguish
|
|
||||||
// worked vs non-worked Saturdays/Sundays at a glance.
|
|
||||||
if (!$hasData && !$isWeekend) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$hasData && null === $contract) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$trackingMode = $contract?->getTrackingMode() ?? TrackingMode::TIME->value;
|
|
||||||
$mode = $this->resolveSegmentMode($trackingMode, $isDriver);
|
|
||||||
$contractName = $contract?->getName();
|
|
||||||
|
|
||||||
if ($mode !== $currentMode) {
|
|
||||||
if (null !== $currentMode && [] !== $currentRows) {
|
|
||||||
$segments[] = [
|
|
||||||
'mode' => $currentMode,
|
|
||||||
'contractName' => $currentName,
|
|
||||||
'rows' => $currentRows,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
$currentMode = $mode;
|
|
||||||
$currentRows = [];
|
|
||||||
$currentName = $contractName;
|
|
||||||
}
|
|
||||||
|
|
||||||
$creditedMinutes = $absenceData['credited'][$date] ?? 0;
|
|
||||||
$absenceLabel = $absenceData['labels'][$date] ?? null;
|
|
||||||
|
|
||||||
$row = [
|
|
||||||
'date' => new DateTimeImmutable($date)->format('d/m/Y'),
|
|
||||||
'absenceLabel' => $absenceLabel,
|
|
||||||
'isWeekend' => $isWeekend,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ('presence' === $mode) {
|
|
||||||
$absentMorning = $absenceData['absentMorning'][$date] ?? false;
|
|
||||||
$absentAfternoon = $absenceData['absentAfternoon'][$date] ?? false;
|
|
||||||
$morning = (($wh?->getIsPresentMorning() ?? false) && !$absentMorning) ? 0.5 : 0.0;
|
|
||||||
$afternoon = (($wh?->getIsPresentAfternoon() ?? false) && !$absentAfternoon) ? 0.5 : 0.0;
|
|
||||||
$total = $morning + $afternoon;
|
|
||||||
|
|
||||||
$row['presentMorning'] = $morning > 0;
|
|
||||||
$row['presentAfternoon'] = $afternoon > 0;
|
|
||||||
$row['total'] = $total > 0 ? (string) $total : '';
|
|
||||||
} elseif ('driver' === $mode) {
|
|
||||||
$dayMin = $wh?->getDayHoursMinutes() ?? 0;
|
|
||||||
$nightMin = $wh?->getNightHoursMinutes() ?? 0;
|
|
||||||
$workshopMin = $wh?->getWorkshopHoursMinutes() ?? 0;
|
|
||||||
$totalMin = $dayMin + $nightMin + $workshopMin + $creditedMinutes;
|
|
||||||
|
|
||||||
$row['dayHours'] = $this->formatMinutes($dayMin);
|
|
||||||
$row['nightHours'] = $this->formatMinutes($nightMin);
|
|
||||||
$row['workshopHours'] = $this->formatMinutes($workshopMin);
|
|
||||||
$row['total'] = $this->formatMinutes($totalMin);
|
|
||||||
} else {
|
|
||||||
$metrics = null !== $wh ? $this->computeMetrics($wh) : new WorkMetrics();
|
|
||||||
$metrics->addCreditedMinutes($creditedMinutes);
|
|
||||||
|
|
||||||
$row['morningFrom'] = $wh?->getMorningFrom() ?? '';
|
|
||||||
$row['morningTo'] = $wh?->getMorningTo() ?? '';
|
|
||||||
$row['afternoonFrom'] = $wh?->getAfternoonFrom() ?? '';
|
|
||||||
$row['afternoonTo'] = $wh?->getAfternoonTo() ?? '';
|
|
||||||
$row['eveningFrom'] = $wh?->getEveningFrom() ?? '';
|
|
||||||
$row['eveningTo'] = $wh?->getEveningTo() ?? '';
|
|
||||||
$row['total'] = $this->formatMinutes($metrics->totalMinutes);
|
|
||||||
}
|
|
||||||
|
|
||||||
$currentRows[] = $row;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (null !== $currentMode && [] !== $currentRows) {
|
|
||||||
$segments[] = [
|
|
||||||
'mode' => $currentMode,
|
|
||||||
'contractName' => $currentName,
|
|
||||||
'rows' => $currentRows,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return $segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveSegmentMode(string $trackingMode, bool $isDriver): string
|
|
||||||
{
|
|
||||||
if ($isDriver) {
|
|
||||||
return 'driver';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (TrackingMode::PRESENCE->value === $trackingMode) {
|
|
||||||
return 'presence';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'time';
|
|
||||||
}
|
|
||||||
|
|
||||||
private function computeMetrics(WorkHour $workHour): WorkMetrics
|
|
||||||
{
|
|
||||||
$ranges = [
|
|
||||||
[$workHour->getMorningFrom(), $workHour->getMorningTo()],
|
|
||||||
[$workHour->getAfternoonFrom(), $workHour->getAfternoonTo()],
|
|
||||||
[$workHour->getEveningFrom(), $workHour->getEveningTo()],
|
|
||||||
];
|
|
||||||
|
|
||||||
$totalMinutes = 0;
|
|
||||||
$nightMinutes = 0;
|
|
||||||
|
|
||||||
foreach ($ranges as [$from, $to]) {
|
|
||||||
$totalMinutes += $this->intervalMinutes($from, $to);
|
|
||||||
$nightMinutes += $this->nightIntervalMinutes($from, $to);
|
|
||||||
}
|
|
||||||
|
|
||||||
$dayMinutes = max(0, $totalMinutes - $nightMinutes);
|
|
||||||
|
|
||||||
return new WorkMetrics(
|
|
||||||
dayMinutes: $dayMinutes,
|
|
||||||
nightMinutes: $nightMinutes,
|
|
||||||
totalMinutes: $totalMinutes,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return null|array{int, int}
|
|
||||||
*/
|
|
||||||
private function resolveInterval(?string $from, ?string $to): ?array
|
|
||||||
{
|
|
||||||
$fromMinutes = $this->toMinutes($from);
|
|
||||||
$toMinutes = $this->toMinutes($to);
|
|
||||||
if (null === $fromMinutes || null === $toMinutes) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$end = $toMinutes <= $fromMinutes ? $toMinutes + 1440 : $toMinutes;
|
|
||||||
|
|
||||||
return [$fromMinutes, $end];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function toMinutes(?string $time): ?int
|
|
||||||
{
|
|
||||||
if (null === $time || '' === $time) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$hours, $minutes] = array_map('intval', explode(':', $time));
|
|
||||||
|
|
||||||
return ($hours * 60) + $minutes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function intervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function nightIntervalMinutes(?string $from, ?string $to): int
|
|
||||||
{
|
|
||||||
$interval = $this->resolveInterval($from, $to);
|
|
||||||
if (null === $interval) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$start, $end] = $interval;
|
|
||||||
$windows = [[0, 360], [1260, 1440]];
|
|
||||||
$total = 0;
|
|
||||||
|
|
||||||
for ($dayOffset = 0; $dayOffset <= 1; ++$dayOffset) {
|
|
||||||
$shift = $dayOffset * 1440;
|
|
||||||
foreach ($windows as [$windowStart, $windowEnd]) {
|
|
||||||
$total += $this->overlap($start, $end, $windowStart + $shift, $windowEnd + $shift);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $total;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function overlap(int $startA, int $endA, int $startB, int $endB): int
|
|
||||||
{
|
|
||||||
$start = max($startA, $startB);
|
|
||||||
$end = min($endA, $endB);
|
|
||||||
|
|
||||||
return max(0, $end - $start);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatMinutes(int $minutes): string
|
|
||||||
{
|
|
||||||
if (0 === $minutes) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
$h = intdiv($minutes, 60);
|
|
||||||
$m = $minutes % 60;
|
|
||||||
|
|
||||||
return 0 === $m ? "{$h}h" : "{$h}h{$m}m";
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sanitizeFilename(string $name): string
|
private function sanitizeFilename(string $name): string
|
||||||
{
|
{
|
||||||
$name = str_replace(' ', '_', $name);
|
$name = str_replace(' ', '_', $name);
|
||||||
|
|||||||
@@ -57,13 +57,17 @@ 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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
271
templates/employee-yearly-hours/print-all.html.twig
Normal file
271
templates/employee-yearly-hours/print-all.html.twig
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Export heures - {% set months = {
|
||||||
|
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
|
||||||
|
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
|
||||||
|
} %}{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 4mm; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 2mm;
|
||||||
|
font-family: Helvetica, sans-serif;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section {
|
||||||
|
page-break-before: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section:first-child {
|
||||||
|
page-break-before: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-bar {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-date {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
font-size: 9px;
|
||||||
|
color: #333;
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 4mm 0 2mm 0;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
table-layout: auto;
|
||||||
|
border: 2px solid #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 9px;
|
||||||
|
background: #d9e2f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
td { font-size: 9px; }
|
||||||
|
td.date { text-align: left; font-weight: bold; }
|
||||||
|
td.absence { text-align: left; color: #c00; }
|
||||||
|
td.time { text-align: center; }
|
||||||
|
td.presence { text-align: center; }
|
||||||
|
td.total { text-align: center; font-weight: bold; }
|
||||||
|
tr.weekend td { background: #f3f3f3; color: #555; }
|
||||||
|
tr.weekend td.date { color: #333; }
|
||||||
|
|
||||||
|
.signature-footer {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
margin-top: 6mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-intro {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6mm;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-blocks {
|
||||||
|
display: table;
|
||||||
|
width: 100%;
|
||||||
|
table-layout: fixed;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 4mm 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block {
|
||||||
|
display: table-cell;
|
||||||
|
border: 1px solid #0a0a0a;
|
||||||
|
padding: 3mm;
|
||||||
|
vertical-align: top;
|
||||||
|
width: 33.33%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .title {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 7mm;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .line {
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.signature-block .signature-line {
|
||||||
|
margin-top: 6mm;
|
||||||
|
margin-bottom: 18mm;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
{% set months = {
|
||||||
|
1:'Janvier', 2:'Février', 3:'Mars', 4:'Avril', 5:'Mai', 6:'Juin',
|
||||||
|
7:'Juillet', 8:'Août', 9:'Septembre', 10:'Octobre', 11:'Novembre', 12:'Décembre'
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for entry in entries %}
|
||||||
|
<div class="employee-section">
|
||||||
|
<div class="title-bar">
|
||||||
|
<h1>
|
||||||
|
{{ entry.employeeName }}{% if entry.contractLabel %} - {{ entry.contractLabel }}{% endif %}<br>
|
||||||
|
{% if month %}{{ months[month] }} {{ year }}{% else %}{{ year }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
<div class="export-date">Exporté le {{ "now"|date('d/m/Y') }} à {{ "now"|date('H:i:s') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for segment in entry.segments %}
|
||||||
|
{% if entry.segments|length > 1 %}
|
||||||
|
<h2>{{ segment.contractName ?? 'Contrat inconnu' }}{% if segment.mode == 'driver' %} (Chauffeur){% endif %}</h2>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if segment.mode == 'presence' %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Présence matin</th>
|
||||||
|
<th>Présence après-midi</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
|
<td class="presence">{{ row.presentMorning ? 'X' : '' }}</td>
|
||||||
|
<td class="presence">{{ row.presentAfternoon ? 'X' : '' }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% elseif segment.mode == 'driver' %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Heures jour</th>
|
||||||
|
<th>Heures nuit</th>
|
||||||
|
<th>Heures atelier</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
|
<td class="time">{{ row.dayHours }}</td>
|
||||||
|
<td class="time">{{ row.nightHours }}</td>
|
||||||
|
<td class="time">{{ row.workshopHours }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Absence</th>
|
||||||
|
<th>Début matin</th>
|
||||||
|
<th>Fin matin</th>
|
||||||
|
<th>Début après-midi</th>
|
||||||
|
<th>Fin après-midi</th>
|
||||||
|
<th>Début soir</th>
|
||||||
|
<th>Fin soir</th>
|
||||||
|
<th>Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in segment.rows %}
|
||||||
|
<tr class="{{ row.isWeekend ? 'weekend' : '' }}">
|
||||||
|
<td class="date">{{ row.date }}</td>
|
||||||
|
<td class="absence">{{ row.absenceLabel ?? '' }}</td>
|
||||||
|
<td class="time">{{ row.morningFrom }}</td>
|
||||||
|
<td class="time">{{ row.morningTo }}</td>
|
||||||
|
<td class="time">{{ row.afternoonFrom }}</td>
|
||||||
|
<td class="time">{{ row.afternoonTo }}</td>
|
||||||
|
<td class="time">{{ row.eveningFrom }}</td>
|
||||||
|
<td class="time">{{ row.eveningTo }}</td>
|
||||||
|
<td class="total">{{ row.total }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="signature-footer">
|
||||||
|
<div class="signature-intro">
|
||||||
|
Nom + Prénom<br>
|
||||||
|
Signature avec mention « bon pour accord »
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="signature-blocks">
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Direction</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Responsable usine</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
<div class="signature-block">
|
||||||
|
<p class="title">Salarié</p>
|
||||||
|
<p class="line">Nom : ...............</p>
|
||||||
|
<p class="line">Prénom : ...............</p>
|
||||||
|
<p class="line">Mention : ........................................</p>
|
||||||
|
<p class="signature-line">Signature :</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -10,6 +10,7 @@ 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;
|
||||||
@@ -176,6 +177,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user