From 91f3b240e1b17fe7919166c7a960e978a7af51fd Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 12:02:45 +0200 Subject: [PATCH] Poser le contrat au moment de l'embauche MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créer un salarié demandait quatre champs et produisait un dossier vide : sans contrat il n'apparaît sur aucune grille et ne se déclare pas, sans équipe il ne s'ordonne nulle part — et jusqu'ici aucun écran ne permettait de rattraper l'un ni l'autre. Le contrat est proposé coché, et reste décochable : un remplaçant se saisit parfois avant que son établissement soit tranché. Dossier, contrat et rattachement entrent dans la même transaction. Créés séparément, un incident au milieu laisserait exactement le dossier incomplet que ce changement vise à supprimer. Ouvrir un contrat reste une capacité distincte de celle de créer un dossier : un gestionnaire d'annuaire n'engage pas l'entreprise. Le refus le dit, et propose de créer le salarié sans contrat. Co-Authored-By: Claude Opus 5 --- src/app/(app)/equipe/AddEmployeeForm.tsx | 204 +++++++++++++++++++++-- src/app/(app)/equipe/page.tsx | 11 +- src/server/employees/actions.ts | 149 ++++++++++++++++- src/server/employees/queries.ts | 20 ++- tests/e2e/conservation.spec.ts | 6 + tests/e2e/documents.spec.ts | 3 + tests/e2e/fiche.spec.ts | 37 ++++ tests/e2e/invitation.spec.ts | 3 + tests/e2e/registre.spec.ts | 3 + tests/e2e/roles.spec.ts | 3 + tests/e2e/securite.spec.ts | 3 + 11 files changed, 423 insertions(+), 19 deletions(-) diff --git a/src/app/(app)/equipe/AddEmployeeForm.tsx b/src/app/(app)/equipe/AddEmployeeForm.tsx index 3978a38..53079f9 100644 --- a/src/app/(app)/equipe/AddEmployeeForm.tsx +++ b/src/app/(app)/equipe/AddEmployeeForm.tsx @@ -1,25 +1,64 @@ 'use client'; -import { useActionState } from 'react'; +import { useActionState, useState } from 'react'; +import { Button } from '@/components/ui/Button'; import { Field, FormError, SubmitButton } from '@/components/ui/Form'; import { PersistentForm } from '@/components/ui/PersistentForm'; import { createEmployeeAction, type ActionState, } from '@/server/employees/actions'; +import type { ContractLocation } from '@/server/employees/queries'; -export function AddEmployeeForm() { +/** + * Embauche. + * + * Un salarié créé sans contrat ni équipe est un dossier que rien ne rattache : + * il n'apparaît sur aucune grille et ne se déclare pas. Le contrat est donc + * proposé d'emblée — et reste décochable, parce qu'un remplaçant se saisit + * parfois avant que son établissement soit tranché. + */ + +const empty: ActionState = {}; + +const CONTRACT_TYPES = [ + ['CDI', 'CDI'], + ['CDD', 'CDD'], + ['APPRENTISSAGE', 'Apprentissage'], + ['STAGIAIRE', 'Stagiaire'], + ['SAISONNIER', 'Saisonnier'], + ['EXTRA', 'Extra'], + ['INTERIM', 'Intérim'], + ['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'], + ['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'], +] as const; + +const selectClass = + 'h-9 min-w-0 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus'; + +export function AddEmployeeForm({ + locations, +}: { + /** Vide quand la capacité d'ouvrir un contrat manque : la section disparaît. */ + locations: ContractLocation[]; +}) { const [state, formAction] = useActionState( createEmployeeAction, - {}, + empty, ); + const [withContract, setWithContract] = useState(locations.length > 0); + const [locationId, setLocationId] = useState(locations[0]?.id ?? ''); + const [forfait, setForfait] = useState(false); + + const teams = + locations.find((location) => location.id === locationId)?.teams ?? []; return (
@@ -27,12 +66,147 @@ export function AddEmployeeForm() {
- +
+ + +
+ + {locations.length > 0 ? ( + <> + + + {withContract ? ( +
+
+ + + + + +
+ +
+ + +
+ +
+ + + {forfait ? ( + + ) : ( + + )} +
+ + {forfait ? ( +
+ + +
+ ) : null} +
+ ) : null} + + ) : null} {state.error} @@ -41,6 +215,16 @@ export function AddEmployeeForm() { {state.ok ? ( Salarié ajouté. ) : null} + {withContract ? null : ( + + )}
); diff --git a/src/app/(app)/equipe/page.tsx b/src/app/(app)/equipe/page.tsx index 9f7ba89..3825ff6 100644 --- a/src/app/(app)/equipe/page.tsx +++ b/src/app/(app)/equipe/page.tsx @@ -6,7 +6,10 @@ import { DirectoryFilters } from '@/app/(app)/equipe/DirectoryFilters'; import { PageBody, PageHeader } from '@/components/shell/PageHeader'; import { Badge, type Tone } from '@/components/ui/Badge'; import { Card, CardHeader, EmptyState } from '@/components/ui/Card'; -import { listEmployees } from '@/server/employees/queries'; +import { + listContractLocations, + listEmployees, +} from '@/server/employees/queries'; export const metadata = { title: 'Équipe · PlanFlow' }; export const dynamic = 'force-dynamic'; @@ -46,6 +49,10 @@ export default async function EquipePage({ ...(one(params.tri) === 'number' ? { sort: 'number' as const } : {}), }); + // Sans la capacité d'ouvrir un contrat, la liste revient vide et le + // formulaire s'en tient à l'identité. + const hiringLocations = await listContractLocations().catch(() => []); + const filtered = directory.rows.length !== directory.total; return ( @@ -185,7 +192,7 @@ export default async function EquipePage({
- +
diff --git a/src/server/employees/actions.ts b/src/server/employees/actions.ts index 455670f..319f3ae 100644 --- a/src/server/employees/actions.ts +++ b/src/server/employees/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from 'next/cache'; import { z } from 'zod'; -import { AuthorizationError } from '@/domain/access/authorize'; +import { AuthorizationError, can } from '@/domain/access/authorize'; import { findOverlaps, validateContract } from '@/domain/contracts/rules'; import { recordAudit } from '@/server/audit'; import { mutate } from '@/server/context'; @@ -31,14 +31,39 @@ const employeeInput = z.object({ employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40), /** Vide = salarié géré sans accès applicatif. */ email: z.string().trim().email('Adresse invalide').or(z.literal('')), + phone: z.string().trim().max(30), }); /** - * Crée un salarié. + * Contrat posé au moment de l'embauche. + * + * Facultatif dans le formulaire, mais tout ou rien une fois entamé : un + * établissement sans date de début produirait un contrat qu'aucune règle ne + * sait valider. + */ +const hiringContractInput = z.object({ + locationId: z.string().min(1, 'Établissement requis'), + teamId: z.string().trim(), + contractType: z.enum(CONTRACT_TYPES), + startDate: z.coerce.date(), + endDate: z.string().trim(), + workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']), + weeklyHours: z.coerce.number().min(0).max(60), + forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(), + forfaitAgreementRef: z.string().trim(), + forfaitAgreedAt: z.string().trim(), +}); + +/** + * Crée un salarié, et l'embauche s'il y a lieu. * * Un salarié **sans compte utilisateur** doit rester créable : la plupart des * équipes de vente ne se connectent jamais à l'outil, et exiger une adresse * électronique les rendrait impossibles à planifier ou à déclarer. + * + * Le contrat et l'équipe sont posés dans la **même transaction** que le + * dossier. Créés séparément, un incident au milieu laisserait un salarié sans + * contrat — c'est-à-dire un dossier qu'aucun écran ne permettait de rattraper. */ export async function createEmployeeAction( _previous: ActionState, @@ -49,14 +74,71 @@ export async function createEmployeeAction( lastName: formData.get('lastName'), employeeNumber: formData.get('employeeNumber'), email: formData.get('email') ?? '', + phone: formData.get('phone') ?? '', }); if (!parsed.success) { return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' }; } + // Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié + // sans contrat reste légitime — un remplaçant se saisit avant que son + // établissement soit tranché. + const wantsContract = formData.get('withContract') === 'on'; + let contract: z.infer | null = null; + let contractEnd: Date | null = null; + let forfaitAgreedAt: Date | null = null; + + if (wantsContract) { + const parsedContract = hiringContractInput.safeParse({ + locationId: formData.get('locationId') ?? '', + teamId: formData.get('teamId') ?? '', + contractType: formData.get('contractType') ?? 'CDI', + startDate: formData.get('startDate'), + endDate: formData.get('endDate') ?? '', + workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY', + weeklyHours: formData.get('weeklyHours') || 35, + forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined, + forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '', + forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '', + }); + + if (!parsedContract.success) { + return { + error: + parsedContract.error.issues[0]?.message ?? 'Contrat invalide', + }; + } + + contract = parsedContract.data; + contractEnd = contract.endDate ? new Date(contract.endDate) : null; + forfaitAgreedAt = contract.forfaitAgreedAt + ? new Date(contract.forfaitAgreedAt) + : null; + + const issues = validateContract({ + startDate: contract.startDate, + endDate: contractEnd, + workTimeArrangement: contract.workTimeArrangement, + weeklyHours: contract.weeklyHours, + forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null, + forfaitAgreementRef: contract.forfaitAgreementRef || null, + forfaitAgreedAt, + }); + + if (issues.length > 0) { + return { error: issues[0]?.message ?? 'Contrat invalide' }; + } + } + try { await mutate('members.create', async (db, actor) => { + // Poser un contrat est une capacité distincte de celle de créer un + // dossier : un gestionnaire d'annuaire n'engage pas l'entreprise. + if (contract && !can(actor, 'members.contract.create')) { + throw new AuthorizationError('members.contract.create'); + } + const role = await db.role.findFirst({ where: { key: 'employee' } }); if (!role) throw new Error('Rôle « employee » introuvable.'); @@ -81,9 +163,64 @@ export async function createEmployeeAction( firstName: parsed.data.firstName, lastName: parsed.data.lastName, personalEmail: parsed.data.email || null, + phone: parsed.data.phone || null, } as never, }); + if (contract) { + const location = await db.location.findUnique({ + where: { id: contract.locationId }, + }); + if (!location) { + throw new ValidationError('Établissement introuvable.'); + } + + const openedContract = await db.userContract.create({ + data: { + membershipId: created.id, + locationId: location.id, + contractType: contract.contractType, + startDate: contract.startDate, + endDate: contractEnd, + workTimeArrangement: contract.workTimeArrangement, + weeklyHours: contract.weeklyHours, + forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null, + forfaitAgreementRef: contract.forfaitAgreementRef || null, + forfaitAgreedAt, + } as never, + }); + + // Sans équipe, le salarié n'apparaît sur aucune grille : le planning + // s'ordonne par équipe, et un rattachement omis se paie à la première + // semaine à couvrir. + if (contract.teamId) { + const team = await db.team.findUnique({ + where: { id: contract.teamId }, + select: { id: true, locationId: true }, + }); + if (!team || team.locationId !== location.id) { + throw new ValidationError( + 'Cette équipe n’appartient pas à l’établissement choisi.', + ); + } + await db.teamMember.create({ + data: { teamId: team.id, membershipId: created.id } as never, + }); + } + + await recordAudit(db, { + actorMembershipId: actor.membershipId, + action: 'contract.create', + entityType: 'UserContract', + entityId: openedContract.id, + after: { + contractType: openedContract.contractType, + startDate: openedContract.startDate.toISOString(), + workTimeArrangement: openedContract.workTimeArrangement, + }, + }); + } + await recordAudit(db, { actorMembershipId: actor.membershipId, action: 'membership.create', @@ -92,13 +229,19 @@ export async function createEmployeeAction( after: { employeeNumber: created.employeeNumber, hasAccount: Boolean(parsed.data.email), + withContract: Boolean(contract), }, }); }); } catch (error) { if (error instanceof ValidationError) return { error: error.message }; if (error instanceof AuthorizationError) { - return { error: "Vous n'avez pas le droit de créer un salarié." }; + return { + error: + error.permission === 'members.contract.create' + ? "Vous n'avez pas le droit d'ouvrir un contrat. Créez le salarié sans contrat, ou faites-le poser." + : "Vous n'avez pas le droit de créer un salarié.", + }; } throw error; } diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index f63098e..c3e5040 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -535,14 +535,26 @@ export const getEmployee = cache(async function getEmployee( * d'administrer les établissements, et emprunter `settings.access` fermerait * la création à un gestionnaire de paie qui n'a rien à y administrer. */ +export interface ContractLocation { + id: string; + name: string; + teams: Array<{ id: string; name: string }>; +} + export const listContractLocations = cache( - async function listContractLocations(): Promise< - Array<{ id: string; name: string }> - > { + async function listContractLocations(): Promise { return query('members.contract.create', async (db) => db.location.findMany({ where: { archivedAt: null }, - select: { id: true, name: true }, + select: { + id: true, + name: true, + teams: { + where: { archivedAt: null }, + select: { id: true, name: true }, + orderBy: { position: 'asc' }, + }, + }, orderBy: { name: 'asc' }, }), ); diff --git a/tests/e2e/conservation.spec.ts b/tests/e2e/conservation.spec.ts index 28505ef..004ee6f 100644 --- a/tests/e2e/conservation.spec.ts +++ b/tests/e2e/conservation.spec.ts @@ -117,6 +117,9 @@ test('une pièce sans politique n’est jamais purgée', async ({ page }) => { await create.getByLabel('Prénom').fill('Garde'); await create.getByLabel('Nom', { exact: true }).fill(`Garde${suffix}`); await create.getByLabel('Matricule').fill(`RET${suffix}`); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); await create.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); @@ -168,6 +171,9 @@ test('une pièce échue est effectivement effacée', async ({ page }) => { await create.getByLabel('Prénom').fill('Purge'); await create.getByLabel('Nom', { exact: true }).fill(`Purge${suffix}`); await create.getByLabel('Matricule').fill(`PUR${suffix}`); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); await create.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); diff --git a/tests/e2e/documents.spec.ts b/tests/e2e/documents.spec.ts index 3ceabf8..266b87a 100644 --- a/tests/e2e/documents.spec.ts +++ b/tests/e2e/documents.spec.ts @@ -31,6 +31,9 @@ async function createEmployee(page: Page, tag: string) { await form.getByLabel('Prénom').fill('Inès'); await form.getByLabel('Nom', { exact: true }).fill(lastName); await form.getByLabel('Matricule').fill(`DOC${suffix}`); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); await form.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); diff --git a/tests/e2e/fiche.spec.ts b/tests/e2e/fiche.spec.ts index 4d55100..7702b97 100644 --- a/tests/e2e/fiche.spec.ts +++ b/tests/e2e/fiche.spec.ts @@ -18,6 +18,9 @@ async function createEmployee(page: Page) { await form.getByLabel('Prénom').fill('Awa'); await form.getByLabel('Nom', { exact: true }).fill(lastName); await form.getByLabel('Matricule').fill(`FIC${suffix}`); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); await form.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); @@ -88,3 +91,37 @@ test('une date de naissance à venir est refusée', async ({ page }) => { await expect(page.getByText('Date de naissance invalide.')).toBeVisible(); }); + +test('une embauche pose le contrat en même temps que le dossier', async ({ + page, +}) => { + const suffix = `${Date.now()}-embauche`; + const lastName = `Embauche${suffix}`; + + await page.goto('/equipe'); + const form = page.locator('form').filter({ hasText: 'Ajouter' }); + await form.getByLabel('Prénom').fill('Sofia'); + await form.getByLabel('Nom', { exact: true }).fill(lastName); + await form.getByLabel('Matricule').fill(`EMB${suffix}`); + + // Le contrat est proposé coché : un dossier créé sans lui n'apparaît sur + // aucune grille et ne se déclare pas. + await expect( + form.getByLabel('Ouvrir un contrat maintenant'), + ).toBeChecked(); + await form.getByLabel('Début du contrat').fill('2026-01-05'); + await form.getByRole('button', { name: 'Ajouter' }).click(); + await expect(page.getByText('Salarié ajouté.')).toBeVisible(); + + // Le contrat est visible depuis l'annuaire, sans passer par la fiche. + await expect(page.getByRole('row', { name: new RegExp(lastName) })).toContainText( + 'CDI', + ); + + await page.getByRole('link', { name: new RegExp(lastName) }).click(); + // Le bandeau porte le contrat : c'est ce qui distingue un dossier embauché + // d'un dossier ouvert. + await expect(page.getByText('Type de contrat')).toBeVisible(); + await page.getByRole('link', { name: 'Contrats', exact: true }).click(); + await expect(page.getByText('35 heures hebdomadaires')).toBeVisible(); +}); diff --git a/tests/e2e/invitation.spec.ts b/tests/e2e/invitation.spec.ts index 573743d..9dad379 100644 --- a/tests/e2e/invitation.spec.ts +++ b/tests/e2e/invitation.spec.ts @@ -26,6 +26,9 @@ async function createEmployee(page: Page, tag: string) { await form.getByLabel('Nom', { exact: true }).fill(lastName); await form.getByLabel('Matricule').fill(`E2E${suffix}`); await form.getByLabel('Adresse électronique').fill(email); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); await form.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); diff --git a/tests/e2e/registre.spec.ts b/tests/e2e/registre.spec.ts index 58fc4c5..2d75f8c 100644 --- a/tests/e2e/registre.spec.ts +++ b/tests/e2e/registre.spec.ts @@ -59,6 +59,9 @@ test('un salarié sans compte applicatif est créable', async ({ page }) => { await form.locator('input[name="firstName"]').fill('Sans'); await form.locator('input[name="lastName"]').fill(nom); await form.locator('input[name="employeeNumber"]').fill(matricule); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); await page.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); diff --git a/tests/e2e/roles.spec.ts b/tests/e2e/roles.spec.ts index 4556a40..7f4e87e 100644 --- a/tests/e2e/roles.spec.ts +++ b/tests/e2e/roles.spec.ts @@ -163,6 +163,9 @@ async function inviteWithRole(page: Page, roleLabel: string) { await create.getByLabel('Nom', { exact: true }).fill(lastName); await create.getByLabel('Matricule').fill(`ROL${suffix}`); await create.getByLabel('Adresse électronique').fill(email); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await create.getByLabel('Ouvrir un contrat maintenant').uncheck(); await create.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible(); diff --git a/tests/e2e/securite.spec.ts b/tests/e2e/securite.spec.ts index f6000f2..a84fc86 100644 --- a/tests/e2e/securite.spec.ts +++ b/tests/e2e/securite.spec.ts @@ -156,6 +156,9 @@ async function createAccessibleEmployee(page: Page) { await form.getByLabel('Nom', { exact: true }).fill(lastName); await form.getByLabel('Matricule').fill(`MFA${suffix}`); await form.getByLabel('Adresse électronique').fill(email); + // Le formulaire propose d’ouvrir un contrat d’emblée : ce parcours n’en veut + // pas, et un salarié sans contrat doit rester créable. + await form.getByLabel('Ouvrir un contrat maintenant').uncheck(); await form.getByRole('button', { name: 'Ajouter' }).click(); await expect(page.getByText('Salarié ajouté.')).toBeVisible();