mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
140 lines
5.9 KiB
TypeScript
140 lines
5.9 KiB
TypeScript
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>
|
||
);
|
||
}
|