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

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