mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
- 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>
207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
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>
|
|
);
|
|
}
|