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 { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import { InfoCard, InfoGrid, InfoRow } from '@/app/(app)/equipe/[id]/InfoCard';
|
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 { 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';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rattachement et périmètre.
|
* 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
|
* contrat, et le changer sans avenant ferait diverger le planning du document
|
||||||
* opposable.
|
* opposable.
|
||||||
*/
|
*/
|
||||||
@@ -22,7 +32,14 @@ export default async function PlacementTab({
|
|||||||
const employee = await getEmployee(id);
|
const employee = await getEmployee(id);
|
||||||
if (!employee) notFound();
|
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 (
|
return (
|
||||||
<InfoGrid>
|
<InfoGrid>
|
||||||
@@ -48,6 +65,21 @@ export default async function PlacementTab({
|
|||||||
label="Responsable hiérarchique"
|
label="Responsable hiérarchique"
|
||||||
value={employee.headline.lineManagerName}
|
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>
|
||||||
|
|
||||||
<InfoCard title="Périmètre d’accès">
|
<InfoCard title="Périmètre d’accès">
|
||||||
@@ -76,6 +108,22 @@ export default async function PlacementTab({
|
|||||||
value={placement?.scopedTeams.join(', ') ?? ''}
|
value={placement?.scopedTeams.join(', ') ?? ''}
|
||||||
/>
|
/>
|
||||||
<InfoRow label="Rôle" value={employee.roleName} />
|
<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>
|
</InfoCard>
|
||||||
</InfoGrid>
|
</InfoGrid>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
allLocations: boolean;
|
||||||
scopedLocations: string[];
|
scopedLocations: string[];
|
||||||
scopedTeams: 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(
|
export const getMemberPlacement = cache(async function getMemberPlacement(
|
||||||
@@ -600,6 +604,7 @@ export const getMemberPlacement = cache(async function getMemberPlacement(
|
|||||||
scopes: {
|
scopes: {
|
||||||
select: {
|
select: {
|
||||||
allLocations: true,
|
allLocations: true,
|
||||||
|
locationId: true,
|
||||||
location: { select: { name: true } },
|
location: { select: { name: true } },
|
||||||
team: { 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)),
|
.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