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