diff --git a/CLAUDE.md b/CLAUDE.md index f15d98f..9ff3cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ Projet **réel** (SPELEV, maintenance d'ascenseurs, Maroc) mené par le Pr. Daai 1. **Design-first** : aucune ligne de code applicatif avant validation par le référent de la charte graphique, des tokens et des maquettes HD (`docs/02-design/`). Chaque release subit une revue « pixel » écrans ↔ maquettes. 2. **Playbook vivant** : chaque phase du cycle de vie a son dossier `docs/0X-*/` (template réutilisable + artefacts réels). Le journal quotidien vit dans `docs/journal/` — **jamais** dans le livre. -3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn. +3. **Périmètre fermé par release** : R0 Fondations → R1 Référentiel → R2 Exploitation → R3 Gestion → R4 Mobile → R5 IA → R6 Mobile tous rôles. On n'ouvre pas Rn+1 avant recette **et** déploiement de Rn. 4. **Déployer tôt** : chaque release part sur le serveur de production (Dokploy) dès sa recette. 5. **Le développeur est le premier utilisateur** : `DEMO_MODE=true` active un **sélecteur de compte démo** (connexion 1 clic sur les comptes seedés, endpoint `POST /auth/demo-login` strictement absent si l'env ne l'active pas). Critère : changer de rôle en < 3 s sans mot de passe. @@ -69,5 +69,8 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) → **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte — tout validé. Android : build natif sur émulateur, passage santé complet. - ✅ **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) — `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription → relecture humaine → suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) → `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,54→2,19 Go). Vérifié réellement (voix de synthèse → texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète note→clôture→réindexation→recherche). 80 tests API, 29 pytest ai, mobile vert. - ✅ **Dictée validée sur iPhone physique (01/08, ADR-004 §5)** : le blocage USB du 22/07 était un faux négatif de `system_profiler` (outil de diagnostic en panne, pas le câble). Deux bugs réels trouvés et corrigés : `expo-audio` iOS exige `setAudioModeAsync({ allowsRecording: true })` avant `record()` (absent, jamais détecté hors appareil réel) ; le correctif des modules précompilés (ADR-005) n'avait jamais été rendu permanent dans `apps/mobile/.env` — corrigé. Chaîne complète vérifiée en vrai : micro → dictée → transcription fidèle → note → clôture → réindexation → **retrouvé par la recherche sémantique**. -- 🔄 **Reprise ici** : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »). Restes : 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. +- fix(mobile) **déconnexion accessible depuis tous les onglets (02/08)** : bug remonté par le référent (une fois authentifié, aucun moyen de revenir en arrière ou de se déconnecter — seule « Ma journée » portait ce contrôle, via un `onLongPress` non découvrable). `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux 4 onglets terrain, tap simple + confirmation, rôle affiché dynamique. +- ✅ **R6 — maquette validée (02/08)** : `maquette-mobile-tous-roles.html`, 7 écrans (synthèse par rôle, Accueil adaptatif, Menu groupé, OT côté Dispatcher, Demandes, Stock, Personnes & statistiques) + 5 décisions actées — D1 barre d'onglets adaptative (Technicien/Technicien limité inchangés + onglet Menu) ; D2 le Menu reprend à l'identique les 4 groupes du web (même matrice, aucune règle nouvelle) ; D3 groupe sans lien visible masqué en entier ; D4 le mobile porte les actions courantes par famille, pas les flux de gestion les plus denses (réservés au web) ; D5 aucune logique de permission propre au mobile. Nouvelle release (R4 est close et R4.1/4.2/4.3 déjà pris par le socle mobile technicien d'origine — pas de réouverture, pas de collision de numérotation). +- ✅ **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. +- 🔄 **Reprise ici** : confirmation du référent sur iPhone (nav adaptative R6.1 + correctif déconnexion) ; puis R6.2 (Parc — Sites, Ascenseurs), R6.3 (Ressources — Stock, Tiers, Fichiers), R6.4 (Pilotage — Statistiques, Personnes, Assistant), sur le modèle des sous-releases R4.1→R4.3. 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/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx index d117a10..b659b04 100644 --- a/apps/mobile/app/(tabs)/_layout.tsx +++ b/apps/mobile/app/(tabs)/_layout.tsx @@ -1,12 +1,19 @@ import { Tabs } from 'expo-router'; import { Text, type ColorValue } from 'react-native'; import { useAssets, useReferenceValues } from '@/api/exploitation'; +import { useMe } from '@/auth/session'; +import { ongletsVisibles } from '@/auth/roles'; +import { usePermissions } from '@/auth/use-permissions'; import { useFile } from '@/file/store'; import { useTokens } from '@/theme/tokens'; -/** La tabbar de la maquette R4. Les onglets à venir restent visibles - * (périmètre annoncé, même patron que la sidebar web) mais mènent à un - * écran « disponible en R4.x ». */ +/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles », + * D1) : le Technicien/Technicien limité gardent exactement leurs 4 onglets + * terrain R4, inchangés. Les autres rôles reçoivent Accueil/OT/Menu (ou une + * variante plus étroite — Demandeur, Vue seule — selon leurs droits, + * `ongletsVisibles`). Tous les écrans sont toujours déclarés : `href: null` + * masque un onglet de la barre sans le retirer du navigateur (le Menu peut + * toujours y pousser directement). */ function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) { return {glyphe}; @@ -16,10 +23,19 @@ export default function CoquilleTabs() { const t = useTokens(); const file = useFile(); const conflits = file.some((s) => s.statut === 'CONFLIT'); + const { data: me } = useMe(); + const { can } = usePermissions(); + const visibles = ongletsVisibles(me?.role.name); // D1 « lecture locale » : le parc (scan D4) et les référentiels du bilan // se préchargent dès l'entrée — le sous-sol n'attend pas qu'on y pense. - useAssets(); - useReferenceValues(); + // Le Demandeur n'a pas ASSETS.view (seulement /assets/options) : inutile + // d'appeler /assets pour lui, l'API répondrait 403. + const peutAssets = can('ASSETS', 'view'); + useAssets({ enabled: peutAssets }); + useReferenceValues({ enabled: peutAssets }); + + const cacher = (nom: string) => (visibles.has(nom) ? undefined : null); + return ( , }} @@ -41,6 +58,7 @@ export default function CoquilleTabs() { , }} @@ -48,6 +66,7 @@ export default function CoquilleTabs() { , }} @@ -55,6 +74,7 @@ export default function CoquilleTabs() { , tabBarBadge: file.length || undefined, @@ -66,6 +86,38 @@ export default function CoquilleTabs() { }, }} /> + , + }} + /> + , + }} + /> + , + }} + /> + , + }} + /> ); } diff --git a/apps/mobile/app/(tabs)/accueil.tsx b/apps/mobile/app/(tabs)/accueil.tsx new file mode 100644 index 0000000..9aa2313 --- /dev/null +++ b/apps/mobile/app/(tabs)/accueil.tsx @@ -0,0 +1,179 @@ +import { router } from 'expo-router'; +import { Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRequests, useWorkOrders } from '@/api/exploitation'; +import { useMe } from '@/auth/session'; +import { usePermissions } from '@/auth/use-permissions'; +import { EnteteTabs } from '@/composants/ui'; +import { PanneauDemandes } from '@/composants/panneau-demandes'; +import { useTokens } from '@/theme/tokens'; + +/** Accueil — maquette « mobile ouvert à tous les rôles », écran 2 : le + * Demandeur y voit directement ses demandes (son seul geste) ; les rôles + * de gestion (Administrateur/Gestionnaire/Dispatcher/Vue seule) y trouvent + * leurs signaux réels (urgence, OT en cours, demandes à traiter) — aucun + * chiffre inventé : seuls WORK_ORDERS/REQUESTS sont déjà câblés sur mobile, + * le reste (stock, préventif du mois…) attend son propre écran (Menu). */ +export default function PageAccueil() { + const { data: me } = useMe(); + if (me?.role.name === 'Demandeur') { + return ( + + + + + + + ); + } + return ; +} + +function TableauDeBordGestion() { + const t = useTokens(); + const { can } = usePermissions(); + const peutOT = can('WORK_ORDERS', 'view'); + const peutDemandes = can('REQUESTS', 'view'); + const { data: workOrders } = useWorkOrders(); + const { data: requests } = useRequests(); + + const ots = workOrders ?? []; + const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED' && o.status !== 'DONE' && o.status !== 'CANCELLED'); + const enCours = ots.filter((o) => o.status === 'OPEN' || o.status === 'IN_PROGRESS').length; + const aTraiter = (requests ?? []).filter((r) => r.status === 'RECEIVED').length; + + return ( + + + + + Accueil + + + {urgences.length ? ( + router.push(`/ot/${urgences[0]!.id}`)} + style={{ + backgroundColor: t.prioBloqueFond, + borderRadius: 10, + padding: 9, + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }} + > + + + {urgences.length === 1 + ? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}` + : `${urgences.length} personnes bloquées`} + + + ) : null} + + {peutOT || peutDemandes ? ( + + {peutOT ? ( + + + {enCours} + + + OT en cours + + + ) : null} + {peutDemandes ? ( + + + {aTraiter} + + + Demandes reçues + + + ) : null} + + ) : null} + + {peutDemandes && aTraiter > 0 ? ( + router.push('/demandes')} + style={{ + flexDirection: 'row', + alignItems: 'center', + gap: 10, + backgroundColor: t.surface, + borderColor: t.bordure, + borderWidth: 1, + borderRadius: 10, + padding: 11, + }} + > + + + + + + Demandes en attente + + + à approuver ou rejeter + + + + {aTraiter} + + + + ) : null} + + {!peutOT && !peutDemandes ? ( + + Rien à afficher ici pour votre rôle — voir le Menu. + + ) : null} + + + ); +} diff --git a/apps/mobile/app/(tabs)/menu.tsx b/apps/mobile/app/(tabs)/menu.tsx new file mode 100644 index 0000000..4679540 --- /dev/null +++ b/apps/mobile/app/(tabs)/menu.tsx @@ -0,0 +1,139 @@ +import { router } from 'expo-router'; +import { Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import type { ObjectCategory, PermissionRight } from '@siop/shared'; +import { useMe } from '@/auth/session'; +import { estRoleTerrain } from '@/auth/roles'; +import { usePermissions } from '@/auth/use-permissions'; +import { EnteteTabs } from '@/composants/ui'; +import { useTokens, type Tokens } from '@/theme/tokens'; + +/** Menu — maquette « mobile ouvert à tous les rôles », écran 3 : reprend à + * l'identique les 4 groupes et l'ordre de la sidebar web (Exploitation / + * Parc / Ressources / Pilotage, coquille.tsx), filtrés par LA MÊME matrice + * (D2 — aucune règle de droit nouvelle). Un groupe sans aucun lien visible + * est masqué en entier (D3) : l'écran est petit, un intitulé vide n'aide + * personne. Ce que « Ma journée »/« Préventif » couvrent déjà pour le + * Technicien n'est pas dupliqué ici. */ + +interface LienMenu { + libelle: string; + permission: [ObjectCategory, PermissionRight]; + ico: string; + cacheEnTerrain?: boolean; // déjà couvert par un onglet terrain dédié + route?: string; + aVenir?: string; // detail affiché sur l'écran « à venir » +} + +const GROUPES: { titre: string; liens: LienMenu[] }[] = [ + { + titre: 'Exploitation', + liens: [ + { libelle: 'Ordres de travail', ico: '☰', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, route: '/(tabs)/ot' }, + { libelle: 'Demandes', ico: '✎', permission: ['REQUESTS', 'view'], route: '/demandes' }, + { libelle: 'Préventif', ico: '✓', permission: ['WORK_ORDERS', 'view'], cacheEnTerrain: true, aVenir: 'Les grilles préventives pour les rôles de gestion arrivent dans une prochaine étape — le Technicien les a déjà dans son onglet dédié.' }, + ], + }, + { + titre: 'Parc', + liens: [ + { libelle: 'Ascenseurs', ico: '▣', permission: ['ASSETS', 'view'], aVenir: 'La liste du parc en mobilité arrive dans une prochaine étape — en attendant, le Scanner et les fiches OT y mènent déjà.' }, + { libelle: 'Sites', ico: '◫', permission: ['LOCATIONS', 'view'], aVenir: 'La liste des sites en mobilité arrive dans une prochaine étape.' }, + { libelle: 'Catégories', ico: '▤', permission: ['SETTINGS', 'view'], aVenir: 'L’administration des catégories reste sur le grand écran pour l’instant.' }, + ], + }, + { + titre: 'Ressources', + liens: [ + { libelle: 'Stock & achats', ico: '◔', permission: ['PARTS', 'view'], aVenir: 'Le stock et les bons de commande en mobilité arrivent dans une prochaine étape.' }, + { libelle: 'Tiers', ico: '⇄', permission: ['PURCHASE_ORDERS', 'view'], aVenir: 'Fournisseurs et clients restent sur le grand écran pour l’instant.' }, + { libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], aVenir: 'La bibliothèque documentaire reste sur le grand écran pour l’instant.' }, + ], + }, + { + 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.' }, + ], + }, +]; + +export default function PageMenu() { + const t = useTokens(); + const { data: me } = useMe(); + const { can } = usePermissions(); + const terrain = estRoleTerrain(me?.role.name); + + const groupesVisibles = GROUPES.map((g) => ({ + ...g, + liens: g.liens.filter( + ({ permission, cacheEnTerrain }) => can(...permission) && !(cacheEnTerrain && terrain), + ), + })).filter((g) => g.liens.length > 0); + + return ( + + + + Menu + {groupesVisibles.length === 0 ? ( + + Rien d'autre accessible à votre rôle ici. + + ) : null} + {groupesVisibles.map((g) => ( + + + {g.titre} + + {g.liens.map((lien) => ( + + ))} + + ))} + + + ); +} + +function LigneMenu({ lien, t }: { lien: LienMenu; t: Tokens }) { + const aller = () => { + if (lien.route) { + router.push(lien.route as never); + } else { + router.push({ pathname: '/a-venir', params: { titre: lien.libelle, detail: lien.aVenir ?? '' } }); + } + }; + return ( + + {lien.ico} + + {lien.libelle} + + + + ); +} diff --git a/apps/mobile/app/(tabs)/nouvelle-demande.tsx b/apps/mobile/app/(tabs)/nouvelle-demande.tsx new file mode 100644 index 0000000..1044c47 --- /dev/null +++ b/apps/mobile/app/(tabs)/nouvelle-demande.tsx @@ -0,0 +1,24 @@ +import { router } from 'expo-router'; +import { ScrollView, Text } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { FormulaireDemande } from '@/composants/formulaire-demande'; +import { EnteteTabs } from '@/composants/ui'; +import { useTokens } from '@/theme/tokens'; + +/** Onglet du Demandeur (maquette « mobile ouvert à tous les rôles », + * écran 5) : le geste qu'il fait le plus mérite son propre onglet plutôt + * qu'un bouton caché dans l'Accueil. */ +export default function PageNouvelleDemande() { + const t = useTokens(); + return ( + + + + + Nouvelle demande + + router.replace('/(tabs)/accueil')} /> + + + ); +} diff --git a/apps/mobile/app/(tabs)/ot.tsx b/apps/mobile/app/(tabs)/ot.tsx new file mode 100644 index 0000000..877c898 --- /dev/null +++ b/apps/mobile/app/(tabs)/ot.tsx @@ -0,0 +1,154 @@ +import { router } from 'expo-router'; +import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + WORK_ORDER_PRIORITY_LABELS, + WORK_ORDER_STATUS_LABELS, + WORK_ORDER_TYPE_LABELS, + type WorkOrderPriority, + type WorkOrderStatus, +} from '@siop/shared'; +import { useWorkOrders } from '@/api/exploitation'; +import { EnteteTabs } from '@/composants/ui'; +import { triJournee } from '@/lib/journee'; +import { useTokens, type Tokens } from '@/theme/tokens'; + +/** Ordres de travail — maquette « mobile ouvert à tous les rôles », écran 4 : + * à la différence de « Ma journée » (Technicien, ses OT uniquement), + * Administrateur/Gestionnaire/Dispatcher ont `viewOther` — l'API renvoie + * déjà TOUS les OT (ADR-003, rien à filtrer ici). Même tri priorité puis + * échéance que « Ma journée » ; ouvrir un OT réutilise la fiche R4 telle + * quelle (elle affiche déjà les actions permises via `allowedTransitions`). */ + +const STYLE_STATUT: Record [string, string]> = { + OPEN: (t) => [t.stOuvert, t.stOuvertFond], + IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond], + ON_HOLD: (t) => [t.stAttente, t.stAttenteFond], + DONE: (t) => [t.stTermine, t.stTermineFond], + CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond], +}; +const STRIE_PRIORITE: Record string> = { + PERSON_TRAPPED: (t) => t.prioBloque, + HIGH: (t) => t.prioHaute, + MEDIUM: (t) => t.prioMoyenne, + LOW: (t) => t.prioBasse, + NONE: () => 'transparent', +}; + +export default function PageOT() { + const t = useTokens(); + const { data: workOrders, refetch, isFetching } = useWorkOrders(); + const ots = triJournee(workOrders ?? []); + const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED'); + + return ( + + + + + + Ordres de travail + + + {ots.length} + + + + {urgences.length ? ( + + + + {urgences.length === 1 + ? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}` + : `${urgences.length} personnes bloquées`} + + + ) : null} + + o.id} + refreshControl={ + void refetch()} tintColor={t.primaire} /> + } + contentContainerStyle={{ gap: 8, paddingBottom: 12 }} + ListEmptyComponent={ + + Aucun OT — tirez pour rafraîchir. + + } + renderItem={({ item: o }) => { + const [enc, fond] = STYLE_STATUT[o.status](t); + return ( + router.push(`/ot/${o.id}`)} + style={{ + backgroundColor: t.surface, + borderColor: t.bordure, + borderWidth: 1, + borderRadius: 12, + borderLeftWidth: 4, + borderLeftColor: STRIE_PRIORITE[o.priority](t), + padding: 11, + gap: 2, + }} + > + + + {o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]} + + + {WORK_ORDER_STATUS_LABELS[o.status]} + + + + {o.title} + + + Asc. {o.assetReference} — {o.siteName} + {o.dueDate + ? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}` + : ''} + {o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED' + ? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}` + : ''} + + + ); + }} + /> + + + ); +} diff --git a/apps/mobile/app/a-venir.tsx b/apps/mobile/app/a-venir.tsx new file mode 100644 index 0000000..671f0b7 --- /dev/null +++ b/apps/mobile/app/a-venir.tsx @@ -0,0 +1,21 @@ +import { useLocalSearchParams } from 'expo-router'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { AVenir } from '@/composants/a-venir'; +import { EnteteFiche } from '@/composants/ui'; +import { useTokens } from '@/theme/tokens'; + +/** Destination générique du Menu pour les familles pas encore portées sur + * mobile (Sites, Stock, Tiers, Personnes, Statistiques, Assistant…) — même + * patron que la sidebar web (release annoncée, jamais un cul-de-sac + * silencieux). `AVenir` existait déjà (a-venir.tsx) mais n'était encore + * câblé nulle part. */ +export default function PageAVenir() { + const t = useTokens(); + const { titre, detail } = useLocalSearchParams<{ titre: string; detail: string }>(); + return ( + + + + + ); +} diff --git a/apps/mobile/app/demandes/index.tsx b/apps/mobile/app/demandes/index.tsx new file mode 100644 index 0000000..abb7d13 --- /dev/null +++ b/apps/mobile/app/demandes/index.tsx @@ -0,0 +1,19 @@ +import { View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { PanneauDemandes } from '@/composants/panneau-demandes'; +import { EnteteFiche } from '@/composants/ui'; +import { useTokens } from '@/theme/tokens'; + +/** Écran poussé depuis le Menu (Administrateur/Gestionnaire/Dispatcher/Vue + * seule — pas d'onglet dédié pour eux, contrairement au Demandeur). */ +export default function PageDemandesPoussee() { + const t = useTokens(); + return ( + + + + + + + ); +} diff --git a/apps/mobile/app/demandes/nouvelle.tsx b/apps/mobile/app/demandes/nouvelle.tsx new file mode 100644 index 0000000..8e3f42f --- /dev/null +++ b/apps/mobile/app/demandes/nouvelle.tsx @@ -0,0 +1,18 @@ +import { router } from 'expo-router'; +import { ScrollView } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { FormulaireDemande } from '@/composants/formulaire-demande'; +import { EnteteFiche } from '@/composants/ui'; +import { useTokens } from '@/theme/tokens'; + +export default function PageNouvelleDemandePoussee() { + const t = useTokens(); + return ( + + + + (router.canGoBack() ? router.back() : router.replace('/demandes'))} /> + + + ); +} diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index f23893b..176ca04 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,9 +1,11 @@ import { Redirect } from 'expo-router'; import { ActivityIndicator, View } from 'react-native'; +import { ongletAccueil } from '@/auth/roles'; import { useMe } from '@/auth/session'; import { useTokens } from '@/theme/tokens'; -/** Aiguillage : session valide → Ma journée ; sinon → connexion. +/** Aiguillage : session valide → l'onglet d'accueil du rôle (Ma journée + * pour le terrain, Accueil pour les autres) ; sinon → connexion. * (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */ export default function Aiguillage() { const t = useTokens(); @@ -16,5 +18,9 @@ export default function Aiguillage() { ); } - return me ? : ; + return me ? ( + + ) : ( + + ); } diff --git a/apps/mobile/src/api/exploitation.ts b/apps/mobile/src/api/exploitation.ts index 6387673..5e5b9c4 100644 --- a/apps/mobile/src/api/exploitation.ts +++ b/apps/mobile/src/api/exploitation.ts @@ -1,5 +1,12 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared'; +import type { + ChecklistState, + ReportUpsert, + RequestApprove, + RequestCreate, + RequestReject, + WorkOrderStatus, +} from '@siop/shared'; import { api, API_URL, unwrap } from './client'; import { lireJeton } from './jeton'; @@ -21,9 +28,10 @@ export function useWorkOrder(id: string) { }); } -export function useAssets() { +export function useAssets(opts?: { enabled?: boolean }) { return useQuery({ queryKey: ['assets'], + enabled: opts?.enabled ?? true, queryFn: async () => (await unwrap(await api.GET('/assets'))).assets, }); } @@ -47,14 +55,70 @@ export function useDocumentsOT(workOrderId: string) { }); } -export function useReferenceValues() { +export function useReferenceValues(opts?: { enabled?: boolean }) { return useQuery({ queryKey: ['reference-values'], + enabled: opts?.enabled ?? true, staleTime: 3600_000, // référentiels administrables : stables en journée queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues, }); } +// ————— Demandes (mobile ouvert à tous les rôles) ————— +// Même endpoint pour tous : l'API scope déjà « les siennes » (Demandeur) ou +// « toutes » (viewOther, ADR-003) — rien à filtrer côté app. + +export function useRequests() { + return useQuery({ + queryKey: ['requests'], + queryFn: async () => (await unwrap(await api.GET('/requests'))).requests, + }); +} + +/** Options minimales (signalement) — accessibles à tout rôle authentifié, + * y compris Demandeur qui n'a pas ASSETS.view (même trou corrigé qu'au web). */ +export function useAssetOptions() { + return useQuery({ + queryKey: ['asset-options'], + queryFn: async () => (await unwrap(await api.GET('/assets/options'))).options, + }); +} + +function useInvalideRequests() { + const queryClient = useQueryClient(); + return () => queryClient.invalidateQueries({ queryKey: ['requests'] }); +} + +export function useCreateRequest() { + const invalide = useInvalideRequests(); + return useMutation({ + mutationFn: async (body: RequestCreate) => unwrap(await api.POST('/requests', { body })), + onSuccess: invalide, + }); +} + +export function useApproveRequest() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, ...body }: RequestApprove & { id: string }) => + unwrap(await api.POST('/requests/{id}/approve', { params: { path: { id } }, body })), + onSuccess: () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['requests'] }), + queryClient.invalidateQueries({ queryKey: ['work-orders'] }), + ]), + }); +} + +export function useRejectRequest() { + const invalide = useInvalideRequests(); + return useMutation({ + mutationFn: async ({ id, ...body }: RequestReject & { id: string }) => + unwrap(await api.POST('/requests/{id}/reject', { params: { path: { id } }, body })), + onSuccess: invalide, + }); +} + function useInvalideOT(id: string) { const queryClient = useQueryClient(); return () => diff --git a/apps/mobile/src/auth/roles.ts b/apps/mobile/src/auth/roles.ts new file mode 100644 index 0000000..dc4b98f --- /dev/null +++ b/apps/mobile/src/auth/roles.ts @@ -0,0 +1,26 @@ +import type { RoleName } from '@siop/shared'; + +/** Barre d'onglets ADAPTATIVE (maquette « mobile ouvert à tous les rôles », + * D1) : le Technicien/Technicien limité gardent exactement leurs onglets + * terrain R4 ; les autres rôles reçoivent Accueil/OT/Menu (ou une variante + * plus étroite selon leurs droits). Décidé une fois ici, pas éparpillé. */ + +const ROLES_TERRAIN: RoleName[] = ['Technicien', 'Technicien limité']; + +export function estRoleTerrain(role: RoleName | undefined): boolean { + return !role || ROLES_TERRAIN.includes(role); +} + +export function ongletsVisibles(role: RoleName | undefined): ReadonlySet { + if (estRoleTerrain(role)) { + return new Set(['journee', 'scanner', 'preventif', 'synchro', 'menu']); + } + if (role === 'Demandeur') return new Set(['accueil', 'nouvelle-demande']); + if (role === 'Vue seule') return new Set(['accueil', 'menu']); + return new Set(['accueil', 'ot', 'menu']); // Administrateur, Gestionnaire, Dispatcher +} + +/** Onglet sur lequel router.replace() après connexion (voir app/index.tsx). */ +export function ongletAccueil(role: RoleName | undefined): 'journee' | 'accueil' { + return estRoleTerrain(role) ? 'journee' : 'accueil'; +} diff --git a/apps/mobile/src/auth/use-permissions.ts b/apps/mobile/src/auth/use-permissions.ts new file mode 100644 index 0000000..1a55dca --- /dev/null +++ b/apps/mobile/src/auth/use-permissions.ts @@ -0,0 +1,26 @@ +import type { ObjectCategory, PermissionRight } from '@siop/shared'; +import { useMe } from './session'; + +/** Lecture UI de la matrice — même patron que le web (use-permissions.ts). + * L'API re-vérifie chaque requête (ADR-003) : ceci ne fait QUE piloter + * l'affichage (onglets, groupes du Menu, boutons d'action). */ +export function usePermissions() { + const { data: me } = useMe(); + const can = (category: ObjectCategory, right: PermissionRight): boolean => { + const entry = me?.permissions.find((p) => p.objectCategory === category); + if (!entry) return false; + switch (right) { + case 'view': + return entry.canView; + case 'viewOther': + return entry.canViewOther; + case 'create': + return entry.canCreate; + case 'edit': + return entry.canEdit; + case 'delete': + return entry.canDelete; + } + }; + return { can }; +} diff --git a/apps/mobile/src/composants/formulaire-demande.tsx b/apps/mobile/src/composants/formulaire-demande.tsx new file mode 100644 index 0000000..921009a --- /dev/null +++ b/apps/mobile/src/composants/formulaire-demande.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { useAssetOptions, useCreateRequest } from '@/api/exploitation'; +import { useTokens } from '@/theme/tokens'; +import { BoutonTel, ChoixTel } from './ui'; + +/** Formulaire « Nouvelle demande » — mêmes champs que la modale de + * signalement du web (asset via `/assets/options`, accessible même sans + * ASSETS.view ; description ; personne bloquée). Utilisé à la fois par + * l'onglet du Demandeur et par le Menu des rôles gestion. */ +export function FormulaireDemande({ surSucces }: { surSucces: (id: string) => void }) { + const t = useTokens(); + const { data: options } = useAssetOptions(); + const creation = useCreateRequest(); + const [assetId, setAssetId] = useState(null); + const [description, setDescription] = useState(''); + const [personneBloquee, setPersonneBloquee] = useState(false); + + const asset = (options ?? []).find((a) => a.id === assetId) ?? null; + const valide = !!assetId && description.trim().length >= 3; + + const envoyer = () => { + if (!assetId) return; + creation.mutate( + { assetId, description: description.trim(), isPersonTrapped: personneBloquee }, + { onSuccess: (r) => surSucces(r.id) }, + ); + }; + + return ( + + ({ id: a.id, label: `${a.reference} — ${a.siteName}` }))} + surChoix={setAssetId} + /> + + + Description * + + + + setPersonneBloquee((v) => !v)} + style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: personneBloquee ? t.prioBloqueFond : t.surface, + borderColor: personneBloquee ? t.danger : t.bordure, + borderWidth: 1, + borderRadius: 10, + padding: 12, + }} + > + + Une personne est-elle bloquée ? + + + {personneBloquee ? 'OUI' : 'NON'} + + + {creation.isError ? ( + + {creation.error.message} + + ) : null} + + + ); +} diff --git a/apps/mobile/src/composants/panneau-demandes.tsx b/apps/mobile/src/composants/panneau-demandes.tsx new file mode 100644 index 0000000..5c2a8cd --- /dev/null +++ b/apps/mobile/src/composants/panneau-demandes.tsx @@ -0,0 +1,185 @@ +import { router } from 'expo-router'; +import { useState } from 'react'; +import { FlatList, Text, TextInput, View } from 'react-native'; +import type { RequestSummary } from '@siop/shared'; +import { + useApproveRequest, + useRejectRequest, + useRequests, +} from '@/api/exploitation'; +import { usePermissions } from '@/auth/use-permissions'; +import { useTokens, type Tokens } from '@/theme/tokens'; +import { BoutonTel } from './ui'; + +/** Panneau Demandes — UN SEUL composant pour tous les rôles (maquette + * « mobile ouvert à tous les rôles », écran 5) : le Demandeur y crée et + * suit SES demandes ; Gestionnaire/Dispatcher/Administrateur y approuvent + * ou rejettent (motif obligatoire, comme au web) ; Vue seule consulte sans + * bouton. L'API renvoie déjà la liste correctement scopée (ADR-003) — ce + * composant affiche ce qu'on lui donne et ne propose que les actions + * permises par `can(...)`. */ +export function PanneauDemandes() { + const t = useTokens(); + const { can } = usePermissions(); + const { data: requests } = useRequests(); + const peutTraiter = can('REQUESTS', 'edit'); + const peutCreer = can('REQUESTS', 'create'); + const toutes = requests ?? []; + const aTraiter = toutes.filter((r) => r.status === 'RECEIVED').length; + + return ( + + + + Demandes + + + {aTraiter} à traiter · {toutes.length} au total + + + {peutCreer ? ( + router.push('/demandes/nouvelle')} /> + ) : null} + r.id} + contentContainerStyle={{ gap: 8, paddingBottom: 12 }} + ListEmptyComponent={ + + Aucune demande pour l'instant. + + } + renderItem={({ item: r }) => } + /> + + ); +} + +const STYLE_STATUT: Record [string, string]> = { + RECEIVED: (t) => [t.stAttente, t.stAttenteFond], + APPROVED: (t) => [t.stTermine, t.stTermineFond], + REJECTED: (t) => [t.stAnnule, t.stAnnuleFond], +}; +const LABEL_STATUT: Record = { + RECEIVED: 'Reçue', + APPROVED: 'Approuvée', + REJECTED: 'Rejetée', +}; + +function CarteDemande({ demande: r, peutTraiter }: { demande: RequestSummary; peutTraiter: boolean }) { + const t = useTokens(); + const approbation = useApproveRequest(); + const rejet = useRejectRequest(); + const [motif, setMotif] = useState(null); // null = fermé + + const [enc, fond] = STYLE_STATUT[r.status](t); + const enCours = r.status === 'RECEIVED'; + + return ( + + + + {r.reference} + + + {LABEL_STATUT[r.status]} + + + + {r.isPersonTrapped ? '⚠ ' : ''} + {r.description} + + + Asc. {r.assetReference} — {r.siteName} · {r.requesterLabel} + + {r.rejectionReason ? ( + + Motif du rejet : {r.rejectionReason} + + ) : null} + + {peutTraiter && enCours ? ( + motif === null ? ( + + + + approbation.mutate({ + id: r.id, + priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH', + }) + } + /> + + + setMotif('')} /> + + + ) : ( + + + + + setMotif(null)} /> + + + rejet.mutate({ id: r.id, reason: motif.trim() }, { onSuccess: () => setMotif(null) })} + /> + + + + ) + ) : null} + + ); +} diff --git a/docs/02-design/maquettes/maquette-mobile-tous-roles.html b/docs/02-design/maquettes/maquette-mobile-tous-roles.html new file mode 100644 index 0000000..3f4dba5 --- /dev/null +++ b/docs/02-design/maquettes/maquette-mobile-tous-roles.html @@ -0,0 +1,764 @@ + + + + + +SIOP V2 — Maquettes HD · Mobile ouvert à tous les rôles + + + + + +
+ SIOP · Maquette — Mobile ouvert à tous les rôles + +
+
+ + +

Intention : avant le détail écran par écran, +le tableau ci-dessous résume la décision D1/D2 pour les 7 rôles : quels onglets la barre +adaptative affiche, et quels groupes du Menu restent visibles (mêmes droits qu'au web, rien de +nouveau dans la matrice). Les 4 onglets terrain du Technicien (encadré safran) sont ceux déjà +livrés en R4 — inchangés.

+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Rôle (compte démo)Onglets de la barreGroupes du Menu visibles
Administrateur
Yasmine Alaoui
AccueilOTMenuExploitation · Parc · Ressources · Pilotage (tout)
Gestionnaire
Nadia Berrada
AccueilOTMenuExploitation · Parc · Ressources · Pilotage (stats en lecture)
Dispatcher
Salma Idrissi
AccueilOTMenuExploitation · Parc (lecture) — Ressources/Pilotage restreints
Technicien
Ahmed Benali
Ma journéeScannerPréventifSynchroMenuMenu quasi vide (ses OT + parc en lecture) — inchangé depuis R4
Technicien limité
Youssef Tazi
Ma journéeScannerPréventifSynchroMenuMenu quasi vide (ses OT uniquement) — inchangé depuis R4
Demandeur
Karim Doukkali
AccueilNouvelle demandeExploitation → Demandes uniquement (Menu quasi vide, D3)
Vue seule
Rachid Bennis
AccueilMenuExploitation · Parc · Ressources · Pilotage (lecture seule, aucun bouton d'action)
+
+
+ + + +
+
+
+
+
Technicien — inchangé (R4)
+
+
+ SIOPTechnicien + Synchro à jourAB +
+
Ma journée jeudi 1 août
+
1 personne bloquée — B2 · Tour Atlas
+
+
OT-2026-0342 · Dépannage Ouvert
+ Personne bloquée en cabine +
Asc. B2 — Tour Atlas · Aïn Diab
+
+
+
OT-2026-0341 · Dépannage En cours
+ Bruit anormal en gaine +
Asc. A1 — Résidence Al Manar
+
+ +
+
+
+
+
Gestionnaire — nouveau
+
+
+ SIOPGestionnaire + Synchro à jourNB +
+
Accueil jeudi 1 août
+
+
6
OT en cours
+
3
Stock sous seuil
+
78 %
Préventif du mois
+
2
Demandes reçues
+
+
+ Stock sous seuil3 pièces à commander + 3
+
+ Demandes reçuesen attente d'approbation + 2
+
+ Invitations en attentelien envoyé, pas encore activé +
+ +
+
+
+
+
Demandeur — nouveau
+
+
+ SIOPDemandeur + Synchro à jourKD +
+
Mes demandes
+
+
DEM-2026-0089 Reçue
+ Éclairage cabine défaillant +
Asc. A2 — Résidence Al Manar
+
+
+
DEM-2026-0081 Traitée
+ Bruit au démarrage +
Asc. A2 — Résidence Al Manar → OT-2026-0336
+
+
+ +
+ +
+
+
+
+ + + + + + + +
+
+
+
+
Dispatcher — tous les OT
+
+
+ SIOPDispatcher + Synchro à jourSI +
+
Ordres de travail 14 en cours
+
+
OT-2026-0342 · Dépannage Ouvert
+ Personne bloquée en cabine +
Asc. B2 — Tour Atlas · non assigné
+
+
+
OT-2026-0341 · Dépannage En cours
+ Bruit anormal en gaine +
Asc. A1 — Résidence Al Manar · Ahmed Benali
+
+
+
OT-2026-0336 · Préventif Ouvert
+ Contrôle mensuel — juillet +
Asc. A2 — Résidence Al Manar · non assigné
+
+
+ +
+ +
+
+
+
+
Assigner — feuille au pouce
+
+
+
+
Assigner OT-2026-0342
+
+ +
Ahmed Benali ▾
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
Demandeur — nouvelle demande
+
+
+ SIOPDemandeur + KD +
+
Nouvelle demande
+
Résidence Al Manar ▾
+
Asc. A2 ▾
+
+
Odeur de brûlé au démarrage, depuis ce matin.
+
+
Personne bloquée — Non
+
+ +
+ +
+
+
+
+
Gestionnaire — approbation
+
+
+ SIOPGestionnaire + NB +
+
Demandes reçues 2
+
+
DEM-2026-0089 Reçue
+ Éclairage cabine défaillant +
Asc. A2 — Résidence Al Manar · Karim Doukkali
+
+ + +
+
+
+
DEM-2026-0090 Reçue
+ Porte palière qui force +
Asc. C1 — Anfa Place · Karim Doukkali
+
+ + +
+
+ +
+
+
+
+ + + +
+
+
+
+
Gestionnaire — stock
+
+
+ SIOPGestionnaire + NB +
+
Stock & achats 3 sous seuil
+
+

Câble de traction 8 mm

+
Quantité2
+
Seuil5
+
FournisseurAscentech Maroc
+ +
+
+

Galet de guidage

+
Quantité1
+
Seuil4
+
FournisseurSchindlerParts
+ +
+ +
+
+
+
+
BC pré-rempli depuis l'alerte
+
+
+ SIOPGestionnaire + NB +
+
Nouveau BC
+
Ascentech Maroc
+
Câble de traction 8 mm
+
10
+
Le détail multi-lignes se termine sur le grand écran (D4).
+
+ +
+
+
+
+
+ + + +
+
+
+
+
Administrateur — statistiques
+
+
+ SIOPAdministrateur + YA +
+
Statistiques 12 derniers mois
+
+
505 MAD
Coût du mois
+
78 %
Taux préventif
+
+
+

Pannes par organe — top 3

+
Portes palières9
+
Câblage6
+
Éclairage cabine4
+
+ +
+
+
+
+
Gestionnaire — personnes & équipes
+
+
+ SIOPGestionnaire + NB +
+
Personnes 12
+ + + +
+ +
+
+
+
+
+ + + + diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 4874d3c..1bf3acf 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,65 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-08-02 — Pr. Daaif (+ Claude) — R6 lancée : mobile ouvert à tous les rôles + +**Contexte** — deux retours du référent en test réel sur iPhone : (1) une fois authentifié, +impossible de revenir en arrière ou de se déconnecter ; (2) l'app mobile devrait être accessible +à tous les rôles, pas seulement au Technicien, avec les mêmes actions que le web. Le premier est +un bug, corrigé dans la foulée ; le second est un changement de fond — R4 avait délibérément +fermé le périmètre au Technicien (décision actée dans ses maquettes). Ouvrir à tous les rôles +veut dire porter sur mobile une bonne partie de ce que fait le web (référentiel, ressources, +personnes, statistiques…) : plus gros que R4 dans son ensemble, donc une **nouvelle release, +R6** (R4 est close depuis le 17/07, R4.1/R4.2/R4.3 déjà pris par le socle mobile technicien +d'origine — pas question de les réutiliser et de brouiller l'historique). + +**Actions** + +- **Correctif déconnexion** : `EnteteTabs` (`composants/ui.tsx`) factorise une entête commune aux + 4 onglets terrain — badge de compte + confirmation `Alert` au tap simple (l'ancien geste, + `onLongPress` sur « Ma journée » seulement, n'était pas découvrable). Le rôle affiché vient du + compte connecté, plus de libellé « Technicien » figé. +- **Maquette R6** (`docs/02-design/maquettes/maquette-mobile-tous-roles.html`, 7 écrans) rédigée + et validée par le référent le jour même : synthèse par rôle, Accueil adaptatif, Menu groupé + filtré par la matrice, OT côté Dispatcher, Demandes (création + approbation), Stock, Personnes + & statistiques. Cinq décisions actées : D1 barre d'onglets adaptative (Technicien/Technicien + limité inchangés + onglet Menu ajouté) ; D2 le Menu reprend à l'identique les 4 groupes du web + (Exploitation/Parc/Ressources/Pilotage), même matrice, aucune règle de droit nouvelle ; D3 un + groupe sans lien visible est masqué en entier (contrairement au web) ; D4 le mobile porte les + actions courantes par famille, pas les flux de gestion les plus denses (réservés au web pour + l'instant) ; D5 aucune logique de permission propre au mobile — un hook `usePermissions()` + calqué sur celui du web. +- **R6.1 — socle** : `usePermissions()` mobile (lit `me.permissions`, déjà renvoyé par + `/users/me`) ; barre d'onglets adaptative (`ongletsVisibles` par rôle, `href: null` pour masquer + sans retirer du navigateur — le Menu peut toujours y pousser) ; écrans Accueil (dashboard réel + pour Administrateur/Gestionnaire/Dispatcher/Vue seule — aucun chiffre inventé, seuls + WORK_ORDERS/REQUESTS sont câblés), OT (liste complète, `viewOther` déjà géré serveur, réutilise + la fiche OT R4 telle quelle — elle dérive déjà ses actions de `allowedTransitions`), Menu + (groupes filtrés, 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 — même patron que le web, + découverte au passage : `GET /assets/options` déjà ouvert à tout rôle authentifié, réutilisé + tel quel). Familles pas encore portées (Sites, Ascenseurs, Stock, Tiers, Fichiers, Statistiques, + Assistant, Personnes) : écran « à venir » honnête (`composants/a-venir.tsx`, déjà présent mais + jamais câblé jusqu'ici) plutôt qu'un lien mort — chacune mérite son propre passage, sur le + modèle R4.1→R4.3. +- Vérifié : 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. + +**Décisions** + +- Nouvelle release **R6** (pas R4.1/4.2/4.3, déjà pris ; pas de réouverture de R4, clos) pour tout + le chantier « mobile ouvert à tous les rôles ». Sous-étapes à venir : R6.2 (Parc — Sites, + Ascenseurs), R6.3 (Ressources — Stock, Tiers, Fichiers), R6.4 (Pilotage — Statistiques, + Personnes, Assistant), sur le modèle des sous-releases R4.1→R4.3. + +**Prochaine étape** : R6.2 (Parc sur mobile) ; confirmation du référent sur iPhone (nav adaptative +et correctif déconnexion) ; restes non bloquants inchangés (recette Android physique, +redéploiement Dokploy, calibrage `AI_SEUIL_*`/darija, `DOKPLOY_WEBHOOK_URL`). + +--- + ## 2026-08-01 — Pr. Daaif (+ Claude) — Dictée validée sur iPhone physique (ADR-004 §5) **Actions**