`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>
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import { cache } from 'react';
|
|
|
|
import { can } from '@/domain/access/authorize';
|
|
import {
|
|
invitationState,
|
|
type InvitationState,
|
|
} from '@/domain/access/invitation';
|
|
import { decryptOptional } from '@/server/crypto';
|
|
import { query } from '@/server/context';
|
|
|
|
/**
|
|
* Lecture des dossiers salariés.
|
|
*
|
|
* `members.salary.view` ne masque pas seulement l'affichage : la rémunération
|
|
* n'est **pas chargée** quand la capacité manque. Un champ absent de la réponse
|
|
* ne peut pas fuiter par le HTML, un journal ou une erreur — contrairement à un
|
|
* champ chargé puis caché à l'écran.
|
|
*/
|
|
|
|
export interface EmployeeListRow {
|
|
id: string;
|
|
employeeNumber: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string | null;
|
|
status: string;
|
|
hasAccount: boolean;
|
|
roleName: string;
|
|
contract: {
|
|
type: string;
|
|
label: string;
|
|
since: Date;
|
|
trialEndDate: Date | null;
|
|
forfaitJours: boolean;
|
|
} | null;
|
|
locationName: string | null;
|
|
}
|
|
|
|
const CONTRACT_LABELS: Record<string, string> = {
|
|
APPRENTISSAGE: 'Apprentissage',
|
|
CDD: 'CDD',
|
|
CDI: 'CDI',
|
|
DIRIGEANT_ASSIMILE_SALARIE: 'Dirigeant assimilé salarié',
|
|
DIRIGEANT_NON_SALARIE: 'Dirigeant non salarié',
|
|
EXTRA: 'Extra',
|
|
INTERIM: 'Intérim',
|
|
STAGIAIRE: 'Stagiaire',
|
|
SAISONNIER: 'Saisonnier',
|
|
};
|
|
|
|
export function contractLabel(type: string): string {
|
|
return CONTRACT_LABELS[type] ?? type;
|
|
}
|
|
|
|
export async function listEmployees(): Promise<EmployeeListRow[]> {
|
|
return query('members.view', async (db) => {
|
|
const memberships = await db.membership.findMany({
|
|
where: { archivedAt: null },
|
|
include: {
|
|
profile: { select: { firstName: true, lastName: true } },
|
|
user: { select: { email: true } },
|
|
role: { select: { name: true } },
|
|
contracts: {
|
|
where: { status: 'ACTIVE' },
|
|
orderBy: { startDate: 'desc' },
|
|
take: 1,
|
|
},
|
|
},
|
|
orderBy: { employeeNumber: 'asc' },
|
|
});
|
|
|
|
const locationIds = [
|
|
...new Set(
|
|
memberships
|
|
.flatMap((membership) => membership.contracts)
|
|
.map((contract) => contract.locationId),
|
|
),
|
|
];
|
|
const locations = await db.location.findMany({
|
|
where: { id: { in: locationIds } },
|
|
select: { id: true, name: true },
|
|
});
|
|
const locationNames = new Map(locations.map((l) => [l.id, l.name]));
|
|
|
|
return memberships.map((membership) => {
|
|
const contract = membership.contracts[0];
|
|
return {
|
|
id: membership.id,
|
|
employeeNumber: membership.employeeNumber,
|
|
firstName: membership.profile?.firstName ?? '',
|
|
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
|
email: membership.user?.email ?? null,
|
|
status: membership.status,
|
|
hasAccount: membership.userId !== null,
|
|
roleName: membership.role.name,
|
|
contract: contract
|
|
? {
|
|
type: contract.contractType,
|
|
label: contractLabel(contract.contractType),
|
|
since: contract.startDate,
|
|
trialEndDate: contract.trialEndDate,
|
|
forfaitJours: contract.workTimeArrangement === 'FORFAIT_JOURS',
|
|
}
|
|
: null,
|
|
locationName: contract
|
|
? (locationNames.get(contract.locationId) ?? null)
|
|
: null,
|
|
};
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Dossier personnel tel qu'il est saisi.
|
|
*
|
|
* Les trois champs chiffrés sont regroupés à part : ils ne sont pas seulement
|
|
* masqués à l'affichage quand la capacité manque, ils sont **absents** de la
|
|
* réponse, et l'écran de saisie qui les porte n'est alors pas rendu du tout.
|
|
* Un formulaire affiché vide renverrait des champs vides, et effacerait ce
|
|
* qu'il n'avait pas le droit de lire.
|
|
*/
|
|
export interface EmployeeProfileDetail {
|
|
firstName: string;
|
|
lastName: string;
|
|
birthDate: Date | null;
|
|
birthPlace: string | null;
|
|
nationality: string | null;
|
|
addressLine1: string | null;
|
|
postalCode: string | null;
|
|
city: string | null;
|
|
country: string | null;
|
|
phone: string | null;
|
|
personalEmail: string | null;
|
|
emergencyContactName: string | null;
|
|
emergencyContactPhone: string | null;
|
|
/** Chiffrés au repos, déchiffrés seulement pour qui a le droit de les lire. */
|
|
sensitive: {
|
|
socialSecurityNumber: string | null;
|
|
iban: string | null;
|
|
bic: string | null;
|
|
} | null;
|
|
}
|
|
|
|
export interface EmployeeDetail extends EmployeeListRow {
|
|
/** Ce que porte le bandeau de la fiche, au-dessus des onglets. */
|
|
headline: {
|
|
jobTitle: string | null;
|
|
lineManagerName: string | null;
|
|
contractEnd: Date | null;
|
|
};
|
|
profile: EmployeeProfileDetail | null;
|
|
contracts: Array<{
|
|
id: string;
|
|
type: string;
|
|
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;
|
|
changes: unknown;
|
|
}>;
|
|
}>;
|
|
canSeeSalary: boolean;
|
|
canInvite: boolean;
|
|
canEdit: boolean;
|
|
/** Dernière invitation émise, quel que soit son sort. */
|
|
invitation: {
|
|
state: InvitationState;
|
|
email: string;
|
|
expiresAt: Date;
|
|
createdAt: Date;
|
|
} | null;
|
|
}
|
|
|
|
/**
|
|
* Mémorisé le temps d'une requête : la fiche est désormais un gabarit à
|
|
* onglets, et le gabarit comme l'onglet ont besoin du même dossier. Sans cela,
|
|
* chaque affichage le lirait deux fois.
|
|
*/
|
|
export const getEmployee = cache(async function getEmployee(
|
|
id: string,
|
|
): Promise<EmployeeDetail | null> {
|
|
return query('members.view', async (db, actor) => {
|
|
const canSeeSalary = can(actor, 'members.salary.view');
|
|
const canSeeDocuments = can(actor, 'members.documents.view');
|
|
|
|
const membership = await db.membership.findUnique({
|
|
where: { id },
|
|
include: {
|
|
user: { select: { email: true } },
|
|
role: { select: { name: true } },
|
|
profile: true,
|
|
contracts: {
|
|
orderBy: { startDate: 'desc' },
|
|
include: {
|
|
amendments: { orderBy: { effectiveDate: 'desc' } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!membership) return null;
|
|
|
|
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
|
|
? { name: locationNames.get(active.locationId) ?? null }
|
|
: null;
|
|
|
|
// L'emploi est porté par le contrat, pas par le dossier : il change par
|
|
// avenant, et c'est l'avenant qui fait foi devant l'inspection.
|
|
const jobTitle = active?.jobTitleId
|
|
? await db.jobTitle.findUnique({
|
|
where: { id: active.jobTitleId },
|
|
select: { name: true },
|
|
})
|
|
: null;
|
|
|
|
const lineManager = membership.lineManagerId
|
|
? await db.membership.findUnique({
|
|
where: { id: membership.lineManagerId },
|
|
select: {
|
|
employeeNumber: true,
|
|
profile: { select: { firstName: true, lastName: true } },
|
|
},
|
|
})
|
|
: null;
|
|
|
|
// La plus récente, pas la seule en attente : « invitation expirée le 3 »
|
|
// est une information utile, et la masquer laisserait croire qu'aucune
|
|
// n'a jamais été envoyée.
|
|
const lastInvitation = await db.invitation.findFirst({
|
|
where: { membershipId: membership.id },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: {
|
|
email: true,
|
|
expiresAt: true,
|
|
acceptedAt: true,
|
|
revokedAt: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
|
|
return {
|
|
id: membership.id,
|
|
employeeNumber: membership.employeeNumber,
|
|
firstName: membership.profile?.firstName ?? '',
|
|
lastName: membership.profile?.lastName ?? membership.employeeNumber,
|
|
email: membership.user?.email ?? null,
|
|
status: membership.status,
|
|
hasAccount: membership.userId !== null,
|
|
roleName: membership.role.name,
|
|
locationName: location?.name ?? null,
|
|
contract: active
|
|
? {
|
|
type: active.contractType,
|
|
label: contractLabel(active.contractType),
|
|
since: active.startDate,
|
|
trialEndDate: active.trialEndDate,
|
|
forfaitJours: active.workTimeArrangement === 'FORFAIT_JOURS',
|
|
}
|
|
: null,
|
|
headline: {
|
|
jobTitle: jobTitle?.name ?? null,
|
|
lineManagerName: lineManager
|
|
? `${lineManager.profile?.firstName ?? ''} ${lineManager.profile?.lastName ?? lineManager.employeeNumber}`.trim()
|
|
: null,
|
|
contractEnd: active?.endDate ?? null,
|
|
},
|
|
profile: membership.profile
|
|
? {
|
|
firstName: membership.profile.firstName,
|
|
lastName: membership.profile.lastName,
|
|
birthDate: membership.profile.birthDate,
|
|
birthPlace: membership.profile.birthPlace,
|
|
nationality: membership.profile.nationality,
|
|
addressLine1: membership.profile.addressLine1,
|
|
postalCode: membership.profile.postalCode,
|
|
city: membership.profile.city,
|
|
country: membership.profile.country,
|
|
phone: membership.profile.phone,
|
|
personalEmail: membership.profile.personalEmail,
|
|
emergencyContactName: membership.profile.emergencyContactName,
|
|
emergencyContactPhone: membership.profile.emergencyContactPhone,
|
|
// Le NIR, l'IBAN et le BIC ne sont déchiffrés que pour un profil
|
|
// habilité — sinon le bloc entier reste absent de la réponse.
|
|
sensitive: canSeeDocuments
|
|
? {
|
|
socialSecurityNumber: decryptOptional(
|
|
membership.profile.socialSecurityNumberEnc,
|
|
),
|
|
iban: decryptOptional(membership.profile.ibanEnc),
|
|
bic: decryptOptional(membership.profile.bicEnc),
|
|
}
|
|
: null,
|
|
}
|
|
: null,
|
|
contracts: membership.contracts.map((contract) => ({
|
|
id: contract.id,
|
|
type: contract.contractType,
|
|
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,
|
|
amendments: contract.amendments.map((amendment) => ({
|
|
id: amendment.id,
|
|
effectiveDate: amendment.effectiveDate,
|
|
reason: amendment.reason,
|
|
changes: amendment.changes,
|
|
})),
|
|
})),
|
|
canSeeSalary,
|
|
canInvite: can(actor, 'members.invite'),
|
|
canEdit: can(actor, 'members.edit'),
|
|
invitation: lastInvitation
|
|
? {
|
|
state: invitationState(lastInvitation, new Date()),
|
|
email: lastInvitation.email,
|
|
expiresAt: lastInvitation.expiresAt,
|
|
createdAt: lastInvitation.createdAt,
|
|
}
|
|
: null,
|
|
};
|
|
});
|
|
});
|
|
|
|
/**
|
|
* É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. */
|
|
contractLocationName: string | null;
|
|
teams: Array<{ id: string; name: string; locationName: string; isPrimary: boolean }>;
|
|
/** Vrai quand le périmètre couvre tout le compte, ouvertures futures comprises. */
|
|
allLocations: boolean;
|
|
scopedLocations: string[];
|
|
scopedTeams: string[];
|
|
}
|
|
|
|
export const getMemberPlacement = cache(async function getMemberPlacement(
|
|
id: string,
|
|
): Promise<MemberPlacement | null> {
|
|
return query('members.view', async (db) => {
|
|
const membership = await db.membership.findUnique({
|
|
where: { id },
|
|
select: {
|
|
contracts: {
|
|
where: { status: 'ACTIVE' },
|
|
orderBy: { startDate: 'desc' },
|
|
take: 1,
|
|
select: { locationId: true },
|
|
},
|
|
teams: {
|
|
select: {
|
|
isPrimary: true,
|
|
team: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
location: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
scopes: {
|
|
select: {
|
|
allLocations: true,
|
|
location: { select: { name: true } },
|
|
team: { select: { name: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!membership) return null;
|
|
|
|
const contractLocationId = membership.contracts[0]?.locationId;
|
|
const contractLocation = contractLocationId
|
|
? await db.location.findUnique({
|
|
where: { id: contractLocationId },
|
|
select: { name: true },
|
|
})
|
|
: null;
|
|
|
|
return {
|
|
contractLocationName: contractLocation?.name ?? null,
|
|
teams: membership.teams.map((member) => ({
|
|
id: member.team.id,
|
|
name: member.team.name,
|
|
locationName: member.team.location.name,
|
|
isPrimary: member.isPrimary,
|
|
})),
|
|
allLocations: membership.scopes.some((scope) => scope.allLocations),
|
|
scopedLocations: [
|
|
...new Set(
|
|
membership.scopes
|
|
.map((scope) => scope.location?.name)
|
|
.filter((name): name is string => Boolean(name)),
|
|
),
|
|
],
|
|
scopedTeams: [
|
|
...new Set(
|
|
membership.scopes
|
|
.map((scope) => scope.team?.name)
|
|
.filter((name): name is string => Boolean(name)),
|
|
),
|
|
],
|
|
};
|
|
});
|
|
});
|