Rendre le contrat vivant depuis sa fiche

`createContractAction` et `createAmendmentAction` existaient depuis le
début sans qu'aucun écran ne les appelle : du code mort côté interface, et
un contrat qu'on ne pouvait ni ouvrir ni modifier une fois le salarié créé.

Trois gestes sur le contrat en cours — lire le détail, déclarer un
changement, terminer — et l'ouverture d'un contrat quand il n'y en a pas.

`endContractAction` date et clôt le contrat plutôt que de le supprimer :
un contrôle porte sur une période révolue, et l'effacer effacerait la
preuve que le salarié a travaillé, ainsi que la base de son solde de tout
compte.

Le formulaire de forfait jours ne découvre la convention individuelle et
sa date d'accord qu'une fois le forfait choisi : la règle métier les exige,
les autres contrats n'en ont que faire.

Les établissements du formulaire de création se lisent avec
`members.contract.create` et non `settings.access` : ouvrir un contrat
n'est pas administrer un établissement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-10 11:37:31 +02:00
1 parent c6abbaadc5
commit bb8f84696a
5 files changed
+622 -42

No files matched your search

@@ -0,0 +1,254 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createAmendmentAction,
endContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Vie du contrat en cours.
*
* Trois gestes et un seul menu : lire le détail, déclarer un changement,
* terminer. Ils sont réunis parce qu'ils portent sur le même objet, et séparés
* du formulaire de création parce qu'un contrat qui existe ne se recrée pas.
*/
const empty: ActionState = {};
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
export interface ActiveContract {
id: string;
label: string;
startDate: string;
endDate: string | null;
trialEndDate: string | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
monthlySalary: string | null;
jobTitle: string | null;
}
const readable = (iso: string | null) =>
iso ? dateFormat.format(new Date(`${iso}T00:00:00Z`)) : '';
type Panel = 'none' | 'detail' | 'amend' | 'end';
export function ContractActions({
contract,
canEdit,
canSeeSalary,
}: {
contract: ActiveContract;
canEdit: boolean;
canSeeSalary: boolean;
}) {
const [panel, setPanel] = useState<Panel>('none');
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-md font-semibold">Contrat en cours</h2>
<span className="flex-1" />
<Button
onClick={() => setPanel(panel === 'detail' ? 'none' : 'detail')}
>
{panel === 'detail' ? 'Masquer le détail' : 'Afficher le détail'}
</Button>
{canEdit ? (
<>
<Button
onClick={() => setPanel(panel === 'amend' ? 'none' : 'amend')}
>
Déclarer un changement
</Button>
<Button
variant="danger"
onClick={() => setPanel(panel === 'end' ? 'none' : 'end')}
>
Terminer le contrat
</Button>
</>
) : null}
</div>
<InfoCard title={contract.label}>
<InfoRow label="Début du contrat" value={readable(contract.startDate)} tnum />
<InfoRow label="Fin du contrat" value={readable(contract.endDate)} tnum />
<InfoRow label="Emploi" value={contract.jobTitle} />
<InfoRow
label="Organisation du temps"
value={
contract.forfaitJours
? `Forfait jours · ${contract.forfaitDaysPerYear ?? '—'} jours par an`
: `${contract.weeklyHours} heures hebdomadaires`
}
/>
{canSeeSalary ? (
<InfoRow
label="Rémunération mensuelle brute"
value={contract.monthlySalary ? `${contract.monthlySalary} €` : ''}
tnum
/>
) : null}
<InfoRow label="Établissement" value={contract.locationName} />
{panel === 'detail' ? (
<>
<InfoRow
label="Fin de période d’essai"
value={readable(contract.trialEndDate)}
tnum
/>
<InfoRow label="Classification" value={contract.classification} />
<InfoRow label="Coefficient" value={contract.coefficient} tnum />
<InfoRow
label="Modulation"
value={contract.isModulated ? 'Oui' : 'Non'}
/>
{contract.forfaitJours ? (
<InfoRow
label="Convention de forfait"
value={contract.forfaitAgreementRef}
/>
) : null}
</>
) : null}
</InfoCard>
{panel === 'amend' ? (
<AmendmentForm
contractId={contract.id}
weeklyHours={contract.weeklyHours}
onDone={() => setPanel('none')}
/>
) : null}
{panel === 'end' ? (
<EndForm contractId={contract.id} onDone={() => setPanel('none')} />
) : null}
</div>
);
}
/**
* Avenant.
*
* Le motif est obligatoire : un changement de durée sans raison écrite est
* indéfendable devant un contrôle, et illisible six mois plus tard.
*/
function AmendmentForm({
contractId,
weeklyHours,
onDone,
}: {
contractId: string;
weeklyHours: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(createAmendmentAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Déclarer un changement">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<div className="flex flex-wrap gap-3">
<Field
label="Date d’effet"
name="effectiveDate"
type="date"
required
/>
<Field
label="Durée hebdomadaire"
name="weeklyHours"
type="number"
step="0.5"
min="0"
max="60"
defaultValue={weeklyHours}
/>
</div>
<Field
label="Motif"
name="reason"
required
hint="Ce que l’avenant change, et pourquoi. Conservé au journal."
/>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Enregistrer l’avenant</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
function EndForm({
contractId,
onDone,
}: {
contractId: string;
onDone: () => void;
}) {
const [state, submit] = useActionState(endContractAction, empty);
const [acknowledged, setAcknowledged] = useState<ActionState>(empty);
if (state !== acknowledged && state.ok) {
setAcknowledged(state);
onDone();
}
return (
<InfoCard title="Terminer le contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="contractId" value={contractId} />
<p className="text-sm text-ink-2">
Le contrat est daté et clos, non supprimé : la période travaillée
reste opposable, et le solde de tout compte s’appuie dessus.
</p>
<div className="flex flex-wrap gap-3">
<Field label="Date de fin" name="endDate" type="date" required />
<Field
label="Motif de fin"
name="endReason"
required
hint="Démission, fin de CDD, rupture conventionnelle…"
/>
</div>
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Terminer le contrat</SubmitButton>
<Button type="button" onClick={onDone}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
@@ -0,0 +1,166 @@
'use client';
import { useActionState, useState } from 'react';
import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard';
import { Button } from '@/components/ui/Button';
import { Field, FormError, SubmitButton } from '@/components/ui/Form';
import {
createContractAction,
type ActionState,
} from '@/server/employees/actions';
/**
* Ouverture d'un contrat.
*
* Le forfait jours découvre ses propres champs : la convention individuelle
* écrite et sa date d'accord sont exigées par la règle métier, et les demander
* en permanence ferait porter à tous les contrats horaires deux champs qui ne
* les concernent pas.
*/
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 rounded-2 border border-line-2 bg-surface px-2 text-sm text-ink-1 outline-none focus-visible:border-focus';
export function NewContractForm({
membershipId,
locations,
}: {
membershipId: string;
locations: Array<{ id: string; name: string }>;
}) {
const [state, submit] = useActionState(createContractAction, empty);
const [forfait, setForfait] = useState(false);
const [open, setOpen] = useState(false);
if (!open) {
return (
<div className="flex justify-center">
<Button variant="primary" onClick={() => setOpen(true)}>
Ouvrir un contrat
</Button>
</div>
);
}
return (
<InfoCard title="Nouveau contrat">
<form action={submit} className="flex flex-col gap-3 pt-1">
<input type="hidden" name="membershipId" value={membershipId} />
<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" required className={selectClass}>
{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">Type de contrat</span>
<select name="contractType" required 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 />
<Field
label="Fin du contrat"
name="endDate"
type="date"
hint="Laissez 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"
hint="Plafond conventionnel : 218 jours, journée de solidarité incluse."
/>
) : (
<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}
<FormError>{state.error}</FormError>
<div className="flex items-center gap-3">
<SubmitButton>Ouvrir le contrat</SubmitButton>
<Button type="button" onClick={() => setOpen(false)}>
Annuler
</Button>
</div>
</form>
</InfoCard>
);
}
+59 -37
View File
@@ -1,14 +1,25 @@
import { notFound } from 'next/navigation';
import { InfoCard, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
import {
ContractActions,
type ActiveContract,
} from '@/app/(app)/equipe/[id]/contrats/ContractActions';
import { NewContractForm } from '@/app/(app)/equipe/[id]/contrats/NewContractForm';
import { Badge } from '@/components/ui/Badge';
import { Card, CardHeader, EmptyState } from '@/components/ui/Card';
import { getEmployee } from '@/server/employees/queries';
import {
getEmployee,
listContractLocations,
} from '@/server/employees/queries';
export const dynamic = 'force-dynamic';
const dateFormat = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' });
/** Les dates traversent la frontière serveur/client en ISO court : un `Date`
* y perd son fuseau, et « 1er novembre » deviendrait « 31 octobre » à l'ouest. */
const iso = (value: Date | null) => value?.toISOString().slice(0, 10) ?? null;
export default async function ContractsTab({
params,
}: {
@@ -21,44 +32,49 @@ export default async function ContractsTab({
const active = employee.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
const past = employee.contracts.filter(
(contract) => contract.id !== active?.id,
);
// La liste n'est chargée que si elle sert : sans contrat en cours, l'écran
// propose d'en ouvrir un, et la capacité peut manquer.
const locations = active ? [] : await listContractLocations().catch(() => []);
const activeContract: ActiveContract | null = active
? {
id: active.id,
label: active.label,
startDate: iso(active.startDate) as string,
endDate: iso(active.endDate),
trialEndDate: iso(active.trialEndDate),
weeklyHours: active.weeklyHours,
forfaitJours: active.forfaitJours,
forfaitDaysPerYear: active.forfaitDaysPerYear,
forfaitAgreementRef: active.forfaitAgreementRef,
isModulated: active.isModulated,
classification: active.classification,
coefficient: active.coefficient,
locationName: active.locationName,
monthlySalary: active.monthlySalary,
jobTitle: employee.headline.jobTitle,
}
: null;
return (
<div className="flex flex-col gap-5">
{active ? (
<InfoCard title="Contrat en cours">
<InfoRow label="Type" value={active.label} />
<InfoRow label="Début du contrat" value={dateFormat.format(active.startDate)} tnum />
<InfoRow
label="Fin du contrat"
value={active.endDate ? dateFormat.format(active.endDate) : ''}
tnum
/>
<InfoRow label="Emploi" value={employee.headline.jobTitle} />
<InfoRow
label="Organisation du temps"
value={
active.forfaitJours
? `Forfait jours · ${active.forfaitDaysPerYear ?? '—'} jours par an`
: `${active.weeklyHours} heures hebdomadaires`
}
/>
{employee.canSeeSalary ? (
<InfoRow
label="Rémunération mensuelle brute"
value={active.monthlySalary ? `${active.monthlySalary} €` : ''}
tnum
/>
) : null}
<InfoRow label="Établissement" value={employee.locationName} />
</InfoCard>
) : (
<EmptyState
title="Aucun contrat en cours"
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
{activeContract ? (
<ContractActions
contract={activeContract}
canEdit={employee.canEdit}
canSeeSalary={employee.canSeeSalary}
/>
) : (
<div className="flex flex-col gap-4">
<EmptyState
title="Aucun contrat en cours"
description="Ce salarié ne peut être ni planifié ni déclaré tant qu’aucune période n’est ouverte."
/>
{employee.canEdit && locations.length > 0 ? (
<NewContractForm membershipId={employee.id} locations={locations} />
) : null}
</div>
)}
<Card>
@@ -70,7 +86,7 @@ export default async function ContractsTab({
<EmptyState title="Aucun contrat enregistré" />
) : (
<ul>
{[...(active ? [active] : []), ...past].map((contract) => (
{employee.contracts.map((contract) => (
<li
key={contract.id}
className="border-b border-line-1 px-4 py-3 last:border-b-0"
@@ -99,6 +115,12 @@ export default async function ContractsTab({
</Badge>
</div>
{contract.endReason ? (
<p className="mt-1 text-xs text-ink-3">
Fin · {contract.endReason}
</p>
) : null}
{contract.amendments.length > 0 ? (
<ul className="mt-2 border-l-2 border-line-2 pl-3">
{contract.amendments.map((amendment) => (
+84
View File
@@ -329,3 +329,87 @@ export async function createAmendmentAction(
if (membershipId) revalidatePath(`/equipe/${membershipId}`);
return { ok: true };
}
const endContractInput = z.object({
contractId: z.string().min(1),
endDate: z.coerce.date(),
endReason: z.string().trim().min(1, 'Motif de fin requis').max(300),
});
/**
* Termine un contrat.
*
* Le contrat n'est pas supprimé : il est **daté et clos**. Un contrôle porte
* sur une période révolue, et effacer un contrat terminé effacerait la preuve
* que le salarié a travaillé — ainsi que la base de son solde de tout compte.
*/
export async function endContractAction(
_previous: ActionState,
formData: FormData,
): Promise<ActionState> {
const parsed = endContractInput.safeParse({
contractId: formData.get('contractId'),
endDate: formData.get('endDate'),
endReason: formData.get('endReason'),
});
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
}
let membershipId = '';
try {
await mutate('members.contract.edit', async (db, actor) => {
const contract = await db.userContract.findUnique({
where: { id: parsed.data.contractId },
});
if (!contract) throw new AuthorizationError('members.contract.edit');
membershipId = contract.membershipId;
if (contract.status === 'ENDED') {
throw new ValidationError('Ce contrat est déjà terminé.');
}
if (parsed.data.endDate < contract.startDate) {
throw new ValidationError(
'La fin du contrat précède son début.',
);
}
await db.userContract.update({
where: { id: contract.id },
data: {
endDate: parsed.data.endDate,
endReason: parsed.data.endReason,
status: 'ENDED',
version: { increment: 1 },
},
});
await recordAudit(db, {
actorMembershipId: actor.membershipId,
action: 'contract.end',
entityType: 'UserContract',
entityId: contract.id,
before: { status: contract.status, endDate: contract.endDate?.toISOString() ?? null },
after: {
status: 'ENDED',
endDate: parsed.data.endDate.toISOString(),
},
reason: parsed.data.endReason,
});
});
} catch (error) {
if (error instanceof ValidationError) return { error: error.message };
if (error instanceof AuthorizationError) {
return { error: "Vous n'avez pas le droit de terminer un contrat." };
}
throw error;
}
if (membershipId) {
revalidatePath(`/equipe/${membershipId}`);
revalidatePath('/equipe');
}
return { ok: true };
}
+59 -5
View File
@@ -155,13 +155,25 @@ export interface EmployeeDetail extends EmployeeListRow {
label: string;
startDate: Date;
endDate: Date | null;
trialEndDate: Date | null;
weeklyHours: string;
forfaitJours: boolean;
forfaitDaysPerYear: string | null;
forfaitAgreementRef: string | null;
isModulated: boolean;
classification: string | null;
coefficient: string | null;
locationName: string | null;
status: string;
endReason: string | null;
/** Absent quand `members.salary.view` manque. */
monthlySalary: string | null;
amendments: Array<{ id: string; effectiveDate: Date; reason: string | null }>;
amendments: Array<{
id: string;
effectiveDate: Date;
reason: string | null;
changes: unknown;
}>;
}>;
canSeeSalary: boolean;
canInvite: boolean;
@@ -207,11 +219,24 @@ export const getEmployee = cache(async function getEmployee(
const active = membership.contracts.find(
(contract) => contract.status === 'ACTIVE',
);
// Tous les établissements portés par l'historique, et pas seulement celui
// du contrat en cours : un contrat terminé ailleurs reste à lire.
const contractLocations = await db.location.findMany({
where: {
id: {
in: [
...new Set(membership.contracts.map((entry) => entry.locationId)),
],
},
},
select: { id: true, name: true },
});
const locationNames = new Map(
contractLocations.map((entry) => [entry.id, entry.name]),
);
const location = active
? await db.location.findUnique({
where: { id: active.locationId },
select: { name: true },
})
? { name: locationNames.get(active.locationId) ?? null }
: null;
// L'emploi est porté par le contrat, pas par le dossier : il change par
@@ -308,10 +333,17 @@ export const getEmployee = cache(async function getEmployee(
label: contractLabel(contract.contractType),
startDate: contract.startDate,
endDate: contract.endDate,
trialEndDate: contract.trialEndDate,
weeklyHours: contract.weeklyHours.toString(),
forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS',
forfaitDaysPerYear: contract.forfaitDaysPerYear?.toString() ?? null,
forfaitAgreementRef: contract.forfaitAgreementRef,
isModulated: contract.isModulated,
classification: contract.classification,
coefficient: contract.coefficient,
locationName: locationNames.get(contract.locationId) ?? null,
status: contract.status,
endReason: contract.endReason,
monthlySalary: canSeeSalary
? (contract.monthlySalary?.toString() ?? null)
: null,
@@ -319,6 +351,7 @@ export const getEmployee = cache(async function getEmployee(
id: amendment.id,
effectiveDate: amendment.effectiveDate,
reason: amendment.reason,
changes: amendment.changes,
})),
})),
canSeeSalary,
@@ -336,6 +369,27 @@ export const getEmployee = cache(async function getEmployee(
});
});
/**
* Établissements où poser un contrat.
*
* Une liste à part de celle des réglages : ouvrir un contrat n'exige pas
* d'administrer les établissements, et emprunter `settings.access` fermerait
* la création à un gestionnaire de paie qui n'a rien à y administrer.
*/
export const listContractLocations = cache(
async function listContractLocations(): Promise<
Array<{ id: string; name: string }>
> {
return query('members.contract.create', async (db) =>
db.location.findMany({
where: { archivedAt: null },
select: { id: true, name: true },
orderBy: { name: 'asc' },
}),
);
},
);
/** Rattachement et périmètre — onglet « Planification et accès ». */
export interface MemberPlacement {
/** Établissement porté par le contrat en cours. Il ne se change qu'en avenant. */