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`). ✅