Recueillir à l'embauche ce qu'exige le contrat

Trois corrections sur le formulaire d'embauche, dont une qui explique le
blocage rencontré sur le planning.

L'équipe devient **requise** dès qu'un contrat est ouvert. Elle était
facultative, avec un « Aucune pour l'instant » qui paraissait anodin : le
planning s'ordonnant par équipe, le salarié n'apparaissait alors sur aucune
grille, et rien ne le signalait avant la première semaine à couvrir. Quand
l'établissement choisi n'a aucune équipe, le champ le dit et renvoie vers les
réglages plutôt que de laisser deviner. Le contrôle est repris côté serveur : la
validation du navigateur ne protège pas une action.

Le type de contrat n'a plus de valeur par défaut, et les neuf valeurs sont
rangées par ordre alphabétique. Un CDI pré-rempli est un type que personne n'a
choisi, alors qu'il commande les règles de durée et la déclaration ; et le
classement par fréquence supposée fait chercher les huit autres.

L'envoi des plannings par SMS disparaît, formulaire, dossier et colonne. Le
drapeau recueillait un consentement pour un canal qui n'a jamais existé et qu'il
n'est pas prévu de brancher. Un consentement conservé sans finalité est une
donnée collectée sans base légale : la minimisation impose de la supprimer, pas
de la garder au cas où.

Une divergence assumée avec le produit audité : la case « ouvrir un contrat
maintenant » reste. Il faut pouvoir créer un administrateur ou un manager invité
sans lui inventer un contrat de travail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-11 15:07:11 +02:00
1 parent 791dd523eb
commit d90afe53a0
8 files changed
+75 -59

No files matched your search

