mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
496 lines
16 KiB
TypeScript
496 lines
16 KiB
TypeScript
import { useState, type FormEvent } from 'react';
|
|
import type { InvitationResponse, UserAdmin } from '@siop/shared';
|
|
import {
|
|
useCreateTeam,
|
|
useInviteUser,
|
|
useLocations,
|
|
useResendInvitation,
|
|
useRoles,
|
|
useTeams,
|
|
useUpdateUser,
|
|
useUsers,
|
|
} from '@/api/referentiel';
|
|
import { usePermissions } from '@/auth/use-permissions';
|
|
import { Avatar, initialsOf } from '@/components/avatar';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Modale } from '@/components/ui/modale';
|
|
|
|
/** Écran 6 des maquettes R1 : personnes (invitation par lien 7 j, jamais de
|
|
* mot de passe créé pour autrui) et équipes par zone. */
|
|
export default function PagePersonnes() {
|
|
const { data: users } = useUsers();
|
|
const { data: teams } = useTeams();
|
|
const { can } = usePermissions();
|
|
const [modaleInvitation, setModaleInvitation] = useState(false);
|
|
const [modaleEquipe, setModaleEquipe] = useState(false);
|
|
const [lien, setLien] = useState<InvitationResponse | null>(null);
|
|
const renvoi = useResendInvitation();
|
|
|
|
return (
|
|
<>
|
|
<div className="entete-page">
|
|
<h1>Personnes & équipes</h1>
|
|
<div className="actions">
|
|
{can('PEOPLE_TEAMS', 'create') ? (
|
|
<Button variant="prim" onClick={() => setModaleInvitation(true)}>
|
|
+ Inviter
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
<div className="carte" style={{ padding: 0 }}>
|
|
<div className="table">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Personne</th>
|
|
<th>Rôle</th>
|
|
<th>Équipe</th>
|
|
<th>Taux horaire</th>
|
|
<th>Sites</th>
|
|
<th>Statut</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{(users ?? []).map((u) => (
|
|
<tr key={u.id}>
|
|
<td>
|
|
<div className="flex items-center gap-[9px]">
|
|
<Avatar
|
|
initials={initialsOf(u.displayName)}
|
|
roleName={u.role.name}
|
|
className="h-[26px] w-[26px] text-[10.5px]"
|
|
/>
|
|
<div>
|
|
<b>{u.displayName}</b>
|
|
<span className="sous">{u.email}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td>{u.role.name}</td>
|
|
<td>{u.teams.map((t) => t.name).join(', ') || '—'}</td>
|
|
<td>
|
|
<CelluleTaux
|
|
userId={u.id}
|
|
taux={u.hourlyRate}
|
|
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>
|
|
) : u.status === 'invited' ? (
|
|
<span className="st attente">Invitation envoyée</span>
|
|
) : (
|
|
<span className="st annule">Désactivé</span>
|
|
)}
|
|
</td>
|
|
<td>
|
|
{u.status === 'invited' && can('PEOPLE_TEAMS', 'edit') ? (
|
|
<Button
|
|
variant="ghost"
|
|
className="px-2 py-1 text-[12px]"
|
|
disabled={renvoi.isPending}
|
|
onClick={() => renvoi.mutate(u.id, { onSuccess: setLien })}
|
|
>
|
|
Renvoyer le lien
|
|
</Button>
|
|
) : null}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div className="carte">
|
|
<h2>Équipes</h2>
|
|
<div className="equipes">
|
|
{(teams ?? []).map((t) => (
|
|
<div className="equipe" key={t.id}>
|
|
<b>{t.name}</b>
|
|
<span className="zone">{t.description ?? '—'}</span>
|
|
<div className="avatars">
|
|
{t.members.map((m) => (
|
|
<Avatar
|
|
key={m.id}
|
|
initials={m.initials}
|
|
roleName={m.roleName}
|
|
className="h-6 w-6 text-[10px]"
|
|
/>
|
|
))}
|
|
{t.members.length === 0 ? (
|
|
<span className="text-[12px] text-encre-3">aucun membre</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{can('PEOPLE_TEAMS', 'create') ? (
|
|
<button
|
|
type="button"
|
|
className="equipe items-center justify-center text-encre-3"
|
|
style={{ borderStyle: 'dashed', cursor: 'pointer' }}
|
|
onClick={() => setModaleEquipe(true)}
|
|
>
|
|
<b style={{ fontWeight: 600 }}>+ Nouvelle équipe</b>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<ModaleInvitation
|
|
ouverte={modaleInvitation}
|
|
surFermeture={setModaleInvitation}
|
|
surLien={setLien}
|
|
/>
|
|
<ModaleEquipe ouverte={modaleEquipe} surFermeture={setModaleEquipe} />
|
|
<ModaleLien lien={lien} surFermeture={() => setLien(null)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
/** Taux horaire courant (R3) — appliqué aux futures saisies de temps ; les
|
|
* temps déjà valorisés gardent leur taux figé. */
|
|
function CelluleTaux({
|
|
userId,
|
|
taux,
|
|
editable,
|
|
}: {
|
|
userId: string;
|
|
taux: number | null;
|
|
editable: boolean;
|
|
}) {
|
|
const maj = useUpdateUser();
|
|
const [edition, setEdition] = useState(false);
|
|
|
|
if (!edition) {
|
|
return (
|
|
<span className="num">
|
|
{taux != null ? `${new Intl.NumberFormat('fr-FR').format(taux)} MAD/h` : '—'}
|
|
{editable ? (
|
|
<Button
|
|
variant="ghost"
|
|
className="ml-1 px-2 py-0.5 text-[12px]"
|
|
aria-label="Modifier le taux horaire"
|
|
onClick={() => setEdition(true)}
|
|
>
|
|
✎
|
|
</Button>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
autoFocus
|
|
defaultValue={taux ?? ''}
|
|
aria-label="Nouveau taux horaire (MAD/h)"
|
|
className="num w-24 rounded-lg border border-bordure-forte bg-surface px-2 py-1 text-encre"
|
|
disabled={maj.isPending}
|
|
onBlur={(e) => {
|
|
const valeur = e.target.value.trim();
|
|
const nombre = Number(valeur);
|
|
if (valeur && nombre > 0 && nombre !== taux) {
|
|
maj.mutate({ id: userId, hourlyRate: nombre }, { onSettled: () => setEdition(false) });
|
|
} else {
|
|
setEdition(false);
|
|
}
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') e.currentTarget.blur();
|
|
if (e.key === 'Escape') setEdition(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/** 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,
|
|
surLien,
|
|
}: {
|
|
ouverte: boolean;
|
|
surFermeture: (o: boolean) => void;
|
|
surLien: (l: InvitationResponse) => void;
|
|
}) {
|
|
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();
|
|
const donnees = new FormData(e.currentTarget);
|
|
const teamId = String(donnees.get('teamId'));
|
|
invitation.mutate(
|
|
{
|
|
email: String(donnees.get('email')),
|
|
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);
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Modale titre="Inviter une personne" ouverte={ouverte} surFermeture={surFermeture}>
|
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
|
<div className="champ">
|
|
<label htmlFor="inv-nom">Nom complet *</label>
|
|
<input id="inv-nom" name="displayName" required placeholder="Prénom Nom" />
|
|
</div>
|
|
<div className="champ">
|
|
<label htmlFor="inv-email">Email professionnel *</label>
|
|
<input id="inv-email" name="email" type="email" required placeholder="prenom.nom@spelev.ma" />
|
|
</div>
|
|
<div className="form-grille">
|
|
<div className="champ">
|
|
<label htmlFor="inv-role">Rôle *</label>
|
|
<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>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="champ">
|
|
<label htmlFor="inv-equipe">Équipe (optionnel)</label>
|
|
<select id="inv-equipe" name="teamId" defaultValue="">
|
|
<option value="">—</option>
|
|
{(teams ?? []).map((t) => (
|
|
<option key={t.id} value={t.id}>{t.name}</option>
|
|
))}
|
|
</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.
|
|
</p>
|
|
{invitation.isError ? (
|
|
<p className="erreur-form" role="alert">{invitation.error.message}</p>
|
|
) : null}
|
|
<div className="pied">
|
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
|
<Button type="submit" variant="prim" disabled={invitation.isPending}>
|
|
Envoyer l'invitation
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Modale>
|
|
);
|
|
}
|
|
|
|
/** L'envoi d'email n'existe pas encore : le lien se copie ici (décision R1). */
|
|
function ModaleLien({
|
|
lien,
|
|
surFermeture,
|
|
}: {
|
|
lien: InvitationResponse | null;
|
|
surFermeture: () => void;
|
|
}) {
|
|
const [copie, setCopie] = useState(false);
|
|
if (!lien) return null;
|
|
const url = `${window.location.origin}/activation?token=${lien.activationToken}`;
|
|
return (
|
|
<Modale titre="Lien d'activation émis" ouverte surFermeture={surFermeture}>
|
|
<p className="text-[13px] text-encre-2">
|
|
Transmettez ce lien à la personne (valable jusqu'au{' '}
|
|
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(lien.expiresAt))}) :
|
|
</p>
|
|
<div className="lien-copiable" data-testid="lien-activation">{url}</div>
|
|
<div className="pied">
|
|
<Button
|
|
onClick={() => {
|
|
void navigator.clipboard.writeText(url);
|
|
setCopie(true);
|
|
}}
|
|
>
|
|
{copie ? 'Copié ✓' : 'Copier le lien'}
|
|
</Button>
|
|
<Button variant="prim" onClick={surFermeture}>Fermer</Button>
|
|
</div>
|
|
</Modale>
|
|
);
|
|
}
|
|
|
|
function ModaleEquipe({
|
|
ouverte,
|
|
surFermeture,
|
|
}: {
|
|
ouverte: boolean;
|
|
surFermeture: (o: boolean) => void;
|
|
}) {
|
|
const creation = useCreateTeam();
|
|
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const donnees = new FormData(e.currentTarget);
|
|
creation.mutate(
|
|
{
|
|
name: String(donnees.get('name')),
|
|
description: String(donnees.get('description')) || undefined,
|
|
},
|
|
{ onSuccess: () => surFermeture(false) },
|
|
);
|
|
};
|
|
return (
|
|
<Modale titre="Nouvelle équipe" ouverte={ouverte} surFermeture={surFermeture}>
|
|
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
|
<div className="champ">
|
|
<label htmlFor="eq-nom">Nom *</label>
|
|
<input id="eq-nom" name="name" required placeholder="Ex. : Rabat Agdal" />
|
|
</div>
|
|
<div className="champ">
|
|
<label htmlFor="eq-desc">Zone couverte</label>
|
|
<input id="eq-desc" name="description" placeholder="Sites couverts…" />
|
|
</div>
|
|
{creation.isError ? (
|
|
<p className="erreur-form" role="alert">{creation.error.message}</p>
|
|
) : null}
|
|
<div className="pied">
|
|
<Button onClick={() => surFermeture(false)}>Annuler</Button>
|
|
<Button type="submit" variant="prim" disabled={creation.isPending}>Créer</Button>
|
|
</div>
|
|
</form>
|
|
</Modale>
|
|
);
|
|
}
|