mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
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>
This commit is contained in:
@@ -1,11 +1,182 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
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 (
|
||||
<AVenir
|
||||
titre="Synchro"
|
||||
release="R4.3"
|
||||
detail="La file d'attente visible et honnête : vos saisies hors-ligne, rejouées dans l'ordre — et les conflits que VOUS tranchez (verrou optimiste, décision D2)."
|
||||
/>
|
||||
<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 l’ordre' : ''}
|
||||
</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 ?? 'L’OT 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 n’est perdu.
|
||||
</Text>
|
||||
<BoutonTel
|
||||
libelle="Voir l’OT à 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 l’ordre 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user