= {
+ EQUIPMENT: 'Catégories d’équipement',
+ COMPONENT_TYPE: 'Types d’organes',
+};
+
+/** Écran 7 des maquettes R1 : référentiels administrables — renommer,
+ * ajouter, (dés)activer. Jamais de suppression si utilisé. */
+export default function PageCategories() {
+ return (
+ <>
+ Catégories
+
+ {CATEGORY_KINDS.map((kind) => (
+
+ ))}
+
+
+ Une catégorie utilisée ne peut pas être supprimée — seulement renommée ou
+ désactivée (elle disparaît des sélecteurs, l'historique reste lisible).
+
+ >
+ );
+}
+
+function ListeCategories({ kind }: { kind: CategoryKind }) {
+ const { data: categories } = useCategories();
+ const { can } = usePermissions();
+ const creation = useCreateCategory();
+ const [nom, setNom] = useState('');
+
+ const liste = (categories ?? []).filter((c) => c.kind === kind);
+ const peutEditer = can('SETTINGS', 'edit');
+
+ const surAjout = (e: FormEvent) => {
+ e.preventDefault();
+ if (!nom.trim()) return;
+ creation.mutate({ kind, name: nom.trim() }, { onSuccess: () => setNom('') });
+ };
+
+ return (
+
+
{TITRES[kind]}
+ {liste.map((categorie) => (
+
+ ))}
+ {can('SETTINGS', 'create') ? (
+
+ ) : null}
+ {creation.isError ? (
+
{creation.error.message}
+ ) : null}
+
+ );
+}
+
+function LigneCategorie({
+ categorie,
+ peutEditer,
+}: {
+ categorie: Category;
+ peutEditer: boolean;
+}) {
+ const maj = useUpdateCategory();
+ const [enEdition, setEnEdition] = useState(false);
+ const [nom, setNom] = useState(categorie.name);
+
+ const renommer = (e: FormEvent) => {
+ e.preventDefault();
+ maj.mutate({ id: categorie.id, name: nom.trim() }, { onSuccess: () => setEnEdition(false) });
+ };
+
+ if (enEdition) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {categorie.name}
+ {!categorie.isActive ? (désactivée) : null}
+
+
+ {categorie.usageCount} {categorie.kind === 'EQUIPMENT' ? 'appareils' : ''}
+
+ {peutEditer ? (
+ <>
+ ·{' '}
+
+ ·{' '}
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/apps/web/src/pages/etiquette.tsx b/apps/web/src/pages/etiquette.tsx
new file mode 100644
index 0000000..e5b3dbc
--- /dev/null
+++ b/apps/web/src/pages/etiquette.tsx
@@ -0,0 +1,76 @@
+import { QRCodeSVG } from 'qrcode.react';
+import { Link, useParams } from 'react-router-dom';
+import { useAsset } from '@/api/referentiel';
+import { Button } from '@/components/ui/button';
+
+/** Écran 5 des maquettes R1 : l'étiquette A6 imprimable — objet PAPIER
+ * (noir sur blanc, même en thème sombre). window.print n'imprime qu'elle. */
+export default function PageEtiquette() {
+ const { id } = useParams<{ id: string }>();
+ const { data: asset } = useAsset(id);
+ if (!asset) return null;
+
+ const url = `${window.location.origin}/q/${asset.reference}`;
+
+ return (
+ <>
+
+ Ascenseurs /{' '}
+ Ascenseur {asset.reference} / Étiquette QR
+
+
+
Étiquette cabine — Ascenseur {asset.reference}
+
+
+
+
+
+
+
+ ▲
▼
+ SIOP
+
+ Maintenance d'ascenseurs
+
+ SPELEV
+
+
+
+
+
+
Ascenseur {asset.reference}
+
+ {asset.siteName}
+
+ {asset.locationName !== asset.siteName ? `${asset.locationName} — ` : ''}
+ {asset.brand} {asset.model ?? ''}
+
+
+
+
+ Un problème ? Une personne bloquée ?
+
+ 📱 Scannez ce code pour signaler
+
+
+ SIOP · {asset.reference}
+ {asset.serialNumber ? ` · ${asset.serialNumber}` : ''} · {url.replace(/^https?:\/\//, '')}
+
+
+
+
Ce que fait le scan
+
+ - Gardien
+ - ouvrira le portail de signalement, équipement prérempli (R2)
+ - Technicien
+ - connecté → ouvre la fiche de l'appareil
+ - Impression
+ - A6 autocollant, noir sur blanc
+
+
+
+ >
+ );
+}
diff --git a/apps/web/src/pages/fiche-ascenseur.tsx b/apps/web/src/pages/fiche-ascenseur.tsx
new file mode 100644
index 0000000..92408bf
--- /dev/null
+++ b/apps/web/src/pages/fiche-ascenseur.tsx
@@ -0,0 +1,202 @@
+import { QRCodeSVG } from 'qrcode.react';
+import { useState, type FormEvent } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import { ASSET_STATUS_LABELS, ASSET_STATUSES, type AssetStatus } from '@siop/shared';
+import {
+ useAddComponent,
+ useAsset,
+ useCategories,
+ useRemoveComponent,
+ useUpdateAsset,
+} from '@/api/referentiel';
+import { usePermissions } from '@/auth/use-permissions';
+import { StatutEquipement } from '@/components/statut-equipement';
+import { Button } from '@/components/ui/button';
+import { Modale } from '@/components/ui/modale';
+import { formatDateFr } from '@/lib/lieux';
+
+/** Fiche appareil — écran validé en R0 (maquette-web, écran 5) ; historique
+ * (R2) et documents (R3) sont annoncés, pas simulés. */
+export default function PageFicheAscenseur() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { data: asset } = useAsset(id);
+ const { can } = usePermissions();
+ const [modaleOrgane, setModaleOrgane] = useState(false);
+ const retrait = useRemoveComponent(id ?? '');
+ const majStatut = useUpdateAsset(id ?? '');
+
+ if (!asset) return null;
+
+ return (
+ <>
+
+ Ascenseurs / Ascenseur {asset.reference}
+
+
+
+ Ascenseur {asset.reference} — {asset.brand} {asset.model ?? ''}
+
+
+
+ {can('ASSETS', 'edit') ? (
+
+ ) : null}
+
+
+
+
+
+
Identité
+
+ - N° de série
+ - {asset.serialNumber ?? '—'}
+ - Marque · modèle
+ - {asset.brand} — {asset.model ?? '—'}
+ - Mise en service
+ - {formatDateFr(asset.commissionedAt)}
+ - Site
+ -
+ {asset.siteName}
+ {asset.locationName !== asset.siteName ? ` — ${asset.locationName}` : ''}
+
+ - Catégorie
+ - {asset.categoryName}
+ - Charge · niveaux
+ -
+ {asset.loadKg ? `${asset.loadKg} kg` : '—'} · {asset.floors ?? '—'} niveaux
+
+
+
+
+
Étiquette cabine
+
+
+
+ Collée en cabine.
+ Scan gardien → signalement prérempli (R2).
+ Scan technicien → cette fiche.
+
+
+
+
+
+
+
Organes
+ {asset.components.map((c) => (
+
+
+ {c.typeName}
+ {c.designation ? {c.designation} : null}
+
+
+ {can('ASSETS', 'edit') ? (
+
+ ) : null}
+
+
+ ))}
+ {asset.components.length === 0 ? (
+
Aucun organe déclaré.
+ ) : null}
+ {can('ASSETS', 'edit') ? (
+
+ ) : null}
+
+
+
+
Historique
+
+ Les interventions de cet appareil apparaîtront ici avec l’exploitation (R2).
+
+
+
+
Documents
+
+ Notices et certificats rejoindront la bibliothèque en R3.
+
+
+
+
+
+ >
+ );
+}
+
+function NouvelOrgane({
+ assetId,
+ ouverte,
+ surFermeture,
+}: {
+ assetId: string;
+ ouverte: boolean;
+ surFermeture: (o: boolean) => void;
+}) {
+ const { data: categories } = useCategories();
+ const ajout = useAddComponent(assetId);
+ const types = (categories ?? []).filter((c) => c.kind === 'COMPONENT_TYPE' && c.isActive);
+
+ const surEnvoi = (e: FormEvent) => {
+ e.preventDefault();
+ const donnees = new FormData(e.currentTarget);
+ ajout.mutate(
+ {
+ typeId: String(donnees.get('typeId')),
+ designation: String(donnees.get('designation')) || undefined,
+ },
+ { onSuccess: () => surFermeture(false) },
+ );
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/fiche-site.tsx b/apps/web/src/pages/fiche-site.tsx
new file mode 100644
index 0000000..cea8212
--- /dev/null
+++ b/apps/web/src/pages/fiche-site.tsx
@@ -0,0 +1,165 @@
+import { useState, type FormEvent } from 'react';
+import { Link, useNavigate, useParams } from 'react-router-dom';
+import { useAssets, useCreateLocation, useLocations } from '@/api/referentiel';
+import { usePermissions } from '@/auth/use-permissions';
+import { CarteGeo } from '@/components/carte-geo';
+import { StatutEquipement } from '@/components/statut-equipement';
+import { Button } from '@/components/ui/button';
+import { Modale } from '@/components/ui/modale';
+
+/** Écran 2 des maquettes R1 : identité du site, hiérarchie d'emplacements,
+ * appareils rattachés. Un organe n'apparaît jamais ici : il suit son appareil. */
+export default function PageFicheSite() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { data: locations } = useLocations();
+ const { data: assets } = useAssets();
+ const { can } = usePermissions();
+ const [modaleZone, setModaleZone] = useState(false);
+
+ const site = locations?.find((l) => l.id === id);
+ if (!site) return null;
+ const zones = (locations ?? []).filter((l) => l.parentId === site.id);
+ const appareils = (assets ?? []).filter((a) => a.siteName === site.name);
+
+ return (
+ <>
+
+ Sites / {site.name}
+
+
+
{site.name}
+
+ {can('ASSETS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
Identité
+
+ - Adresse
+ - {[site.address, site.city].filter(Boolean).join(' — ') || '—'}
+ - Position
+ -
+ {site.latitude != null
+ ? `${site.latitude} N · ${Math.abs(site.longitude!)} O`
+ : 'non posée'}
+
+ - Gardien
+ -
+ {site.guardianName ?? '—'}
+ {site.guardianPhone ? (
+ · {site.guardianPhone}
+ ) : null}
+
+ - Appareils
+ - {site.assetCount}
+
+
+ {site.latitude != null && site.longitude != null ? (
+
+ ) : (
+
Position non posée — modifiez le site pour la placer.
+ )}
+
+
+
+
Emplacements & appareils
+
+ -
+ {site.name}
+ site
+ {site.assetCount} appareils
+
+ {zones.map((zone) => (
+ -
+ {zone.name}
+ zone
+
+ {appareils
+ .filter((a) => a.locationId === zone.id)
+ .map((a) => a.reference)
+ .join(' · ') || '—'}
+
+
+ ))}
+
+ {can('LOCATIONS', 'create') ? (
+
+ ) : null}
+
+
+
Ascenseurs du site
+ {appareils.map((a) => (
+
+
+ {a.reference} — {a.brand} {a.model ?? ''}
+
+
+
+
+
+ ))}
+ {appareils.length === 0 ? (
+
Aucun appareil rattaché pour l’instant.
+ ) : null}
+
+
+
+ >
+ );
+}
+
+function NouvelleZone({
+ siteId,
+ ouverte,
+ surFermeture,
+}: {
+ siteId: string;
+ ouverte: boolean;
+ surFermeture: (o: boolean) => void;
+}) {
+ const creation = useCreateLocation();
+ const surEnvoi = (e: FormEvent) => {
+ e.preventDefault();
+ const nom = String(new FormData(e.currentTarget).get('name'));
+ creation.mutate(
+ { name: nom, parentId: siteId },
+ { onSuccess: () => surFermeture(false) },
+ );
+ };
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/nouvel-ascenseur.tsx b/apps/web/src/pages/nouvel-ascenseur.tsx
new file mode 100644
index 0000000..62ca0d8
--- /dev/null
+++ b/apps/web/src/pages/nouvel-ascenseur.tsx
@@ -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([
+ { 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) => {
+ 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 (
+
+
+ Ascenseurs / Nouvel ascenseur
+
+
Nouvel ascenseur
+
+
+ );
+}
diff --git a/apps/web/src/pages/personnes.tsx b/apps/web/src/pages/personnes.tsx
new file mode 100644
index 0000000..d76d223
--- /dev/null
+++ b/apps/web/src/pages/personnes.tsx
@@ -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(null);
+ const renvoi = useResendInvitation();
+
+ return (
+ <>
+
+
Personnes & équipes
+
+ {can('PEOPLE_TEAMS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ | Personne |
+ Rôle |
+ Équipe |
+ Statut |
+ |
+
+
+
+ {(users ?? []).map((u) => (
+
+
+
+
+
+ {u.displayName}
+ {u.email}
+
+
+ |
+ {u.role.name} |
+ {u.teams.map((t) => t.name).join(', ') || '—'} |
+
+ {u.status === 'active' ? (
+ Actif
+ ) : u.status === 'invited' ? (
+ Invitation envoyée
+ ) : (
+ Désactivé
+ )}
+ |
+
+ {u.status === 'invited' && can('PEOPLE_TEAMS', 'edit') ? (
+
+ ) : null}
+ |
+
+ ))}
+
+
+
+
+
+
Équipes
+
+ {(teams ?? []).map((t) => (
+
+
{t.name}
+
{t.description ?? '—'}
+
+ {t.members.map((m) => (
+
+ ))}
+ {t.members.length === 0 ? (
+
aucun membre
+ ) : null}
+
+
+ ))}
+ {can('PEOPLE_TEAMS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
+ 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) => {
+ 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 (
+
+
+
+ );
+}
+
+/** 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 (
+
+
+ Transmettez ce lien à la personne (valable jusqu'au{' '}
+ {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(lien.expiresAt))}) :
+
+ {url}
+
+
+
+
+
+ );
+}
+
+function ModaleEquipe({
+ ouverte,
+ surFermeture,
+}: {
+ ouverte: boolean;
+ surFermeture: (o: boolean) => void;
+}) {
+ const creation = useCreateTeam();
+ const surEnvoi = (e: FormEvent) => {
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/sites.tsx b/apps/web/src/pages/sites.tsx
new file mode 100644
index 0000000..04061ee
--- /dev/null
+++ b/apps/web/src/pages/sites.tsx
@@ -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 (
+ <>
+
+
Sites
+
+ {sites.length} sites · {totalAppareils} ascenseurs
+
+
+ {can('LOCATIONS', 'create') ? (
+
+ ) : null}
+
+
+
+
+
+
+
+
+ | Site |
+ Ascenseurs |
+ Gardien |
+ Zones |
+
+
+
+ {sites.map((site) => (
+ navigate(`/sites/${site.id}`)}
+ style={{ cursor: 'pointer' }}
+ >
+ |
+ {site.name}
+
+ {[site.address, site.city].filter(Boolean).join(', ') || '—'}
+
+ |
+ {site.assetCount} |
+ {site.guardianName ?? '—'} |
+ {site.zones.length} |
+
+ ))}
+
+
+
+
+
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}`),
+ }))}
+ />
+
+
+ >
+ );
+}
+
+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) => {
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/web/src/styles/app.css b/apps/web/src/styles/app.css
index 5af0bac..a548168 100644
--- a/apps/web/src/styles/app.css
+++ b/apps/web/src/styles/app.css
@@ -536,6 +536,220 @@ table {
margin: 6px 4px;
}
+/* ═══ R1 — Référentiel (classes extraites de maquette-r1.html validée) ═══ */
+/* Statuts d'ÉQUIPEMENT (distincts des statuts OT) */
+.st.arret { color: var(--danger); background: var(--prio-bloque-fond); }
+.st.maintenance { color: var(--st-encours); background: var(--st-encours-fond); }
+
+/* Carte géographique — le conteneur ; Leaflet rend à l'intérieur */
+.geo {
+ position: relative;
+ border: 1px solid var(--bordure);
+ border-radius: var(--rayon);
+ overflow: hidden;
+ min-height: 320px;
+ background: var(--surface-2);
+}
+.geo .leaflet-container {
+ position: absolute;
+ inset: 0;
+ font: inherit;
+}
+/* Pin maquette (divIcon Leaflet) */
+.pin-geo {
+ width: 26px;
+ height: 26px;
+ border-radius: 50% 50% 50% 0;
+ transform: rotate(-45deg) translate(4px, -4px);
+ background: var(--primaire);
+ border: 2px solid var(--surface);
+ box-shadow: var(--ombre-menu);
+}
+.pin-geo::after {
+ content: '';
+ position: absolute;
+ inset: 7px;
+ border-radius: 50%;
+ background: var(--surface);
+}
+.pin-geo.alerte { background: var(--prio-bloque); }
+
+/* Arborescence des emplacements */
+.arbre { list-style: none; padding: 0; font-size: 13.5px; }
+.arbre li { position: relative; padding: 7px 0 7px 26px; border-bottom: 1px solid var(--bordure); }
+.arbre li:last-child { border-bottom: 0; }
+.arbre li::before { content: ''; position: absolute; left: 8px; top: 0; bottom: 0; width: 1px; background: var(--bordure); }
+.arbre li::after { content: ''; position: absolute; left: 8px; top: 50%; width: 12px; height: 1px; background: var(--bordure); }
+.arbre li.racine { padding-left: 0; }
+.arbre li.racine::before, .arbre li.racine::after { display: none; }
+.arbre .meta { color: var(--encre-2); font-size: 12px; margin-left: 8px; }
+.arbre .fin { float: right; color: var(--encre-2); font-size: 12px; }
+
+/* Formulaires R1 */
+.form-grille { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 14px; }
+@media (max-width: 900px) { .form-grille { grid-template-columns: 1fr; } }
+.form-grille .pleine-l { grid-column: 1/-1; }
+.champ select {
+ width: 100%;
+ padding: 9px 12px;
+ border: 1px solid var(--bordure-forte);
+ border-radius: var(--rayon-controle);
+ font: inherit;
+ background: var(--surface);
+ color: var(--encre);
+}
+.ligne-organe { display: flex; gap: 8px; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--bordure); }
+.ligne-organe:last-of-type { border-bottom: 0; }
+.ligne-organe select, .ligne-organe input {
+ padding: 8px 11px;
+ border: 1px solid var(--bordure-forte);
+ border-radius: var(--rayon-controle);
+ font: inherit;
+ background: var(--surface);
+ color: var(--encre);
+}
+.btn-suppr { color: var(--encre-3); padding: 6px; border-radius: 6px; border: 0; background: none; }
+.btn-suppr:hover { color: var(--danger); background: var(--prio-bloque-fond); }
+
+/* Étiquette QR imprimable (A6 — objet papier, blanc même en thème sombre) */
+.zone-impression { display: flex; gap: 24px; align-items: flex-start; flex-wrap: wrap; }
+.etiquette {
+ width: 340px;
+ background: #fff;
+ color: #1b2534;
+ border: 1px solid var(--bordure-forte);
+ border-radius: 12px;
+ padding: 22px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ box-shadow: var(--ombre-menu);
+}
+.etiquette .tete { display: flex; align-items: center; gap: 10px; border-bottom: 2px solid #1b2534; padding-bottom: 10px; }
+.etiquette .tete .marque { display: flex; flex-direction: column; line-height: 0.55; font-size: 12px; color: #dd8a0b; }
+.etiquette .tete b { font-size: 20px; letter-spacing: 0.04em; }
+.etiquette .corps { display: flex; gap: 16px; align-items: center; }
+.etiquette .qr-grand { flex: none; border: 1px solid #dce3ec; border-radius: 8px; padding: 6px; background: #fff; }
+.etiquette h3 { font-size: 15px; font-weight: 700; margin: 0; }
+.etiquette .ou { font-size: 12px; color: #55647a; }
+.etiquette .consigne {
+ font-size: 12.5px;
+ background: #fdf3e3;
+ border: 1px dashed #dd8a0b;
+ color: #8a5a06;
+ border-radius: 8px;
+ padding: 8px 10px;
+ text-align: center;
+ font-weight: 600;
+}
+.etiquette .ref { font-size: 11px; color: #8494ab; text-align: center; font-variant-numeric: tabular-nums; }
+@media print {
+ body * { visibility: hidden; }
+ .etiquette, .etiquette * { visibility: visible; }
+ .etiquette { position: fixed; inset: 0 auto auto 0; box-shadow: none; margin: 10mm; }
+}
+
+/* Modale (Radix Dialog) — style maquette */
+.voile-radix {
+ position: fixed;
+ inset: 0;
+ background: color-mix(in srgb, var(--encre) 32%, transparent);
+ z-index: 70;
+}
+.modale {
+ position: fixed;
+ top: 10vh;
+ left: 50%;
+ transform: translateX(-50%);
+ width: 460px;
+ max-width: 94vw;
+ max-height: 82vh;
+ overflow-y: auto;
+ background: var(--surface);
+ border: 1px solid var(--bordure);
+ border-radius: 12px;
+ box-shadow: var(--ombre-menu);
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ z-index: 71;
+}
+.modale h3 { font-size: 15px; font-weight: 700; }
+.modale .pied { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
+.note-douce {
+ font-size: 12px;
+ color: var(--encre-2);
+ background: var(--surface-2);
+ border-radius: 8px;
+ padding: 8px 10px;
+}
+.lien-copiable {
+ font-family: var(--police-mono);
+ font-size: 11.5px;
+ word-break: break-all;
+ background: var(--surface-2);
+ border: 1px dashed var(--bordure-forte);
+ border-radius: 8px;
+ padding: 8px 10px;
+}
+
+/* Équipes */
+.equipes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
+@media (max-width: 1000px) { .equipes { grid-template-columns: 1fr; } }
+.equipe {
+ border: 1px solid var(--bordure);
+ border-radius: var(--rayon);
+ padding: 12px 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ background: var(--surface);
+}
+.equipe b { font-size: 13.5px; }
+.equipe .zone { font-size: 12px; color: var(--encre-2); }
+
+/* Tables denses (liste ascenseurs / sites / personnes) */
+.table { width: 100%; overflow-x: auto; }
+.table table { width: 100%; font-size: 13.5px; }
+.table th {
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--encre-2);
+ text-align: left;
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--bordure);
+ background: var(--surface-2);
+ white-space: nowrap;
+}
+.table td { padding: 9px 12px; border-bottom: 1px solid var(--bordure); vertical-align: middle; }
+.table tbody tr:hover { background: var(--primaire-doux); }
+.table .ref { font-weight: 700; color: var(--primaire); }
+.table .sous { color: var(--encre-2); font-size: 12px; display: block; }
+.ligne-bloque td:first-child { box-shadow: inset 3px 0 0 var(--prio-bloque); }
+.filajout { color: var(--encre-3); font-size: 12.5px; }
+.filajout a { color: var(--encre-2); text-decoration: none; }
+.filtres { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
+.filtres select {
+ border: 1px solid var(--bordure-forte);
+ border-radius: var(--rayon-controle);
+ padding: 7px 11px;
+ font-size: 13px;
+ background: var(--surface);
+ color: var(--encre);
+}
+.infos { display: grid; grid-template-columns: 130px 1fr; row-gap: 9px; font-size: 13.5px; }
+.infos dt { color: var(--encre-2); }
+.infos dd { font-weight: 500; }
+.infos a { color: var(--primaire); text-decoration: none; font-weight: 600; }
+.item-liste { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--bordure); font-size: 13.5px; }
+.item-liste:last-child { border-bottom: 0; }
+.item-liste .fin { margin-left: auto; display: flex; align-items: center; gap: 10px; color: var(--encre-2); font-size: 12.5px; }
+.avatars { display: flex; }
+.avatars .avatar { width: 24px; height: 24px; font-size: 10px; border: 2px solid var(--surface); margin-left: -6px; }
+.avatars .avatar:first-child { margin-left: 0; }
+
/* ═══ Page /design : nuanciers ═══ */
.nuancier {
display: flex;
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 9ce8d57..c0ce4a6 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -11,7 +11,12 @@ export default defineConfig({
include: ['src/**/*.test.{ts,tsx}'],
},
resolve: {
- alias: { '@': path.resolve(__dirname, 'src') },
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ // Le paquet partagé est compilé en CJS pour l'API ; côté Vite on
+ // consomme sa SOURCE TypeScript (exports nommés, HMR, pas de rebuild).
+ '@siop/shared': path.resolve(__dirname, '../../packages/shared/src/index.ts'),
+ },
},
server: {
// Même topologie qu'en prod (nginx : /api → apps/api sans préfixe)
diff --git a/docs/journal/journal.md b/docs/journal/journal.md
index 4c0153c..0e7b0eb 100644
--- a/docs/journal/journal.md
+++ b/docs/journal/journal.md
@@ -4,6 +4,23 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
+## 2026-07-16 — Pr. Daaif (+ Claude) — R1.2 : écrans web du référentiel
+
+**Actions**
+
+- **7 écrans fidèles à maquette-r1.html** : Sites (liste + **carte Leaflet/OSM réelle**, pins maquette, création en modale avec position posée au clic), Fiche site (arbre site→zones, appareils, ajout de zone), Ascenseurs (table dense, filtres site/statut, strie rouge sur appareil à l'arrêt), Fiche appareil (écran validé R0 : identité, organes ± ajout/retrait, changement de statut, **QR réel** ; historique R2 et documents R3 annoncés sans simulation), Nouvel ascenseur (identité → rattachement → organes ligne à ligne), **Étiquette A6 imprimable** (`window.print` n'imprime qu'elle, objet papier même en sombre), Personnes & équipes (invitation → **lien d'activation affiché à copier** — pas d'email en R1, décision explicite ; renvoi de lien ; équipes), Catégories (ajout, renommage inline, désactivation).
+- **Page /activation** (cible du lien d'invitation, moule visuel de la connexion) — la personne choisit son mot de passe et entre directement.
+- **Navigation pilotée par la matrice** : les entrées livrées n'apparaissent que si `canView` (Catégories exige SETTINGS) ; boutons de création/édition conditionnés par `can()` — l'API re-vérifie de toute façon.
+- **e2e Playwright** : la **recette R1 officielle rejouée intégralement** (site → zone → appareil+organe → étiquette → invitation → activation du compte → l'invité est connecté) + un parcours « la matrice pilote l'UI » (Technicien : lecture sans boutons) ; purge idempotente des données de test en globalSetup. 5 tests verts, 4 vitest, build prod, lint.
+
+**Leçon**
+
+- Paquet workspace CJS + Vite : les exports nommés runtime échouent (pas de pré-bundle des paquets liés) → alias Vite `@siop/shared` → **source TypeScript** ; l'API garde le dist CJS. Symptôme vicieux : app blanche, tests e2e tous rouges.
+
+**Prochaine étape** : R1.3 — recette R1 avec le référent (revue pixel écrans ↔ maquette-r1), déploiement (CI → Dokploy), tag `release/r1`. Puis R2 Exploitation (maquettes déjà validées pour OT/demandes/portail).
+
+---
+
## 2026-07-16 — Pr. Daaif (+ Claude) — R1.1 : socle backend du référentiel
**Actions**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2b24c1a..2b450db 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -108,6 +108,9 @@ importers:
'@fontsource/manrope':
specifier: ^5.1.0
version: 5.2.8
+ '@radix-ui/react-dialog':
+ specifier: ^1.1.19
+ version: 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.4
version: 2.1.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -123,18 +126,27 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ leaflet:
+ specifier: ^1.9.4
+ version: 1.9.4
lucide-react:
specifier: ^0.469.0
version: 0.469.0(react@19.2.7)
openapi-fetch:
specifier: ^0.13.4
version: 0.13.8
+ qrcode.react:
+ specifier: ^4.2.0
+ version: 4.2.0(react@19.2.7)
react:
specifier: ^19.1.0
version: 19.2.7
react-dom:
specifier: ^19.1.0
version: 19.2.7(react@19.2.7)
+ react-leaflet:
+ specifier: ^5.0.0
+ version: 5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react-router-dom:
specifier: ^7.1.0
version: 7.18.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -148,6 +160,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.1.0
version: 4.3.2(vite@6.4.3(@types/node@24.13.3)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.49.0)(tsx@4.23.1))
+ '@types/leaflet':
+ specifier: ^1.9.21
+ version: 1.9.21
'@types/react':
specifier: ^19.0.0
version: 19.2.17
@@ -1242,6 +1257,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-dialog@1.1.19':
+ resolution: {integrity: sha512-+HhbN2+YtkRgVirjZ2afMeutQRuGOrdkWR5+EFC58SJojGmtyNQwYzgi6tHBpOxvFHefMtPeHdgtjz0BOGxFQg==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-direction@1.1.2':
resolution: {integrity: sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==}
peerDependencies:
@@ -1461,6 +1489,13 @@ packages:
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
+ '@react-leaflet/core@3.0.0':
+ resolution: {integrity: sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
'@redocly/ajv@8.11.2':
resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==}
@@ -1808,6 +1843,9 @@ packages:
'@types/express@5.0.6':
resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
@@ -1832,6 +1870,9 @@ packages:
'@types/jsonwebtoken@9.0.10':
resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==}
+ '@types/leaflet@1.9.21':
+ resolution: {integrity: sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==}
+
'@types/methods@1.1.4':
resolution: {integrity: sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==}
@@ -3315,6 +3356,9 @@ packages:
resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==}
engines: {node: '>=6'}
+ leaflet@1.9.4:
+ resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
+
leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
engines: {node: '>=6'}
@@ -3819,6 +3863,11 @@ packages:
pure-rand@6.1.0:
resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==}
+ qrcode.react@4.2.0:
+ resolution: {integrity: sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==}
+ peerDependencies:
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
qs@6.15.3:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==}
engines: {node: '>=0.6'}
@@ -3846,6 +3895,13 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ react-leaflet@5.0.0:
+ resolution: {integrity: sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==}
+ peerDependencies:
+ leaflet: ^1.9.0
+ react: ^19.0.0
+ react-dom: ^19.0.0
+
react-refresh@0.17.0:
resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
engines: {node: '>=0.10.0'}
@@ -5723,6 +5779,28 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
+ '@radix-ui/react-dialog@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.5
+ '@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-dismissable-layer': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-focus-guards': 1.1.4(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-focus-scope': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-portal': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-presence': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ '@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
+ '@radix-ui/react-use-controllable-state': 1.2.3(@types/react@19.2.17)(react@19.2.7)
+ aria-hidden: 1.2.6
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+ react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.7)
+ optionalDependencies:
+ '@types/react': 19.2.17
+ '@types/react-dom': 19.2.3(@types/react@19.2.17)
+
'@radix-ui/react-direction@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -5928,6 +6006,12 @@ snapshots:
'@radix-ui/rect@1.1.2': {}
+ '@react-leaflet/core@3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
+ dependencies:
+ leaflet: 1.9.4
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
'@redocly/ajv@8.11.2':
dependencies:
fast-deep-equal: 3.1.3
@@ -6208,6 +6292,8 @@ snapshots:
'@types/express-serve-static-core': 5.1.2
'@types/serve-static': 2.2.0
+ '@types/geojson@7946.0.16': {}
+
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 24.13.3
@@ -6236,6 +6322,10 @@ snapshots:
'@types/ms': 2.1.0
'@types/node': 24.13.3
+ '@types/leaflet@1.9.21':
+ dependencies:
+ '@types/geojson': 7946.0.16
+
'@types/methods@1.1.4': {}
'@types/ms@2.1.0': {}
@@ -8087,6 +8177,8 @@ snapshots:
kleur@3.0.3: {}
+ leaflet@1.9.4: {}
+
leven@3.1.0: {}
levn@0.4.1:
@@ -8516,6 +8608,10 @@ snapshots:
pure-rand@6.1.0: {}
+ qrcode.react@4.2.0(react@19.2.7):
+ dependencies:
+ react: 19.2.7
+
qs@6.15.3:
dependencies:
es-define-property: 1.0.1
@@ -8549,6 +8645,13 @@ snapshots:
react-is@18.3.1: {}
+ react-leaflet@5.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
+ dependencies:
+ '@react-leaflet/core': 3.0.0(leaflet@1.9.4)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
+ leaflet: 1.9.4
+ react: 19.2.7
+ react-dom: 19.2.7(react@19.2.7)
+
react-refresh@0.17.0: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):