From 93724b9d66199c892faec7e711d2db16dfb14a0d Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 10 Aug 2026 12:05:01 +0200 Subject: [PATCH] =?UTF-8?q?Rendre=20le=20rattachement=20et=20le=20p=C3=A9r?= =?UTF-8?q?im=C3=A8tre=20modifiables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../[id]/planification/PlacementPanel.tsx | 186 ++++++++++++++++ .../(app)/equipe/[id]/planification/page.tsx | 54 ++++- src/server/employees/placement-actions.ts | 202 ++++++++++++++++++ src/server/employees/queries.ts | 38 ++++ 4 files changed, 477 insertions(+), 3 deletions(-) create mode 100644 src/app/(app)/equipe/[id]/planification/PlacementPanel.tsx create mode 100644 src/server/employees/placement-actions.ts diff --git a/src/app/(app)/equipe/[id]/planification/PlacementPanel.tsx b/src/app/(app)/equipe/[id]/planification/PlacementPanel.tsx new file mode 100644 index 0000000..d5da872 --- /dev/null +++ b/src/app/(app)/equipe/[id]/planification/PlacementPanel.tsx @@ -0,0 +1,186 @@ +'use client'; + +import { useActionState, useState } from 'react'; + +import { InfoCard } from '@/app/(app)/equipe/[id]/InfoCard'; +import { Button } from '@/components/ui/Button'; +import { FormError, SubmitButton } from '@/components/ui/Form'; +import { + updateScopeAction, + updateTeamsAction, + type PlacementActionState, +} from '@/server/employees/placement-actions'; +import type { ContractLocation } from '@/server/employees/queries'; + +/** + * Périmètre et rattachement, en saisie. + * + * Deux formulaires plutôt qu'un : ils ne portent ni la même décision ni la + * même capacité, et les réunir donnerait à l'un le pouvoir de l'autre. + */ + +const empty: PlacementActionState = {}; + +export function ScopeForm({ + membershipId, + locations, + allLocations, + scopedLocationIds, +}: { + membershipId: string; + locations: ContractLocation[]; + allLocations: boolean; + scopedLocationIds: string[]; +}) { + const [state, save] = useActionState(updateScopeAction, empty); + const [acknowledged, setAcknowledged] = useState(empty); + const [editing, setEditing] = useState(false); + const [all, setAll] = useState(allLocations); + + if (state !== acknowledged && state.ok) { + setAcknowledged(state); + setEditing(false); + } + + if (!editing) { + return ( + + ); + } + + return ( +
+ + + + + {all ? null : ( +
+ + Établissements + + {locations.map((location) => ( + + ))} +
+ )} + + {state.error} + +
+ Enregistrer le périmètre + +
+
+ ); +} + +export function TeamsForm({ + membershipId, + locations, + teamIds, + primaryTeamId, +}: { + membershipId: string; + locations: ContractLocation[]; + teamIds: string[]; + primaryTeamId: string | null; +}) { + const [state, save] = useActionState(updateTeamsAction, empty); + const [acknowledged, setAcknowledged] = useState(empty); + const [editing, setEditing] = useState(false); + + if (state !== acknowledged && state.ok) { + setAcknowledged(state); + setEditing(false); + } + + const withTeams = locations.filter((location) => location.teams.length > 0); + + if (!editing) { + return ( + + ); + } + + return ( +
+ + + {withTeams.length === 0 ? ( +

+ Aucune équipe déclarée. Elles se créent dans les réglages des + établissements. +

+ ) : ( + + {withTeams.map((location) => ( +
+ + {location.name} + + {location.teams.map((team) => ( +
+ + +
+ ))} +
+ ))} +
+ )} + + {state.error} + +
+ Enregistrer les équipes + +
+
+ ); +} diff --git a/src/app/(app)/equipe/[id]/planification/page.tsx b/src/app/(app)/equipe/[id]/planification/page.tsx index 1dafe66..a417d8d 100644 --- a/src/app/(app)/equipe/[id]/planification/page.tsx +++ b/src/app/(app)/equipe/[id]/planification/page.tsx @@ -1,15 +1,25 @@ import { notFound } from 'next/navigation'; import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard'; +import { + ScopeForm, + TeamsForm, +} from '@/app/(app)/equipe/[id]/planification/PlacementPanel'; import { Badge } from '@/components/ui/Badge'; -import { getEmployee, getMemberPlacement } from '@/server/employees/queries'; +import { can } from '@/domain/access/authorize'; +import { requireSession } from '@/server/context'; +import { + getEmployee, + getMemberPlacement, + listPlacementOptions, +} from '@/server/employees/queries'; export const dynamic = 'force-dynamic'; /** * Rattachement et périmètre. * - * L'établissement de rattachement est en lecture seule : il est porté par le + * 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. */ @@ -22,7 +32,14 @@ export default async function PlacementTab({ const employee = await getEmployee(id); if (!employee) notFound(); - const placement = await getMemberPlacement(id); + const [placement, locations, session] = await Promise.all([ + getMemberPlacement(id), + listPlacementOptions(), + requireSession(), + ]); + + const canScope = can(session.actor, 'settings.roles.manage'); + const canTeams = can(session.actor, 'settings.teams.manage'); return ( @@ -48,6 +65,21 @@ export default async function PlacementTab({ label="Responsable hiérarchique" value={employee.headline.lineManagerName} /> + +

+ L’établissement vient du contrat : il se change par avenant, pas ici. +

+ + {canTeams ? ( +
+ +
+ ) : null} @@ -76,6 +108,22 @@ export default async function PlacementTab({ value={placement?.scopedTeams.join(', ') ?? ''} /> + + {canScope ? ( +
+ +
+ ) : ( +

+ Élargir un périmètre revient à distribuer des droits : la capacité + « Gérer les rôles » est requise. +

+ )}
); diff --git a/src/server/employees/placement-actions.ts b/src/server/employees/placement-actions.ts new file mode 100644 index 0000000..108484e --- /dev/null +++ b/src/server/employees/placement-actions.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/server/employees/queries.ts b/src/server/employees/queries.ts index c3e5040..aaed3b0 100644 --- a/src/server/employees/queries.ts +++ b/src/server/employees/queries.ts @@ -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 { + // `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' }, + }), + ); + }, +);