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:
172
apps/web/src/pages/sites.tsx
Normal file
172
apps/web/src/pages/sites.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCreateLocation, useLocations } from '@/api/referentiel';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { CarteGeo } from '@/components/carte-geo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Modale } from '@/components/ui/modale';
|
||||
import { construireArbre } from '@/lib/lieux';
|
||||
|
||||
/** Écran 1 des maquettes R1 : liste dense + carte OSM (positions PostGIS). */
|
||||
export default function PageSites() {
|
||||
const navigate = useNavigate();
|
||||
const { data: locations } = useLocations();
|
||||
const { can } = usePermissions();
|
||||
const [modale, setModale] = useState(false);
|
||||
|
||||
const sites = construireArbre(locations ?? []);
|
||||
const totalAppareils = sites.reduce((n, s) => n + s.assetCount, 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="entete-page">
|
||||
<h1>Sites</h1>
|
||||
<span className="filajout">
|
||||
{sites.length} sites · {totalAppareils} ascenseurs
|
||||
</span>
|
||||
<div className="actions">
|
||||
{can('LOCATIONS', 'create') ? (
|
||||
<Button variant="prim" onClick={() => setModale(true)}>
|
||||
+ Nouveau site
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grille-2">
|
||||
<div className="carte" style={{ padding: 0 }}>
|
||||
<div className="table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Site</th>
|
||||
<th>Ascenseurs</th>
|
||||
<th>Gardien</th>
|
||||
<th>Zones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sites.map((site) => (
|
||||
<tr
|
||||
key={site.id}
|
||||
onClick={() => navigate(`/sites/${site.id}`)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<td>
|
||||
<b>{site.name}</b>
|
||||
<span className="sous">
|
||||
{[site.address, site.city].filter(Boolean).join(', ') || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="num">{site.assetCount}</td>
|
||||
<td>{site.guardianName ?? '—'}</td>
|
||||
<td className="num">{site.zones.length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<CarteGeo
|
||||
pins={sites
|
||||
.filter((s) => s.latitude != null && s.longitude != null)
|
||||
.map((s) => ({
|
||||
id: s.id,
|
||||
latitude: s.latitude!,
|
||||
longitude: s.longitude!,
|
||||
libelle: `${s.name} · ${s.assetCount}`,
|
||||
onClick: () => navigate(`/sites/${s.id}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<NouveauSite ouverte={modale} surFermeture={setModale} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function NouveauSite({
|
||||
ouverte,
|
||||
surFermeture,
|
||||
}: {
|
||||
ouverte: boolean;
|
||||
surFermeture: (o: boolean) => void;
|
||||
}) {
|
||||
const creation = useCreateLocation();
|
||||
const navigate = useNavigate();
|
||||
const [position, setPosition] = useState<{ lat: number; lng: number } | null>(null);
|
||||
|
||||
const surEnvoi = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const donnees = new FormData(e.currentTarget);
|
||||
creation.mutate(
|
||||
{
|
||||
name: String(donnees.get('name')),
|
||||
address: String(donnees.get('address')) || undefined,
|
||||
city: String(donnees.get('city')) || undefined,
|
||||
guardianName: String(donnees.get('guardianName')) || undefined,
|
||||
guardianPhone: String(donnees.get('guardianPhone')) || undefined,
|
||||
latitude: position?.lat,
|
||||
longitude: position?.lng,
|
||||
},
|
||||
{
|
||||
onSuccess: (site) => {
|
||||
surFermeture(false);
|
||||
navigate(`/sites/${site.id}`);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modale titre="Nouveau site" ouverte={ouverte} surFermeture={surFermeture}>
|
||||
<form onSubmit={surEnvoi} className="flex flex-col gap-3">
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-nom">Nom du site *</label>
|
||||
<input id="ns-nom" name="name" required placeholder="Ex. : Résidence Les Palmiers" />
|
||||
</div>
|
||||
<div className="form-grille">
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-adresse">Adresse</label>
|
||||
<input id="ns-adresse" name="address" placeholder="Bd, rue…" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-ville">Ville</label>
|
||||
<input id="ns-ville" name="city" placeholder="Casablanca" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-gardien">Gardien</label>
|
||||
<input id="ns-gardien" name="guardianName" />
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label htmlFor="ns-tel">Téléphone gardien</label>
|
||||
<input id="ns-tel" name="guardianPhone" placeholder="06 …" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="champ">
|
||||
<label>Position — cliquez sur la carte</label>
|
||||
<CarteGeo
|
||||
className="min-h-[180px]"
|
||||
zoom={11}
|
||||
onClick={(lat, lng) => setPosition({ lat, lng })}
|
||||
pins={
|
||||
position
|
||||
? [{ id: 'nouveau', latitude: position.lat, longitude: position.lng, libelle: 'Nouveau site' }]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<p className="mt-1 text-[12px] text-encre-3 num">
|
||||
{position ? `${position.lat} N · ${position.lng} E` : 'Aucune position posée'}
|
||||
</p>
|
||||
</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 le site
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modale>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user