Files
planflow/src/server/employees/queries.ts
T
MichaelandClaude Opus 5 bb8f84696a 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>
2026-08-10 11:37:31 +02:00

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)),
),
],
};
});
});