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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user