mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r6): R6.6 — Demandeur restreint à son site
Trouvé en recette : GET /assets/options n'avait aucun filtre — tout Demandeur voyait le parc complet dans le sélecteur d'équipement de "Nouvelle demande", sur le web ET le mobile (même endpoint partagé). Risque réel : signaler accidentellement une panne sur un ascenseur qu'on ne gère pas. Corrigé à la racine, sur les deux plateformes à la fois : - Relation many-to-many User↔Location (assignedSites/assignedUsers, migration r6_demandeur_sites, même style que Team.members) — vide = aucune restriction, comportement historique inchangé pour tous les rôles sauf un Demandeur affecté à un site. - AssetsService.allowedLocationIds(user) : sites + zones filles autorisés, ou null si aucune restriction — réutilisée par options() ET par RequestsService.create (défense en profondeur : un assetId soumis directement hors périmètre est rejeté, 400). - UsersService : assertTopLevelSites (un Demandeur est affecté à un site, jamais une zone) ; invite()/update() branchent locationIds (remplace l'affectation, comme teamIds). - Web (personnes.tsx) : ModaleInvitation affiche les sites à cocher pour un rôle Demandeur ; colonne "Sites" éditable via une modale dédiée. - Mobile (formulaire-demande.tsx) : bouton "Scanner l'étiquette" en raccourci — résout uniquement contre les options déjà chargées (déjà filtrées), jamais de repli sur le parc complet qui annulerait la restriction. Aucun changement à useAssetOptions() : le filtrage serveur profite automatiquement au formulaire mobile. - Seed : Karim Doukkali (démo) rattaché à Tour Atlas. Bug trouvé en vérification avant tout commit : create() comparait allowed.includes(dto.assetId), mais allowed est une liste d'ids de sites/zones, pas d'ids d'appareils — aurait rejeté à tort tout signalement d'un Demandeur affecté, y compris dans son propre périmètre. Corrigé (comparaison sur asset.locationId) ; méthode renommée allowedAssetIds → allowedLocationIds pour que le nom dise ce qu'elle retourne. exploitation.e2e-spec.ts mis à jour (A1/C1 → A2/B1, dans le site de Karim — sinon rejetés par la nouvelle règle, comportement voulu). 79/80 tests API verts, le seul échec (documents-analytics, monthCost) est le flake calendaire déjà identifié cette session, sans rapport. Typecheck/tests/lint verts sur les 4 paquets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { InvitationResponse } from '@siop/shared';
|
||||
import type { InvitationResponse, UserAdmin } from '@siop/shared';
|
||||
import {
|
||||
useCreateTeam,
|
||||
useInviteUser,
|
||||
useLocations,
|
||||
useResendInvitation,
|
||||
useRoles,
|
||||
useTeams,
|
||||
@@ -46,6 +47,7 @@ export default function PagePersonnes() {
|
||||
<th>Rôle</th>
|
||||
<th>Équipe</th>
|
||||
<th>Taux horaire</th>
|
||||
<th>Sites</th>
|
||||
<th>Statut</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
@@ -75,6 +77,13 @@ export default function PagePersonnes() {
|
||||
editable={can('PEOPLE_TEAMS', 'edit')}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{u.role.name === 'Demandeur' ? (
|
||||
<CelluleSites utilisateur={u} editable={can('PEOPLE_TEAMS', 'edit')} />
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{u.status === 'active' ? (
|
||||
<span className="st termine">Actif</span>
|
||||
@@ -206,6 +215,90 @@ function CelluleTaux({
|
||||
);
|
||||
}
|
||||
|
||||
/** Sites autorisés en signalement (R6.6) — un Demandeur ne choisit son
|
||||
* équipement que parmi ceux de ses sites rattachés (web + mobile, même
|
||||
* filtre côté API : `/assets/options`). */
|
||||
function CelluleSites({
|
||||
utilisateur,
|
||||
editable,
|
||||
}: {
|
||||
utilisateur: UserAdmin;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const [modale, setModale] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<span className="num">
|
||||
{utilisateur.assignedSites.map((s) => s.name).join(', ') || '—'}
|
||||
{editable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="ml-1 px-2 py-0.5 text-[12px]"
|
||||
aria-label="Modifier les sites rattachés"
|
||||
onClick={() => setModale(true)}
|
||||
>
|
||||
✎
|
||||
</Button>
|
||||
) : null}
|
||||
</span>
|
||||
<ModaleSites utilisateur={utilisateur} ouverte={modale} surFermeture={setModale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleSites({
|
||||
utilisateur,
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
utilisateur: UserAdmin;
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const { data: locations } = useLocations();
|
||||
const sites = (locations ?? []).filter((l) => l.parentId === null);
|
||||
const maj = useUpdateUser();
|
||||
const [coches, setCoches] = useState<string[]>(() => utilisateur.assignedSites.map((s) => s.id));
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
maj.mutate(
|
||||
{ id: utilisateur.id, locationIds: coches },
|
||||
{ onSuccess: () => surFermeture(false) },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre={`Sites de ${utilisateur.displayName}`} ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
{sites.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={coches.includes(s.id)}
|
||||
onChange={(e) =>
|
||||
setCoches((c) => (e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id)))
|
||||
}
|
||||
/>
|
||||
{s.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
Le signalement se limite aux sites cochés ci-dessus. Aucun site coché = aucune
|
||||
restriction (accès au parc complet, comportement historique).
|
||||
</p>
|
||||
{maj.isError ? <p className="erreur-form" role="alert">{maj.error.message}</p> : null}
|
||||
<div className="pied">
|
||||
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
||||
<Button type="submit" variant="prim" disabled={maj.isPending}>Enregistrer</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleInvitation({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
@@ -217,7 +310,12 @@ function ModaleInvitation({
|
||||
}) {
|
||||
const { data: roles } = useRoles();
|
||||
const { data: teams } = useTeams();
|
||||
const { data: locations } = useLocations();
|
||||
const invitation = useInviteUser();
|
||||
const [roleId, setRoleId] = useState('');
|
||||
const [sitesCoches, setSitesCoches] = useState<string[]>([]);
|
||||
const roleName = roles?.find((r) => r.id === roleId)?.name;
|
||||
const sites = (locations ?? []).filter((l) => l.parentId === null);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -229,10 +327,13 @@ function ModaleInvitation({
|
||||
displayName: String(donnees.get('displayName')),
|
||||
roleId: String(donnees.get('roleId')),
|
||||
teamIds: teamId ? [teamId] : undefined,
|
||||
locationIds: sitesCoches.length ? sitesCoches : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (reponse) => {
|
||||
surFermeture(false);
|
||||
setRoleId('');
|
||||
setSitesCoches([]);
|
||||
surLien(reponse);
|
||||
},
|
||||
},
|
||||
@@ -253,7 +354,13 @@ function ModaleInvitation({
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="inv-role">Rôle *</label>
|
||||
<select id="inv-role" name="roleId" required defaultValue="">
|
||||
<select
|
||||
id="inv-role"
|
||||
name="roleId"
|
||||
required
|
||||
value={roleId}
|
||||
onChange={(e) => setRoleId(e.target.value)}
|
||||
>
|
||||
<option value="" disabled>Choisir…</option>
|
||||
{(roles ?? []).map((r) => (
|
||||
<option key={r.id} value={r.id}>{r.name}</option>
|
||||
@@ -270,6 +377,30 @@ function ModaleInvitation({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{roleName === 'Demandeur' ? (
|
||||
<div className="champ">
|
||||
<label>Sites autorisés en signalement (optionnel)</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
{sites.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 text-[13px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sitesCoches.includes(s.id)}
|
||||
onChange={(e) =>
|
||||
setSitesCoches((c) =>
|
||||
e.target.checked ? [...c, s.id] : c.filter((id) => id !== s.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
{s.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="note-douce">
|
||||
Aucun site coché = aucune restriction (accès au parc complet).
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<p className="note-douce">
|
||||
La personne recevra un <b>lien d'activation valable 7 jours</b> pour choisir
|
||||
son mot de passe. Aucun compte n'est actif avant cela.
|
||||
|
||||
Reference in New Issue
Block a user