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:
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