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>
This commit is contained in:
pr-daaif
2026-07-17 03:47:04 +01:00
parent 514fc7c391
commit a22ea60f83
18 changed files with 1326 additions and 15 deletions

View File

@@ -1,11 +1,158 @@
import { AVenir } from '@/composants/a-venir';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { router } from 'expo-router';
import { useRef, useState } from 'react';
import { Platform, Text, TextInput, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useAssets } from '@/api/exploitation';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui';
import { analyseScan } from '@/lib/scan';
import { useTokens } from '@/theme/tokens';
/** Écran 4 de la maquette R4 (D4) : le QR de l'étiquette A6 contient l'URL
* portail `…/q/REF` — la résolution se fait D'ABORD dans le parc déjà en
* cache (donc en sous-sol aussi). Repli : saisie de la référence. */
export default function PageScanner() {
const t = useTokens();
const horsLigne = useHorsLigne();
const [permission, demanderPermission] = useCameraPermissions();
const { data: assets, refetch } = useAssets();
const [manuel, setManuel] = useState('');
const [erreur, setErreur] = useState<string | null>(null);
const dernierScan = useRef(0);
const resoudre = async (brut: string) => {
const reference = analyseScan(brut);
if (!reference) {
setErreur('Ce code nest pas une étiquette SIOP.');
return;
}
// 1 · le parc en cache (fonctionne hors-ligne)
let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
// 2 · sinon, un rafraîchissement si le réseau est là
if (!appareil && !horsLigne) {
const frais = await refetch();
appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
}
if (!appareil) {
setErreur(
horsLigne
? `« ${reference} » n'est pas dans le parc synchronisé — réessayez au retour du réseau.`
: `Aucun appareil « ${reference} » dans le parc.`,
);
return;
}
setErreur(null);
setManuel('');
router.push(`/ascenseur/${appareil.id}`);
};
const surScan = ({ data }: { data: string }) => {
const maintenant = Date.now();
if (maintenant - dernierScan.current < 1500) return; // anti-rafale
dernierScan.current = maintenant;
void resoudre(data);
};
const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
return (
<AVenir
titre="Scanner"
release="R4.2"
detail="Visez le QR de l'étiquette de cabine (posée en R1) — la fiche s'ouvrira même hors-ligne, sur le parc déjà synchronisé."
/>
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Scanner
</Text>
{cameraUtilisable ? (
<View style={{ flex: 1, borderRadius: 12, overflow: 'hidden' }}>
<CameraView
style={{ flex: 1 }}
barcodeScannerSettings={{ barcodeTypes: ['qr'] }}
onBarcodeScanned={surScan}
/>
<Text
style={{
position: 'absolute',
bottom: 12,
alignSelf: 'center',
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
}}
>
Visez le QR de létiquette de cabine
</Text>
</View>
) : (
<View
style={{
flex: 1,
borderRadius: 12,
backgroundColor: '#131c2c',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 20,
}}
>
<View
style={{
width: 150,
height: 150,
borderRadius: 14,
borderWidth: 2.5,
borderColor: t.safran,
}}
/>
<Text
style={{
color: '#dfe7f2',
fontFamily: 'Manrope_600SemiBold',
fontSize: 12.5,
textAlign: 'center',
}}
>
{Platform.OS === 'web'
? 'Caméra indisponible sur web — saisissez la référence ci-dessous.'
: 'Lappareil photo sert uniquement à lire les étiquettes du parc.'}
</Text>
{Platform.OS !== 'web' && !permission?.granted ? (
<BoutonTel libelle="Autoriser la caméra" surAppui={() => void demanderPermission()} />
) : null}
</View>
)}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
accessibilityLabel="Référence de l'appareil"
placeholder="Référence (A1, B2…)"
placeholderTextColor={t.encre3}
autoCapitalize="characters"
value={manuel}
onChangeText={(v) => {
setManuel(v);
setErreur(null);
}}
onSubmitEditing={() => void resoudre(manuel)}
style={{
flex: 1,
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<BoutonTel libelle="Ouvrir" desactive={!manuel.trim()} surAppui={() => void resoudre(manuel)} />
</View>
{erreur ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{erreur}
</Text>
) : null}
</View>
</SafeAreaView>
);
}