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
@@ -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