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,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<PlacementActionState>(empty);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [all, setAll] = useState(allLocations);
|
||||
|
||||
if (state !== acknowledged && state.ok) {
|
||||
setAcknowledged(state);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<Button onClick={() => setEditing(true)}>Modifier le périmètre</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="allLocations"
|
||||
checked={all}
|
||||
onChange={(event) => setAll(event.target.checked)}
|
||||
className="mt-0.5 size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">Accès généralisé</span>
|
||||
<span className="block text-micro text-ink-3">
|
||||
Tous les établissements, y compris ceux qui ouvriront plus tard.
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{all ? null : (
|
||||
<fieldset className="flex flex-col gap-2 rounded-3 border border-line-1 p-3">
|
||||
<legend className="px-1 text-micro font-semibold text-ink-1">
|
||||
Établissements
|
||||
</legend>
|
||||
{locations.map((location) => (
|
||||
<label key={location.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="locationIds"
|
||||
value={location.id}
|
||||
defaultChecked={scopedLocationIds.includes(location.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{location.name}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer le périmètre</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
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<PlacementActionState>(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 (
|
||||
<Button onClick={() => setEditing(true)}>Modifier les équipes</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={save} className="flex flex-col gap-3 pt-3">
|
||||
<input type="hidden" name="membershipId" value={membershipId} />
|
||||
|
||||
{withTeams.length === 0 ? (
|
||||
<p className="text-sm text-ink-2">
|
||||
Aucune équipe déclarée. Elles se créent dans les réglages des
|
||||
établissements.
|
||||
</p>
|
||||
) : (
|
||||
<InfoCard title="Équipes">
|
||||
{withTeams.map((location) => (
|
||||
<fieldset key={location.id} className="flex flex-col gap-2 py-2">
|
||||
<legend className="text-micro font-semibold text-ink-1">
|
||||
{location.name}
|
||||
</legend>
|
||||
{location.teams.map((team) => (
|
||||
<div key={team.id} className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="teamIds"
|
||||
value={team.id}
|
||||
defaultChecked={teamIds.includes(team.id)}
|
||||
className="size-4 accent-[var(--accent)]"
|
||||
/>
|
||||
{team.name}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-micro text-ink-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="primaryTeamId"
|
||||
value={team.id}
|
||||
defaultChecked={primaryTeamId === team.id}
|
||||
className="size-3.5 accent-[var(--accent)]"
|
||||
/>
|
||||
Principale
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</fieldset>
|
||||
))}
|
||||
</InfoCard>
|
||||
)}
|
||||
|
||||
<FormError>{state.error}</FormError>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<SubmitButton>Enregistrer les équipes</SubmitButton>
|
||||
<Button type="button" onClick={() => setEditing(false)}>
|
||||
Annuler
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<InfoGrid>
|
||||
@@ -48,6 +65,21 @@ export default async function PlacementTab({
|
||||
label="Responsable hiérarchique"
|
||||
value={employee.headline.lineManagerName}
|
||||
/>
|
||||
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
L’établissement vient du contrat : il se change par avenant, pas ici.
|
||||
</p>
|
||||
|
||||
{canTeams ? (
|
||||
<div className="pt-3">
|
||||
<TeamsForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
teamIds={placement?.teamIds ?? []}
|
||||
primaryTeamId={placement?.primaryTeamId ?? null}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</InfoCard>
|
||||
|
||||
<InfoCard title="Périmètre d’accès">
|
||||
@@ -76,6 +108,22 @@ export default async function PlacementTab({
|
||||
value={placement?.scopedTeams.join(', ') ?? ''}
|
||||
/>
|
||||
<InfoRow label="Rôle" value={employee.roleName} />
|
||||
|
||||
{canScope ? (
|
||||
<div className="pt-3">
|
||||
<ScopeForm
|
||||
membershipId={employee.id}
|
||||
locations={locations}
|
||||
allLocations={placement?.allLocations ?? false}
|
||||
scopedLocationIds={placement?.scopedLocationIds ?? []}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="pt-3 text-micro text-ink-3">
|
||||
Élargir un périmètre revient à distribuer des droits : la capacité
|
||||
« Gérer les rôles » est requise.
|
||||
</p>
|
||||
)}
|
||||
</InfoCard>
|
||||
</InfoGrid>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user