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