Poser le contrat au moment de l'embauche

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 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 12:02:45 +02:00
1 parent 5a113de02f
commit 91f3b240e1
11 files changed
+423 -19

No files matched your search

+194 -10
View File
@@ -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<ActionState, FormData>(
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 (
<PersistentForm
action={formAction}
resetAfter={state.ok ? state : null}
className="flex flex-col gap-3"
className="flex flex-col gap-4"
>
<div className="flex flex-wrap gap-3">
<Field label="Prénom" name="firstName" required />
@@ -27,12 +66,147 @@ export function AddEmployeeForm() {
<Field label="Matricule" name="employeeNumber" required />
</div>
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Un salarié sans adresse reste plannifiable et déclarable ; il n'aura simplement pas d'accès à l'application."
/>
<div className="flex flex-wrap gap-3">
<Field
label="Adresse électronique"
name="email"
type="email"
hint="Facultative. Sans adresse, le salarié reste planifiable et déclarable ; il n’aura simplement pas d’accès à l’application."
/>
<Field label="Téléphone mobile" name="phone" type="tel" />
</div>
{locations.length > 0 ? (
<>
<label className="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="withContract"
checked={withContract}
onChange={(event) => setWithContract(event.target.checked)}
className="size-4 accent-[var(--accent)]"
/>
Ouvrir un contrat maintenant
</label>
{withContract ? (
<div className="flex flex-col gap-3 rounded-3 border border-line-1 bg-surface-2 p-4">
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Établissement</span>
<select
name="locationId"
className={selectClass}
value={locationId}
onChange={(event) => setLocationId(event.target.value)}
>
{locations.map((location) => (
<option key={location.id} value={location.id}>
{location.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 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>
{teams.map((team) => (
<option key={team.id} value={team.id}>
{team.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">Type de contrat</span>
<select name="contractType" className={selectClass}>
{CONTRACT_TYPES.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<div className="flex flex-wrap gap-3">
<Field
label="Début du contrat"
name="startDate"
type="date"
required={withContract}
/>
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Vide pour un contrat sans terme."
/>
</div>
<div className="flex flex-wrap gap-3">
<label className="flex min-w-0 flex-1 flex-col gap-1.5">
<span className="text-sm font-medium">
Organisation du temps
</span>
<select
name="workTimeArrangement"
className={selectClass}
onChange={(event) =>
setForfait(event.target.value === 'FORFAIT_JOURS')
}
>
<option value="HOURLY">Décompte horaire</option>
<option value="FORFAIT_JOURS">Forfait jours</option>
</select>
</label>
{forfait ? (
<Field
label="Jours par an"
name="forfaitDaysPerYear"
type="number"
step="0.5"
min="0"
max="218"
defaultValue="218"
/>
) : (
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue="35"
/>
)}
</div>
{forfait ? (
<div className="flex flex-wrap gap-3">
<Field
label="Référence de la convention de forfait"
name="forfaitAgreementRef"
required
hint="Sans convention individuelle écrite, le forfait est inopposable."
/>
<Field
label="Date d’accord du salarié"
name="forfaitAgreedAt"
type="date"
required
/>
</div>
) : null}
</div>
) : null}
</>
) : null}
<FormError>{state.error}</FormError>
@@ -41,6 +215,16 @@ export function AddEmployeeForm() {
{state.ok ? (
<span className="text-xs text-ok-soft-ink">Salarié ajouté.</span>
) : null}
{withContract ? null : (
<Button
type="button"
variant="ghost"
onClick={() => setWithContract(true)}
disabled={locations.length === 0}
>
Ajouter un contrat
</Button>
)}
</div>
</PersistentForm>
);
+9 -2
View File
@@ -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({
<Card>
<CardHeader title="Ajouter un collaborateur" />
<div className="p-4">
<AddEmployeeForm />
<AddEmployeeForm locations={hiringLocations} />
</div>
</Card>
</PageBody>