mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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:
@@ -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 n’est 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.'
|
||||
: 'L’appareil 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user