Poser le contrat au moment de l'embauche
Créer un salarié demandait quatre champs et produisait un dossier vide : sans contrat il n'apparaît sur aucune grille et ne se déclare pas, sans équipe il ne s'ordonne nulle part — et jusqu'ici aucun écran ne permettait de rattraper l'un ni l'autre. Le contrat est proposé coché, et reste décochable : un remplaçant se saisit parfois avant que son établissement soit tranché. Dossier, contrat et rattachement entrent dans la même transaction. Créés séparément, un incident au milieu laisserait exactement le dossier incomplet que ce changement vise à supprimer. Ouvrir un contrat reste une capacité distincte de celle de créer un dossier : un gestionnaire d'annuaire n'engage pas l'entreprise. Le refus le dit, et propose de créer le salarié sans contrat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a113de02f
commit
91f3b240e1
11 files changed
+423
-19
No files matched your search
@@ -3,7 +3,7 @@
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { AuthorizationError, can } from '@/domain/access/authorize';
|
||||
import { findOverlaps, validateContract } from '@/domain/contracts/rules';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
@@ -31,14 +31,39 @@ const employeeInput = z.object({
|
||||
employeeNumber: z.string().trim().min(1, 'Matricule requis').max(40),
|
||||
/** Vide = salarié géré sans accès applicatif. */
|
||||
email: z.string().trim().email('Adresse invalide').or(z.literal('')),
|
||||
phone: z.string().trim().max(30),
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée un salarié.
|
||||
* Contrat posé au moment de l'embauche.
|
||||
*
|
||||
* Facultatif dans le formulaire, mais tout ou rien une fois entamé : un
|
||||
* établissement sans date de début produirait un contrat qu'aucune règle ne
|
||||
* sait valider.
|
||||
*/
|
||||
const hiringContractInput = z.object({
|
||||
locationId: z.string().min(1, 'Établissement requis'),
|
||||
teamId: z.string().trim(),
|
||||
contractType: z.enum(CONTRACT_TYPES),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.string().trim(),
|
||||
workTimeArrangement: z.enum(['HOURLY', 'FORFAIT_JOURS']),
|
||||
weeklyHours: z.coerce.number().min(0).max(60),
|
||||
forfaitDaysPerYear: z.coerce.number().min(0).max(400).optional(),
|
||||
forfaitAgreementRef: z.string().trim(),
|
||||
forfaitAgreedAt: z.string().trim(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Crée un salarié, et l'embauche s'il y a lieu.
|
||||
*
|
||||
* Un salarié **sans compte utilisateur** doit rester créable : la plupart des
|
||||
* équipes de vente ne se connectent jamais à l'outil, et exiger une adresse
|
||||
* électronique les rendrait impossibles à planifier ou à déclarer.
|
||||
*
|
||||
* Le contrat et l'équipe sont posés dans la **même transaction** que le
|
||||
* dossier. Créés séparément, un incident au milieu laisserait un salarié sans
|
||||
* contrat — c'est-à-dire un dossier qu'aucun écran ne permettait de rattraper.
|
||||
*/
|
||||
export async function createEmployeeAction(
|
||||
_previous: ActionState,
|
||||
@@ -49,14 +74,71 @@ export async function createEmployeeAction(
|
||||
lastName: formData.get('lastName'),
|
||||
employeeNumber: formData.get('employeeNumber'),
|
||||
email: formData.get('email') ?? '',
|
||||
phone: formData.get('phone') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
// Le contrat n'est tenté que si le formulaire l'a ouvert : créer un salarié
|
||||
// sans contrat reste légitime — un remplaçant se saisit avant que son
|
||||
// établissement soit tranché.
|
||||
const wantsContract = formData.get('withContract') === 'on';
|
||||
let contract: z.infer<typeof hiringContractInput> | null = null;
|
||||
let contractEnd: Date | null = null;
|
||||
let forfaitAgreedAt: Date | null = null;
|
||||
|
||||
if (wantsContract) {
|
||||
const parsedContract = hiringContractInput.safeParse({
|
||||
locationId: formData.get('locationId') ?? '',
|
||||
teamId: formData.get('teamId') ?? '',
|
||||
contractType: formData.get('contractType') ?? 'CDI',
|
||||
startDate: formData.get('startDate'),
|
||||
endDate: formData.get('endDate') ?? '',
|
||||
workTimeArrangement: formData.get('workTimeArrangement') ?? 'HOURLY',
|
||||
weeklyHours: formData.get('weeklyHours') || 35,
|
||||
forfaitDaysPerYear: formData.get('forfaitDaysPerYear') || undefined,
|
||||
forfaitAgreementRef: formData.get('forfaitAgreementRef') ?? '',
|
||||
forfaitAgreedAt: formData.get('forfaitAgreedAt') ?? '',
|
||||
});
|
||||
|
||||
if (!parsedContract.success) {
|
||||
return {
|
||||
error:
|
||||
parsedContract.error.issues[0]?.message ?? 'Contrat invalide',
|
||||
};
|
||||
}
|
||||
|
||||
contract = parsedContract.data;
|
||||
contractEnd = contract.endDate ? new Date(contract.endDate) : null;
|
||||
forfaitAgreedAt = contract.forfaitAgreedAt
|
||||
? new Date(contract.forfaitAgreedAt)
|
||||
: null;
|
||||
|
||||
const issues = validateContract({
|
||||
startDate: contract.startDate,
|
||||
endDate: contractEnd,
|
||||
workTimeArrangement: contract.workTimeArrangement,
|
||||
weeklyHours: contract.weeklyHours,
|
||||
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
|
||||
forfaitAgreementRef: contract.forfaitAgreementRef || null,
|
||||
forfaitAgreedAt,
|
||||
});
|
||||
|
||||
if (issues.length > 0) {
|
||||
return { error: issues[0]?.message ?? 'Contrat invalide' };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.create', async (db, actor) => {
|
||||
// Poser un contrat est une capacité distincte de celle de créer un
|
||||
// dossier : un gestionnaire d'annuaire n'engage pas l'entreprise.
|
||||
if (contract && !can(actor, 'members.contract.create')) {
|
||||
throw new AuthorizationError('members.contract.create');
|
||||
}
|
||||
|
||||
const role = await db.role.findFirst({ where: { key: 'employee' } });
|
||||
if (!role) throw new Error('Rôle « employee » introuvable.');
|
||||
|
||||
@@ -81,9 +163,64 @@ export async function createEmployeeAction(
|
||||
firstName: parsed.data.firstName,
|
||||
lastName: parsed.data.lastName,
|
||||
personalEmail: parsed.data.email || null,
|
||||
phone: parsed.data.phone || null,
|
||||
} as never,
|
||||
});
|
||||
|
||||
if (contract) {
|
||||
const location = await db.location.findUnique({
|
||||
where: { id: contract.locationId },
|
||||
});
|
||||
if (!location) {
|
||||
throw new ValidationError('Établissement introuvable.');
|
||||
}
|
||||
|
||||
const openedContract = await db.userContract.create({
|
||||
data: {
|
||||
membershipId: created.id,
|
||||
locationId: location.id,
|
||||
contractType: contract.contractType,
|
||||
startDate: contract.startDate,
|
||||
endDate: contractEnd,
|
||||
workTimeArrangement: contract.workTimeArrangement,
|
||||
weeklyHours: contract.weeklyHours,
|
||||
forfaitDaysPerYear: contract.forfaitDaysPerYear ?? null,
|
||||
forfaitAgreementRef: contract.forfaitAgreementRef || null,
|
||||
forfaitAgreedAt,
|
||||
} as never,
|
||||
});
|
||||
|
||||
// Sans équipe, le salarié n'apparaît sur aucune grille : le planning
|
||||
// s'ordonne par équipe, et un rattachement omis se paie à la première
|
||||
// semaine à couvrir.
|
||||
if (contract.teamId) {
|
||||
const team = await db.team.findUnique({
|
||||
where: { id: contract.teamId },
|
||||
select: { id: true, locationId: true },
|
||||
});
|
||||
if (!team || team.locationId !== location.id) {
|
||||
throw new ValidationError(
|
||||
'Cette équipe n’appartient pas à l’établissement choisi.',
|
||||
);
|
||||
}
|
||||
await db.teamMember.create({
|
||||
data: { teamId: team.id, membershipId: created.id } as never,
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'contract.create',
|
||||
entityType: 'UserContract',
|
||||
entityId: openedContract.id,
|
||||
after: {
|
||||
contractType: openedContract.contractType,
|
||||
startDate: openedContract.startDate.toISOString(),
|
||||
workTimeArrangement: openedContract.workTimeArrangement,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.create',
|
||||
@@ -92,13 +229,19 @@ export async function createEmployeeAction(
|
||||
after: {
|
||||
employeeNumber: created.employeeNumber,
|
||||
hasAccount: Boolean(parsed.data.email),
|
||||
withContract: Boolean(contract),
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: "Vous n'avez pas le droit de créer un salarié." };
|
||||
return {
|
||||
error:
|
||||
error.permission === 'members.contract.create'
|
||||
? "Vous n'avez pas le droit d'ouvrir un contrat. Créez le salarié sans contrat, ou faites-le poser."
|
||||
: "Vous n'avez pas le droit de créer un salarié.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -535,14 +535,26 @@ export const getEmployee = cache(async function getEmployee(
|
||||
* d'administrer les établissements, et emprunter `settings.access` fermerait
|
||||
* la création à un gestionnaire de paie qui n'a rien à y administrer.
|
||||
*/
|
||||
export interface ContractLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
teams: Array<{ id: string; name: string }>;
|
||||
}
|
||||
|
||||
export const listContractLocations = cache(
|
||||
async function listContractLocations(): Promise<
|
||||
Array<{ id: string; name: string }>
|
||||
> {
|
||||
async function listContractLocations(): Promise<ContractLocation[]> {
|
||||
return query('members.contract.create', async (db) =>
|
||||
db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
teams: {
|
||||
where: { archivedAt: null },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { position: 'asc' },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user