feat(absences) : avancement module absences + suppression du portail client

Deux lots regroupés sur la branche feat/absence-management.

Suppression complète du portail client :
- retire ROLE_CLIENT (security.yaml) ; User::getRoles() ajoute toujours ROLE_USER
- supprime l'entité ClientTicket (+ repo, states, relations), User.client et
  User.allowedProjects, NotificationService, ProjectAllowedExtension, le bloc
  ROLE_CLIENT de MailAccessChecker
- front : pages /portal, layout portal, composants client-ticket/,
  AdminClientTicketTab, services/dto/i18n/docs associés
- fixtures : retire les users client-liot / client-acme
- migration Version20260522110000 (drop client_ticket, user_allowed_projects,
  colonnes liées ; task_document.task_id -> NOT NULL)
- tests : retire les cas obsolètes testant le blocage des clients sur le mail

Module gestion des absences (WIP) :
- entités / migrations (Version20260521160000, Version20260522090000)
- pages absences.vue / team-absences.vue, composants frontend/components/absence/
- services front, AccrueLeaveCommand, PublicHolidayController

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matthieu
2026-05-22 11:31:31 +02:00
parent de98924fd3
commit 2a0b202d32
109 changed files with 3918 additions and 3656 deletions

View File

@@ -0,0 +1,571 @@
# Réorganisation gestion employés — Plan d'implémentation
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Sortir l'édition des informations RH du `UserDrawer` (qui ne garde que la case « Employé ») vers un onglet « Employés » dédié dans `team-absences`, avec une liste users⋈soldes et un drawer d'édition.
**Architecture:** Réorganisation 100 % frontend. Les champs employé existent déjà sur l'entité `User` (backend) et le DTO `UserData`/`UserWrite` ; la persistance passe par `usersService.update()` (PATCH partiel, sans écrasement). La liste de l'onglet joint `usersService.getAll()` (filtré `isEmployee`) avec `absenceService.getBalances({ type: 'cp' })`.
**Tech Stack:** Nuxt 4 / Vue 3 Composition API, TypeScript, composants `@malio/layer-ui` (MalioDate, MalioSelect, MalioInputText, MalioDataTable, MalioDrawer, MalioCheckbox), i18n `@nuxtjs/i18n`.
**Conventions projet :**
- 4 espaces d'indentation, TypeScript strict.
- Pas de framework de test frontend → vérification au navigateur (serveur dev sur `http://localhost:3002`, Chrome DevTools MCP) + compilation HMR sans erreur console.
- **Commits gérés par l'utilisateur** : ne committer qu'après son feu vert explicite (règle CLAUDE.md). Les étapes « Commit » sont fournies mais à déclencher sur demande. Format : `<type>(<scope>) : <message>`.
---
### Task 1 : Clés i18n
**Files:**
- Modify: `frontend/i18n/locales/fr.json` (objet `absences.admin`)
- [ ] **Step 1 : Ajouter l'onglet et le bloc `employees`**
Dans `absences.admin`, ajouter `"employees"` à `tabs`, et un nouveau bloc `employees`. Repérer le bloc existant :
```json
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes" },
```
le remplacer par :
```json
"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes", "employees": "Employés" },
```
puis ajouter, à la suite des clés de `admin` (par ex. après `"adjust"`), le bloc :
```json
"employees": {
"columns": {
"name": "Nom",
"contract": "Contrat",
"cpTaken": "CP pris",
"cpRemaining": "CP restants"
},
"empty": "Aucun employé. Cochez « Employé » sur un utilisateur dans l'administration.",
"noContract": "—",
"drawer": {
"title": "Informations employé",
"save": "Enregistrer"
},
"fields": {
"hireDate": "Date d'embauche",
"endDate": "Date de sortie",
"contractType": "Type de contrat",
"familySituation": "Situation familiale",
"workTimeRatio": "Temps de travail (ex : 1.0)",
"annualLeaveDays": "CP annuels (jours)",
"referencePeriodStart": "Début période réf. (MM-DD)",
"initialLeaveBalance": "Solde CP initial",
"nbChildren": "Nombre d'enfants"
},
"contract": {
"cdi": "CDI",
"cdd": "CDD",
"stage": "Stage",
"alternance": "Alternance",
"autre": "Autre"
},
"family": {
"celibataire": "Célibataire",
"marie": "Marié(e)",
"pacse": "Pacsé(e)",
"divorce": "Divorcé(e)",
"veuf": "Veuf(ve)"
}
}
```
- [ ] **Step 2 : Vérifier la validité JSON**
Run: `cd frontend && python3 -c "import json; json.load(open('i18n/locales/fr.json')); print('OK')"`
Expected: `OK`
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/i18n/locales/fr.json
git commit -m "feat(absences) : clés i18n onglet et drawer employés"
```
---
### Task 2 : Composant `EmployeeDrawer.vue`
**Files:**
- Create: `frontend/components/absence/EmployeeDrawer.vue`
- [ ] **Step 1 : Créer le composant**
Crée `frontend/components/absence/EmployeeDrawer.vue` avec ce contenu exact :
```vue
<template>
<MalioDrawer v-model="open" drawer-class="max-w-lg">
<template #header>
<div>
<h2 class="text-xl font-bold">{{ $t('absences.admin.employees.drawer.title') }}</h2>
<p v-if="user" class="text-sm text-neutral-500">{{ user.username }}</p>
</div>
</template>
<form v-if="user" class="grid grid-cols-1 gap-4 sm:grid-cols-2" @submit.prevent="save">
<MalioDate
v-model="form.hireDate"
:label="$t('absences.admin.employees.fields.hireDate')"
group-class="w-full"
/>
<MalioDate
v-model="form.endDate"
:label="$t('absences.admin.employees.fields.endDate')"
group-class="w-full"
/>
<MalioSelect
v-model="form.contractType"
:label="$t('absences.admin.employees.fields.contractType')"
:options="contractOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioSelect
v-model="form.familySituation"
:label="$t('absences.admin.employees.fields.familySituation')"
:options="familyOptions"
empty-option-label=""
group-class="w-full"
/>
<MalioInputText
v-model="form.workTimeRatio"
:label="$t('absences.admin.employees.fields.workTimeRatio')"
input-class="w-full"
/>
<MalioInputText
v-model="form.annualLeaveDays"
:label="$t('absences.admin.employees.fields.annualLeaveDays')"
input-class="w-full"
/>
<MalioInputText
v-model="form.referencePeriodStart"
:label="$t('absences.admin.employees.fields.referencePeriodStart')"
input-class="w-full"
/>
<MalioInputText
v-model="form.initialLeaveBalance"
:label="$t('absences.admin.employees.fields.initialLeaveBalance')"
input-class="w-full"
/>
<MalioInputText
v-model="form.nbChildren"
:label="$t('absences.admin.employees.fields.nbChildren')"
input-class="w-full"
/>
<div class="col-span-full mt-2 flex justify-end">
<MalioButton
:label="$t('absences.admin.employees.drawer.save')"
button-class="w-auto px-6"
:disabled="submitting"
@click="save"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { ContractType, FamilySituation, UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users'
const props = defineProps<{
modelValue: boolean
user: UserData | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'saved': []
}>()
const { t } = useI18n()
const { update } = useUserService()
const open = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
})
const submitting = ref(false)
const contractOptions = [
{ label: t('absences.admin.employees.contract.cdi'), value: 'CDI' },
{ label: t('absences.admin.employees.contract.cdd'), value: 'CDD' },
{ label: t('absences.admin.employees.contract.stage'), value: 'STAGE' },
{ label: t('absences.admin.employees.contract.alternance'), value: 'ALTERNANCE' },
{ label: t('absences.admin.employees.contract.autre'), value: 'AUTRE' },
]
const familyOptions = [
{ label: t('absences.admin.employees.family.celibataire'), value: 'CELIBATAIRE' },
{ label: t('absences.admin.employees.family.marie'), value: 'MARIE' },
{ label: t('absences.admin.employees.family.pacse'), value: 'PACSE' },
{ label: t('absences.admin.employees.family.divorce'), value: 'DIVORCE' },
{ label: t('absences.admin.employees.family.veuf'), value: 'VEUF' },
]
const form = reactive({
hireDate: null as string | null,
endDate: null as string | null,
contractType: null as ContractType | null,
familySituation: null as FamilySituation | null,
workTimeRatio: '1.0',
annualLeaveDays: '25',
referencePeriodStart: '06-01',
initialLeaveBalance: '0',
nbChildren: '0',
})
function hydrate(u: UserData | null) {
if (!u) return
form.hireDate = u.hireDate ? u.hireDate.slice(0, 10) : null
form.endDate = u.endDate ? u.endDate.slice(0, 10) : null
form.contractType = u.contractType ?? null
form.familySituation = u.familySituation ?? null
form.workTimeRatio = String(u.workTimeRatio ?? 1)
form.annualLeaveDays = String(u.annualLeaveDays ?? 25)
form.referencePeriodStart = u.referencePeriodStart ?? '06-01'
form.initialLeaveBalance = String(u.initialLeaveBalance ?? 0)
form.nbChildren = String(u.nbChildren ?? 0)
}
watch(() => props.modelValue, (isOpen) => {
if (isOpen) hydrate(props.user)
})
async function save() {
if (!props.user) return
submitting.value = true
try {
await update(props.user.id, {
isEmployee: true,
hireDate: form.hireDate || null,
endDate: form.endDate || null,
contractType: form.contractType,
familySituation: form.familySituation,
workTimeRatio: Number(form.workTimeRatio) || 1,
annualLeaveDays: Number(form.annualLeaveDays) || 0,
referencePeriodStart: form.referencePeriodStart || '06-01',
initialLeaveBalance: Number(form.initialLeaveBalance) || 0,
nbChildren: Number(form.nbChildren) || 0,
})
emit('saved')
open.value = false
} finally {
submitting.value = false
}
}
</script>
```
- [ ] **Step 2 : Vérifier la compilation**
Le serveur dev (`http://localhost:3002`) recompile à la sauvegarde. Vérifier qu'aucune erreur de compilation/HMR n'apparaît dans la console du terminal `make dev-nuxt` ni dans la console navigateur. (Le composant n'est pas encore monté ; cette étape ne fait que valider la syntaxe.)
- [ ] **Step 3 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/components/absence/EmployeeDrawer.vue
git commit -m "feat(absences) : drawer d'édition des informations employé"
```
---
### Task 3 : Onglet « Employés » dans `team-absences`
**Files:**
- Modify: `frontend/pages/team-absences.vue`
- [ ] **Step 1 : Ajouter l'import du service users et le type**
Après les imports existants (`useAbsenceHelpers`), ajouter :
```ts
import { useUserService } from "~/services/users";
import type { UserData } from "~/services/dto/user-data";
```
Et après la déclaration `type BalanceRow = ...`, ajouter le type de ligne :
```ts
type EmployeeRow = UserData & {
contractText: string;
cpTakenText: string;
cpRemainingText: string;
};
```
- [ ] **Step 2 : Ajouter l'onglet à `tabs`**
Remplacer le tableau `tabs` (qui se termine par l'onglet `balances`) en ajoutant l'entrée employés :
```ts
const tabs = [
{
key: "requests",
label: t("absences.admin.tabs.requests"),
icon: "mdi:format-list-bulleted",
},
{
key: "calendar",
label: t("absences.admin.tabs.calendar"),
icon: "mdi:calendar-month",
},
{
key: "balances",
label: t("absences.admin.tabs.balances"),
icon: "mdi:scale-balance",
},
{
key: "employees",
label: t("absences.admin.tabs.employees"),
icon: "mdi:account-group",
},
];
```
- [ ] **Step 3 : Ajouter l'état, les colonnes et les lignes de l'onglet**
Après `const balances = ref<AbsenceBalance[]>([]);`, ajouter :
```ts
const employees = ref<UserData[]>([]);
const employeeDrawerOpen = ref(false);
const selectedEmployee = ref<UserData | null>(null);
```
Après `const balanceRows = computed(...)`, ajouter colonnes + lignes :
```ts
const employeeColumns = [
{ key: "username", label: t("absences.admin.employees.columns.name") },
{ key: "contractText", label: t("absences.admin.employees.columns.contract") },
{ key: "cpTakenText", label: t("absences.admin.employees.columns.cpTaken") },
{ key: "cpRemainingText", label: t("absences.admin.employees.columns.cpRemaining") },
];
const employeeRows = computed<EmployeeRow[]>(() => {
// Map user.id -> solde CP de la période courante.
const cpByUser = new Map<number, AbsenceBalance>();
for (const b of balances.value) {
if (b.type === "cp") cpByUser.set(b.user.id, b);
}
const dash = t("absences.admin.employees.noContract");
return employees.value.map((u) => {
const cp = cpByUser.get(u.id);
return {
...u,
contractText: u.contractType ?? dash,
cpTakenText: cp ? formatDays(cp.taken) : dash,
cpRemainingText: cp ? formatDays(cp.available) : dash,
};
});
});
```
- [ ] **Step 4 : Ajouter le chargement et l'ouverture du drawer**
Après `async function loadBalances() {...}`, ajouter :
```ts
async function loadEmployees() {
const all = await useUserService().getAll();
employees.value = all.filter((u) => u.isEmployee);
}
function openEmployee(item: Record<string, unknown>) {
selectedEmployee.value = item as EmployeeRow;
employeeDrawerOpen.value = true;
}
```
Puis inclure `loadEmployees()` au montage. Remplacer le `onMounted` existant :
```ts
onMounted(async () => {
await Promise.all([reloadRequests(), loadBalances()]);
});
```
par :
```ts
onMounted(async () => {
await Promise.all([reloadRequests(), loadBalances(), loadEmployees()]);
});
```
- [ ] **Step 5 : Ajouter le slot d'onglet dans le template**
Juste après la fermeture du slot `</template>` de l'onglet `#balances` (avant `</MalioTabList>`), ajouter :
```vue
<!-- Employees -->
<template #employees>
<div class="min-h-[30rem] pt-10">
<MalioDataTable
:columns="employeeColumns"
:items="employeeRows"
:total-items="employeeRows.length"
:empty-message="$t('absences.admin.employees.empty')"
@row-click="openEmployee"
/>
</div>
</template>
```
- [ ] **Step 6 : Monter le drawer employé**
Après le composant `<AbsenceBalanceAdjustDrawer ... />` (avant `</div>` de fin de template), ajouter :
```vue
<EmployeeDrawer
v-model="employeeDrawerOpen"
:user="selectedEmployee"
@saved="loadEmployees"
/>
```
- [ ] **Step 7 : Vérification navigateur**
Aller sur `http://localhost:3002/team-absences`, onglet « Employés ». Vérifier : liste des users `isEmployee` avec Nom / Contrat / CP pris / CP restants ; clic sur une ligne ouvre le drawer ; aucune erreur console.
- [ ] **Step 8 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/pages/team-absences.vue
git commit -m "feat(absences) : onglet Employés (liste + ouverture drawer)"
```
---
### Task 4 : Allègement du `UserDrawer`
**Files:**
- Modify: `frontend/components/user/UserDrawer.vue`
- [ ] **Step 1 : Réduire le bloc RH du template à la seule case**
Remplacer le bloc (lignes ~74-107) :
```vue
<!-- RH / Absences -->
<div class="mt-6 border-t border-neutral-200 pt-4">
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
<div v-if="form.isEmployee" class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<!-- ... tous les champs détaillés ... -->
</div>
</div>
```
par :
```vue
<!-- RH / Absences -->
<div class="mt-6 border-t border-neutral-200 pt-4">
<MalioCheckbox v-model="form.isEmployee" label="Employé (soumis à la gestion des absences)" />
<p v-if="form.isEmployee" class="mt-2 text-xs text-neutral-500">
Les informations RH (contrat, dates, CP) se gèrent dans Absences équipe onglet Employés.
</p>
</div>
```
- [ ] **Step 2 : Nettoyer l'état du formulaire**
Dans `const form = reactive({...})`, supprimer les champs détaillés et ne garder que `isEmployee`. Résultat :
```ts
const form = reactive({
username: '',
password: '',
roles: [] as string[],
clientId: null as number | null,
allowedProjectIds: [] as number[],
isEmployee: false,
})
```
- [ ] **Step 3 : Nettoyer l'hydratation à l'ouverture**
Dans le `watch(() => props.modelValue, ...)`, supprimer toutes les lignes `form.hireDate = ...``form.nbChildren = ...` des deux branches (`props.item` et `else`). Conserver `form.isEmployee = props.item.isEmployee ?? false` (branche édition) et `form.isEmployee = false` (branche création).
- [ ] **Step 4 : Ne plus envoyer les champs détaillés dans le payload**
Dans `handleSubmit`, réduire le `payload` aux champs de compte + `isEmployee` :
```ts
const payload: UserWrite = {
username: form.username.trim(),
roles: form.roles,
client: form.clientId !== null ? `/api/clients/${form.clientId}` : null,
allowedProjects: form.clientId !== null
? form.allowedProjectIds.map((id) => `/api/projects/${id}`)
: [],
isEmployee: form.isEmployee,
}
if (form.password) {
payload.plainPassword = form.password
}
```
- [ ] **Step 5 : Supprimer les imports/constantes devenus inutiles**
Dans le `<script setup>` : supprimer `contractOptions` et `familyOptions` (constantes locales) ; retirer `ContractType, FamilySituation` de l'import `~/services/dto/user-data` (garder `UserData, UserWrite`). Vérifier qu'aucune autre référence ne subsiste.
Run: `cd frontend && grep -n "contractOptions\|familyOptions\|ContractType\|FamilySituation\|hireDate\|nbChildren" components/user/UserDrawer.vue`
Expected: aucune ligne (sortie vide).
- [ ] **Step 6 : Vérification navigateur**
Aller sur `http://localhost:3002/admin`, ouvrir un utilisateur. Vérifier : seule la case « Employé » + la note ; cocher/décocher et enregistrer fonctionne ; rouvrir un employé déjà renseigné depuis l'onglet Employés → ses champs RH sont intacts (non écrasés par l'enregistrement du UserDrawer). Aucune erreur console.
- [ ] **Step 7 : Commit** (sur feu vert utilisateur)
```bash
git add frontend/components/user/UserDrawer.vue
git commit -m "refactor(users) : UserDrawer ne gère plus que le flag Employé"
```
---
### Task 5 : Vérification de bout en bout
**Files:** aucun (vérification navigateur via Chrome DevTools MCP)
- [ ] **Step 1 : Flux complet**
1. `admin` → ouvrir un user non-employé → cocher « Employé » → enregistrer.
2. `team-absences` → onglet « Employés » → l'utilisateur apparaît.
3. Clic sur sa ligne → `EmployeeDrawer` s'ouvre → renseigner dates (JJ/MM/AAAA), contrat, CP annuels → enregistrer.
4. La liste se recharge ; rouvrir la ligne → valeurs persistées.
5. Retour `admin` sur le même user → seule la case « Employé » (toujours cochée), pas de champ RH.
- [ ] **Step 2 : Contrôle console**
Vérifier l'absence d'erreurs/warnings Vue sur les trois écrans (admin, onglet Employés, drawer).
- [ ] **Step 3 : Commit final éventuel** (sur feu vert utilisateur)
Si des ajustements ont été faits pendant la vérification, les committer avec un message approprié.
---
## Self-review (couverture du spec)
- UserDrawer réduit à la case « Employé » → Task 4. ✅
- Onglet « Employés » admin-only (page déjà `middleware: ["admin"]`) → Task 3. ✅
- Liste Nom · Contrat · CP pris · CP restants (users ⋈ soldes cp) → Task 3 (Steps 3-5). ✅
- Drawer d'édition en composants Malio (MalioDate/MalioSelect/MalioInputText) → Task 2. ✅
- Persistance via `usersService.update` (PATCH partiel) → Task 2 (Step 1, `save`). ✅
- i18n regroupé sous `absences.admin.employees.*` + onglet → Task 1. ✅
- Pas de placeholder, types cohérents (`EmployeeRow`, `UserData`, `ContractType`/`FamilySituation`), noms de méthodes alignés (`loadEmployees`, `openEmployee`, `save`). ✅

