Files
siop2/apps/mobile/app/(tabs)/synchro.tsx
pr-daaif c8b3c1769a feat(r4.3): file d'écriture, verrou optimiste D2, Synchro & conflits
Serveur : updatedAt exposé au détail OT ; baseUpdatedAt optionnel sur
transition/coche/bilan → 409 « Conflit de version » contextualisé (qui,
quand) ; toute écriture secondaire (coche, bilan, commentaire, conso,
MO) fait avancer la version — sans version fournie, le web est
inchangé. ADR-003 : sécurité & protocole de routage mobile (qui vit où
sur l'appareil, purge complète à la déconnexion — correctif réel : la
file et le cache persisté survivaient au logout).

Mobile : file persistée AsyncStorage rejouée dans l'ordre — succès →
propagation de la version fraîche aux saisies restantes du même OT
(nos écritures ne se conflictent pas entre elles, un écart étranger
reste détecté) ; coupure → tout attend ; refus → CONFLIT, la file
s'arrête, l'humain tranche (voir l'OT / rejouer sur version à jour /
abandonner). Transitions, coches, bilan et photos (D5, compressées
~1600 px) passent par la file avec patch optimiste du cache ; écran
Synchro (badge tabbar ambre/rouge) ; préchargement parc + référentiels
(le bilan hors-ligne a ses vocabulaires).

Recette « mode avion » 13/13 en Expo web piloté : gestes hors-ligne →
3 en file → modification concurrente de Salma → conflit tranché →
serveur Terminé avec bilan. 17 tests jest-expo, 74 tests API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 09:50:02 +01:00

183 lines
7.0 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 { useQueryClient } from '@tanstack/react-query';
import { router } from 'expo-router';
import { FlatList, Pressable, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useHorsLigne } from '@/auth/session';
import { BoutonTel } from '@/composants/ui';
import { useFile, type Saisie } from '@/file/store';
import { abandonnerSaisie, rejouer, rejouerSurVersionAJour } from '@/file/synchro';
import { useTokens } from '@/theme/tokens';
/** Écran 7 de la maquette R4 : la file VISIBLE et honnête. Rejeu dans
* l'ordre ; un conflit (verrou D2) arrête la file sur l'élément, montre le
* message de l'API (qui, quand) et VOUS tranchez — rien n'est écrasé ni
* perdu en silence. */
const ICONES: Record<Saisie['type'], string> = {
TRANSITION: '🏁',
COCHE: '✓',
BILAN: '📋',
PHOTO: '🖼',
};
export default function PageSynchro() {
const t = useTokens();
const horsLigne = useHorsLigne();
const file = useFile();
const queryClient = useQueryClient();
const conflit = file.find((s) => s.statut === 'CONFLIT');
const heure = (iso: string) =>
new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(new Date(iso));
return (
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
<View style={{ flex: 1, padding: 14, gap: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
Synchro
</Text>
<Text style={{ marginLeft: 'auto', fontFamily: 'Manrope_600SemiBold', fontSize: 11, color: t.encre3 }}>
{conflit ? 'arrêtée sur le conflit' : file.length ? 'rejouée dans lordre' : ''}
</Text>
</View>
{conflit ? (
<View
style={{
backgroundColor: t.prioBloqueFond,
borderColor: t.danger,
borderWidth: 1.5,
borderRadius: 12,
padding: 12,
gap: 8,
}}
>
<Text style={{ color: t.danger, fontFamily: 'Manrope_800ExtraBold', fontSize: 13 }}>
Conflit sur {conflit.otReference}
</Text>
<Text style={{ color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
{conflit.erreur ?? 'LOT a été modifié pendant que vous étiez hors-ligne.'}
</Text>
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
Votre saisie (« {conflit.libelle} », {heure(conflit.creeA)}) est conservée telle
quelle dans la file rien nest perdu.
</Text>
<BoutonTel
libelle="Voir lOT à jour"
variante="contour"
surAppui={() => router.push(`/ot/${conflit.otId}`)}
/>
<BoutonTel
libelle="Rejouer ma saisie sur la version à jour"
surAppui={() => void rejouerSurVersionAJour(conflit.id, queryClient)}
/>
<BoutonTel
libelle="Abandonner ma saisie"
variante="gris"
surAppui={() => void abandonnerSaisie(conflit.id, queryClient)}
/>
</View>
) : null}
<FlatList
data={file}
keyExtractor={(s) => s.id}
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
ListEmptyComponent={
<View style={{ padding: 24, alignItems: 'center', gap: 6 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 15, color: t.encre }}>
Rien en attente
</Text>
<Text
style={{
fontFamily: 'Manrope_400Regular',
fontSize: 12.5,
color: t.encre2,
textAlign: 'center',
}}
>
Toutes vos saisies sont sur le serveur. Les gestes faits hors-ligne apparaîtront
ici, rejoués dans lordre au retour du réseau.
</Text>
</View>
}
renderItem={({ item: s }) => (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
gap: 10,
backgroundColor: t.surface,
borderColor: s.statut === 'CONFLIT' ? t.danger : t.bordure,
borderWidth: 1,
borderRadius: 10,
paddingHorizontal: 12,
paddingVertical: 10,
}}
>
<View
style={{
width: 28,
height: 28,
borderRadius: 8,
backgroundColor: t.surface2,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 13 }}>{ICONES[s.type]}</Text>
</View>
<View style={{ flex: 1 }}>
<Text numberOfLines={1} style={{ fontFamily: 'Manrope_700Bold', fontSize: 12.5, color: t.encre }}>
{s.libelle}
</Text>
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11, color: t.encre2 }}>
{s.otReference} · {heure(s.creeA)}
</Text>
</View>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 10.5,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 999,
overflow: 'hidden',
color:
s.statut === 'CONFLIT' ? t.danger : s.statut === 'ENVOI' ? t.stOuvert : t.stAttente,
backgroundColor:
s.statut === 'CONFLIT'
? t.prioBloqueFond
: s.statut === 'ENVOI'
? t.stOuvertFond
: t.stAttenteFond,
}}
>
{s.statut === 'CONFLIT' ? 'conflit' : s.statut === 'ENVOI' ? 'envoi…' : 'en attente'}
</Text>
</View>
)}
/>
{file.length && !horsLigne ? (
<Pressable
accessibilityRole="button"
onPress={() => void rejouer(queryClient)}
style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 14 }}>
Rejouer la file maintenant
</Text>
</Pressable>
) : null}
{horsLigne ? (
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_600SemiBold', fontSize: 12, textAlign: 'center' }}>
Hors-ligne la file repartira seule au retour du réseau.
</Text>
) : null}
</View>
</SafeAreaView>
);
}