feat(r6): mobile ouvert à tous les rôles — maquette + socle R6.1

R4 avait délibérément fermé le mobile au Technicien. Le référent demande
l'ouverture à tous les rôles avec les mêmes droits qu'au web — nouvelle
release R6 (R4 est close, R4.1/4.2/4.3 déjà pris par le socle mobile
technicien d'origine, pas de réouverture ni de collision de numérotation).

Maquette (docs/02-design/maquettes/maquette-mobile-tous-roles.html, 7
écrans) validée par le référent : D1 barre d'onglets adaptative par rôle
(Technicien/Technicien limité inchangés + onglet Menu ajouté) ; D2 le Menu
reprend à l'identique les 4 groupes du web, même matrice de permissions ;
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 ;
D5 aucune logique de permission propre au mobile.

R6.1 — socle : usePermissions() mobile calqué sur le web ; barre d'onglets
adaptative (ongletsVisibles par rôle, href:null masque sans retirer du
navigateur) ; Accueil (dashboard réel pour Administrateur/Gestionnaire/
Dispatcher/Vue seule, aucun chiffre inventé) ; OT (liste complète,
viewOther déjà géré serveur, réutilise la fiche OT R4 telle quelle) ; Menu
(groupes filtrés, vide masqué) ; Demandes (PanneauDemandes — un seul
composant pour tous les rôles : création/suivi Demandeur, approbation/
rejet Gestionnaire/Dispatcher/Administrateur, lecture Vue seule).
Familles pas encore portées : écran « à venir » plutôt qu'un lien mort.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-08-02 11:19:44 +01:00
parent 3e9e708116
commit ab0f260d0f
17 changed files with 1857 additions and 13 deletions

View File

@@ -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,542,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 noteclôtureréindexationrecherche). 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 R0R5.
- 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, 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.1R4.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 R0R5 (v1) + R6 en cours.

View File

@@ -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 <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
@@ -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 (
<Tabs
screenOptions={{
@@ -34,6 +50,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="journee"
options={{
href: cacher('journee'),
title: 'Ma journée',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
@@ -41,6 +58,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="scanner"
options={{
href: cacher('scanner'),
title: 'Scanner',
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
}}
@@ -48,6 +66,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="preventif"
options={{
href: cacher('preventif'),
title: 'Préventif',
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
}}
@@ -55,6 +74,7 @@ export default function CoquilleTabs() {
<Tabs.Screen
name="synchro"
options={{
href: cacher('synchro'),
title: 'Synchro',
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
tabBarBadge: file.length || undefined,
@@ -66,6 +86,38 @@ export default function CoquilleTabs() {
},
}}
/>
<Tabs.Screen
name="accueil"
options={{
href: cacher('accueil'),
title: 'Accueil',
tabBarIcon: ({ color }) => <Pic glyphe="⌂" couleur={color} />,
}}
/>
<Tabs.Screen
name="ot"
options={{
href: cacher('ot'),
title: 'OT',
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
}}
/>
<Tabs.Screen
name="nouvelle-demande"
options={{
href: cacher('nouvelle-demande'),
title: 'Nouvelle demande',
tabBarIcon: ({ color }) => <Pic glyphe="+" couleur={color} />,
}}
/>
<Tabs.Screen
name="menu"
options={{
href: cacher('menu'),
title: 'Menu',
tabBarIcon: ({ color }) => <Pic glyphe="☷" couleur={color} />,
}}
/>
</Tabs>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1 }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteTabs />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}
return <TableauDeBordGestion />;
}
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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Accueil
</Text>
{urgences.length ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${urgences[0]!.id}`)}
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</Pressable>
) : null}
{peutOT || peutDemandes ? (
<View style={{ flexDirection: 'row', gap: 8 }}>
{peutOT ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.primaire }}>
{enCours}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
OT en cours
</Text>
</View>
) : null}
{peutDemandes ? (
<View
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 10,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
{aTraiter}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
Demandes reçues
</Text>
</View>
) : null}
</View>
) : null}
{peutDemandes && aTraiter > 0 ? (
<Pressable
accessibilityRole="button"
onPress={() => router.push('/demandes')}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14, color: t.primaire }}></Text>
</View>
<View style={{ flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
Demandes en attente
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
à approuver ou rejeter
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
color: t.stAttente,
backgroundColor: t.stAttenteFond,
paddingHorizontal: 7,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{aTraiter}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
) : null}
{!peutOT && !peutDemandes ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien à afficher ici pour votre rôle voir le Menu.
</Text>
) : null}
</View>
</SafeAreaView>
);
}

View File

@@ -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: 'Ladministration des catégories reste sur le grand écran pour linstant.' },
],
},
{
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 linstant.' },
{ libelle: 'Fichiers', ico: '▧', permission: ['ASSETS', 'view'], aVenir: 'La bibliothèque documentaire reste sur le grand écran pour linstant.' },
],
},
{
titre: 'Pilotage',
liens: [
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], aVenir: 'Les statistiques en mobilité arrivent dans une prochaine étape.' },
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'Lassistant IA reste sur le grand écran et la clôture mobile pour linstant.' },
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], aVenir: 'La gestion des personnes et équipes en mobilité arrive dans une prochaine étape.' },
],
},
];
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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 14, flex: 1 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>Menu</Text>
{groupesVisibles.length === 0 ? (
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', textAlign: 'center', padding: 24 }}>
Rien d'autre accessible à votre rôle ici.
</Text>
) : null}
{groupesVisibles.map((g) => (
<View key={g.titre} style={{ gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_800ExtraBold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre3,
}}
>
{g.titre}
</Text>
{g.liens.map((lien) => (
<LigneMenu key={lien.libelle} lien={lien} t={t} />
))}
</View>
))}
</View>
</SafeAreaView>
);
}
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 (
<Pressable
accessibilityRole="button"
onPress={aller}
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 11,
}}
>
<Text style={{ width: 22, textAlign: 'center', fontSize: 14, color: t.primaire }}>{lien.ico}</Text>
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{lien.libelle}
</Text>
<Text style={{ color: t.encre3, fontSize: 13 }}></Text>
</Pressable>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteTabs />
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Nouvelle demande
</Text>
<FormulaireDemande surSucces={() => router.replace('/(tabs)/accueil')} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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<WorkOrderStatus, (t: Tokens) => [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<WorkOrderPriority, (t: Tokens) => 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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ padding: 14, gap: 10, flex: 1 }}>
<EnteteTabs />
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Ordres de travail
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{ots.length}
</Text>
</View>
{urgences.length ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderRadius: 10,
padding: 9,
flexDirection: 'row',
alignItems: 'center',
gap: 8,
}}
>
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
{urgences.length === 1
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
: `${urgences.length} personnes bloquées`}
</Text>
</View>
) : null}
<FlatList
data={ots}
keyExtractor={(o) => o.id}
refreshControl={
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucun OT tirez pour rafraîchir.
</Text>
}
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => 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,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
flex: 1,
}}
>
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[o.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{o.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
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]}`
: ''}
</Text>
</Pressable>
);
}}
/>
</View>
</SafeAreaView>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<EnteteFiche titre={titre ?? '—'} />
<AVenir titre={titre ?? '—'} release="une prochaine étape" detail={detail ?? ''} />
</SafeAreaView>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<EnteteFiche titre="Demandes" />
<PanneauDemandes />
</View>
</SafeAreaView>
);
}