View File

@@ -0,0 +1,119 @@
# Refonte UX du formulaire « Nouvelle demande d'absence »
Date : 2026-05-21
Composant : `frontend/components/absence/AbsenceRequestDrawer.vue`
Branche : `feat/absence-management`
## Contexte & problème
Le formulaire actuel fonctionne mais est inutilisable côté UX :
- `VueDatePicker` brut **non thématisé** (couleur violette par défaut, calendrier au rendu anglo-saxon / semaine au dimanche) → aspect « cassé », rien à voir avec PayFit.
- `<label>` et `<input type="file">` bruts au lieu des composants Malio.
- **Aucune erreur explicite** : le bouton « Valider » est simplement grisé via `canSubmit` quand un champ manque, sans dire pourquoi.
- `preview` qui échoue en silence (`catch { preview.value = null }`).
- Solde insuffisant signalé par un simple warning ambre.
Cible : reproduire l'ergonomie PayFit (référence fournie par l'utilisateur) — apparition progressive des champs, deux champs date saisissables + popup, pills de demi-journée, lignes de solde, erreurs explicites.
## Objectifs
1. Look & ergonomie alignés sur PayFit, cohérents avec le design system Malio.
2. Apparition **progressive** des champs au fil des choix.
3. **Erreurs explicites** par champ, affichées au clic « Valider », qui se vident dès correction.
4. Champs de date en français `JJ / MM / AAAA`, calendrier FR / semaine au lundi.
5. Justificatif via `MalioInputUpload`, affiché seulement si le type l'exige.
## Hors périmètre
- Aucun changement backend : payload (`AbsenceRequestWrite`), endpoints `create` / `preview` / `uploadJustification` inchangés.
- Aucune modification des autres composants du module (traités séparément dans les points #2 à #7 de la revue).
## Layout cible (drawer `max-w-xl`, 1 colonne, apparition progressive)
**Étape 1 — toujours visible**
- `Type d'absence` : `MalioSelect` (options = policies actives). Erreur inline si vide au submit.
**Étape 2 — apparaît dès qu'un type est choisi**
- `Date de début` : champ texte `JJ / MM / AAAA` saisissable + icône calendrier (popup) + bouton effacer. `VueDatePicker` mono-date, thématisé (voir ci-dessous).
- Pills demi-journée début : `Journée entière` | `Matin` | `Après-midi` (défaut : Journée entière).
- Ligne `Solde au <date début> : X jours` (valeur = `preview.available`, alignée à droite, **non repliable**).
**Étape 3 — apparaît dès que la date de début est renseignée**
- `Date de fin` : même composant que la date de début.
- Pills demi-journée fin : `Journée entière` | `Matin` (défaut : Journée entière).
- Ligne `Durée de la demande : N jours` (valeur = `preview.countedDays`).
- Ligne `Solde après validation : Y jours` (valeur = `preview.projectedAvailable`, non repliable). Si `< 0` → bandeau ambre **non bloquant** « solde après = … j, demande soumise pour validation ».
**Étape 4**
- `Justificatif` : `MalioInputUpload`, affiché **uniquement si** `selectedPolicy.justificationRequired`. Label avec `*`. Erreur inline si requis et absent.
- `Commentaire` (optionnel) : `MalioInputTextArea`, placeholder « Écrire un commentaire… ».
**Footer**
- `[ Annuler (tertiary) ] [ Valider (primary) ]`, bouton **toujours actif**.
## Datepicker — thématisation
Reprendre le pattern de `frontend/components/ui/DateFilter.vue` :
- `VueDatePicker` mono-date, `:enable-time-picker="false"`, `auto-apply`, `text-input` activé (saisie clavier), `format``JJ/MM/AAAA`.
- `locale="fr"` (chaîne) + semaine au lundi (`week-start: 1`).
- Variables CSS `--dp-primary-color: #222783`, `--dp-border-color`, `--dp-hover-color`, `--dp-font-size`, `font-family: inherit` (scopées au composant).
- `:min-date` sur la date de fin = date de début ; `:max-date` sur la date de début = date de fin.
## Pills demi-journée → payload
Segment de boutons (style pill, bordure + fond bleu primaire quand sélectionné).
| Pill début | `startHalfDay` | Pill fin | `endHalfDay` |
|------------|----------------|----------|--------------|
| Journée entière | `null` | Journée entière | `null` |
| Matin | `matin` | Matin | `matin` |
| Après-midi | `apres_midi` | — | — |
Cas particulier — **demande sur une seule journée** (`startDate == endDate`) : seules les pills de début sont pertinentes (`Journée entière` / `Matin` / `Après-midi`) ; les pills de fin sont masquées et `endHalfDay` recopie `startHalfDay`. Le décompte reste calculé par `preview` côté backend.
## Validation
État réactif `errors: { type?: string; startDate?: string; endDate?: string; justification?: string }`.
`validate()` (au clic « Valider ») remplit `errors` :
- `type` manquant → `absences.form.errors.typeRequired`
- `startDate` manquante → `absences.form.errors.startRequired`
- `endDate` manquante → `absences.form.errors.endRequired`
- `endDate < startDate``absences.form.errors.endBeforeStart`
- `preview.countedDays <= 0``absences.form.errors.zeroDays`
- justificatif requis et absent → `absences.form.errors.justificationRequired`
Si `errors` non vide → on stoppe la soumission. Des `watch` vident chaque message dès que le champ correspondant redevient valide.
Le solde insuffisant **n'est pas** une erreur bloquante (seul le bandeau ambre).
### Erreurs serveur
- `service.create` / `uploadJustification` : le toast d'erreur de `useApi` reste ; en plus, un bandeau d'erreur en tête de formulaire affiche le message renvoyé (422 de validation).
- `preview` : conserver le `catch` pour les coupures réseau, mais ne plus masquer une erreur de validation de période (afficher le message si l'API en renvoie un).
## Calcul live (preview)
Inchangé sur le principe : `watch` debouncé (300 ms) sur `[type, startDate, endDate, startHalfDay, endHalfDay]``service.preview(payload)`. Les lignes de solde et de durée se mettent à jour à partir du résultat (`available`, `countedDays`, `projectedAvailable`).
## i18n
Nouvelles clés sous `absences.form.errors.*` dans `frontend/i18n/locales/fr.json` :
`typeRequired`, `startRequired`, `endRequired`, `endBeforeStart`, `zeroDays`, `justificationRequired`, plus `absences.form.balanceAt` (« Solde au {date} ») et `absences.form.duration` (« Durée de la demande »).
## Découpage du composant
`AbsenceRequestDrawer.vue` orchestre l'état du formulaire. Pour garder le fichier focalisé, extraire :
- `AbsenceDateField.vue` : champ date thématisé + pills demi-journée (props : `modelValue`, `halfValue`, `halfOptions`, `label`, `error`, `min`/`max`).
Le reste (lignes de solde, bandeau, footer) reste inline dans le drawer.
## Critères d'acceptation
- Au chargement, seul « Type d'absence » est visible ; les sections suivantes apparaissent au fur et à mesure.
- Les dates s'affichent et se saisissent en `JJ / MM / AAAA`, calendrier en français, semaine au lundi, thème bleu primaire.
- Cliquer « Valider » sans remplir → messages d'erreur explicites sous les champs concernés ; ils disparaissent dès correction.
- Un type à justificatif obligatoire affiche le champ d'upload et bloque la soumission tant qu'aucun fichier n'est fourni.
- Une période dépassant le solde affiche le bandeau ambre mais reste soumissible.
- Le payload envoyé au backend est identique à l'actuel.

View File

@@ -0,0 +1,96 @@
# Réorganisation de la gestion des employés (module Absences)
Date : 2026-05-22
Branche : `feat/absence-management`
Statut : design approuvé, prêt pour plan d'implémentation
## Contexte
Aujourd'hui, le `UserDrawer` (admin → utilisateurs) porte deux responsabilités mélangées :
1. l'administration du compte (nom, mot de passe, rôles, client, projets) ;
2. **tout le détail RH/employé** : case « Employé » + un bloc de champs (date d'embauche, date de sortie, type de contrat, situation familiale, temps de travail, CP annuels, début de période de référence, solde CP initial, nombre d'enfants).
Ces champs existent déjà sur l'entité `User` (backend) et dans le DTO `UserData`/`UserWrite` (frontend). La persistance se fait via l'API user (`PATCH /api/users/{id}`).
On veut séparer ces deux préoccupations : le `UserDrawer` ne décide plus que **si un utilisateur est un employé** ; l'édition des informations RH se fait dans un espace dédié, dans le module Absences.
## Objectifs
- `UserDrawer` : ne conserver que la case à cocher « Employé ».
- `team-absences` : ajouter un onglet « Employés » (la page est déjà `middleware: ["admin"]`, donc admin-only).
- L'onglet liste les utilisateurs marqués `isEmployee` avec leurs soldes de congés.
- Un drawer dédié permet d'éditer les informations RH d'un employé.
Hors périmètre : création d'utilisateur (reste dans l'admin), modification du flag `isEmployee` ailleurs que dans le `UserDrawer`, backend (déjà en place).
## Composants
### 1. `frontend/components/user/UserDrawer.vue`
- Supprimer le bloc détaillé employé (`hireDate`, `endDate`, `contractType`, `familySituation`, `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart`, `initialLeaveBalance`, `nbChildren`).
- Conserver **uniquement** la case `isEmployee` (« Employé (soumis à la gestion des absences) »).
- Le payload de sauvegarde du user **n'envoie plus** les champs détaillés, pour ne pas les écraser. Il continue d'envoyer `isEmployee` et les champs de compte existants.
- Nettoyer l'état du formulaire et les imports devenus inutiles (options contrat / situation familiale si elles ne servent plus que là).
### 2. `frontend/pages/team-absences.vue` — onglet « Employés »
- Ajouter un 4ᵉ onglet dans `tabs` : `{ key: 'employees', label: t('absences.admin.tabs.employees'), icon: 'mdi:account-group' }`.
- Slot `#employees` : `MalioDataTable` avec les colonnes **Nom · Contrat · CP pris · CP restants**.
- Clic sur une ligne → ouvre `EmployeeDrawer` avec l'utilisateur sélectionné (cohérent avec l'onglet Demandes qui ouvre le détail au clic ligne).
- Chargement des données :
- `usersService.getAll()` filtré sur `isEmployee === true` ;
- `absenceService.getBalances({ type: 'cp' })` → map par `user.id` pour récupérer `taken` (CP pris) et `available` (CP restants) ;
- fusion en lignes de tableau (`taken`/`available` à `—` si pas de solde CP pour l'employé).
- Recharger la liste après `saved` du drawer.
### 3. `frontend/components/absence/EmployeeDrawer.vue` (nouveau)
- Props : `modelValue: boolean`, `user: UserData | null`.
- Events : `update:modelValue`, `saved`.
- Formulaire en **composants Malio** :
- `MalioDate` : `hireDate`, `endDate` (valeurs ISO `YYYY-MM-DD`) ;
- `MalioSelect` : `contractType` (CDI/CDD/Stage/Alternance/Autre), `familySituation` (Célibataire/Marié/Pacsé/Divorcé/Veuf) ;
- `MalioInputText` : `workTimeRatio`, `annualLeaveDays`, `referencePeriodStart` (MM-DD), `initialLeaveBalance`, `nbChildren`.
- À l'ouverture, initialiser le formulaire depuis `props.user` ; remettre à jour si `user` change.
- Sauvegarde : `usersService.update(user.id, { …champs employé })` ; à la réussite, émettre `saved` et fermer.
- En-tête : nom de l'employé.
### 4. i18n (`frontend/i18n/locales/fr.json`)
Nouvelles clés regroupées sous `absences.admin.employees.*` (et l'onglet sous `absences.admin.tabs.employees`) :
- onglet : `absences.admin.tabs.employees` = « Employés » ;
- colonnes liste : `absences.admin.employees.columns.{name,contract,cpTaken,cpRemaining}` ;
- drawer : `absences.admin.employees.drawer.title` + libellés de champs sous `absences.admin.employees.fields.*`.
Les libellés de contrat et de situation familiale (CDI/CDD/…, Célibataire/Marié/…) actuellement codés en dur dans `UserDrawer` sont déplacés avec leur drawer ; on les passe en clés i18n à cette occasion.
## Flux de données
```
UserDrawer --PATCH isEmployee--> User (backend)
|
team-absences (onglet Employés) |
getAll() ∩ isEmployee <-----------+
getBalances({type:'cp'}) --> map user.id -> {taken, available}
=> lignes tableau (nom, contrat, CP pris, CP restants)
|
clic ligne
v
EmployeeDrawer(user) --usersService.update--> User (backend)
|
@saved --> recharge liste
```
## Découpage / responsabilités
- `UserDrawer` : compte + flag employé. Ne connaît plus le détail RH.
- Onglet Employés : vue dérivée en lecture (jointure users ⋈ soldes), aiguille vers le drawer.
- `EmployeeDrawer` : seule unité qui édite les champs RH ; interface claire (`user` en entrée, `saved` en sortie), testable isolément.
## Vérification
- `UserDrawer` : ouvrir un user, vérifier qu'il ne reste que la case « Employé », cocher/décocher et sauvegarder sans perte des autres champs RH (qui ne sont plus envoyés).
- Onglet Employés : la liste affiche les users `isEmployee` avec CP pris/restants cohérents avec l'onglet Soldes.
- `EmployeeDrawer` : éditer un employé (dates en JJ/MM/AAAA, selects FR), sauvegarder, vérifier la persistance (recharge) et l'absence d'erreurs console.
- Vérification navigateur via Chrome DevTools sur les trois écrans.