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