Files
siop2/apps/mobile/src/composants/ui.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

266 lines
7.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 } 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>
);
}