Faire de la fiche salarié un dossier à onglets, et le rendre saisissable
La fiche empilait tout sur une page, en lecture seule. Le dossier personnel existait pourtant en base — date et lieu de naissance, nationalité, adresse, contact d'urgence, NIR et IBAN chiffrés — sans qu'aucun écran ne l'écrive : un salarié créé restait un dossier vide que rien ne permettait de remplir. Cinq onglets, cinq routes : une fiche s'envoie par lien et se rouvre au même endroit. Le bandeau porte ce qui ne change pas d'un onglet à l'autre, et le gabarit le tient une fois pour toutes. Deux points d'écriture et non un seul. L'état civil et les coordonnées demandent `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de pouvoir les lire. Fondus dans un même formulaire, un profil habilité à modifier mais pas à lire aurait renvoyé des champs vides — et effacé ce qui ne lui avait jamais été montré. Le journal retient qu'ils ont été renseignés, jamais leur valeur. Les parcours qui déposaient une pièce ou ouvraient un accès passent désormais par l'onglet qui les porte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
5a05fda2f9
commit
c6abbaadc5
18 files changed
+1529
-195
No files matched your search
@@ -1,3 +1,5 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import { counterView, type LedgerEntry } from '@/domain/absences/ledger';
|
||||
import { monthDates, monthLabel, type Month } from '@/domain/planning/month';
|
||||
@@ -71,6 +73,103 @@ export function statusLabel(status: AbsenceRequest['status']): string {
|
||||
return STATUS_LABEL[status];
|
||||
}
|
||||
|
||||
export interface MemberAbsences {
|
||||
requests: AbsenceRequest[];
|
||||
counters: CounterSummary[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Absences d'un salarié — onglet de sa fiche.
|
||||
*
|
||||
* Le tableau mensuel répond à « qui est absent ce mois-ci » ; il ne répond pas
|
||||
* à « qu'a pris ce salarié depuis son entrée ». D'où cette lecture, bornée à
|
||||
* une personne et non à un mois.
|
||||
*
|
||||
* Rend `null` plutôt que de lever quand le dossier consulté n'est pas le sien
|
||||
* et que la capacité manque : l'onglet se retire, le reste de la fiche tient.
|
||||
*/
|
||||
export const listMemberAbsences = cache(async function listMemberAbsences(
|
||||
membershipId: string,
|
||||
): Promise<MemberAbsences | null> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
if (
|
||||
membershipId !== actor.membershipId &&
|
||||
!can(actor, 'timeoff.view_others')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const canSeeMedical = can(actor, 'members.documents.view');
|
||||
|
||||
const member = await db.membership.findUnique({
|
||||
where: { id: membershipId },
|
||||
select: {
|
||||
employeeNumber: true,
|
||||
profile: { select: { firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
if (!member) return null;
|
||||
|
||||
const name =
|
||||
`${member.profile?.firstName ?? ''} ${member.profile?.lastName ?? member.employeeNumber}`.trim();
|
||||
|
||||
const timeOffs = await db.timeOff.findMany({
|
||||
where: { membershipId },
|
||||
include: { absenceType: true },
|
||||
orderBy: { startDate: 'desc' },
|
||||
});
|
||||
|
||||
const counters = await db.counter.findMany({
|
||||
where: { membershipId },
|
||||
include: { operations: true },
|
||||
});
|
||||
|
||||
return {
|
||||
requests: timeOffs.map((entry) => ({
|
||||
id: entry.id,
|
||||
membershipId: entry.membershipId,
|
||||
name,
|
||||
typeLabel:
|
||||
entry.absenceType.isSocialSecurity && !canSeeMedical
|
||||
? 'Absence'
|
||||
: entry.absenceType.name,
|
||||
colorKey: entry.absenceType.colorKey,
|
||||
isSocialSecurity: entry.absenceType.isSocialSecurity,
|
||||
startDate: entry.startDate.toISOString().slice(0, 10),
|
||||
endDate: entry.endDate.toISOString().slice(0, 10),
|
||||
startHalfDay: entry.startHalfDay,
|
||||
endHalfDay: entry.endHalfDay,
|
||||
days: Number(entry.countedDays?.toString() ?? '0'),
|
||||
status: entry.status,
|
||||
comment:
|
||||
entry.absenceType.isSocialSecurity && !canSeeMedical
|
||||
? null
|
||||
: entry.comment,
|
||||
decisionComment: entry.decisionComment,
|
||||
requestedAt: entry.requestedAt,
|
||||
})),
|
||||
counters: counters.map((counter) => {
|
||||
const entries: LedgerEntry[] = counter.operations.map((operation) => ({
|
||||
kind: operation.kind,
|
||||
quantity: Number(operation.quantity.toString()),
|
||||
unit: operation.unit,
|
||||
effectiveDate: operation.effectiveDate.toISOString().slice(0, 10),
|
||||
}));
|
||||
const view = counterView(entries, 0);
|
||||
return {
|
||||
membershipId: counter.membershipId,
|
||||
name,
|
||||
counterType: counter.counterType,
|
||||
accrued: view.accrued,
|
||||
taken: view.taken,
|
||||
balance: view.balance,
|
||||
projected: view.projected,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
export async function getAbsenceBoard(month: Month): Promise<AbsenceBoard> {
|
||||
return query('timeoff.view_own', async (db, actor) => {
|
||||
const canSeeOthers = can(actor, 'timeoff.view_others');
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError, can } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
import { encryptOptional } from '@/server/crypto';
|
||||
|
||||
/**
|
||||
* Saisie du dossier personnel — matrice n° 1 et registre unique du personnel.
|
||||
*
|
||||
* Deux points d'écriture et non un seul. L'état civil et les coordonnées se
|
||||
* modifient avec `members.edit` ; le NIR, l'IBAN et le BIC exigent en plus de
|
||||
* pouvoir les **lire**. Les fondre dans une seule action laisserait un profil
|
||||
* habilité à modifier mais pas à lire renvoyer des champs vides — et effacer
|
||||
* ainsi ce qui ne lui avait jamais été montré.
|
||||
*/
|
||||
|
||||
export interface ProfileActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
/** Un champ laissé vide vaut « non renseigné », pas la chaîne vide. */
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((value) => (value === '' ? null : value));
|
||||
|
||||
const profileInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
firstName: z.string().trim().min(1, 'Prénom requis').max(80),
|
||||
lastName: z.string().trim().min(1, 'Nom requis').max(80),
|
||||
birthDate: optionalText(10),
|
||||
birthPlace: optionalText(120),
|
||||
nationality: optionalText(80),
|
||||
personalEmail: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(180)
|
||||
.refine(
|
||||
(value) => value === '' || z.string().email().safeParse(value).success,
|
||||
'Adresse électronique invalide',
|
||||
)
|
||||
.transform((value) => (value === '' ? null : value)),
|
||||
phone: optionalText(30),
|
||||
addressLine1: optionalText(180),
|
||||
postalCode: optionalText(12),
|
||||
city: optionalText(120),
|
||||
country: optionalText(80),
|
||||
emergencyContactName: optionalText(120),
|
||||
emergencyContactPhone: optionalText(30),
|
||||
});
|
||||
|
||||
export async function updateProfileAction(
|
||||
_previous: ProfileActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProfileActionState> {
|
||||
const parsed = profileInput.safeParse(
|
||||
Object.fromEntries(
|
||||
[
|
||||
'membershipId',
|
||||
'firstName',
|
||||
'lastName',
|
||||
'birthDate',
|
||||
'birthPlace',
|
||||
'nationality',
|
||||
'personalEmail',
|
||||
'phone',
|
||||
'addressLine1',
|
||||
'postalCode',
|
||||
'city',
|
||||
'country',
|
||||
'emergencyContactName',
|
||||
'emergencyContactPhone',
|
||||
].map((key) => [key, formData.get(key) ?? '']),
|
||||
),
|
||||
);
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId, birthDate, ...rest } = parsed.data;
|
||||
|
||||
// Une date de naissance postérieure à aujourd'hui n'est pas une faute de
|
||||
// frappe anodine : elle fausse l'âge, et l'âge commande l'emploi des mineurs.
|
||||
let parsedBirthDate: Date | null = null;
|
||||
if (birthDate) {
|
||||
const candidate = new Date(`${birthDate}T00:00:00Z`);
|
||||
if (Number.isNaN(candidate.getTime()) || candidate > new Date()) {
|
||||
return { error: 'Date de naissance invalide.' };
|
||||
}
|
||||
parsedBirthDate = candidate;
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.edit', async (db, actor) => {
|
||||
const existing = await db.employeeProfile.findUnique({
|
||||
where: { membershipId },
|
||||
select: { firstName: true, lastName: true },
|
||||
});
|
||||
if (!existing) throw new ValidationError('Dossier introuvable.');
|
||||
|
||||
await db.employeeProfile.update({
|
||||
where: { membershipId },
|
||||
data: { ...rest, birthDate: parsedBirthDate } as never,
|
||||
});
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.profile.update',
|
||||
entityType: 'EmployeeProfile',
|
||||
entityId: membershipId,
|
||||
before: existing,
|
||||
after: { firstName: rest.firstName, lastName: rest.lastName },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return { error: 'Vous n’avez pas le droit de modifier ce dossier.' };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath('/equipe');
|
||||
revalidatePath(`/equipe/${membershipId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const sensitiveInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
socialSecurityNumber: optionalText(20),
|
||||
iban: optionalText(40),
|
||||
bic: optionalText(15),
|
||||
});
|
||||
|
||||
/**
|
||||
* Écrit les trois champs chiffrés au repos.
|
||||
*
|
||||
* Le NIR est contrôlé sur sa forme — quinze chiffres — et non sur sa clé : un
|
||||
* refus fondé sur un calcul que le saisisseur ne peut pas refaire à la main
|
||||
* bloquerait des dossiers valides sans jamais dire lesquels.
|
||||
*/
|
||||
export async function updateSensitiveAction(
|
||||
_previous: ProfileActionState,
|
||||
formData: FormData,
|
||||
): Promise<ProfileActionState> {
|
||||
const parsed = sensitiveInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
socialSecurityNumber: formData.get('socialSecurityNumber') ?? '',
|
||||
iban: formData.get('iban') ?? '',
|
||||
bic: formData.get('bic') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId } = parsed.data;
|
||||
const nir = parsed.data.socialSecurityNumber?.replace(/\s/g, '') ?? null;
|
||||
const iban = parsed.data.iban?.replace(/\s/g, '').toUpperCase() ?? null;
|
||||
const bic = parsed.data.bic?.replace(/\s/g, '').toUpperCase() ?? null;
|
||||
|
||||
if (nir && !/^\d{15}$/.test(nir)) {
|
||||
return { error: 'Le numéro de sécurité sociale compte quinze chiffres.' };
|
||||
}
|
||||
if (iban && !/^[A-Z]{2}\d{2}[A-Z0-9]{10,30}$/.test(iban)) {
|
||||
return { error: 'IBAN invalide.' };
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('members.edit', async (db, actor) => {
|
||||
// La capacité de lecture est exigée pour écrire : sans elle, le
|
||||
// formulaire n'aurait pas pu afficher la valeur en place, et l'envoyer
|
||||
// reviendrait à écraser à l'aveugle.
|
||||
if (!can(actor, 'members.documents.view')) {
|
||||
throw new AuthorizationError('members.documents.view');
|
||||
}
|
||||
|
||||
await db.employeeProfile.update({
|
||||
where: { membershipId },
|
||||
data: {
|
||||
socialSecurityNumberEnc: encryptOptional(nir),
|
||||
ibanEnc: encryptOptional(iban),
|
||||
bicEnc: encryptOptional(bic),
|
||||
} as never,
|
||||
});
|
||||
|
||||
// Le journal ne retient que **ce qui a été renseigné**, jamais la valeur :
|
||||
// il se relit, s'exporte et se conserve longtemps.
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.profile.sensitive.update',
|
||||
entityType: 'EmployeeProfile',
|
||||
entityId: membershipId,
|
||||
after: {
|
||||
socialSecurityNumber: nir ? 'renseigné' : 'effacé',
|
||||
iban: iban ? 'renseigné' : 'effacé',
|
||||
bic: bic ? 'renseigné' : 'effacé',
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: 'Vous n’avez pas le droit de modifier ces données protégées.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}`);
|
||||
return { ok: true };
|
||||
}
|
||||
+176
-14
@@ -1,3 +1,5 @@
|
||||
import { cache } from 'react';
|
||||
|
||||
import { can } from '@/domain/access/authorize';
|
||||
import {
|
||||
invitationState,
|
||||
@@ -108,16 +110,45 @@ export async function listEmployees(): Promise<EmployeeListRow[]> {
|
||||
});
|
||||
}
|
||||
|
||||
export interface EmployeeDetail extends EmployeeListRow {
|
||||
profile: {
|
||||
birthDate: Date | null;
|
||||
city: string | null;
|
||||
phone: string | null;
|
||||
personalEmail: string | null;
|
||||
/** Chiffré au repos, déchiffré seulement pour qui a le droit de le lire. */
|
||||
/**
|
||||
* 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;
|
||||
@@ -134,6 +165,7 @@ export interface EmployeeDetail extends EmployeeListRow {
|
||||
}>;
|
||||
canSeeSalary: boolean;
|
||||
canInvite: boolean;
|
||||
canEdit: boolean;
|
||||
/** Dernière invitation émise, quel que soit son sort. */
|
||||
invitation: {
|
||||
state: InvitationState;
|
||||
@@ -143,7 +175,14 @@ export interface EmployeeDetail extends EmployeeListRow {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export async function getEmployee(id: string): Promise<EmployeeDetail | 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');
|
||||
@@ -175,6 +214,25 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | 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.
|
||||
@@ -209,18 +267,38 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
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,
|
||||
// Le NIR et l'IBAN ne sont déchiffrés que pour un profil habilité.
|
||||
socialSecurityNumber: canSeeDocuments
|
||||
? decryptOptional(membership.profile.socialSecurityNumberEnc)
|
||||
: null,
|
||||
iban: canSeeDocuments
|
||||
? decryptOptional(membership.profile.ibanEnc)
|
||||
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,
|
||||
@@ -245,6 +323,7 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
})),
|
||||
canSeeSalary,
|
||||
canInvite: can(actor, 'members.invite'),
|
||||
canEdit: can(actor, 'members.edit'),
|
||||
invitation: lastInvitation
|
||||
? {
|
||||
state: invitationState(lastInvitation, new Date()),
|
||||
@@ -255,4 +334,87 @@ export async function getEmployee(id: string): Promise<EmployeeDetail | null> {
|
||||
: null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** 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)),
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user