Rendre le rattachement et le périmètre modifiables
L'onglet montrait les équipes et le périmètre sans jamais permettre de les changer : un salarié rattaché à la mauvaise équipe n'apparaissait pas sur la bonne grille, et rien à l'écran n'y remédiait. Deux formulaires et deux capacités, parce que ce sont deux décisions. Élargir un périmètre revient à donner accès à des dossiers et des plannings qu'on n'avait pas : c'est distribuer des droits, donc `settings.roles.manage`. Rattacher à une équipe relève de l'organisation du travail, donc `settings.teams.manage`. Les réunir donnerait à l'une le pouvoir de l'autre. Le périmètre est remplacé, pas fusionné : un périmètre qui ne fait que grandir ne se réduit jamais, et retirer un établissement resterait sans effet — l'exact contraire de ce que l'écran promet. Un périmètre vide sans accès généralisé est refusé : il ne laisserait plus rien voir. L'établissement de rattachement reste en lecture seule. Il est porté par le contrat, et le changer sans avenant ferait diverger le planning du document opposable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
91f3b240e1
commit
93724b9d66
4 files changed
+477
-3
No files matched your search
@@ -0,0 +1,202 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthorizationError } from '@/domain/access/authorize';
|
||||
import { recordAudit } from '@/server/audit';
|
||||
import { mutate } from '@/server/context';
|
||||
|
||||
/**
|
||||
* Rattachement et périmètre.
|
||||
*
|
||||
* Deux capacités distinctes, parce que ce sont deux décisions distinctes :
|
||||
*
|
||||
* - le **périmètre** dit ce qu'une personne voit du compte. L'élargir revient à
|
||||
* lui donner accès à des dossiers et des plannings qu'elle n'avait pas :
|
||||
* c'est distribuer des droits, donc `settings.roles.manage` ;
|
||||
* - le **rattachement** dit sur quelle grille elle apparaît. C'est de
|
||||
* l'organisation du travail, donc `settings.teams.manage`.
|
||||
*
|
||||
* L'établissement du contrat n'est modifiable ni par l'une ni par l'autre : il
|
||||
* est porté par le contrat, et ne se change que par avenant.
|
||||
*/
|
||||
|
||||
export interface PlacementActionState {
|
||||
error?: string;
|
||||
ok?: boolean;
|
||||
}
|
||||
|
||||
class ValidationError extends Error {}
|
||||
|
||||
const scopeInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
allLocations: z.boolean(),
|
||||
locationIds: z.array(z.string().min(1)),
|
||||
});
|
||||
|
||||
export async function updateScopeAction(
|
||||
_previous: PlacementActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlacementActionState> {
|
||||
const parsed = scopeInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
allLocations: formData.get('allLocations') === 'on',
|
||||
locationIds: formData.getAll('locationIds').map(String),
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId, allLocations, locationIds } = parsed.data;
|
||||
|
||||
if (!allLocations && locationIds.length === 0) {
|
||||
return {
|
||||
error:
|
||||
'Sans accès généralisé, désignez au moins un établissement — sinon cette personne ne verrait plus rien.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.roles.manage', async (db, actor) => {
|
||||
const membership = await db.membership.findUnique({
|
||||
where: { id: membershipId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!membership) throw new ValidationError('Salarié introuvable.');
|
||||
|
||||
const known = await db.location.findMany({
|
||||
where: { id: { in: locationIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== locationIds.length) {
|
||||
throw new ValidationError('Établissement inconnu.');
|
||||
}
|
||||
|
||||
const before = await db.membershipScope.findMany({
|
||||
where: { membershipId },
|
||||
select: { allLocations: true, locationId: true },
|
||||
});
|
||||
|
||||
// Remplacement et non fusion : un périmètre qui ne fait que grandir ne
|
||||
// se réduit jamais, et le retrait d'un établissement resterait sans
|
||||
// effet — l'exact contraire de ce que l'écran promet.
|
||||
await db.membershipScope.deleteMany({ where: { membershipId } });
|
||||
|
||||
if (allLocations) {
|
||||
await db.membershipScope.create({
|
||||
data: { membershipId, allLocations: true } as never,
|
||||
});
|
||||
} else {
|
||||
for (const locationId of locationIds) {
|
||||
await db.membershipScope.create({
|
||||
data: { membershipId, allLocations: false, locationId } as never,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.scope.update',
|
||||
entityType: 'Membership',
|
||||
entityId: membershipId,
|
||||
before: { scopes: before },
|
||||
after: { allLocations, locationIds },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error:
|
||||
'Modifier un périmètre revient à distribuer des droits : la capacité « Gérer les rôles » est requise.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}/planification`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const teamsInput = z.object({
|
||||
membershipId: z.string().min(1),
|
||||
teamIds: z.array(z.string().min(1)),
|
||||
primaryTeamId: z.string().trim(),
|
||||
});
|
||||
|
||||
export async function updateTeamsAction(
|
||||
_previous: PlacementActionState,
|
||||
formData: FormData,
|
||||
): Promise<PlacementActionState> {
|
||||
const parsed = teamsInput.safeParse({
|
||||
membershipId: formData.get('membershipId') ?? '',
|
||||
teamIds: formData.getAll('teamIds').map(String),
|
||||
primaryTeamId: formData.get('primaryTeamId') ?? '',
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return { error: parsed.error.issues[0]?.message ?? 'Formulaire invalide' };
|
||||
}
|
||||
|
||||
const { membershipId, teamIds, primaryTeamId } = parsed.data;
|
||||
|
||||
if (primaryTeamId && !teamIds.includes(primaryTeamId)) {
|
||||
return {
|
||||
error: 'L’équipe principale doit figurer parmi les équipes cochées.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await mutate('settings.teams.manage', async (db, actor) => {
|
||||
const known = await db.team.findMany({
|
||||
where: { id: { in: teamIds } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (known.length !== teamIds.length) {
|
||||
throw new ValidationError('Équipe inconnue.');
|
||||
}
|
||||
|
||||
const before = await db.teamMember.findMany({
|
||||
where: { membershipId },
|
||||
select: { teamId: true, isPrimary: true },
|
||||
});
|
||||
|
||||
await db.teamMember.deleteMany({ where: { membershipId } });
|
||||
|
||||
for (const teamId of teamIds) {
|
||||
await db.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
membershipId,
|
||||
// Une seule principale : c'est elle qui décide de la colonne où la
|
||||
// personne apparaît quand la grille n'en montre qu'une.
|
||||
isPrimary: teamId === (primaryTeamId || teamIds[0]),
|
||||
} as never,
|
||||
});
|
||||
}
|
||||
|
||||
await recordAudit(db, {
|
||||
actorMembershipId: actor.membershipId,
|
||||
action: 'membership.teams.update',
|
||||
entityType: 'Membership',
|
||||
entityId: membershipId,
|
||||
before: { teams: before },
|
||||
after: { teamIds, primaryTeamId: primaryTeamId || teamIds[0] || null },
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) return { error: error.message };
|
||||
if (error instanceof AuthorizationError) {
|
||||
return {
|
||||
error: 'La capacité « Gérer les équipes » est requise.',
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
revalidatePath(`/equipe/${membershipId}/planification`);
|
||||
revalidatePath('/equipe');
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -570,6 +570,10 @@ export interface MemberPlacement {
|
||||
allLocations: boolean;
|
||||
scopedLocations: string[];
|
||||
scopedTeams: string[];
|
||||
/** Les mêmes, par identifiant : ce que les formulaires cochent. */
|
||||
scopedLocationIds: string[];
|
||||
teamIds: string[];
|
||||
primaryTeamId: string | null;
|
||||
}
|
||||
|
||||
export const getMemberPlacement = cache(async function getMemberPlacement(
|
||||
@@ -600,6 +604,7 @@ export const getMemberPlacement = cache(async function getMemberPlacement(
|
||||
scopes: {
|
||||
select: {
|
||||
allLocations: true,
|
||||
locationId: true,
|
||||
location: { select: { name: true } },
|
||||
team: { select: { name: true } },
|
||||
},
|
||||
@@ -640,6 +645,39 @@ export const getMemberPlacement = cache(async function getMemberPlacement(
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
],
|
||||
scopedLocationIds: [
|
||||
...new Set(
|
||||
membership.scopes
|
||||
.map((scope) => scope.locationId)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
),
|
||||
],
|
||||
teamIds: membership.teams.map((member) => member.team.id),
|
||||
primaryTeamId:
|
||||
membership.teams.find((member) => member.isPrimary)?.team.id ?? null,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/** Établissements et équipes où rattacher quelqu'un. */
|
||||
export const listPlacementOptions = cache(
|
||||
async function listPlacementOptions(): Promise<ContractLocation[]> {
|
||||
// `members.view` suffit : ces noms sont déjà affichés par l'annuaire, et
|
||||
// exiger davantage fermerait l'écran à qui a le droit de le lire.
|
||||
return query('members.view', async (db) =>
|
||||
db.location.findMany({
|
||||
where: { archivedAt: null },
|
||||
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