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.
Intention : le Technicien garde « Ma
+journée » telle quelle (rien à revalider). Les autres rôles reçoivent un Accueil propre à
+leur travail : le Gestionnaire voit ses signaux de pilotage et des raccourcis vers ce qui demande
+une action ; le Demandeur voit directement ses demandes et le geste qu'il fait le plus (en
+créer une nouvelle) — jamais un écran vide générique.
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
Intention : le Menu reprend, à l'identique,
+les 4 groupes et l'ordre du sidebar web — Exploitation / Parc / Ressources / Pilotage — et
+les mêmes permissions (`can(catégorie, 'view')`). Un rôle qui n'a droit à rien dans un groupe ne
+voit pas le groupe du tout (D3) : comparez Administrateur (tout) et Demandeur (presque rien) sur
+le MÊME écran, sans aucune règle de plus dans le code.
+
+
+
+
+
Administrateur — tout visible
+
+
+ ♦SIOPAdministrateur
+ YA
+
+
Menu
+
+
Exploitation
+
▦Tableau de bord›
+
☰Ordres de travail1›
+
✎Demandes2›
+
✓Préventif›
+
+
+
Parc
+
▣Ascenseurs›
+
◫Sites›
+
+
+
Ressources
+
◔Stock & achats3›
+
⇄Tiers›
+
+
+
Pilotage
+
◈Statistiques›
+
◎Personnes & équipes›
+
+
+
+
+
+
+
Demandeur — presque vide (D3)
+
+
+ ♦SIOPDemandeur
+ KD
+
+
Menu
+
+
Exploitation
+
✎Mes demandes›
+
+
Parc, Ressources et Pilotage ne sont pas accessibles à ce rôle — groupes masqués, pas de section vide affichée.
+
+
+
+
+
+
+
+
Intention : contrairement au
+Technicien (qui ne voit que SES OT), le Dispatcher a `viewOther` — il voit tous les OT et peut en
+créer/assigner. Le mobile porte cette différence : liste complète, bouton « + » et une action
+rapide « Assigner » par carte (bottom-sheet, même pattern que le sélecteur de bilan codé de R4).
+
+
+
+
+
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 ▾
+
+
+
+
+
+
+
+
+
+
+
Intention : le Demandeur crée sa demande
+en mobilité (site, ascenseur si connu, description, urgence) exactement comme au web mais sur un
+formulaire au pouce ; le Gestionnaire/Dispatcher approuve ou rejette (motif obligatoire au rejet,
+comme au web) directement depuis la carte.
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
Intention : le Gestionnaire consulte le
+stock en mobilité et déclenche l'action courante — commander une pièce sous seuil — sans passer
+par le grand écran. La fiche pièce reprend les lignes déjà connues (quantité, seuil, dernier
+prix, fournisseur). Le bon de commande détaillé multi-lignes reste au web (D4).
+
+
+
+
+
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).
+
+
+
+
+
+
+
+
+
+
Intention : deux écrans « Pilotage » côte
+à côte — les Statistiques en lecture (mêmes chiffres que `/analytics/summary`, en cartes plutôt
+qu'en graphes denses) et Personnes & équipes, avec l'action d'invitation déjà connue du web
+(lien 7 jours) portée sur mobile.
+
+
+
+
+
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
+
◎Ahmed BenaliTechnicien
+
◎Youssef TaziTechnicien limité
+
◎Salma IdrissiDispatcher
+
+
+
+
+
+
+
+
+
+
+
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**