mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +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:
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal file
206
apps/web/src/pages/nouvel-ascenseur.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user