diff --git a/CLAUDE.md b/CLAUDE.md
index ae28b0e..1853fb4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -74,5 +74,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- ✅ **R6.1 — socle** : `usePermissions()` mobile (calqué sur le web, lit `me.permissions`) ; barre d'onglets adaptative par rôle (`ongletsVisibles`, `href: null` masque sans retirer du navigateur) ; écrans Accueil (dashboard réel pour Administrateur/Gestionnaire/Dispatcher/Vue seule, aucun chiffre inventé), OT (liste complète `viewOther`, réutilise la fiche OT R4 telle quelle), Menu (groupes filtrés par la matrice, groupe vide masqué), Demandes (`PanneauDemandes` — un seul composant pour tous les rôles : création/suivi pour le Demandeur, approbation/rejet à motif pour Gestionnaire/Dispatcher/Administrateur, lecture seule pour Vue seule ; `GET /assets/options`, déjà ouvert à tout rôle authentifié depuis R2.3, réutilisé pour le sélecteur d'équipement). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, Assistant, Personnes) : écran « à venir » honnête plutôt qu'un lien mort. Typecheck propre, 17 tests Jest verts, lint 5/5 paquets — pas de vérification visuelle en navigateur de mon côté (aucun outil de ce type dans cet environnement), à confirmer par le référent sur l'iPhone déjà connecté au serveur Metro.
- ✅ **R6.2 — Parc (Sites, Ascenseurs)** : `useLocations()` mobile ; écran Sites (premier niveau, `parentId===null`) ; fiche site (identité, zones, ascenseurs du site — consultation seule, D4, pas de carte ni d'édition sur mobile) ; écran Ascenseurs (parc complet déjà préchargé, D1) ouvrant la fiche appareil R4 telle quelle (générique, aucune modification nécessaire). Menu : Ascenseurs/Sites routent réellement, Catégories reste à venir (admin, hors périmètre). Typecheck propre, 17 tests Jest, lint 5/5 — exécution directe de la maquette déjà validée, pas de nouveau tour de design.
- ✅ **R6.3 — Ressources (Stock, Tiers, Fichiers)** : `api/ressources.ts` (usePartners/useParts/usePart/useCreatePurchaseOrder/useDocuments) ; écran Stock (sous-seuil en tête) + fiche pièce + « Commander » pré-rempli en une ligne (fournisseur figé, quantité = manquant jusqu'au seuil, prix = dernier connu — le BC multi-lignes détaillé reste au web, D4) ; Tiers en lecture seule (création/édition réservées au web) ; Fichiers en métadonnées seules — l'ouverture/téléchargement demande `expo-sharing` (dépendance native absente, donc un nouveau build) et est explicitement différée plutôt qu'ajoutée à la légère. Menu branché. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché (toutes les opérations existaient déjà depuis R3).
-- 🔄 **Reprise ici** : R6.4 (Pilotage — Statistiques, Personnes, Assistant), sur le modèle des sous-releases R4.1→R4.3. Confirmation du référent sur iPhone (nav adaptative R6.1 + correctif déconnexion) toujours en attente. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire), `expo-sharing` pour l'ouverture de documents mobile.
+- ✅ **R6.4 — Pilotage (Statistiques, Personnes)** : `EXPO_PUBLIC_WEB_URL`/`WEB_URL` (lien d'activation vers la page web, pas d'équivalent mobile) ; `api/pilotage.ts` ; écran Statistiques (période 3/6/12, coût du mois, taux préventif, pannes par organe, top équipements — cartes plutôt que graphes, D4) ; écran Personnes & équipes (liste + statut, Inviter, lien d'activation en texte sélectionnable — `expo-clipboard` différée, même raisonnement que `expo-sharing` en R6.3 ; taux horaire/rôles/équipes restent au web). **Assistant reste à venir** dans le Menu — un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient Carte/LigneInfo/EnteteFiche déjà validés ; mérite son propre tour de design-first. Typecheck propre, 17 tests Jest, lint 5/5, contrat non touché.
+- 🔄 **Reprise ici — R6 quasi close** (Exploitation/Parc/Ressources/Pilotage livrés pour tous les rôles, hors Assistant) : confirmation du référent sur iPhone (R6.1 + correctif déconnexion) toujours en attente — condition de la clôture réelle ; maquette dédiée à trancher pour l'Assistant mobile ; `expo-sharing` (ouverture de documents) et `expo-clipboard` (copie du lien d'activation) en réserve pour une prochaine étape native. Restes non bloquants inchangés : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »), recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire).
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5 (v1) + R6 en cours.
diff --git a/apps/mobile/.env b/apps/mobile/.env
index d8c48fd..382d4e8 100644
--- a/apps/mobile/.env
+++ b/apps/mobile/.env
@@ -14,3 +14,8 @@ EXPO_PUBLIC_USE_RN_FETCH=1
# Force la recompilation depuis les sources — voir ADR-005.
EXPO_USE_PRECOMPILED_MODULES=0
RCT_USE_PREBUILT_RNCORE=0
+
+# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
+# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
+# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
+EXPO_PUBLIC_WEB_URL=http://localhost:5173
diff --git a/apps/mobile/app/(tabs)/menu.tsx b/apps/mobile/app/(tabs)/menu.tsx
index 575b159..11aeda4 100644
--- a/apps/mobile/app/(tabs)/menu.tsx
+++ b/apps/mobile/app/(tabs)/menu.tsx
@@ -53,9 +53,9 @@ const GROUPES: { titre: string; liens: LienMenu[] }[] = [
{
titre: 'Pilotage',
liens: [
- { libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], aVenir: 'Les statistiques en mobilité arrivent dans une prochaine étape.' },
- { libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'L’assistant IA reste sur le grand écran et la clôture mobile pour l’instant.' },
- { libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], aVenir: 'La gestion des personnes et équipes en mobilité arrive dans une prochaine étape.' },
+ { libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
+ { libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'L’assistant IA reste sur le grand écran et la clôture mobile pour l’instant — un chat sourcé est un nouveau patron d’écran, pas encore maquetté sur mobile.' },
+ { libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
],
},
];
diff --git a/apps/mobile/app/personnes/index.tsx b/apps/mobile/app/personnes/index.tsx
new file mode 100644
index 0000000..3b1b81b
--- /dev/null
+++ b/apps/mobile/app/personnes/index.tsx
@@ -0,0 +1,94 @@
+import { router } from 'expo-router';
+import { FlatList, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useUsers } from '@/api/pilotage';
+import { usePermissions } from '@/auth/use-permissions';
+import { BoutonTel, EnteteFiche } from '@/composants/ui';
+import { useTokens, type Tokens } from '@/theme/tokens';
+
+const LABEL_STATUT: Record<'active' | 'invited' | 'disabled', string> = {
+ active: 'Actif',
+ invited: 'Invitation envoyée',
+ disabled: 'Désactivé',
+};
+
+/** Personnes & équipes — R6.4 (Pilotage). Consultation + « Inviter » (D4) —
+ * taux horaire, équipes, rôles restent gérés depuis le web pour l'instant. */
+export default function PagePersonnes() {
+ const t = useTokens();
+ const { data: users } = useUsers();
+ const { can } = usePermissions();
+ const personnes = [...(users ?? [])].sort((a, b) => a.displayName.localeCompare(b.displayName));
+
+ return (
+
+
+
+ {can('PEOPLE_TEAMS', 'create') ? (
+ router.push('/personnes/inviter')} />
+ ) : null}
+ u.id}
+ contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
+ ListEmptyComponent={
+
+ Personne accessible.
+
+ }
+ renderItem={({ item: u }) => }
+ />
+
+
+ );
+}
+
+function LignePersonne({
+ utilisateur: u,
+ t,
+}: {
+ utilisateur: { id: string; displayName: string; email: string; role: { name: string }; teams: { name: string }[]; status: 'active' | 'invited' | 'disabled' };
+ t: Tokens;
+}) {
+ const [enc, fond] =
+ u.status === 'active'
+ ? [t.stTermine, t.stTermineFond]
+ : u.status === 'invited'
+ ? [t.stAttente, t.stAttenteFond]
+ : [t.stAnnule, t.stAnnuleFond];
+ return (
+
+
+
+ {u.displayName}
+
+
+ {LABEL_STATUT[u.status]}
+
+
+
+ {u.role.name}
+ {u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
+
+
+ );
+}
diff --git a/apps/mobile/app/personnes/inviter.tsx b/apps/mobile/app/personnes/inviter.tsx
new file mode 100644
index 0000000..2309bb2
--- /dev/null
+++ b/apps/mobile/app/personnes/inviter.tsx
@@ -0,0 +1,99 @@
+import { router } from 'expo-router';
+import { useState } from 'react';
+import { ScrollView, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useInviteUser, useRoles } from '@/api/pilotage';
+import { BoutonTel, ChoixTel, EnteteFiche } from '@/composants/ui';
+import { useTokens } from '@/theme/tokens';
+
+/** Invitation — R6.4. Jamais de mot de passe créé pour autrui (décision R1) :
+ * seul un lien d'activation valable 7 j est émis, à transmettre à la main. */
+export default function PageInviter() {
+ const t = useTokens();
+ const { data: roles } = useRoles();
+ const invitation = useInviteUser();
+ const [displayName, setDisplayName] = useState('');
+ const [email, setEmail] = useState('');
+ const [roleId, setRoleId] = useState(null);
+
+ const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
+ const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
+
+ return (
+
+
+
+
+
+ Nom complet *
+
+
+
+
+
+ E-mail *
+
+
+
+ ({ id: r.id, label: r.name }))}
+ surChoix={setRoleId}
+ />
+ {invitation.isError ? (
+
+ {invitation.error.message}
+
+ ) : null}
+
+ invitation.mutate(
+ { displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
+ {
+ onSuccess: (r) =>
+ router.replace({
+ pathname: '/personnes/lien',
+ params: { token: r.activationToken, expiresAt: r.expiresAt },
+ }),
+ },
+ )
+ }
+ />
+
+
+ );
+}
diff --git a/apps/mobile/app/personnes/lien.tsx b/apps/mobile/app/personnes/lien.tsx
new file mode 100644
index 0000000..cabf04f
--- /dev/null
+++ b/apps/mobile/app/personnes/lien.tsx
@@ -0,0 +1,43 @@
+import { router, useLocalSearchParams } from 'expo-router';
+import { ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { WEB_URL } from '@/api/client';
+import { BoutonTel, Carte, EnteteFiche } from '@/composants/ui';
+import { useTokens } from '@/theme/tokens';
+
+/** Lien d'activation émis — R6.4. Pas de presse-papiers natif sur mobile
+ * pour l'instant (`expo-clipboard` demanderait un nouveau build natif,
+ * même raisonnement que l'ouverture de document en Ressources) : le texte
+ * est sélectionnable (`Text selectable`, RN natif, aucune dépendance) —
+ * appui long → copier, comme partout sur le téléphone. */
+export default function PageLienActivation() {
+ const t = useTokens();
+ const { token, expiresAt } = useLocalSearchParams<{ token: string; expiresAt: string }>();
+ const url = `${WEB_URL}/activation?token=${token}`;
+
+ return (
+
+
+
+
+ Transmettez ce lien à la personne (valable jusqu'au{' '}
+ {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
+
+
+
+ {url}
+
+
+
+ Appui long sur le lien pour le sélectionner et le copier.
+
+
+ router.replace('/personnes')} />
+
+
+
+ );
+}
diff --git a/apps/mobile/app/statistiques/index.tsx b/apps/mobile/app/statistiques/index.tsx
new file mode 100644
index 0000000..1def942
--- /dev/null
+++ b/apps/mobile/app/statistiques/index.tsx
@@ -0,0 +1,130 @@
+import { useState } from 'react';
+import { Pressable, ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useAnalyticsSummary } from '@/api/pilotage';
+import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
+import { useTokens } from '@/theme/tokens';
+
+const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 });
+const PERIODES = [3, 6, 12] as const;
+
+/** Statistiques — R6.4 (Pilotage). Maquette écran 7 : les mêmes chiffres
+ * que `/analytics/summary` (web), en cartes plutôt qu'en graphes denses
+ * (D4) — coût du mois, taux préventif, pannes par organe. */
+export default function PageStatistiques() {
+ const t = useTokens();
+ const [periode, setPeriode] = useState<(typeof PERIODES)[number]>(12);
+ const { data } = useAnalyticsSummary(periode);
+
+ return (
+
+
+
+
+ {PERIODES.map((p) => (
+ setPeriode(p)}
+ style={{
+ flex: 1,
+ paddingVertical: 8,
+ borderRadius: 8,
+ alignItems: 'center',
+ backgroundColor: periode === p ? t.primaire : t.surface,
+ borderWidth: 1,
+ borderColor: periode === p ? t.primaire : t.bordure,
+ }}
+ >
+
+ {p} mois
+
+
+ ))}
+
+
+ {data ? (
+ <>
+
+
+
+ {fmtMAD.format(data.monthCost)}
+
+
+ Coût du mois
+
+
+
+
+ {data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
+
+
+ Taux préventif
+
+
+
+
+
+
+
+
+
+
+
+ {data.failuresByComponent.slice(0, 5).map((f) => (
+
+ ))}
+ {!data.failuresByComponent.length ? (
+
+ Aucune panne codée sur la période.
+
+ ) : null}
+
+
+
+ {data.topAssets.slice(0, 5).map((a) => (
+
+ ))}
+ {!data.topAssets.length ? (
+
+ Aucune donnée sur la période.
+
+ ) : null}
+
+ >
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/src/api/client.ts b/apps/mobile/src/api/client.ts
index ee3d1e8..bcea239 100644
--- a/apps/mobile/src/api/client.ts
+++ b/apps/mobile/src/api/client.ts
@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
+/** Origine du web (R6.4 — lien d'activation « Personnes », même page
+ * `/activation?token=…` que le web, il n'existe pas d'équivalent mobile).
+ * À définir par environnement comme EXPO_PUBLIC_API_URL. */
+export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:5173';
+
export const api = createClient({ baseUrl: API_URL });
api.use({
diff --git a/apps/mobile/src/api/pilotage.ts b/apps/mobile/src/api/pilotage.ts
new file mode 100644
index 0000000..c835da2
--- /dev/null
+++ b/apps/mobile/src/api/pilotage.ts
@@ -0,0 +1,39 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { InvitationCreate } from '@siop/shared';
+import { api, unwrap } from './client';
+
+/** Hooks R6.4 — Pilotage (Statistiques, Personnes & équipes) : mêmes
+ * opérations que le web (gestion.ts/referentiel.ts), lecture + une action
+ * courante (inviter) sur mobile — pas la gestion complète des rôles/
+ * équipes/taux horaires, réservée au web (D4). */
+
+export function useAnalyticsSummary(months: 3 | 6 | 12 = 12) {
+ return useQuery({
+ queryKey: ['analytics', months],
+ queryFn: async () =>
+ unwrap(await api.GET('/analytics/summary', { params: { query: { months: String(months) } } })),
+ });
+}
+
+export function useUsers() {
+ return useQuery({
+ queryKey: ['users'],
+ queryFn: async () => (await unwrap(await api.GET('/users'))).users,
+ });
+}
+
+export function useRoles() {
+ return useQuery({
+ queryKey: ['roles'],
+ queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
+ });
+}
+
+export function useInviteUser() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (body: InvitationCreate) =>
+ unwrap(await api.POST('/users/invitations', { body })),
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
+ });
+}
diff --git a/docs/journal/journal.md b/docs/journal/journal.md
index 54b8541..380b3c6 100644
--- a/docs/journal/journal.md
+++ b/docs/journal/journal.md
@@ -4,6 +4,49 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
---
+## 2026-08-02 — Pr. Daaif (+ Claude) — R6.4 : Pilotage sur mobile (Statistiques, Personnes)
+
+**Actions**
+
+- `EXPO_PUBLIC_WEB_URL` (nouveau, `.env`) + `WEB_URL` (`api/client.ts`) : le lien d'activation
+ « Personnes » pointe la page web `/activation?token=…` (il n'existe pas d'équivalent mobile,
+ même page que R1) — variable par environnement comme `EXPO_PUBLIC_API_URL`.
+- `apps/mobile/src/api/pilotage.ts` (nouveau) : `useAnalyticsSummary`, `useUsers`, `useRoles`,
+ `useInviteUser`.
+- Écran **Statistiques** (`app/statistiques/index.tsx`) : maquette écran 7 — sélecteur de période
+ 3/6/12 mois, coût du mois, taux préventif, OT clôturés, pannes par organe (top 5), top
+ équipements en coût. Mêmes chiffres que `/analytics/summary` (web), en cartes plutôt qu'en
+ graphes denses (D4).
+- Écran **Personnes & équipes** (`app/personnes/index.tsx`) : liste avec statut (actif/invitation
+ envoyée/désactivé) ; **Inviter** (`app/personnes/inviter.tsx` — nom, e-mail, rôle) ; **lien
+ d'activation émis** (`app/personnes/lien.tsx`) affiché en texte **sélectionnable** (`Text
+ selectable`, RN natif) plutôt que via presse-papiers — `expo-clipboard` est une dépendance
+ native absente du projet, même raisonnement que `expo-sharing` en R6.3 : différée plutôt
+ qu'ajoutée à la légère, un appui long suffit pour copier. Taux horaire, rôles, équipes restent
+ gérés depuis le web (D4).
+- Menu : Statistiques et Personnes & équipes routent réellement. **Assistant reste à venir** — un
+ chat sourcé (citations, refus honnête) est un nouveau patron d'écran, jamais maquetté sur
+ mobile (contrairement à Sites/Ascenseurs/Stock/Tiers/Personnes qui réutilisaient des patrons
+ déjà validés Carte/LigneInfo/EnteteFiche) : mérite son propre tour de design-first plutôt que
+ d'être exécuté par réflexe en fin de release.
+- Vérifié : typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.
+
+**Décisions**
+
+- Aucune nouvelle sur le fond — exécution de la maquette R6 déjà validée. Deux mêmes arbitrages
+ que R6.3 reconduits explicitement : pas de nouvelle dépendance native au milieu d'une passe
+ (`expo-clipboard` comme `expo-sharing`), et l'Assistant IA mobile est repoussé à un maquettage
+ dédié plutôt que forcé dans le périmètre « actions courantes » de R6.
+
+**Prochaine étape** — **R6 quasi close** : Exploitation/Parc/Ressources/Pilotage (hors Assistant)
+livrés pour tous les rôles. Restes : confirmation du référent sur iPhone (R6.1 + correctif
+déconnexion, toujours en attente — condition de la clôture réelle de R6) ; maquette dédiée pour
+l'Assistant mobile (question ouverte : l'ajouter à R6 ou une release à part) ; `expo-sharing` et
+`expo-clipboard` pour les flux mobiles restés en lecture seule ; restes non bloquants inchangés
+(Dokploy, Android physique, `AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`).
+
+---
+
## 2026-08-02 — Pr. Daaif (+ Claude) — R6.3 : Ressources sur mobile (Stock, Tiers, Fichiers)
**Actions**