feat(r6): R6.4 — Pilotage sur mobile (Statistiques, Personnes)

EXPO_PUBLIC_WEB_URL/WEB_URL pour le lien d'activation (pointe la page web
/activation?token=…, aucun équivalent mobile). api/pilotage.ts
(useAnalyticsSummary, useUsers, useRoles, useInviteUser).

Écran Statistiques (maquette écran 7) : période 3/6/12 mois, coût du mois,
taux préventif, OT clôturés, pannes par organe, top équipements — cartes
plutôt que graphes denses (D4).

Écran Personnes & équipes : liste + statut, Inviter (nom/e-mail/rôle),
lien d'activation en texte sélectionnable (Text selectable, RN natif) —
expo-clipboard est une dépendance native absente, différée comme
expo-sharing en R6.3 plutôt qu'ajoutée à la légère. Taux horaire/rôles/
équipes restent gérés au web (D4).

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 des patrons déjà validés — mérite son propre
tour de design-first plutôt qu'être exécuté par réflexe en fin de release.

R6 quasi close (Exploitation/Parc/Ressources/Pilotage livrés pour tous les
rôles, hors Assistant) — reste la confirmation du référent sur iPhone
(R6.1 + correctif déconnexion) avant clôture réelle.

Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 11:43:57 +01:00
parent f210d4f801
commit 4c1a2d67a0
10 changed files with 463 additions and 4 deletions

View File

@@ -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 mole des sous-releases R4.1R4.3. Confirmation du 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 pventif, 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 R0R5 (v1) + R6 en cours.

View File

@@ -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

View File

@@ -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: 'Lassistant IA reste sur le grand écran et la clôture mobile pour linstant.' },
{ 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: 'Lassistant IA reste sur le grand écran et la clôture mobile pour linstant — un chat sourcé est un nouveau patron décran, pas encore maquetté sur mobile.' },
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
],
},
];

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Personnes" />
{can('PEOPLE_TEAMS', 'create') ? (
<BoutonTel libelle="+ Inviter" surAppui={() => router.push('/personnes/inviter')} />
) : null}
<FlatList
data={personnes}
keyExtractor={(u) => u.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Personne accessible.
</Text>
}
renderItem={({ item: u }) => <LignePersonne utilisateur={u} t={t} />}
/>
</View>
</SafeAreaView>
);
}
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 (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{u.displayName}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
}}
>
{LABEL_STATUT[u.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
{u.role.name}
{u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
</Text>
</View>
);
}

View File

@@ -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<string | null>(null);
const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Inviter" />
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Nom complet <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="Nom complet"
value={displayName}
onChangeText={setDisplayName}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
E-mail <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="E-mail"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
</View>
<ChoixTel
libelle="Rôle"
requis
valeur={role ? { id: role.id, label: role.name } : null}
options={(roles ?? []).map((r) => ({ id: r.id, label: r.name }))}
surChoix={setRoleId}
/>
{invitation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{invitation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Émettre le lien d'activation"
desactive={!valide || invitation.isPending}
surAppui={() =>
invitation.mutate(
{ displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
{
onSuccess: (r) =>
router.replace({
pathname: '/personnes/lien',
params: { token: r.activationToken, expiresAt: r.expiresAt },
}),
},
)
}
/>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
<EnteteFiche titre="Lien d'activation émis" />
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre2 }}>
Transmettez ce lien à la personne (valable jusqu'au{' '}
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
</Text>
<Carte>
<Text
selectable
style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.primaire }}
>
{url}
</Text>
</Carte>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre3 }}>
Appui long sur le lien pour le sélectionner et le copier.
</Text>
<View style={{ marginTop: 8 }}>
<BoutonTel libelle="Terminé" surAppui={() => router.replace('/personnes')} />
</View>
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Statistiques" />
<View style={{ flexDirection: 'row', gap: 8 }}>
{PERIODES.map((p) => (
<Pressable
key={p}
accessibilityRole="button"
onPress={() => 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,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: periode === p ? '#fff' : t.encre2,
}}
>
{p} mois
</Text>
</Pressable>
))}
</View>
{data ? (
<>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.primaire }}>
{fmtMAD.format(data.monthCost)}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Coût du mois
</Text>
</View>
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Taux préventif
</Text>
</View>
</View>
<Carte titre={`OT clôturés · ${data.months} mois`}>
<LigneInfo nom="Total" valeur={String(data.closed.total)} />
<LigneInfo nom="Dont préventif" valeur={String(data.closed.preventive)} />
<LigneInfo
nom="Délai moyen"
valeur={data.avgResolutionDays != null ? `${data.avgResolutionDays.toFixed(1)} j` : '—'}
/>
</Carte>
<Carte titre={`Pannes par organe · ${data.months} mois`}>
{data.failuresByComponent.slice(0, 5).map((f) => (
<LigneInfo key={f.label} nom={f.label} valeur={String(f.count)} />
))}
{!data.failuresByComponent.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune panne codée sur la période.
</Text>
) : null}
</Carte>
<Carte titre="Top équipements en coût">
{data.topAssets.slice(0, 5).map((a) => (
<LigneInfo
key={a.reference}
nom={`Asc. ${a.reference}${a.siteName}`}
valeur={fmtMAD.format(a.total)}
/>
))}
{!data.topAssets.length ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
Aucune donnée sur la période.
</Text>
) : null}
</Carte>
</>
) : null}
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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<paths>({ baseUrl: API_URL });
api.use({

View File

@@ -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'] }),
});
}

View File

@@ -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**