Skip to content

Commit 66dc9f0

Browse files
authored
Merge pull request #185 from mathiscapart/feat/unitlead-ecrans
feat(admin): #128 — unités encadrées à l'écran et dans la visibilité d'un chef
2 parents 9e62bdb + c8c1531 commit 66dc9f0

25 files changed

Lines changed: 524 additions & 33 deletions

File tree

‎CLAUDE.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ interroge `context7`. Ne devine jamais une signature.
7070
l'`AuditLog` sont dans la **même transaction Prisma**. Jamais de mutation sans trace.
7171
- **Toute Server Action / page sensible commence par `can(user, "…")`** (`src/lib/permissions.ts`,
7272
source unique de la matrice de rôles). Pas de contrôle d'accès ad hoc.
73+
- **Unité d'appartenance ≠ unités encadrées** (D-045) : `User.unit` est l'unité dont la personne fait
74+
partie (une seule) ; `UnitLead` (`ledUnits`) porte les unités qu'un CHEF encadre, son seul périmètre
75+
d'action (`inUnitScope`, `canActOnUnit`), qui ne lit jamais `User.unit` pour lui. La visibilité
76+
(salons, annonces, agenda, notifications d'événement) prend les deux. `UnitLead` ne s'écrit que
77+
par `writeLeadUnits` (`src/modules/admin/actions.ts`, verrouillé par `unit-lead-writes.test.ts`).
7378
- **better-auth**, jamais NextAuth. **SQLite** en dev ET en prod.
7479
- Code et identifiants en **anglais**, UI/messages utilisateur/commits/doc en **français**.
7580
- **Aucun port exposé en prod** : tout entre par cloudflared → Traefik. Ne jamais ajouter de `ports:`

‎DECISIONS.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -839,3 +839,12 @@ Deux défauts laissés par #97.
839839
- Pédagogie : la demande de 2e validation va aux CHEF qui encadrent l'unité du jeune, plus aux chefs qui en sont seulement membres.
840840
- `setUserRoles` ne recrée plus d'unité encadrée sur un compte anonymisé.
841841
- Restent sur `User.unit` comme **appartenance**, à trancher à l'étape 4 : accès aux salons d'unité, audiences d'annonces, notifications et rappels d'événements, iCal, tableau de bord, unité par défaut du bilan des présences.
842+
843+
**Amendement (étape 4 de #128)** : les unités encadrées donnent aussi la **visibilité** (décision du responsable).
844+
- Unités d'un compte = appartenance (`User.unit`) + unités encadrées. Ces dernières ne comptent que pour un CHEF (`ledUnitsOf`), comme le périmètre.
845+
- Salons (`canAccessChannel`) : accès si une de ses unités est dans `accessUnits`. Exclusion (`excludeUnits`) seulement si **toutes** ses unités sont exclues ; un compte sans unité n'est jamais exclu ; l'ADMIN passe toujours.
846+
- Annonces : un chef est dans l'audience des unités qu'il encadre (notification, visibilité, taux de lecture), sans y amener ses parents.
847+
- Événements : la notification de création, modification ou annulation va aussi aux chefs qui encadrent l'unité (`leaderIds` de `resolveUnitAudience`, hors `allIds`). Le flux iCal inclut les événements des unités encadrées. Les relances d'inscription (`reminders.ts`) et les campagnes de cotisation restent sur l'appartenance.
848+
- Tableau de bord : inchangé. Tout CHEF a `event.manage`, pour qui le prochain événement n'est pas filtré par unité.
849+
- Bilan des présences : l'unité présélectionnée est la première unité encadrée visible, sinon l'unité d'appartenance.
850+
- Écran : `/admin/utilisateurs/[id]/modifier` propose « Unités encadrées » (`setUserLeadUnits`). L'audit `USER_LEAD_UNITS_CHANGED` porte l'avant (`previousUnits`) et l'après (`units`). Pour un compte non Chef, l'éditeur explique qu'il faut d'abord le rôle Chef.

‎src/app/(app)/admin/utilisateurs/[id]/modifier/page.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
BirthDateEditor,
1010
ChangePasswordDialog,
1111
DeleteUserButton,
12+
LeadUnitsEditor,
1213
ReactivateButton,
1314
RolesEditor,
1415
SuspendButton,
@@ -59,6 +60,8 @@ export default async function EditUserAccountPage({ params }: PageProps) {
5960
status: true,
6061
// SAFE-01 — alimente BirthDateEditor (seul chemin de correction).
6162
birthDate: true,
63+
// #128 — unités encadrées (LeadUnitsEditor).
64+
unitLeads: { select: { unit: true } },
6265
},
6366
});
6467
if (!target) notFound();
@@ -115,11 +118,17 @@ export default async function EditUserAccountPage({ params }: PageProps) {
115118
<div>
116119
<h2 className="text-lg font-black text-earth">Gestion du compte</h2>
117120
<p className="text-sm text-trail">
118-
Unité, rôles, statut et accès de {fullName}.
121+
Unité d&apos;appartenance, unités encadrées, rôles, statut et accès de{" "}
122+
{fullName}.
119123
</p>
120124
</div>
121125
<div className="flex flex-wrap items-center gap-2">
122126
<UnitEditor userId={target.id} currentUnit={target.unit} />
127+
<LeadUnitsEditor
128+
userId={target.id}
129+
currentUnits={target.unitLeads.map((l) => l.unit)}
130+
isChef={roles.includes("CHEF")}
131+
/>
123132
<BirthDateEditor
124133
userId={target.id}
125134
currentBirthDate={toDateInput(target.birthDate)}

‎src/app/(app)/admin/utilisateurs/page.tsx‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,12 @@ function SortLink({
8686
);
8787
}
8888

89+
// #128 — unités encadrées, dans l'ordre du catalogue.
90+
function ledUnitsLabel(unitLeads: { unit: string }[]): string | null {
91+
const units = UNITS.filter((u) => unitLeads.some((l) => l.unit === u));
92+
return units.length > 0 ? `Encadre : ${units.join(", ")}` : null;
93+
}
94+
8995
function roleLabels(roles: string[]): string {
9096
if (roles.length === 0) return "Aucun rôle";
9197
return roles.map((r) => ROLE_LABEL[r as Role] ?? r).join(", ");
@@ -96,6 +102,7 @@ interface PageProps {
96102
q?: string;
97103
role?: string;
98104
unit?: string;
105+
ledUnit?: string;
99106
status?: string;
100107
sort?: string;
101108
}>;
@@ -111,17 +118,21 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
111118
search: q,
112119
role: params.role || undefined,
113120
unit: params.unit || undefined,
121+
ledUnit: params.ledUnit || undefined,
114122
status: params.status || undefined,
115123
sort,
116124
});
117-
const isFiltered = Boolean(q || params.role || params.unit || params.status);
125+
const isFiltered = Boolean(
126+
q || params.role || params.unit || params.ledUnit || params.status,
127+
);
118128

