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?
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?
ibanEnc Bytes?
bicEnc Bytes?
+60 -25
View File
@@ -25,18 +25,35 @@ import type {
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 = [
['CDI', 'CDI'],
['CDD', 'CDD'],
['APPRENTISSAGE', 'Apprentissage'],
['STAGIAIRE', 'Stagiaire'],
['SAISONNIER', 'Saisonnier'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['CDD', 'CDD'],
['CDI', 'CDI'],
['DIRIGEANT_ASSIMILE_SALARIE', 'Dirigeant assimilé salarié'],
['DIRIGEANT_NON_SALARIE', 'Dirigeant non salarié'],
['EXTRA', 'Extra'],
['INTERIM', 'Intérim'],
['SAISONNIER', 'Saisonnier'],
['STAGIAIRE', 'Stagiaire'],
] 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 =
'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" />
<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" />
</Section>
@@ -186,9 +189,22 @@ export function AddEmployeeForm({
/>
</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">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
<span className="text-sm font-medium">
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]) => (
<option key={value} value={value}>
{label}
@@ -279,16 +295,35 @@ export function AddEmployeeForm({
</select>
</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">
<span className="text-sm font-medium">Équipe</span>
<select name="teamId" className={selectClass}>
<option value="">Aucune pour l’instant</option>
<span className="text-sm font-medium">
Équipe <Required />
</span>
<select
name="teamId"
required
defaultValue=""
className={selectClass}
>
<option value="" disabled>
Sélectionnez une équipe
</option>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</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 className="flex min-w-0 flex-col gap-1.5">
@@ -48,7 +48,6 @@ export interface ProfileFields {
personalEmail: string;
phone: string;
landline: string;
smsSchedules: boolean;
addressLine1: string;
addressLine2: string;
postalCode: string;
@@ -217,20 +216,6 @@ export function PersonalInfoPanel({
defaultValue={profile.personalEmail}
/>
<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="Adresse" name="addressLine1" defaultValue={profile.addressLine1} />
<Field
@@ -310,10 +295,6 @@ export function PersonalInfoPanel({
<InfoCard title="Coordonnées">
<InfoRow label="Adresse électronique personnelle" value={profile.personalEmail} />
<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="Adresse" value={profile.addressLine1} />
<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),
phone: text(employee.profile.phone),
landline: text(employee.profile.landline),
smsSchedules: employee.profile.smsSchedules,
addressLine1: text(employee.profile.addressLine1),
addressLine2: text(employee.profile.addressLine2),
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('')),
phone: 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({
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),
startDate: z.coerce.date(),
/** 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') ?? '',
phone: formData.get('phone') ?? '',
landline: formData.get('landline') ?? '',
smsSchedules: formData.get('smsSchedules') === 'on',
});
if (!parsed.success) {
@@ -225,7 +226,6 @@ export async function createEmployeeAction(
personalEmail: parsed.data.email || null,
phone: parsed.data.phone || null,
landline: parsed.data.landline || null,
smsSchedules: parsed.data.smsSchedules,
} as never,
});
-4
View File
@@ -91,7 +91,6 @@ const profileInput = z.object({
.transform((value) => (value === '' ? null : value)),
phone: optionalText(30),
landline: optionalText(30),
smsSchedules: z.boolean(),
addressLine1: optionalText(180),
addressLine2: optionalText(180),
postalCode: optionalText(12),
@@ -132,9 +131,6 @@ export async function updateProfileAction(
'emergencyContactPhone',
].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) {
-2
View File
@@ -285,7 +285,6 @@ export interface EmployeeProfileDetail {
country: string | null;
phone: string | null;
landline: string | null;
smsSchedules: boolean;
personalEmail: string | null;
emergencyContactName: string | null;
emergencyContactPhone: string | null;
@@ -487,7 +486,6 @@ export const getEmployee = cache(async function getEmployee(
country: membership.profile.country,
phone: membership.profile.phone,
landline: membership.profile.landline,
smsSchedules: membership.profile.smsSchedules,
personalEmail: membership.profile.personalEmail,
emergencyContactName: membership.profile.emergencyContactName,
emergencyContactPhone: membership.profile.emergencyContactPhone,