mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r6): R6.4 — Pilotage sur mobile (Statistiques, Personnes)
EXPO_PUBLIC_WEB_URL/WEB_URL pour le lien d'activation (pointe la page web /activation?token=…, aucun équivalent mobile). api/pilotage.ts (useAnalyticsSummary, useUsers, useRoles, useInviteUser). Écran Statistiques (maquette écran 7) : période 3/6/12 mois, coût du mois, taux préventif, OT clôturés, pannes par organe, top équipements — cartes plutôt que graphes denses (D4). Écran Personnes & équipes : liste + statut, Inviter (nom/e-mail/rôle), lien d'activation en texte sélectionnable (Text selectable, RN natif) — expo-clipboard est une dépendance native absente, différée comme expo-sharing en R6.3 plutôt qu'ajoutée à la légère. Taux horaire/rôles/ équipes restent gérés au web (D4). Assistant reste "à venir" dans le Menu : un chat sourcé est un nouveau patron d'écran jamais maquetté sur mobile, contrairement aux autres familles qui réutilisaient des patrons déjà validés — mérite son propre tour de design-first plutôt qu'être exécuté par réflexe en fin de release. R6 quasi close (Exploitation/Parc/Ressources/Pilotage livrés pour tous les rôles, hors Assistant) — reste la confirmation du référent sur iPhone (R6.1 + correctif déconnexion) avant clôture réelle. Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,3 +14,8 @@ EXPO_PUBLIC_USE_RN_FETCH=1
|
||||
# Force la recompilation depuis les sources — voir ADR-005.
|
||||
EXPO_USE_PRECOMPILED_MODULES=0
|
||||
RCT_USE_PREBUILT_RNCORE=0
|
||||
|
||||
# Origine du web — R6.4, lien d'activation « Personnes » (/activation?token=…,
|
||||
# il n'existe pas d'équivalent mobile). À ajuster par environnement comme
|
||||
# EXPO_PUBLIC_API_URL (IP LAN ou domaine de prod).
|
||||
EXPO_PUBLIC_WEB_URL=http://localhost:5173
|
||||
|
||||
@@ -53,9 +53,9 @@ const GROUPES: { titre: string; liens: LienMenu[] }[] = [
|
||||
{
|
||||
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.' },
|
||||
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
|
||||
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'L’assistant IA reste sur le grand écran et la clôture mobile pour l’instant — un chat sourcé est un nouveau patron d’écran, pas encore maquetté sur mobile.' },
|
||||
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
94
apps/mobile/app/personnes/index.tsx
Normal file
94
apps/mobile/app/personnes/index.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useUsers } from '@/api/pilotage';
|
||||
import { usePermissions } from '@/auth/use-permissions';
|
||||
import { BoutonTel, EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
const LABEL_STATUT: Record<'active' | 'invited' | 'disabled', string> = {
|
||||
active: 'Actif',
|
||||
invited: 'Invitation envoyée',
|
||||
disabled: 'Désactivé',
|
||||
};
|
||||
|
||||
/** Personnes & équipes — R6.4 (Pilotage). Consultation + « Inviter » (D4) —
|
||||
* taux horaire, équipes, rôles restent gérés depuis le web pour l'instant. */
|
||||
export default function PagePersonnes() {
|
||||
const t = useTokens();
|
||||
const { data: users } = useUsers();
|
||||
const { can } = usePermissions();
|
||||
const personnes = [...(users ?? [])].sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre="Personnes" />
|
||||
{can('PEOPLE_TEAMS', 'create') ? (
|
||||
<BoutonTel libelle="+ Inviter" surAppui={() => router.push('/personnes/inviter')} />
|
||||
) : null}
|
||||
<FlatList
|
||||
data={personnes}
|
||||
keyExtractor={(u) => u.id}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||
Personne accessible.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: u }) => <LignePersonne utilisateur={u} t={t} />}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function LignePersonne({
|
||||
utilisateur: u,
|
||||
t,
|
||||
}: {
|
||||
utilisateur: { id: string; displayName: string; email: string; role: { name: string }; teams: { name: string }[]; status: 'active' | 'invited' | 'disabled' };
|
||||
t: Tokens;
|
||||
}) {
|
||||
const [enc, fond] =
|
||||
u.status === 'active'
|
||||
? [t.stTermine, t.stTermineFond]
|
||||
: u.status === 'invited'
|
||||
? [t.stAttente, t.stAttenteFond]
|
||||
: [t.stAnnule, t.stAnnuleFond];
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
padding: 12,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||
{u.displayName}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: enc,
|
||||
backgroundColor: fond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
}}
|
||||
>
|
||||
{LABEL_STATUT[u.status]}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||
{u.role.name}
|
||||
{u.teams.length ? ` · ${u.teams.map((eq) => eq.name).join(', ')}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
99
apps/mobile/app/personnes/inviter.tsx
Normal file
99
apps/mobile/app/personnes/inviter.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { router } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
import { ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useInviteUser, useRoles } from '@/api/pilotage';
|
||||
import { BoutonTel, ChoixTel, EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Invitation — R6.4. Jamais de mot de passe créé pour autrui (décision R1) :
|
||||
* seul un lien d'activation valable 7 j est émis, à transmettre à la main. */
|
||||
export default function PageInviter() {
|
||||
const t = useTokens();
|
||||
const { data: roles } = useRoles();
|
||||
const invitation = useInviteUser();
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [roleId, setRoleId] = useState<string | null>(null);
|
||||
|
||||
const role = (roles ?? []).find((r) => r.id === roleId) ?? null;
|
||||
const valide = displayName.trim().length >= 2 && /\S+@\S+\.\S+/.test(email) && !!roleId;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
|
||||
<EnteteFiche titre="Inviter" />
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||
Nom complet <Text style={{ color: t.danger }}>*</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="Nom complet"
|
||||
value={displayName}
|
||||
onChangeText={setDisplayName}
|
||||
style={{
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderRadius: 9,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 9,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ gap: 4 }}>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
|
||||
E-mail <Text style={{ color: t.danger }}>*</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
accessibilityLabel="E-mail"
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
style={{
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderRadius: 9,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 9,
|
||||
color: t.encre,
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 13,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
<ChoixTel
|
||||
libelle="Rôle"
|
||||
requis
|
||||
valeur={role ? { id: role.id, label: role.name } : null}
|
||||
options={(roles ?? []).map((r) => ({ id: r.id, label: r.name }))}
|
||||
surChoix={setRoleId}
|
||||
/>
|
||||
{invitation.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{invitation.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
<BoutonTel
|
||||
libelle="Émettre le lien d'activation"
|
||||
desactive={!valide || invitation.isPending}
|
||||
surAppui={() =>
|
||||
invitation.mutate(
|
||||
{ displayName: displayName.trim(), email: email.trim(), roleId: roleId! },
|
||||
{
|
||||
onSuccess: (r) =>
|
||||
router.replace({
|
||||
pathname: '/personnes/lien',
|
||||
params: { token: r.activationToken, expiresAt: r.expiresAt },
|
||||
}),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
43
apps/mobile/app/personnes/lien.tsx
Normal file
43
apps/mobile/app/personnes/lien.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { router, useLocalSearchParams } from 'expo-router';
|
||||
import { ScrollView, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { WEB_URL } from '@/api/client';
|
||||
import { BoutonTel, Carte, EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** Lien d'activation émis — R6.4. Pas de presse-papiers natif sur mobile
|
||||
* pour l'instant (`expo-clipboard` demanderait un nouveau build natif,
|
||||
* même raisonnement que l'ouverture de document en Ressources) : le texte
|
||||
* est sélectionnable (`Text selectable`, RN natif, aucune dépendance) —
|
||||
* appui long → copier, comme partout sur le téléphone. */
|
||||
export default function PageLienActivation() {
|
||||
const t = useTokens();
|
||||
const { token, expiresAt } = useLocalSearchParams<{ token: string; expiresAt: string }>();
|
||||
const url = `${WEB_URL}/activation?token=${token}`;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 12 }}>
|
||||
<EnteteFiche titre="Lien d'activation émis" />
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre2 }}>
|
||||
Transmettez ce lien à la personne (valable jusqu'au{' '}
|
||||
{new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(expiresAt))}) :
|
||||
</Text>
|
||||
<Carte>
|
||||
<Text
|
||||
selectable
|
||||
style={{ fontFamily: 'Manrope_700Bold', fontSize: 13, color: t.primaire }}
|
||||
>
|
||||
{url}
|
||||
</Text>
|
||||
</Carte>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre3 }}>
|
||||
Appui long sur le lien pour le sélectionner et le copier.
|
||||
</Text>
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<BoutonTel libelle="Terminé" surAppui={() => router.replace('/personnes')} />
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
130
apps/mobile/app/statistiques/index.tsx
Normal file
130
apps/mobile/app/statistiques/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useAnalyticsSummary } from '@/api/pilotage';
|
||||
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
const fmtMAD = new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'MAD', maximumFractionDigits: 0 });
|
||||
const PERIODES = [3, 6, 12] as const;
|
||||
|
||||
/** Statistiques — R6.4 (Pilotage). Maquette écran 7 : les mêmes chiffres
|
||||
* que `/analytics/summary` (web), en cartes plutôt qu'en graphes denses
|
||||
* (D4) — coût du mois, taux préventif, pannes par organe. */
|
||||
export default function PageStatistiques() {
|
||||
const t = useTokens();
|
||||
const [periode, setPeriode] = useState<(typeof PERIODES)[number]>(12);
|
||||
const { data } = useAnalyticsSummary(periode);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre="Statistiques" />
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{PERIODES.map((p) => (
|
||||
<Pressable
|
||||
key={p}
|
||||
accessibilityRole="button"
|
||||
onPress={() => setPeriode(p)}
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
backgroundColor: periode === p ? t.primaire : t.surface,
|
||||
borderWidth: 1,
|
||||
borderColor: periode === p ? t.primaire : t.bordure,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 12.5,
|
||||
color: periode === p ? '#fff' : t.encre2,
|
||||
}}
|
||||
>
|
||||
{p} mois
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{data ? (
|
||||
<>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.primaire }}>
|
||||
{fmtMAD.format(data.monthCost)}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||
Coût du mois
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||
{data.preventiveRate != null ? `${Math.round(data.preventiveRate * 100)} %` : '—'}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre2 }}>
|
||||
Taux préventif
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Carte titre={`OT clôturés · ${data.months} mois`}>
|
||||
<LigneInfo nom="Total" valeur={String(data.closed.total)} />
|
||||
<LigneInfo nom="Dont préventif" valeur={String(data.closed.preventive)} />
|
||||
<LigneInfo
|
||||
nom="Délai moyen"
|
||||
valeur={data.avgResolutionDays != null ? `${data.avgResolutionDays.toFixed(1)} j` : '—'}
|
||||
/>
|
||||
</Carte>
|
||||
|
||||
<Carte titre={`Pannes par organe · ${data.months} mois`}>
|
||||
{data.failuresByComponent.slice(0, 5).map((f) => (
|
||||
<LigneInfo key={f.label} nom={f.label} valeur={String(f.count)} />
|
||||
))}
|
||||
{!data.failuresByComponent.length ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Aucune panne codée sur la période.
|
||||
</Text>
|
||||
) : null}
|
||||
</Carte>
|
||||
|
||||
<Carte titre="Top équipements en coût">
|
||||
{data.topAssets.slice(0, 5).map((a) => (
|
||||
<LigneInfo
|
||||
key={a.reference}
|
||||
nom={`Asc. ${a.reference} — ${a.siteName}`}
|
||||
valeur={fmtMAD.format(a.total)}
|
||||
/>
|
||||
))}
|
||||
{!data.topAssets.length ? (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Aucune donnée sur la période.
|
||||
</Text>
|
||||
) : null}
|
||||
</Carte>
|
||||
</>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,11 @@ import { lireJeton } from './jeton';
|
||||
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
|
||||
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
|
||||
|
||||
/** Origine du web (R6.4 — lien d'activation « Personnes », même page
|
||||
* `/activation?token=…` que le web, il n'existe pas d'équivalent mobile).
|
||||
* À définir par environnement comme EXPO_PUBLIC_API_URL. */
|
||||
export const WEB_URL = process.env.EXPO_PUBLIC_WEB_URL ?? 'http://localhost:5173';
|
||||
|
||||
export const api = createClient<paths>({ baseUrl: API_URL });
|
||||
|
||||
api.use({
|
||||
|
||||
39
apps/mobile/src/api/pilotage.ts
Normal file
39
apps/mobile/src/api/pilotage.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import type { InvitationCreate } from '@siop/shared';
|
||||
import { api, unwrap } from './client';
|
||||
|
||||
/** Hooks R6.4 — Pilotage (Statistiques, Personnes & équipes) : mêmes
|
||||
* opérations que le web (gestion.ts/referentiel.ts), lecture + une action
|
||||
* courante (inviter) sur mobile — pas la gestion complète des rôles/
|
||||
* équipes/taux horaires, réservée au web (D4). */
|
||||
|
||||
export function useAnalyticsSummary(months: 3 | 6 | 12 = 12) {
|
||||
return useQuery({
|
||||
queryKey: ['analytics', months],
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET('/analytics/summary', { params: { query: { months: String(months) } } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUsers() {
|
||||
return useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: async () => (await unwrap(await api.GET('/users'))).users,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRoles() {
|
||||
return useQuery({
|
||||
queryKey: ['roles'],
|
||||
queryFn: async () => (await unwrap(await api.GET('/roles'))).roles,
|
||||
});
|
||||
}
|
||||
|
||||
export function useInviteUser() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: InvitationCreate) =>
|
||||
unwrap(await api.POST('/users/invitations', { body })),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user