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:
pr-daaif
2026-07-16 13:06:45 +01:00
parent 266ffaaf1b
commit 9778629827
29 changed files with 2350 additions and 35 deletions

View File

@@ -0,0 +1,206 @@
import { useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useCategories, useCreateAsset, useLocations } from '@/api/referentiel';
import { Button } from '@/components/ui/button';
import { construireArbre } from '@/lib/lieux';
interface LigneOrgane {
cle: number;
typeId: string;
designation: string;
}
/** Écran 4 des maquettes R1 — le parcours de recette : identité →
* rattachement (site → zone) → organes ligne à ligne. QR généré à la création. */
export default function PageNouvelAscenseur() {
const navigate = useNavigate();
const [params] = useSearchParams();
const { data: locations } = useLocations();
const { data: categories } = useCategories();
const creation = useCreateAsset();
const sites = construireArbre(locations ?? []);
const [siteId, setSiteId] = useState(params.get('site') ?? '');
const [organes, setOrganes] = useState<LigneOrgane[]>([
{ cle: 1, typeId: '', designation: '' },
]);
const siteChoisi = sites.find((s) => s.id === siteId);
const equipements = (categories ?? []).filter((c) => c.kind === 'EQUIPMENT' && c.isActive);
const typesOrganes = (categories ?? []).filter(
(c) => c.kind === 'COMPONENT_TYPE' && c.isActive,
);
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const donnees = new FormData(e.currentTarget);
const champ = (nom: string) => String(donnees.get(nom) ?? '').trim();
const dateMes = champ('commissionedAt');
creation.mutate(
{
reference: champ('reference'),
brand: champ('brand'),
model: champ('model') || undefined,
serialNumber: champ('serialNumber') || undefined,
commissionedAt: dateMes ? new Date(dateMes).toISOString() : undefined,
loadKg: champ('loadKg') ? Number(champ('loadKg')) : undefined,
floors: champ('floors') ? Number(champ('floors')) : undefined,
categoryId: champ('categoryId'),
locationId: champ('locationId'),
components: organes
.filter((o) => o.typeId)
.map((o) => ({ typeId: o.typeId, designation: o.designation || undefined })),
},
{ onSuccess: (asset) => navigate(`/ascenseurs/${asset.id}`) },
);
};
return (
<div style={{ maxWidth: 860 }} className="flex flex-col gap-4">
<div className="filajout">
<Link to="/ascenseurs">Ascenseurs</Link> / Nouvel ascenseur
</div>
<div className="entete-page"><h1>Nouvel ascenseur</h1></div>
<form onSubmit={surEnvoi} className="flex flex-col gap-4">
<div className="carte">
<h2>1 · Identité</h2>
<div className="form-grille">
<div className="champ">
<label htmlFor="na-ref">Référence *</label>
<input id="na-ref" name="reference" required placeholder="Ex. : B3" />
</div>
<div className="champ">
<label htmlFor="na-serie">N° de série</label>
<input id="na-serie" name="serialNumber" placeholder="Plaque constructeur" />
</div>
<div className="champ">
<label htmlFor="na-marque">Marque *</label>
<input id="na-marque" name="brand" required placeholder="Ex. : Schindler" />
</div>
<div className="champ">
<label htmlFor="na-modele">Modèle</label>
<input id="na-modele" name="model" placeholder="Ex. : 3300" />
</div>
<div className="champ">
<label htmlFor="na-mes">Mise en service</label>
<input id="na-mes" name="commissionedAt" type="date" />
</div>
<div className="champ">
<label htmlFor="na-categorie">Catégorie *</label>
<select id="na-categorie" name="categoryId" required defaultValue="">
<option value="" disabled>Choisir</option>
{equipements.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="na-charge">Charge (kg)</label>
<input id="na-charge" name="loadKg" type="number" min="1" placeholder="630" />
</div>
<div className="champ">
<label htmlFor="na-niveaux">Niveaux desservis</label>
<input id="na-niveaux" name="floors" type="number" min="1" placeholder="8" />
</div>
</div>
</div>
<div className="carte">
<h2>2 · Rattachement</h2>
<div className="form-grille">
<div className="champ">
<label htmlFor="na-site">Site *</label>
<select
id="na-site"
required
value={siteId}
onChange={(e) => setSiteId(e.target.value)}
>
<option value="" disabled>Choisir</option>
{sites.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<div className="champ">
<label htmlFor="na-zone">Emplacement dans le site *</label>
<select id="na-zone" name="locationId" required defaultValue="" disabled={!siteChoisi}>
<option value="" disabled>{siteChoisi ? 'Choisir…' : 'Choisissez un site'}</option>
{siteChoisi ? (
<option value={siteChoisi.id}> le site lui-même </option>
) : null}
{siteChoisi?.zones.map((z) => (
<option key={z.id} value={z.id}>{z.name}</option>
))}
</select>
</div>
</div>
</div>
<div className="carte">
<h2>
3 · Organes{' '}
<span style={{ fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}>
suivis individuellement (pannes par organe, essais réglementaires)
</span>
</h2>
{organes.map((organe, index) => (
<div className="ligne-organe" key={organe.cle}>
<select
aria-label={`Type de l'organe ${index + 1}`}
value={organe.typeId}
onChange={(e) =>
setOrganes((o) =>
o.map((x) => (x.cle === organe.cle ? { ...x, typeId: e.target.value } : x)),
)
}
>
<option value="">Type</option>
{typesOrganes.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<input
aria-label={`Désignation de l'organe ${index + 1}`}
style={{ flex: 2 }}
placeholder="Désignation…"
value={organe.designation}
onChange={(e) =>
setOrganes((o) =>
o.map((x) =>
x.cle === organe.cle ? { ...x, designation: e.target.value } : x,
),
)
}
/>
<button
type="button"
className="btn-suppr"
aria-label={`Retirer l'organe ${index + 1}`}
onClick={() => setOrganes((o) => o.filter((x) => x.cle !== organe.cle))}
>
</button>
</div>
))}
<Button
variant="ghost"
className="mt-2"
onClick={() =>
setOrganes((o) => [...o, { cle: Date.now(), typeId: '', designation: '' }])
}
>
+ Ajouter un organe
</Button>
</div>
{creation.isError ? (
<p className="erreur-form" role="alert">{creation.error.message}</p>
) : null}
<div className="flex justify-end gap-2">
<Button onClick={() => navigate('/ascenseurs')}>Annuler</Button>
<Button type="submit" variant="prim" disabled={creation.isPending}>
Créer l'ascenseur (QR généré)
</Button>
</div>
</form>
</div>
);
}