Files
Lesstime/docs/superpowers/plans/2026-05-22-employee-management-reorg.md
Matthieu 2a0b202d32 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>
2026-05-22 11:31:31 +02:00

20 KiB

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 :

"tabs": { "requests": "Demandes", "calendar": "Calendrier", "balances": "Soldes" },

le remplacer par :

"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 :

"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)
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 :

<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)
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 :

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 :

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 :

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 :

const employees = ref<UserData[]>([]);
const employeeDrawerOpen = ref(false);
const selectedEmployee = ref<UserData | null>(null);

Après const balanceRows = computed(...), ajouter colonnes + lignes :

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 :

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 :

onMounted(async () => {
    await Promise.all([reloadRequests(), loadBalances()]);
});

par :

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 :

            <!-- 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 :

        <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)
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) :

            <!-- 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 :

            <!-- 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 :

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 :

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