View File

@@ -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 (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre="Nouvelle demande" />
<FormulaireDemande surSucces={() => (router.canGoBack() ? router.back() : router.replace('/demandes'))} />
</ScrollView>
</SafeAreaView>
);
}

View File

@@ -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() {
</View>
);
}
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
return me ? (
<Redirect href={`/(tabs)/${ongletAccueil(me.role.name)}`} />
) : (
<Redirect href="/connexion" />
);
}

View File

@@ -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 () =>

View File

@@ -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<string> {
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';
}

View File

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

View File

@@ -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<string | null>(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 (
<View style={{ gap: 12 }}>
<ChoixTel
libelle="Équipement"
requis
valeur={asset ? { id: asset.id, label: `${asset.reference}${asset.siteName}` } : null}
options={(options ?? []).map((a) => ({ id: a.id, label: `${a.reference}${a.siteName}` }))}
surChoix={setAssetId}
/>
<View style={{ gap: 4 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
Description <Text style={{ color: t.danger }}>*</Text>
</Text>
<TextInput
accessibilityLabel="Description du problème"
placeholder="Ex. : la porte ne se ferme plus au 3ᵉ étage"
placeholderTextColor={t.encre3}
multiline
value={description}
onChangeText={setDescription}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
minHeight: 64,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
textAlignVertical: 'top',
}}
/>
</View>
<Pressable
accessibilityRole="switch"
accessibilityLabel="Une personne est-elle bloquée ?"
accessibilityState={{ checked: personneBloquee }}
onPress={() => 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,
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 13,
color: personneBloquee ? t.danger : t.encre,
}}
>
Une personne est-elle bloquée ?
</Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 13, color: personneBloquee ? t.danger : t.encre3 }}>
{personneBloquee ? 'OUI' : 'NON'}
</Text>
</Pressable>
{creation.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{creation.error.message}
</Text>
) : null}
<BoutonTel
libelle="Envoyer la demande"
desactive={!valide || creation.isPending}
surAppui={envoyer}
/>
</View>
);
}

View File

@@ -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 (
<View style={{ flex: 1, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Demandes
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{aTraiter} à traiter · {toutes.length} au total
</Text>
</View>
{peutCreer ? (
<BoutonTel libelle="+ Nouvelle demande" surAppui={() => router.push('/demandes/nouvelle')} />
) : null}
<FlatList
data={toutes}
keyExtractor={(r) => r.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune demande pour l'instant.
</Text>
}
renderItem={({ item: r }) => <CarteDemande demande={r} peutTraiter={peutTraiter} />}
/>
</View>
);
}
const STYLE_STATUT: Record<RequestSummary['status'], (t: Tokens) => [string, string]> = {
RECEIVED: (t) => [t.stAttente, t.stAttenteFond],
APPROVED: (t) => [t.stTermine, t.stTermineFond],
REJECTED: (t) => [t.stAnnule, t.stAnnuleFond],
};
const LABEL_STATUT: Record<RequestSummary['status'], string> = {
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<string | null>(null); // null = fermé
const [enc, fond] = STYLE_STATUT[r.status](t);
const enCours = r.status === 'RECEIVED';
return (
<View
style={{
backgroundColor: t.surface,
borderColor: r.isPersonTrapped && enCours ? t.danger : t.bordure,
borderWidth: r.isPersonTrapped && enCours ? 1.5 : 1,
borderRadius: 12,
padding: 12,
gap: 6,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Text
style={{
flex: 1,
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.6,
textTransform: 'uppercase',
color: t.encre2,
}}
>
{r.reference}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: enc,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{LABEL_STATUT[r.status]}
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{r.isPersonTrapped ? ' ' : ''}
{r.description}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {r.assetReference} — {r.siteName} · {r.requesterLabel}
</Text>
{r.rejectionReason ? (
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 12, color: t.danger }}>
Motif du rejet : {r.rejectionReason}
</Text>
) : null}
{peutTraiter && enCours ? (
motif === null ? (
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Approuver → OT"
variante="vert"
surAppui={() =>
approbation.mutate({
id: r.id,
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
})
}
/>
</View>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setMotif('')} />
</View>
</View>
) : (
<View style={{ gap: 6, marginTop: 4 }}>
<TextInput
accessibilityLabel="Motif du rejet"
placeholder="Motif (lisible par le demandeur) *"
placeholderTextColor={t.encre3}
value={motif}
onChangeText={setMotif}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
paddingHorizontal: 10,
paddingVertical: 9,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
}}
/>
<View style={{ flexDirection: 'row', gap: 8 }}>
<View style={{ flex: 1 }}>
<BoutonTel libelle="Annuler" variante="contour" surAppui={() => setMotif(null)} />
</View>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Confirmer le rejet"
desactive={motif.trim().length < 3}
surAppui={() => rejet.mutate({ id: r.id, reason: motif.trim() }, { onSuccess: () => setMotif(null) })}
/>
</View>
</View>
</View>
)
) : null}
</View>
);
}

