mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
139
apps/mobile/app/(tabs)/menu.tsx
Normal file
139
apps/mobile/app/(tabs)/menu.tsx
Normal 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: '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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user