Files
siop2/apps/mobile/app/ot/[id]/cloture.tsx
pr-daaif a22ea60f83 feat(r4.2): terrain mobile — scan QR, fiches ascenseur/OT, clôture, grille
Scanner expo-camera (QR seulement) : analyseScan testée — URL portail
…/q/REF des étiquettes A6 ou référence tapée, QR étrangers refusés ;
résolution D4 dans le parc en cache d'abord (sous-sol inclus), message
honnête sinon. Fiche ascenseur en consultation (D3, historique scopé
« voir autre »). Fiche OT : un bouton principal selon la machine à
états, coûts figés, garde alimentée par closureBlockers (source unique
API). Clôture terrain : bilan codé 6 champs au pouce (sélecteur plein
écran), garde visible, clôture en ligne. Préventif : mes grilles →
checklist cochable, appui long = N/A, aria-checked (leçon RN web :
accessibilityState.checked ne produit pas aria-checked).

Écritures en ligne assumées en R4.2 (bandeaux hors-ligne explicites) —
la file persistée et le verrou optimiste sont R4.3 (D1/D2).

Vérifié 12/12 en Expo web piloté : scan → fiche → OT-0341 (505 MAD,
garde), coche/décoche restituée, OT de test Démarrer → bilan → Terminé,
zéro erreur console, données purgées. 12 tests jest-expo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:47:04 +01:00

145 lines
5.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { router, useLocalSearchParams } from 'expo-router';
import { useState } from 'react';
import { Alert, ScrollView, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import {
BILAN_FIELD_LABELS,
BILAN_FIELDS,
REQUIRED_BILAN_FIELDS,
type BilanField,
type ReportUpsert,
} from '@siop/shared';
import { useBilan, useReferenceValues, useTransition, useWorkOrder } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Écran 3 de la maquette R4 : le bilan codé R2 au pouce — 3 champs requis,
* garde visible et bloquante (les MESSAGES viennent de l'API : une seule
* source de vérité, comme le web). Clôture en ligne en R4.2. */
const CHAMP_VERS_ID: Record<BilanField, keyof ReportUpsert> = {
DOOR_STATE: 'doorStateId',
CABIN_POSITION: 'cabinPositionId',
ANOMALY: 'anomalyId',
EXTERNAL_CAUSE: 'externalCauseId',
ACTION_TAKEN: 'actionTakenId',
COMPONENT_CONCERNED: 'componentConcernedId',
};
const CHAMP_VERS_VALEUR = {
DOOR_STATE: 'doorState',
CABIN_POSITION: 'cabinPosition',
ANOMALY: 'anomaly',
EXTERNAL_CAUSE: 'externalCause',
ACTION_TAKEN: 'actionTaken',
COMPONENT_CONCERNED: 'componentConcerned',
} as const;
export default function PageCloture() {
const t = useTokens();
const horsLigne = useHorsLigne();
const { id } = useLocalSearchParams<{ id: string }>();
const { data: ot } = useWorkOrder(id);
const { data: valeurs } = useReferenceValues();
const bilan = useBilan(id);
const transition = useTransition(id);
const [choix, setChoix] = useState<Partial<Record<BilanField, string | null>>>({});
if (!ot) return null;
const optionsDe = (champ: BilanField) =>
(valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
/** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
if (champ in choix) {
const vid = choix[champ];
if (!vid) return null;
const v = (valeurs ?? []).find((x) => x.id === vid);
return v ? { id: v.id, label: v.label } : null;
}
return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
};
const enregistrer = (apres?: () => void) => {
const corps: ReportUpsert = {};
for (const champ of BILAN_FIELDS) {
if (champ in choix) corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
}
bilan.mutate(corps, {
onSuccess: apres,
onError: (e) => Alert.alert('Bilan refusé', e.message),
});
};
const cloturer = () =>
enregistrer(() =>
transition.mutate(
{ to: 'DONE' },
{
onSuccess: () => router.replace(`/ot/${id}`),
onError: (e) => Alert.alert('Clôture bloquée', e.message),
},
),
);
const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
<EnteteFiche titre={`Clôturer ${ot.reference}`} />
<Carte titre="Bilan d'intervention — requis pour clôturer">
<View style={{ gap: 10 }}>
{[0, 2, 4].map((rang) => (
<View key={rang} style={{ flexDirection: 'row', gap: 8 }}>
{[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
<ChoixTel
key={champ}
libelle={BILAN_FIELD_LABELS[champ]}
requis={REQUIRED_BILAN_FIELDS.includes(champ)}
valeur={valeurDe(champ)}
options={optionsDe(champ)}
surChoix={(vid) => setChoix((c) => ({ ...c, [champ]: vid }))}
/>
))}
</View>
))}
</View>
{manquants.length ? (
<Text style={{ color: t.alerte, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
{manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
{manquants.length > 1 ? 'manquent' : 'manque'} la clôture restera bloquée (même garde
que le web).
</Text>
) : null}
</Carte>
<BoutonTel
libelle={bilan.isPending ? 'Enregistrement…' : 'Enregistrer le bilan'}
variante="contour"
desactive={horsLigne || bilan.isPending}
surAppui={() => enregistrer()}
/>
<BoutonTel
libelle={
manquants.length
? 'Clôturer (bilan incomplet)'
: transition.isPending
? 'Clôture…'
: "Clôturer l'intervention"
}
variante={manquants.length ? 'gris' : 'vert'}
desactive={horsLigne || manquants.length > 0 || bilan.isPending || transition.isPending}
surAppui={cloturer}
/>
{horsLigne ? (
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12 }}>
Hors-ligne : la clôture en file arrive en R4.3 pour linstant, revenez au réseau.
</Text>
) : null}
</ScrollView>
</SafeAreaView>
);
}