mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
Le Gestionnaire approuvait une demande sur mobile sans pouvoir assigner
l'OT à un technicien (l'OT partait non assigné) — remonté en recette :
"ce n'est pas à lui d'agir comme un technicien". Le web le fait déjà
(PanneauApprobation, demandes.tsx).
PanneauDemandes.tsx : "Approuver → OT" ouvre un panneau avec un ChoixTel
"Assigner à" (techniciens actifs, même filtre que le web —
status active && role.name.startsWith('Technicien')) avant de confirmer.
assigneeIds déjà supporté par l'API et le contrat — aucun changement
serveur nécessaire, seule l'UI mobile manquait cette étape.
Vérifié de bout en bout : demande → approuvée avec technicien assigné →
OT confirmé avec le bon assignee. Typecheck/tests/lint verts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
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 { useUsers } from '@/api/pilotage';
|
|
import { usePermissions } from '@/auth/use-permissions';
|
|
import { useTokens, type Tokens } from '@/theme/tokens';
|
|
import { BoutonTel, ChoixTel } 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 { data: users } = useUsers();
|
|
const approbation = useApproveRequest();
|
|
const rejet = useRejectRequest();
|
|
// Un seul panneau ouvert à la fois : approbation (avec assignation — ce
|
|
// n'est pas au Gestionnaire d'agir comme un technicien, il délègue) ou
|
|
// rejet (motif obligatoire). null = fermé, les deux boutons côte à côte.
|
|
const [panneau, setPanneau] = useState<'approuver' | 'rejeter' | null>(null);
|
|
const [motif, setMotif] = useState('');
|
|
const [assigneId, setAssigneId] = useState<string | null>(null);
|
|
|
|
const techniciens = (users ?? []).filter(
|
|
(u) => u.status === 'active' && u.role.name.startsWith('Technicien'),
|
|
);
|
|
const technicien = techniciens.find((tt) => tt.id === assigneId) ?? null;
|
|
|
|
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 ? (
|
|
panneau === null ? (
|
|
<View style={{ flexDirection: 'row', gap: 8, marginTop: 4 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<BoutonTel libelle="Approuver → OT" variante="vert" surAppui={() => setPanneau('approuver')} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<BoutonTel libelle="Rejeter" variante="gris" surAppui={() => setPanneau('rejeter')} />
|
|
</View>
|
|
</View>
|
|
) : panneau === 'approuver' ? (
|
|
<View style={{ gap: 6, marginTop: 4 }}>
|
|
<ChoixTel
|
|
libelle="Assigner à"
|
|
valeur={technicien ? { id: technicien.id, label: technicien.displayName } : null}
|
|
options={techniciens.map((tt) => ({ id: tt.id, label: tt.displayName }))}
|
|
surChoix={setAssigneId}
|
|
/>
|
|
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
<View style={{ flex: 1 }}>
|
|
<BoutonTel
|
|
libelle="Annuler"
|
|
variante="contour"
|
|
surAppui={() => {
|
|
setPanneau(null);
|
|
setAssigneId(null);
|
|
}}
|
|
/>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<BoutonTel
|
|
libelle="Confirmer l'approbation"
|
|
variante="vert"
|
|
desactive={approbation.isPending}
|
|
surAppui={() =>
|
|
approbation.mutate(
|
|
{
|
|
id: r.id,
|
|
priority: r.isPersonTrapped ? 'PERSON_TRAPPED' : 'HIGH',
|
|
assigneeIds: assigneId ? [assigneId] : undefined,
|
|
},
|
|
{ onSuccess: () => setPanneau(null) },
|
|
)
|
|
}
|
|
/>
|
|
</View>
|
|
</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={() => {
|
|
setPanneau(null);
|
|
setMotif('');
|
|
}}
|
|
/>
|
|
</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: () => setPanneau(null) },
|
|
)
|
|
}
|
|
/>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
)
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|