@@ -0,0 +1,11 @@
-- ---------------------------------------------------------------------------
-- Retrait de l'envoi des plannings par SMS.
--
-- Le drapeau était un consentement recueilli pour un canal qui n'a jamais
-- existé : aucun envoi de SMS n'est branché, et il n'est pas prévu de l'être.
-- Un consentement conservé sans finalité est une donnée collectée sans base
-- légale — la minimisation impose de la supprimer, pas de la garder « au cas
-- où ».
-- ---------------------------------------------------------------------------
ALTER TABLE "EmployeeProfile" DROP COLUMN IF EXISTS "smsSchedules";
-4
View File
@@ -603,10 +603,6 @@ model EmployeeProfile {
landline String? landline String?
personalEmail String? personalEmail String?
/// Envoi des plannings par SMS. Un consentement, donc explicitement faux
/// par défaut : la charge de la preuve pèse sur l'employeur.
smsSchedules Boolean @default(false)
socialSecurityNumberEnc Bytes? socialSecurityNumberEnc Bytes?
ibanEnc Bytes? ibanEnc Bytes?
bicEnc Bytes? bicEnc Bytes?
+60 -25
View File
@@ -25,18 +25,35 @@ import type {
const empty: ActionState = {}; const empty: ActionState = {};
/**
* Les neuf types de contrat, par ordre alphabétique.
*
* Pas de « CDI en premier » : le classement par fréquence supposée fait chercher
* les huit autres, et se lit comme un défaut à accepter. L'ordre alphabétique
* ne privilégie rien et se parcourt sans réfléchir.
*/
const CONTRACT_TYPES = [ const CONTRACT_TYPES = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'], ['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'], ['CDD', 'CDD'],
['SAISONNIER', 'Saisonnier'], ['CDI', 'CDI'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'], ['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'], ['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['SAISONNIER', 'Saisonnier'],
['STAGIAIRE', 'Stagiaire'],
] as const; ] as const;
/** Marque d'obligation, annoncée aussi aux lecteurs d'écran. */
function Required() {
return (
<span className="text-danger">
<span aria-hidden>*</span>
<span className="sr-only"> (requis)</span>
</span>
);
}
const selectClass = 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'; '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';
@@ -136,20 +153,6 @@ export function AddEmployeeForm({
/> />
<Field label="Téléphone mobile" name="phone" type="tel" /> <Field label="Téléphone mobile" name="phone" type="tel" />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Envoyer les plannings par SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" /> <Field label="Téléphone fixe" name="landline" type="tel" />
</Section> </Section>
@@ -186,9 +189,22 @@ export function AddEmployeeForm({
/> />
</div> </div>
{/* Aucune valeur par défaut : un type de contrat pré-rempli est
un type que personne n'a choisi, et il commande les règles de
durée comme la déclaration. */}
<label className="flex min-w-0 flex-col gap-1.5"> <label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span> <span className="text-sm font-medium">
<select name="contractType" className={selectClass}> Type de contrat <Required />
</span>
<select
name="contractType"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez un type
</option>
{CONTRACT_TYPES.map(([value, label]) => ( {CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}> <option key={value} value={value}>
{label} {label}
@@ -279,16 +295,35 @@ export function AddEmployeeForm({
</select> </select>
</label> </label>
{/* L'équipe est **requise** avec un contrat. Le planning
s'ordonne par équipe : un salarié rattaché à l'établissement
mais à aucune équipe n'apparaît sur aucune grille, et rien ne
le signale avant la première semaine à couvrir. */}
<label className="flex min-w-0 flex-col gap-1.5"> <label className="flex min-w-0 flex-col gap-1.5">
<span className="text-sm font-medium">Équipe</span> <span className="text-sm font-medium">
<select name="teamId" className={selectClass}> Équipe <Required />
<option value="">Aucune pour l’instant</option> </span>
<select
name="teamId"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez une équipe
</option>
{teams.map((team) => ( {teams.map((team) => (
<option key={team.id} value={team.id}> <option key={team.id} value={team.id}>
{team.name} {team.name}
</option> </option>
))} ))}
</select> </select>
{teams.length === 0 ? (
<span className="text-micro text-danger">
Cet établissement n’a aucune équipe : créez-en une dans
Réglages · Établissements avant d’ouvrir un contrat ici.
</span>
) : null}
</label> </label>
<label className="flex min-w-0 flex-col gap-1.5"> <label className="flex min-w-0 flex-col gap-1.5">
@@ -48,7 +48,6 @@ export interface ProfileFields {
personalEmail: string; personalEmail: string;
phone: string; phone: string;
landline: string; landline: string;
smsSchedules: boolean;
addressLine1: string; addressLine1: string;
addressLine2: string; addressLine2: string;
postalCode: string; postalCode: string;
@@ -217,20 +216,6 @@ export function PersonalInfoPanel({
defaultValue={profile.personalEmail} defaultValue={profile.personalEmail}
/> />
<Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} /> <Field label="Téléphone mobile" name="phone" type="tel" defaultValue={profile.phone} />
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
name="smsSchedules"
defaultChecked={profile.smsSchedules}
className="mt-0.5 size-4 accent-[var(--accent)]"
/>
<span>
<span className="font-medium">Notifications SMS</span>
<span className="block text-micro text-ink-3">
Consentement du salarié : à recueillir avant de l’activer.
</span>
</span>
</label>
<Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} /> <Field label="Téléphone fixe" name="landline" type="tel" defaultValue={profile.landline} />
<Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} /> <Field label="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field <Field
@@ -310,10 +295,6 @@ export function PersonalInfoPanel({
<InfoCard title="Coordonnées"> <InfoCard title="Coordonnées">
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} /> <InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
<InfoRow label="Téléphone mobile" value={profile.phone} tnum /> <InfoRow label="Téléphone mobile" value={profile.phone} tnum />
<InfoRow
label="Notifications SMS"
value={profile.smsSchedules ? 'Oui' : 'Non'}
/>
<InfoRow label="Téléphone fixe" value={profile.landline} tnum /> <InfoRow label="Téléphone fixe" value={profile.landline} tnum />
<InfoRow label="Adresse" value={profile.addressLine1} /> <InfoRow label="Adresse" value={profile.addressLine1} />
<InfoRow label="Complément d’adresse" value={profile.addressLine2} /> <InfoRow label="Complément d’adresse" value={profile.addressLine2} />
-1
View File
@@ -52,7 +52,6 @@ export default async function PersonalTab({
personalEmail: text(employee.profile.personalEmail), personalEmail: text(employee.profile.personalEmail),
phone: text(employee.profile.phone), phone: text(employee.profile.phone),
landline: text(employee.profile.landline), landline: text(employee.profile.landline),
smsSchedules: employee.profile.smsSchedules,
addressLine1: text(employee.profile.addressLine1), addressLine1: text(employee.profile.addressLine1),
addressLine2: text(employee.profile.addressLine2), addressLine2: text(employee.profile.addressLine2),
postalCode: text(employee.profile.postalCode), postalCode: text(employee.profile.postalCode),
+4 -4
View File
@@ -38,7 +38,6 @@ const employeeInput = z.object({
email: z.string().trim().email('Adresse invalide').or(z.literal('')), email: z.string().trim().email('Adresse invalide').or(z.literal('')),
phone: z.string().trim().max(30), phone: z.string().trim().max(30),
landline: z.string().trim().max(30), landline: z.string().trim().max(30),
smsSchedules: z.boolean(),
}); });
/** /**
@@ -50,7 +49,10 @@ const employeeInput = z.object({
*/ */
const hiringContractInput = z.object({ const hiringContractInput = z.object({
locationId: z.string().min(1, 'Établissement requis'), locationId: z.string().min(1, 'Établissement requis'),
teamId: z.string().trim(), // Requise avec un contrat : le planning s'ordonne par équipe, et un salarié
// sans rattachement n'apparaît sur aucune grille. Le formulaire l'impose déjà,
// mais le contrôle du navigateur ne protège pas une action serveur.
teamId: z.string().trim().min(1, 'Choisissez une équipe.'),
contractType: z.enum(CONTRACT_TYPES), contractType: z.enum(CONTRACT_TYPES),
startDate: z.coerce.date(), startDate: z.coerce.date(),
/** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */ /** Heure de prise de poste : la DPAE la demande, la date seule n'y suffit pas. */
@@ -92,7 +94,6 @@ export async function createEmployeeAction(
email: formData.get('email') ?? '', email: formData.get('email') ?? '',
phone: formData.get('phone') ?? '', phone: formData.get('phone') ?? '',
landline: formData.get('landline') ?? '', landline: formData.get('landline') ?? '',
smsSchedules: formData.get('smsSchedules') === 'on',
}); });
if (!parsed.success) { if (!parsed.success) {
@@ -225,7 +226,6 @@ export async function createEmployeeAction(
personalEmail: parsed.data.email || null, personalEmail: parsed.data.email || null,
phone: parsed.data.phone || null, phone: parsed.data.phone || null,
landline: parsed.data.landline || null, landline: parsed.data.landline || null,
smsSchedules: parsed.data.smsSchedules,
} as never, } as never,
}); });
-4
View File
@@ -91,7 +91,6 @@ const profileInput = z.object({
.transform((value) => (value === '' ? null : value)), .transform((value) => (value === '' ? null : value)),
phone: optionalText(30), phone: optionalText(30),
landline: optionalText(30), landline: optionalText(30),
smsSchedules: z.boolean(),
addressLine1: optionalText(180), addressLine1: optionalText(180),
addressLine2: optionalText(180), addressLine2: optionalText(180),
postalCode: optionalText(12), postalCode: optionalText(12),
@@ -132,9 +131,6 @@ export async function updateProfileAction(
'emergencyContactPhone', 'emergencyContactPhone',
].map((key) => [key, formData.get(key) ?? '']), ].map((key) => [key, formData.get(key) ?? '']),
), ),
// Une case décochée n'est pas envoyée : son absence vaut « non », et la
// lire comme une chaîne vide en ferait une valeur inconnue.
smsSchedules: formData.get('smsSchedules') === 'on',
}); });
if (!parsed.success) { if (!parsed.success) {
-2
View File
@@ -285,7 +285,6 @@ export interface EmployeeProfileDetail {
country: string | null; country: string | null;
phone: string | null; phone: string | null;
landline: string | null; landline: string | null;
smsSchedules: boolean;
personalEmail: string | null; personalEmail: string | null;
emergencyContactName: string | null; emergencyContactName: string | null;
emergencyContactPhone: string | null; emergencyContactPhone: string | null;
@@ -487,7 +486,6 @@ export const getEmployee = cache(async function getEmployee(
country: membership.profile.country, country: membership.profile.country,
phone: membership.profile.phone, phone: membership.profile.phone,
landline: membership.profile.landline, landline: membership.profile.landline,
smsSchedules: membership.profile.smsSchedules,
personalEmail: membership.profile.personalEmail, personalEmail: membership.profile.personalEmail,
emergencyContactName: membership.profile.emergencyContactName, emergencyContactName: membership.profile.emergencyContactName,
emergencyContactPhone: membership.profile.emergencyContactPhone, emergencyContactPhone: membership.profile.emergencyContactPhone,