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();