View File

@@ -0,0 +1,764 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SIOP V2 — Maquettes HD · Mobile ouvert à tous les rôles</title>
</head>
<body>
<!--
SIOP V2 — Maquette HD « Mobile ouvert à tous les rôles » (design-first).
À VALIDER PAR LE RÉFÉRENT AVANT TOUT CODE EXPO (principe n°1).
R4 avait délibérément fermé le mobile au Technicien (périmètre R4 D3). Ici :
ouvrir l'app à TOUS les rôles, avec les MÊMES droits qu'au web (aucune
nouvelle logique de permission — le mobile lit la même matrice, ADR-003).
Tokens et gabarit téléphone repris tels quels de maquette-r4.html.
Les 4 écrans terrain déjà livrés et recettés (Ma journée, Scanner, Préventif,
Synchro) restent INCHANGÉS pour le Technicien — cette maquette ajoute une
couche autour, elle ne les remplace pas.
Décisions proposées à validation (récapitulées aussi dans le journal) :
D1 — Barre d'onglets ADAPTATIVE selon le rôle connecté (pas un seul jeu
d'onglets pour tout le monde) ; Technicien/Technicien limité gardent
exactement leurs onglets terrain actuels + un nouvel onglet « Menu ».
D2 — Le Menu mobile reprend EXACTEMENT les 4 groupes du web (Exploitation /
Parc / Ressources / Pilotage), dans le même ordre, filtrés par la même
matrice de permissions déjà en base — aucune règle de droit nouvelle.
D3 — Contrairement au web (le groupe reste affiché même vide), un groupe
SANS AUCUN lien visible est masqué en entier sur mobile — l'écran est
petit, un intitulé de groupe vide n'aide personne.
D4 — Le mobile porte les actions COURANTES de chaque famille (consulter,
créer une pièce/BC simple, approuver/rejeter, inviter) mais pas les
flux de gestion les plus denses (BC multi-lignes détaillé, revue pixel
de document) qui restent réservés au grand écran pour l'instant.
D5 — Le mobile n'acquiert AUCUNE logique de permission propre : un hook
`usePermissions()` calqué sur celui du web, lisant `me.permissions`
(déjà renvoyé par `/users/me`, déjà mis en cache par l'app).
-->
<style>
/* ═══ Tokens (répliques de ../tokens.css) ═══ */
:root{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--police-ui:"Manrope","Avenir Next","Segoe UI Variable",system-ui,sans-serif;
--rayon:10px;--rayon-controle:8px;--ombre-menu:0 8px 24px rgb(16 30 54/.14);
--transition:140ms ease-out;
}
@media (prefers-color-scheme:dark){:root{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}}
:root[data-theme="light"]{
--primaire:#1f4fb8;--primaire-actif:#1a439c;--primaire-doux:#eaf0fb;
--safran:#dd8a0b;--safran-doux:#fdf3e3;
--fond:#f5f7fa;--surface:#ffffff;--surface-2:#eef2f7;
--bordure:#dce3ec;--bordure-forte:#b9c4d4;
--encre:#1b2534;--encre-2:#55647a;--encre-3:#8494ab;
--st-ouvert:#3d6fe0;--st-ouvert-fond:#e9effc;--st-encours:#6d5bd8;--st-encours-fond:#efecfa;
--st-attente:#b96f07;--st-attente-fond:#fbf1df;--st-termine:#178a50;--st-termine-fond:#e6f5ec;
--st-annule:#68788f;--st-annule-fond:#edf0f4;
--prio-bloque:#d92626;--prio-bloque-fond:#fdeaea;--prio-haute:#d92626;
--prio-moyenne:#b96f07;--prio-basse:#178a50;--prio-aucune:#8494ab;
--succes:#178a50;--alerte:#b96f07;--danger:#d92626;--info:#3d6fe0;
--graf-1:#3b69d6;--graf-2:#dd8a0b;--graf-3:#1f9e8e;--graf-4:#8a63d2;--graf-grille:#e6ebf2;
--nav-fond:#16233b;--nav-encre:#c6d2e4;--nav-encre-vif:#ffffff;--nav-bordure:#24344f;
--ombre-menu:0 8px 24px rgb(16 30 54/.14);
}
:root[data-theme="dark"]{
--primaire:#6e92e8;--primaire-actif:#8facf0;--primaire-doux:#1d2c4c;
--safran:#e89a1f;--safran-doux:#33270f;
--fond:#0f1622;--surface:#182234;--surface-2:#1e2a40;
--bordure:#2b3850;--bordure-forte:#3d4d6b;
--encre:#e8edf5;--encre-2:#a7b4c8;--encre-3:#6d7d96;
--st-ouvert:#7da2ee;--st-ouvert-fond:#1c2a47;--st-encours:#a394ec;--st-encours-fond:#262040;
--st-attente:#e0a33c;--st-attente-fond:#322510;--st-termine:#4bc084;--st-termine-fond:#12301f;
--st-annule:#93a3ba;--st-annule-fond:#222d3f;
--prio-bloque:#f26d6d;--prio-bloque-fond:#3a1414;--prio-haute:#f26d6d;
--prio-moyenne:#e0a33c;--prio-basse:#4bc084;--prio-aucune:#6d7d96;
--succes:#4bc084;--alerte:#e0a33c;--danger:#f26d6d;--info:#7da2ee;
--graf-1:#5580e4;--graf-2:#c7820a;--graf-3:#26a896;--graf-4:#9673de;--graf-grille:#24304a;
--nav-fond:#0c1524;--nav-encre:#a7b4c8;--nav-encre-vif:#f2f6fc;--nav-bordure:#1e2c44;
--ombre-menu:0 8px 24px rgb(0 0 0/.45);
}
/* ═══ Base ═══ */
*{box-sizing:border-box;margin:0}
html,body{background:var(--fond)}
body{font-family:var(--police-ui);color:var(--encre);font-size:14px;line-height:1.5}
button{font:inherit;cursor:pointer;border:0;background:none;color:inherit}
button:focus-visible,a:focus-visible{outline:2px solid var(--primaire);outline-offset:2px;border-radius:4px}
.num{font-variant-numeric:tabular-nums}
/* ═══ Barre de revue (outil maquette, pas l'app) ═══ */
.revue{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:12px;flex-wrap:wrap;
padding:10px 16px;background:var(--nav-fond);color:var(--nav-encre);font-size:13px}
.revue b{color:var(--nav-encre-vif);letter-spacing:.02em}
.revue .onglets{display:flex;gap:4px;flex-wrap:wrap}
.revue .onglets button{padding:6px 10px;border-radius:6px;color:var(--nav-encre)}
.revue .onglets button:hover{background:rgb(255 255 255/.08);color:var(--nav-encre-vif)}
.revue .onglets button[aria-selected="true"]{background:rgb(255 255 255/.12);color:var(--nav-encre-vif);
box-shadow:inset 3px 0 0 var(--safran)}
.revue .droite{margin-left:auto;display:flex;align-items:center;gap:8px}
.revue .theme{padding:6px 10px;border:1px solid var(--nav-bordure);border-radius:6px}
.note-ecran{max-width:1120px;margin:14px auto 0;padding:0 20px;color:var(--encre-2);font-size:13px}
.note-ecran b{color:var(--encre)}
/* ═══ Écrans ═══ */
.ecran{display:none;max-width:1120px;margin:18px auto 44px;padding:0 20px}
.ecran.actif{display:block}
.tels{display:flex;gap:28px;justify-content:center;flex-wrap:wrap}
.tel{width:310px;border:1px solid var(--bordure-forte);border-radius:28px;overflow:hidden;background:var(--fond);
box-shadow:var(--ombre-menu);display:flex;flex-direction:column}
.tel .encoche{height:26px;background:var(--nav-fond);display:flex;justify-content:center;align-items:center;flex:none}
.tel .encoche i{width:70px;height:7px;border-radius:999px;background:rgb(255 255 255/.18)}
.tel .legende{flex:none;text-align:center;font-size:11.5px;font-weight:700;letter-spacing:.05em;
text-transform:uppercase;color:var(--encre-3);padding:8px;background:var(--surface-2)}
.ecran-tel{padding:14px;display:flex;flex-direction:column;gap:10px;min-height:560px}
/* Entête app */
.entete-tel{display:flex;align-items:center;gap:8px}
.entete-tel .flamme{color:var(--safran);font-size:16px}
.entete-tel b{font-size:16px;letter-spacing:.03em}
.entete-tel small{color:var(--encre-3);font-size:10.5px;margin-top:3px}
.entete-tel .fin{margin-left:auto;display:flex;align-items:center;gap:6px}
.avatar-tel{width:26px;height:26px;border-radius:50%;background:var(--st-encours);color:#fff;
display:flex;align-items:center;justify-content:center;font-size:10.5px;font-weight:700}
.sync{font-size:10.5px;font-weight:700;color:var(--st-termine);background:var(--st-termine-fond);
padding:2px 8px;border-radius:999px;white-space:nowrap}
.sync.off{color:var(--st-attente);background:var(--st-attente-fond)}
.titre-tel{font-size:17px;font-weight:800;display:flex;align-items:center;gap:8px}
.titre-tel .filet{margin-left:auto;font-size:11px;color:var(--encre-3);font-weight:600}
/* Bandeaux */
.bandeau-off{display:flex;align-items:center;gap:8px;background:var(--st-attente-fond);color:var(--st-attente);
border:1px solid currentColor;border-radius:10px;padding:8px 10px;font-size:12px;font-weight:700}
.bandeau-urgence{display:flex;align-items:center;gap:8px;background:var(--prio-bloque-fond);color:var(--prio-bloque);
border-radius:10px;padding:8px 10px;font-size:12px;font-weight:800}
.point-pulse{width:8px;height:8px;border-radius:50%;background:currentColor;flex:none}
/* Cartes OT */
.carte-ot-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:11px 12px;
border-left:4px solid transparent}
.carte-ot-tel.bloque{border-left-color:var(--prio-bloque)}
.carte-ot-tel.haute{border-left-color:var(--prio-haute)}
.carte-ot-tel.moyenne{border-left-color:var(--prio-moyenne)}
.carte-ot-tel.basse{border-left-color:var(--prio-basse)}
.carte-ot-tel .type-t{font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--encre-2);
font-weight:700;display:flex;justify-content:space-between;align-items:center;gap:6px}
.carte-ot-tel b{display:block;margin:3px 0 2px;font-size:14px}
.carte-ot-tel .ou{color:var(--encre-2);font-size:12px}
.st{font-size:10.5px;font-weight:700;padding:2px 8px;border-radius:999px;white-space:nowrap}
.st.ouvert{color:var(--st-ouvert);background:var(--st-ouvert-fond)}
.st.encours{color:var(--st-encours);background:var(--st-encours-fond)}
.st.attente{color:var(--st-attente);background:var(--st-attente-fond)}
.st.termine{color:var(--st-termine);background:var(--st-termine-fond)}
.st.annule{color:var(--st-annule);background:var(--st-annule-fond)}
.pastille-file{font-size:10.5px;font-weight:700;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 8px;border-radius:999px}
/* Boutons */
.btn-tel{display:flex;justify-content:center;align-items:center;gap:8px;padding:12px;border-radius:10px;
font-weight:700;font-size:14.5px;background:var(--primaire);color:#fff;width:100%}
.btn-tel.vert{background:var(--succes)}
.btn-tel.rouge{background:var(--danger)}
.btn-tel.gris{background:var(--bordure-forte);color:var(--surface)}
.btn-tel.contour{background:var(--surface);color:var(--primaire);border:1.5px solid var(--primaire)}
.paire-boutons{display:flex;gap:8px}
.paire-boutons .btn-tel{padding:10px;font-size:13px}
/* Fiche : cartes d'info */
.carte-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:12px;padding:12px;
display:flex;flex-direction:column;gap:8px}
.carte-tel h4{font-size:10.5px;letter-spacing:.07em;text-transform:uppercase;color:var(--encre-2)}
.ligne-info{display:flex;justify-content:space-between;gap:10px;font-size:12.5px}
.ligne-info span:first-child{color:var(--encre-2)}
.ligne-info b{text-align:right}
.garde{font-size:11.5px;color:var(--alerte);font-weight:600}
/* Tabbar */
.tabbar{margin-top:auto;display:flex;border-top:1px solid var(--bordure);background:var(--surface);
padding:6px 4px 8px}
.tabbar a{flex:1;display:flex;flex-direction:column;align-items:center;gap:2px;font-size:9.5px;
font-weight:700;color:var(--encre-3);text-decoration:none;position:relative}
.tabbar a.actif{color:var(--primaire)}
.tabbar a .pic{font-size:17px;line-height:1.2}
.tabbar .badge-tab{position:absolute;top:-3px;right:calc(50% - 16px);background:var(--st-attente);
color:#fff;font-size:9px;font-weight:800;padding:0 5px;border-radius:999px}
.tabbar .badge-tab.rouge{background:var(--prio-bloque)}
/* ═══ Nouveautés propres à cette maquette ═══ */
/* KPI mini-cartes (Accueil / Statistiques) */
.kpis-tel{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.kpi-tel{background:var(--surface);border:1px solid var(--bordure);border-radius:10px;padding:9px 10px}
.kpi-tel .val{font-size:19px;font-weight:800;color:var(--encre)}
.kpi-tel .lbl{font-size:10.5px;color:var(--encre-2);font-weight:600}
.kpi-tel.accent .val{color:var(--primaire)}
/* Raccourcis (Accueil) */
.raccourci-tel{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
border-radius:10px;padding:10px 11px}
.raccourci-tel .ico{width:28px;height:28px;border-radius:8px;background:var(--primaire-doux);color:var(--primaire);
display:flex;align-items:center;justify-content:center;font-size:14px;flex:none}
.raccourci-tel .txt{flex:1;display:flex;flex-direction:column}
.raccourci-tel b{font-size:12.5px}
.raccourci-tel small{color:var(--encre-2);font-size:11px}
.raccourci-tel .chev{color:var(--encre-3)}
.raccourci-tel .num{font-size:10.5px;font-weight:800;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 7px;border-radius:999px}
/* Menu groupé (mirroir de la nav web) */
.menu-groupe{display:flex;flex-direction:column;gap:6px}
.menu-groupe .titre-groupe{font-size:10.5px;font-weight:800;letter-spacing:.07em;text-transform:uppercase;
color:var(--encre-3);padding:4px 2px 0}
.menu-item{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--bordure);
border-radius:10px;padding:10px 11px}
.menu-item .ico{width:24px;text-align:center;font-size:14px;color:var(--primaire);flex:none}
.menu-item b{flex:1;font-size:12.5px;font-weight:700}
.menu-item .chev{color:var(--encre-3);font-size:13px}
.menu-item .num{font-size:10.5px;font-weight:800;color:var(--st-attente);background:var(--st-attente-fond);
padding:2px 7px;border-radius:999px}
.menu-vide{color:var(--encre-3);font-size:12px;text-align:center;padding:20px 8px;font-weight:600}
/* Formulaire mobile */
.champ-tel{display:flex;flex-direction:column;gap:4px}
.champ-tel label{font-size:11px;font-weight:700;color:var(--encre-2)}
.champ-tel .valeur-champ{border:1.5px solid var(--bordure-forte);border-radius:9px;background:var(--surface);
padding:9px 10px;font-size:13px;font-weight:600;color:var(--encre)}
.champ-tel textarea.valeur-champ{min-height:56px}
/* Tableau de synthèse (hors gabarit téléphone) */
.table-roles{width:100%;border-collapse:collapse;background:var(--surface);border:1px solid var(--bordure);
border-radius:12px;overflow:hidden;font-size:12.5px}
.table-roles th,.table-roles td{padding:10px 12px;text-align:left;border-bottom:1px solid var(--bordure)}
.table-roles thead th{background:var(--surface-2);font-size:10.5px;letter-spacing:.05em;text-transform:uppercase;
color:var(--encre-2)}
.table-roles tbody tr:last-child td{border-bottom:0}
.table-roles td.role{font-weight:800}
.table-roles .oui{color:var(--st-termine);font-weight:800}
.table-roles .non{color:var(--encre-3)}
.chip-onglet{display:inline-block;background:var(--primaire-doux);color:var(--primaire);font-weight:700;
font-size:11px;padding:2px 8px;border-radius:999px;margin:2px 3px 2px 0}
.chip-onglet.terrain{background:var(--safran-doux);color:var(--safran)}
</style>
<header class="revue">
<b>SIOP · Maquette — Mobile ouvert à tous les rôles</b>
<nav class="onglets" role="tablist" aria-label="Écrans">
<button role="tab" aria-selected="true" data-cible="synthese">1 · Synthèse par rôle</button>
<button role="tab" aria-selected="false" data-cible="accueil">2 · Accueil adaptatif</button>
<button role="tab" aria-selected="false" data-cible="menu">3 · Menu groupé</button>
<button role="tab" aria-selected="false" data-cible="ot-dispatch">4 · OT — Dispatcher</button>
<button role="tab" aria-selected="false" data-cible="demandes">5 · Demandes</button>
<button role="tab" aria-selected="false" data-cible="stock">6 · Stock & achats</button>
<button role="tab" aria-selected="false" data-cible="pilotage">7 · Personnes & statistiques</button>
</nav>
<div class="droite"><button class="theme" id="btn-theme">Thème : auto</button></div>
</header>
<!-- ═══ 1 · SYNTHÈSE PAR RÔLE ═══ -->
<p class="note-ecran" data-note="synthese"><b>Intention :</b> avant le détail écran par écran,
le tableau ci-dessous résume la <b>décision D1/D2</b> 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.</p>
<section class="ecran actif" id="synthese">
<div style="overflow-x:auto">
<table class="table-roles">
<thead><tr><th>Rôle (compte démo)</th><th>Onglets de la barre</th><th>Groupes du Menu visibles</th></tr></thead>
<tbody>
<tr>
<td class="role">Administrateur<br><small style="font-weight:500;color:var(--encre-3)">Yasmine Alaoui</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(tout)</span></td>
</tr>
<tr>
<td class="role">Gestionnaire<br><small style="font-weight:500;color:var(--encre-3)">Nadia Berrada</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(stats en lecture)</span></td>
</tr>
<tr>
<td class="role">Dispatcher<br><small style="font-weight:500;color:var(--encre-3)">Salma Idrissi</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">OT</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc (lecture) <span style="color:var(--encre-3)">— Ressources/Pilotage restreints</span></td>
</tr>
<tr style="box-shadow:inset 3px 0 0 var(--safran)">
<td class="role">Technicien<br><small style="font-weight:500;color:var(--encre-3)">Ahmed Benali</small></td>
<td><span class="chip-onglet terrain">Ma journée</span><span class="chip-onglet terrain">Scanner</span><span class="chip-onglet terrain">Préventif</span><span class="chip-onglet terrain">Synchro</span><span class="chip-onglet">Menu</span></td>
<td>Menu quasi vide (ses OT + parc en lecture) — <b>inchangé depuis R4</b></td>
</tr>
<tr style="box-shadow:inset 3px 0 0 var(--safran)">
<td class="role">Technicien limité<br><small style="font-weight:500;color:var(--encre-3)">Youssef Tazi</small></td>
<td><span class="chip-onglet terrain">Ma journée</span><span class="chip-onglet terrain">Scanner</span><span class="chip-onglet terrain">Préventif</span><span class="chip-onglet terrain">Synchro</span><span class="chip-onglet">Menu</span></td>
<td>Menu quasi vide (ses OT uniquement) — <b>inchangé depuis R4</b></td>
</tr>
<tr>
<td class="role">Demandeur<br><small style="font-weight:500;color:var(--encre-3)">Karim Doukkali</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">Nouvelle demande</span></td>
<td>Exploitation → Demandes uniquement <span style="color:var(--encre-3)">(Menu quasi vide, D3)</span></td>
</tr>
<tr>
<td class="role">Vue seule<br><small style="font-weight:500;color:var(--encre-3)">Rachid Bennis</small></td>
<td><span class="chip-onglet">Accueil</span><span class="chip-onglet">Menu</span></td>
<td>Exploitation · Parc · Ressources · Pilotage <span style="color:var(--encre-3)">(lecture seule, aucun bouton d'action)</span></td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- ═══ 2 · ACCUEIL ADAPTATIF ═══ -->
<p class="note-ecran" data-note="accueil" hidden><b>Intention :</b> le Technicien garde « Ma
journée » telle quelle (rien à revalider). Les autres rôles reçoivent un <b>Accueil</b> 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.</p>
<section class="ecran" id="accueil">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Technicien — inchangé (R4)</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Technicien</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">AB</span></span>
</div>
<div class="titre-tel">Ma journée <span class="filet">jeudi 1 août</span></div>
<div class="bandeau-urgence"><span class="point-pulse"></span>1 personne bloquée — B2 · Tour Atlas</div>
<div class="carte-ot-tel bloque">
<div class="type-t">OT-2026-0342 · Dépannage <span class="st ouvert">Ouvert</span></div>
<b>Personne bloquée en cabine</b>
<div class="ou">Asc. B2 — Tour Atlas · Aïn Diab</div>
</div>
<div class="carte-ot-tel haute">
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
<b>Bruit anormal en gaine</b>
<div class="ou">Asc. A1 — Résidence Al Manar</div>
</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Ma journée</a>
<a href="#"><span class="pic"></span>Scanner</a>
<a href="#"><span class="pic"></span>Préventif</a>
<a href="#"><span class="pic"></span>Synchro</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — nouveau</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Accueil <span class="filet">jeudi 1 août</span></div>
<div class="kpis-tel">
<div class="kpi-tel accent"><div class="val">6</div><div class="lbl">OT en cours</div></div>
<div class="kpi-tel"><div class="val">3</div><div class="lbl">Stock sous seuil</div></div>
<div class="kpi-tel"><div class="val">78 %</div><div class="lbl">Préventif du mois</div></div>
<div class="kpi-tel"><div class="val">2</div><div class="lbl">Demandes reçues</div></div>
</div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Stock sous seuil</b><small>3 pièces à commander</small></span>
<span class="num">3</span><span class="chev"></span></div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Demandes reçues</b><small>en attente d'approbation</small></span>
<span class="num">2</span><span class="chev"></span></div>
<div class="raccourci-tel"><span class="ico"></span>
<span class="txt"><b>Invitations en attente</b><small>lien envoyé, pas encore activé</small></span>
<span class="chev"></span></div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — nouveau</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Mes demandes</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0089 <span class="st attente">Reçue</span></div>
<b>Éclairage cabine défaillant</b>
<div class="ou">Asc. A2 — Résidence Al Manar</div>
</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0081 <span class="st termine">Traitée</span></div>
<b>Bruit au démarrage</b>
<div class="ou">Asc. A2 — Résidence Al Manar → OT-2026-0336</div>
</div>
<div style="margin-top:auto">
<button class="btn-tel">+ Nouvelle demande</button>
</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 3 · MENU GROUPÉ ═══ -->
<p class="note-ecran" data-note="menu" hidden><b>Intention :</b> le Menu reprend, à l'identique,
les 4 groupes et l'ordre du sidebar web — <b>Exploitation / Parc / Ressources / Pilotage</b> — 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.</p>
<section class="ecran" id="menu">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Administrateur — tout visible</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Administrateur</small>
<span class="fin"><span class="avatar-tel">YA</span></span>
</div>
<div class="titre-tel">Menu</div>
<div class="menu-groupe">
<div class="titre-groupe">Exploitation</div>
<div class="menu-item"><span class="ico"></span><b>Tableau de bord</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Ordres de travail</b><span class="num">1</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Demandes</b><span class="num">2</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Préventif</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Parc</div>
<div class="menu-item"><span class="ico"></span><b>Ascenseurs</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Sites</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Ressources</div>
<div class="menu-item"><span class="ico"></span><b>Stock & achats</b><span class="num">3</span><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Tiers</b><span class="chev"></span></div>
</div>
<div class="menu-groupe">
<div class="titre-groupe">Pilotage</div>
<div class="menu-item"><span class="ico"></span><b>Statistiques</b><span class="chev"></span></div>
<div class="menu-item"><span class="ico"></span><b>Personnes & équipes</b><span class="chev"></span></div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — presque vide (D3)</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Menu</div>
<div class="menu-groupe">
<div class="titre-groupe">Exploitation</div>
<div class="menu-item"><span class="ico"></span><b>Mes demandes</b><span class="chev"></span></div>
</div>
<div class="menu-vide">Parc, Ressources et Pilotage ne sont pas accessibles à ce rôle —<br>groupes masqués, pas de section vide affichée.</div>
<nav class="tabbar">
<a class="actif" href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 4 · OT — DISPATCHER ═══ -->
<p class="note-ecran" data-note="ot-dispatch" hidden><b>Intention :</b> 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).</p>
<section class="ecran" id="ot-dispatch">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Dispatcher — tous les OT</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Dispatcher</small>
<span class="fin"><span class="sync">Synchro à jour</span><span class="avatar-tel">SI</span></span>
</div>
<div class="titre-tel">Ordres de travail <span class="filet">14 en cours</span></div>
<div class="carte-ot-tel bloque">
<div class="type-t">OT-2026-0342 · Dépannage <span class="st ouvert">Ouvert</span></div>
<b>Personne bloquée en cabine</b>
<div class="ou">Asc. B2 — Tour Atlas · non assigné</div>
</div>
<div class="carte-ot-tel haute">
<div class="type-t">OT-2026-0341 · Dépannage <span class="st encours">En cours</span></div>
<b>Bruit anormal en gaine</b>
<div class="ou">Asc. A1 — Résidence Al Manar · Ahmed Benali</div>
</div>
<div class="carte-ot-tel moyenne">
<div class="type-t">OT-2026-0336 · Préventif <span class="st ouvert">Ouvert</span></div>
<b>Contrôle mensuel — juillet</b>
<div class="ou">Asc. A2 — Résidence Al Manar · non assigné</div>
</div>
<div style="margin-top:auto">
<button class="btn-tel">+ Nouvel OT</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Assigner — feuille au pouce</div>
<div class="ecran-tel" style="justify-content:flex-end;padding:0">
<div style="flex:1;background:rgb(9 14 22/.45)"></div>
<div style="background:var(--surface);border-radius:16px 16px 0 0;padding:14px;display:flex;flex-direction:column;gap:10px">
<div style="font-weight:800;font-size:15px">Assigner OT-2026-0342</div>
<div class="champ-tel">
<label>Technicien</label>
<div class="valeur-champ">Ahmed Benali ▾</div>
</div>
<button class="btn-tel">Assigner</button>
<button class="btn-tel contour">Annuler</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 5 · DEMANDES ═══ -->
<p class="note-ecran" data-note="demandes" hidden><b>Intention :</b> 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.</p>
<section class="ecran" id="demandes">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Demandeur — nouvelle demande</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Demandeur</small>
<span class="fin"><span class="avatar-tel">KD</span></span>
</div>
<div class="titre-tel">Nouvelle demande</div>
<div class="champ-tel"><label>Site</label><div class="valeur-champ">Résidence Al Manar ▾</div></div>
<div class="champ-tel"><label>Ascenseur (si connu)</label><div class="valeur-champ">Asc. A2 ▾</div></div>
<div class="champ-tel"><label>Description</label>
<div class="valeur-champ" style="min-height:56px">Odeur de brûlé au démarrage, depuis ce matin.</div>
</div>
<div class="champ-tel"><label>Urgence</label><div class="valeur-champ">Personne bloquée — Non</div></div>
<div style="margin-top:auto">
<button class="btn-tel">Envoyer la demande</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic">+</span>Nouvelle demande</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — approbation</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Demandes reçues <span class="filet">2</span></div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0089 <span class="st attente">Reçue</span></div>
<b>Éclairage cabine défaillant</b>
<div class="ou">Asc. A2 — Résidence Al Manar · Karim Doukkali</div>
<div class="paire-boutons" style="margin-top:8px">
<button class="btn-tel vert">Approuver</button>
<button class="btn-tel rouge">Rejeter</button>
</div>
</div>
<div class="carte-ot-tel">
<div class="type-t">DEM-2026-0090 <span class="st attente">Reçue</span></div>
<b>Porte palière qui force</b>
<div class="ou">Asc. C1 — Anfa Place · Karim Doukkali</div>
<div class="paire-boutons" style="margin-top:8px">
<button class="btn-tel vert">Approuver</button>
<button class="btn-tel rouge">Rejeter</button>
</div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a class="actif" href="#"><span class="pic"></span>OT</a>
<a href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
</div>
</section>
<!-- ═══ 6 · STOCK & ACHATS ═══ -->
<p class="note-ecran" data-note="stock" hidden><b>Intention :</b> 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).</p>
<section class="ecran" id="stock">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — stock</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Stock & achats <span class="filet">3 sous seuil</span></div>
<div class="carte-tel">
<h4>Câble de traction 8 mm</h4>
<div class="ligne-info"><span>Quantité</span><b style="color:var(--danger)">2</b></div>
<div class="ligne-info"><span>Seuil</span><b>5</b></div>
<div class="ligne-info"><span>Fournisseur</span><b>Ascentech Maroc</b></div>
<button class="btn-tel" style="margin-top:4px">Commander</button>
</div>
<div class="carte-tel">
<h4>Galet de guidage</h4>
<div class="ligne-info"><span>Quantité</span><b style="color:var(--danger)">1</b></div>
<div class="ligne-info"><span>Seuil</span><b>4</b></div>
<div class="ligne-info"><span>Fournisseur</span><b>SchindlerParts</b></div>
<button class="btn-tel" style="margin-top:4px">Commander</button>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">BC pré-rempli depuis l'alerte</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Nouveau BC</div>
<div class="champ-tel"><label>Fournisseur</label><div class="valeur-champ">Ascentech Maroc</div></div>
<div class="champ-tel"><label>Pièce</label><div class="valeur-champ">Câble de traction 8 mm</div></div>
<div class="champ-tel"><label>Quantité</label><div class="valeur-champ">10</div></div>
<div class="garde">Le détail multi-lignes se termine sur le grand écran (D4).</div>
<div style="margin-top:auto">
<button class="btn-tel">Créer le BC</button>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 7 · PERSONNES & STATISTIQUES ═══ -->
<p class="note-ecran" data-note="pilotage" hidden><b>Intention :</b> 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.</p>
<section class="ecran" id="pilotage">
<div class="tels">
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Administrateur — statistiques</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Administrateur</small>
<span class="fin"><span class="avatar-tel">YA</span></span>
</div>
<div class="titre-tel">Statistiques <span class="filet">12 derniers mois</span></div>
<div class="kpis-tel">
<div class="kpi-tel accent"><div class="val">505 MAD</div><div class="lbl">Coût du mois</div></div>
<div class="kpi-tel"><div class="val">78 %</div><div class="lbl">Taux préventif</div></div>
</div>
<div class="carte-tel">
<h4>Pannes par organe — top 3</h4>
<div class="ligne-info"><span>Portes palières</span><b>9</b></div>
<div class="ligne-info"><span>Câblage</span><b>6</b></div>
<div class="ligne-info"><span>Éclairage cabine</span><b>4</b></div>
</div>
<nav class="tabbar">
<a href="#"><span class="pic"></span>Accueil</a>
<a href="#"><span class="pic"></span>OT</a>
<a class="actif" href="#"><span class="pic"></span>Menu</a>
</nav>
</div>
</div>
<div class="tel">
<div class="encoche"><i></i></div>
<div class="legende">Gestionnaire — personnes & équipes</div>
<div class="ecran-tel">
<div class="entete-tel">
<span class="flamme"></span><b>SIOP</b><small>Gestionnaire</small>
<span class="fin"><span class="avatar-tel">NB</span></span>
</div>
<div class="titre-tel">Personnes <span class="filet">12</span></div>
<div class="menu-item"><span class="ico"></span><b>Ahmed Benali</b><span style="color:var(--encre-3);font-size:11px">Technicien</span></div>
<div class="menu-item"><span class="ico"></span><b>Youssef Tazi</b><span style="color:var(--encre-3);font-size:11px">Technicien limité</span></div>
<div class="menu-item"><span class="ico"></span><b>Salma Idrissi</b><span style="color:var(--encre-3);font-size:11px">Dispatcher</span></div>
<div style="margin-top:auto">
<button class="btn-tel">+ Inviter</button>
</div>
</div>
</div>
</div>
</section>
<script>
(function(){
var onglets=document.querySelectorAll('.revue .onglets button');
var ecrans=document.querySelectorAll('.ecran');
var notes=document.querySelectorAll('.note-ecran');
onglets.forEach(function(b){b.addEventListener('click',function(){
onglets.forEach(function(x){x.setAttribute('aria-selected','false')});
b.setAttribute('aria-selected','true');
var cible=b.dataset.cible;
ecrans.forEach(function(e){e.classList.toggle('actif',e.id===cible)});
notes.forEach(function(n){n.hidden=(n.dataset.note!==cible)});
window.scrollTo({top:0});
})});
var ordre=['auto','light','dark'];var i=0;
var bt=document.getElementById('btn-theme');
bt.addEventListener('click',function(){
i=(i+1)%3;var m=ordre[i];
if(m==='auto'){document.documentElement.removeAttribute('data-theme')}
else{document.documentElement.setAttribute('data-theme',m)}
bt.textContent='Thème : '+(m==='auto'?'auto':(m==='light'?'clair':'sombre'));
});
})();
</script>
</body>
</html>

View File

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