119129
// Lien conservant les filtres et ne changeant que le tri (en-têtes cliquables).
120130
const sortHref = (next: string) => {
121131
const sp = new URLSearchParams();
122132
if (q) sp.set("q", q);
123133
if (params.role) sp.set("role", params.role);
124134
if (params.unit) sp.set("unit", params.unit);
135+
if (params.ledUnit) sp.set("ledUnit", params.ledUnit);
125136
if (params.status) sp.set("status", params.status);
126137
if (next !== "status") sp.set("sort", next);
127138
const qs = sp.toString();
@@ -132,7 +143,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
132143
// réutilise les mêmes noeuds et ne les resynchronise pas sur `defaultValue`.
133144
// Cette clé les remonte quand l'URL change — sans quoi « Reset » viderait
134145
// l'URL en laissant les filtres affichés à l'écran.
135-
const filtersKey = `${q}|${params.role ?? ""}|${params.unit ?? ""}|${params.status ?? ""}|${sort}`;
146+
const filtersKey = `${q}|${params.role ?? ""}|${params.unit ?? ""}|${params.ledUnit ?? ""}|${params.status ?? ""}|${sort}`;
136147

137148
// Colonne triable : un clic trie en ascendant, un second bascule en descendant.
138149
const sortColumn = (key: "name" | "unit" | "status") => {
@@ -177,7 +188,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
177188
rechargeable) et la page reste un Server Component. */}
178189
<UserFiltersForm
179190
key={filtersKey}
180-
className="grid gap-3 rounded-2xl bg-snow p-4 shadow-card md:grid-cols-[minmax(0,2fr)_1fr_1fr_1fr_auto]"
191+
className="grid gap-3 rounded-2xl bg-snow p-4 shadow-card md:grid-cols-[minmax(0,2fr)_1fr_1fr_1fr_1fr_auto]"
181192
>
182193
<label className="space-y-1.5">
183194
<span className="text-xs font-bold uppercase tracking-wider text-trail">
@@ -228,6 +239,23 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
228239
))}
229240
</select>
230241
</label>
242+
<label className="space-y-1.5">
243+
<span className="text-xs font-bold uppercase tracking-wider text-trail">
244+
Encadre
245+
</span>
246+
<select
247+
name="ledUnit"
248+
defaultValue={params.ledUnit ?? ""}
249+
className="h-10 w-full rounded-md border border-input bg-snow px-3 text-sm"
250+
>
251+
<option value="">Toutes</option>
252+
{UNITS.map((u) => (
253+
<option key={u} value={u}>
254+
{UNIT_LABEL[u]}
255+
</option>
256+
))}
257+
</select>
258+
</label>
231259
<label className="space-y-1.5">
232260
<span className="text-xs font-bold uppercase tracking-wider text-trail">
233261
Statut
@@ -295,6 +323,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
295323
const suspended = u.status === "SUSPENDED";
296324
const rejected = u.status === "REJECTED";
297325
const roles = parseRoles(u.roles);
326+
const ledLabel = ledUnitsLabel(u.unitLeads);
298327
// La SECRÉTAIRE ne peut pas gérer un compte ADMIN/RG (l'ADMIN, si).
299328
const canManage =
300329
isAdmin || !roles.some((r) => PRIVILEGED_ROLES.has(r));
@@ -324,6 +353,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
324353
<p className="text-xs text-trail">{u.email}</p>
325354
)}
326355
{u.unit ? <p className="text-xs text-trail">{u.unit}</p> : null}
356+
{ledLabel ? <p className="text-xs text-trail">{ledLabel}</p> : null}
327357
</div>
328358
<span
329359
className={cn(
@@ -398,6 +428,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
398428
const suspended = u.status === "SUSPENDED";
399429
const rejected = u.status === "REJECTED";
400430
const roles = parseRoles(u.roles);
431+
const ledLabel = ledUnitsLabel(u.unitLeads);
401432
const canManage =
402433
isAdmin || !roles.some((r) => PRIVILEGED_ROLES.has(r));
403434
return (
@@ -433,6 +464,7 @@ export default async function AdminUtilisateursPage({ searchParams }: PageProps)
433464
</td>
434465
<td className="px-4 py-3">
435466
<span className="text-trail">{u.unit ?? "—"}</span>
467+
{ledLabel ? <p className="text-xs text-trail">{ledLabel}</p> : null}
436468
</td>
437469
<td className="px-4 py-3">
438470
<span className="text-trail">{roleLabels(roles)}</span>

‎src/app/(app)/admin/utilisateurs/user-actions.tsx‎

Lines changed: 110 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
deleteUser,
2424
reactivateUser,
2525
setUserBirthDate,
26+
setUserLeadUnits,
2627
setUserRoles,
2728
setUserUnit,
2829
suspendUser,
@@ -191,9 +192,10 @@ export function UnitEditor({
191192
</DialogTrigger>
192193
<DialogContent className="max-w-sm">
193194
<DialogHeader>
194-
<DialogTitle>Unité / branche</DialogTitle>
195+
<DialogTitle>Unité d&apos;appartenance</DialogTitle>
195196
<DialogDescription>
196-
Choisis la branche de la personne (ou « Aucune »).
197+
Choisis la branche dont la personne fait partie (ou « Aucune »).
198+
Les unités qu&apos;un chef encadre se règlent à part.
197199
</DialogDescription>
198200
</DialogHeader>
199201
<div className="space-y-2">
@@ -233,6 +235,112 @@ export function UnitEditor({
233235
);
234236
}
235237

238+
// #128 — unités ENCADRÉES d'un chef (table `UnitLead`), distinctes de son
239+
// unité d'appartenance : un compagnon peut encadrer les Louveteaux. Réservé aux
240+
// comptes Chef : pour les autres, l'éditeur l'explique au lieu de proposer un
241+
// choix que `setUserLeadUnits` refuserait.
242+
export function LeadUnitsEditor({
243+
userId,
244+
currentUnits,
245+
isChef,
246+
}: {
247+
userId: string;
248+
currentUnits: string[];
249+
isChef: boolean;
250+
}) {
251+
const router = useRouter();
252+
const [open, setOpen] = useState(false);
253+
const [selected, setSelected] = useState<Set<string>>(new Set(currentUnits));
254+
const [pending, start] = useTransition();
255+
256+
function toggle(unit: string) {
257+
setSelected((prev) => {
258+
const next = new Set(prev);
259+
if (next.has(unit)) next.delete(unit);
260+
else next.add(unit);
261+
return next;
262+
});
263+
}
264+
265+
function save() {
266+
const fd = new FormData();
267+
fd.set("userId", userId);
268+
for (const u of selected) fd.append("units", u);
269+
start(async () => {
270+
const res = await setUserLeadUnits(emptyState, fd);
271+
if (res.error) toast.error(res.error);
272+
else {
273+
toast.success("Unités encadrées mises à jour.");
274+
setOpen(false);
275+
router.refresh();
276+
}
277+
});
278+
}
279+
280+
const label =
281+
currentUnits.length > 0
282+
? `Encadre : ${UNITS.filter((u) => currentUnits.includes(u))
283+
.map((u) => UNIT_SHORT[u])
284+
.join(", ")}`
285+
: "Unités encadrées —";
286+
287+
return (
288+
<Dialog
289+
open={open}
290+
onOpenChange={(v) => {
291+
setOpen(v);
292+
if (v) setSelected(new Set(currentUnits));
293+
}}
294+
>
295+
<DialogTrigger asChild>
296+
<Button variant="outline" size="sm">
297+
{label}
298+
</Button>
299+
</DialogTrigger>
300+
<DialogContent className="max-w-sm">
301+
<DialogHeader>
302+
<DialogTitle>Unités encadrées</DialogTitle>
303+
<DialogDescription>
304+
Les branches dont la personne est chef. Elles peuvent différer de son
305+
unité d&apos;appartenance (ex. un compagnon chef chez les Louveteaux).
306+
</DialogDescription>
307+
</DialogHeader>
308+
{isChef ? (
309+
<div className="space-y-2">
310+
{UNITS.map((u) => (
311+
<label
312+
key={u}
313+
className="flex cursor-pointer items-center gap-2 rounded-lg p-2 hover:bg-sand"
314+
>
315+
<input
316+
type="checkbox"
317+
checked={selected.has(u)}
318+
onChange={() => toggle(u)}
319+
className="size-4 accent-forest"
320+
/>
321+
<span className="text-sm text-earth">{UNIT_LABEL[u]}</span>
322+
</label>
323+
))}
324+
</div>
325+
) : (
326+
<p role="alert" className="rounded-lg bg-sand p-3 text-sm text-earth">
327+
Seul un compte Chef peut encadrer une unité. Ajoute d&apos;abord le
328+
rôle « Chef » dans « Rôles » : il encadrera alors son unité
329+
d&apos;appartenance (s&apos;il en a une), modifiable ensuite ici.
330+
</p>
331+
)}
332+
<DialogFooter>
333+
{isChef ? (
334+
<Button onClick={save} disabled={pending}>
335+
{pending ? "Enregistrement…" : "Enregistrer"}
336+
</Button>
337+
) : null}
338+
</DialogFooter>
339+
</DialogContent>
340+
</Dialog>
341+
);
342+
}
343+
236344
// SAFE-01 — seul chemin de correction d'une date de naissance déjà posée :
237345
// l'intéressé ne peut que la renseigner une fois. Livré dans le même lot que le
238346
// verrou, sinon une faute de frappe à l'inscription serait sans recours.

‎src/app/(app)/membres/[id]/page.tsx‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { LoanStatusBadge } from "@/components/loans/LoanStatusBadge";
77
import { Button } from "@/components/ui/button";
88
import { EmptyState } from "@/components/ui/empty-state";
99
import { UserAvatar } from "@/components/ui/user-avatar";
10+
import { UNIT_LABEL, UNITS } from "@/lib/enums";
1011
import { getCurrentUser } from "@/lib/get-current-user";
1112
import { can, canActOnUnit, effectiveRoles } from "@/lib/permissions";
1213
import { cn } from "@/lib/utils";
@@ -72,6 +73,8 @@ export default async function MemberDetailPage({ params }: PageProps) {
7273
// l'email est un placeholder interne (@piloti.invalid), pas une vraie
7374
// adresse de contact.
7475
const noLoginAccount = isJeune && user.canLogin === false;
76+
// #128 — unités encadrées (chef), distinctes de l'unité d'appartenance.
77+
const ledUnits = UNITS.filter((u) => user.unitLeads.some((l) => l.unit === u));
7578
const hasProfile =
7679
!!user.profession || !!user.skills || !!user.availability || !!user.helpNotes;
7780

@@ -168,6 +171,11 @@ export default async function MemberDetailPage({ params }: PageProps) {
168171
{user.unit ? (
169172
<p className="text-sm text-trail">{user.unit}</p>
170173
) : null}
174+
{ledUnits.length > 0 ? (
175+
<p className="text-sm text-trail">
176+
Encadre : {ledUnits.map((u) => UNIT_LABEL[u]).join(", ")}
177+
</p>
178+
) : null}
171179
</div>
172180
</div>
173181

‎src/app/(app)/planning/presences/page.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,11 @@ export default async function AttendanceDashboardPage({ searchParams }: PageProp
2727

2828
const { unit } = await searchParams;
2929
const fromQuery = unit && visibles.includes(unit as Unit) ? (unit as Unit) : null;
30+
// Par défaut : la première unité encadrée (#128), sinon l'unité d'appartenance.
31+
const fromLed = visibles.find((u) => user.ledUnits.includes(u)) ?? null;
3032
const fromUser =
3133
user.unit && visibles.includes(user.unit as Unit) ? (user.unit as Unit) : null;
32-
const selectedUnit: Unit = fromQuery ?? fromUser ?? visibles[0];
34+
const selectedUnit: Unit = fromQuery ?? fromLed ?? fromUser ?? visibles[0];
3335

3436
const dashboard = await getUnitAttendanceDashboard(selectedUnit);
3537

0 commit comments

Comments
 (0)