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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
179
apps/mobile/app/(tabs)/accueil.tsx
Normal file
179
apps/mobile/app/(tabs)/accueil.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal file
24
apps/mobile/app/(tabs)/nouvelle-demande.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
apps/mobile/app/(tabs)/ot.tsx
Normal file
154
apps/mobile/app/(tabs)/ot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
apps/mobile/app/a-venir.tsx
Normal file
21
apps/mobile/app/a-venir.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
apps/mobile/app/demandes/index.tsx
Normal file
19
apps/mobile/app/demandes/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
apps/mobile/app/demandes/nouvelle.tsx
Normal file
18
apps/mobile/app/demandes/nouvelle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
26
apps/mobile/src/auth/roles.ts
Normal file
26
apps/mobile/src/auth/roles.ts
Normal 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';
|
||||
}
|
||||
26
apps/mobile/src/auth/use-permissions.ts
Normal file
26
apps/mobile/src/auth/use-permissions.ts
Normal 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 };
|
||||
}
|
||||
105
apps/mobile/src/composants/formulaire-demande.tsx
Normal file
105
apps/mobile/src/composants/formulaire-demande.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
apps/mobile/src/composants/panneau-demandes.tsx
Normal file
185
apps/mobile/src/composants/panneau-demandes.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user