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

@@ -177,8 +177,10 @@ export default function PageJournee() {
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
<View
<Pressable
accessibilityRole="button"
accessibilityLabel={`${o.reference}${o.title}`}
onPress={() => router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
@@ -230,7 +232,7 @@ export default function PageJournee() {
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
</Text>
</View>
</Pressable>
);
}}
/>

View File

@@ -1,11 +1,76 @@
import { AVenir } from '@/composants/a-venir';
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useWorkOrders } from '@/api/exploitation';
import { ChipStatut } from '@/composants/ui';
import { useTokens } from '@/theme/tokens';
/** Onglet Préventif : mes grilles du moment — chaque tuile mène à la
* checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
export default function PagePreventif() {
const t = useTokens();
const { data: workOrders } = useWorkOrders();
const grilles = (workOrders ?? [])
.filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
.sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
return (
<AVenir
titre="Préventif"
release="R4.2"
detail="La grille du mois, cochable au pouce et hors-ligne — chaque coche partira en file individuellement."
/>
<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 }}>
Préventif
</Text>
<FlatList
data={grilles}
keyExtractor={(w) => w.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
Aucune grille préventive en cours pour vous.
</Text>
}
renderItem={({ item: w }) => (
<Pressable
accessibilityRole="button"
onPress={() => router.push(`/ot/${w.id}/grille`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 2,
}}
>
<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,
}}
>
{w.reference}
</Text>
<ChipStatut statut={w.status} />
</View>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
{w.title}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
Asc. {w.assetReference} {w.siteName}
{w.dueDate
? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
: ''}
</Text>
</Pressable>
)}
/>
</View>
</SafeAreaView>
);
}

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>
);
}