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

@@ -0,0 +1,265 @@
import { router } from 'expo-router';
import { useState, type ReactNode } from 'react';
import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
import { useTokens, type Tokens } from '@/theme/tokens';
/** Briques d'écran de la maquette R4 — cartes, chips, boutons, sélecteur. */
export function Carte({ titre, children }: { titre?: string; children: ReactNode }) {
const t = useTokens();
return (
<View
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 12,
padding: 12,
gap: 8,
}}
>
{titre ? (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
letterSpacing: 0.8,
textTransform: 'uppercase',
color: t.encre2,
}}
>
{titre}
</Text>
) : null}
{children}
</View>
);
}
export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
const t = useTokens();
return (
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: 10 }}>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12.5, color: t.encre2 }}>
{nom}
</Text>
{typeof valeur === 'string' ? (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 12.5,
color: t.encre,
flexShrink: 1,
textAlign: 'right',
}}
>
{valeur}
</Text>
) : (
valeur
)}
</View>
);
}
const STYLE_STATUT: Record<WorkOrderStatus, (t: Tokens) => [string, string]> = {
OPEN: (t) => [t.stOuvert, t.stOuvertFond],
IN_PROGRESS: (t) => [t.stEncours, t.stEncoursFond],
ON_HOLD: (t) => [t.stAttente, t.stAttenteFond],
DONE: (t) => [t.stTermine, t.stTermineFond],
CANCELLED: (t) => [t.stAnnule, t.stAnnuleFond],
};
export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
const t = useTokens();
const [encre, fond] = STYLE_STATUT[statut](t);
return (
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
color: encre,
backgroundColor: fond,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
}}
>
{WORK_ORDER_STATUS_LABELS[statut]}
</Text>
);
}
export function BoutonTel({
libelle,
variante = 'prim',
desactive,
surAppui,
}: {
libelle: string;
variante?: 'prim' | 'vert' | 'contour' | 'gris';
desactive?: boolean;
surAppui: () => void;
}) {
const t = useTokens();
const fonds = { prim: t.primaire, vert: t.succes, contour: t.surface, gris: t.bordureForte };
return (
<Pressable
accessibilityRole="button"
disabled={desactive}
onPress={surAppui}
style={{
backgroundColor: desactive ? t.bordureForte : fonds[variante],
borderWidth: variante === 'contour' ? 1.5 : 0,
borderColor: t.primaire,
borderRadius: 10,
padding: 12,
alignItems: 'center',
}}
>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 14,
color: variante === 'contour' && !desactive ? t.primaire : '#fff',
}}
>
{libelle}
</Text>
</Pressable>
);
}
export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
const t = useTokens();
return (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Retour"
onPress={() => (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
hitSlop={10}
>
<Text style={{ fontSize: 22, color: t.primaire, fontFamily: 'Manrope_700Bold' }}></Text>
</Pressable>
<Text
numberOfLines={1}
style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre, flex: 1 }}
>
{titre}
</Text>
{apres}
</View>
);
}
/** Sélecteur au pouce (RN n'a pas de <select>) : un champ qui ouvre une
* liste plein écran — utilisé par le bilan codé. */
export function ChoixTel({
libelle,
requis,
valeur,
options,
surChoix,
}: {
libelle: string;
requis?: boolean;
valeur: { id: string; label: string } | null;
options: { id: string; label: string }[];
surChoix: (id: string | null) => void;
}) {
const t = useTokens();
const [ouvert, setOuvert] = useState(false);
return (
<View style={{ gap: 4, flex: 1 }}>
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 11, color: t.encre2 }}>
{libelle} {requis ? <Text style={{ color: t.danger }}>*</Text> : null}
</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel={libelle}
onPress={() => setOuvert(true)}
style={{
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 9,
backgroundColor: t.surface,
paddingHorizontal: 10,
paddingVertical: 9,
}}
>
<Text
numberOfLines={1}
style={{
fontFamily: 'Manrope_600SemiBold',
fontSize: 13,
color: valeur ? t.encre : t.encre3,
}}
>
{valeur ? valeur.label : 'Sélectionner…'}
</Text>
</Pressable>
<Modal visible={ouvert} animationType="slide" transparent onRequestClose={() => setOuvert(false)}>
<Pressable
style={{ flex: 1, backgroundColor: 'rgba(9,14,22,.55)' }}
onPress={() => setOuvert(false)}
/>
<View
style={{
backgroundColor: t.surface,
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
maxHeight: '70%',
padding: 14,
gap: 4,
}}
>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
{libelle}
</Text>
<ScrollView>
<Pressable
accessibilityRole="button"
onPress={() => {
surChoix(null);
setOuvert(false);
}}
style={{ paddingVertical: 11 }}
>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 14, color: t.encre3 }}>
(laisser vide)
</Text>
</Pressable>
{options.map((o) => (
<Pressable
key={o.id}
accessibilityRole="button"
onPress={() => {
surChoix(o.id);
setOuvert(false);
}}
style={{
paddingVertical: 11,
borderTopWidth: 1,
borderTopColor: t.bordure,
}}
>
<Text
style={{
fontFamily: 'Manrope_600SemiBold',
fontSize: 14,
color: o.id === valeur?.id ? t.primaire : t.encre,
}}
>
{o.label} {o.id === valeur?.id ? '✓' : ''}
</Text>
</Pressable>
))}
</ScrollView>
</View>
</Modal>
</View>
);
}