mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r1.2): écrans web du référentiel — 7 écrans fidèles à maquette-r1
- Sites : liste + carte Leaflet/OSM réelle (pins maquette, positions PostGIS), création en modale avec position posée au clic sur la carte - Fiche site : arbre site→zones, appareils rattachés, ajout de zone - Ascenseurs : table dense, filtres site/statut, strie rouge à l'arrêt ; fiche appareil (organes ± ajout/retrait, statut, QR réel) ; création identité → rattachement → organes ; étiquette A6 imprimable (objet papier, window.print n'imprime qu'elle) - Personnes & équipes : invitation → lien d'activation affiché à copier (pas d'email en R1, décision explicite), renvoi de lien, équipes ; page /activation (choix du mot de passe, connexion directe) - Catégories : ajout, renommage inline, désactivation - navigation et actions pilotées par la matrice (usePermissions) — l'API re-vérifie chaque requête - e2e Playwright : recette R1 officielle rejouée intégralement (site → zone → appareil+organe → étiquette → invitation → activation) + parcours « la matrice pilote l'UI » ; purge idempotente en globalSetup - alias Vite @siop/shared → source TS (exports nommés CJS ↯ workspace lié) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
297
apps/web/src/pages/personnes.tsx
Normal file
297
apps/web/src/pages/personnes.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { InvitationResponse } from '@siop/shared';
|
||||
import {
|
||||
useCreateTeam,
|
||||
useInviteUser,
|
||||
useResendInvitation,
|
||||
useRoles,
|
||||
useTeams,
|
||||
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>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>
|
||||
{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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModaleInvitation({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
surLien,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
surLien: (l: InvitationResponse) => void;
|
||||
}) {
|
||||
const { data: roles } = useRoles();
|
||||
const { data: teams } = useTeams();
|
||||
const invitation = useInviteUser();
|
||||
|
||||
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,
|
||||
},
|
||||
{
|
||||
onSuccess: (reponse) => {
|
||||
surFermeture(false);
|
||||
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 defaultValue="">
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user