mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r4.1): socle mobile Expo — connexion démo, Ma journée, lecture hors-ligne
apps/mobile (Expo SDK 57, TS strict, workspace pnpm) : connexion e-mail/mdp + sélecteur démo ADR-002 (uniquement si l'API l'expose), tabbar de la maquette validée (onglets futurs marqués R4.2/R4.3), « Ma journée » triée priorité puis échéance (fonction pure testée), urgence en tête, pastille de synchro. D1 en actes (lecture) : cache TanStack persisté dans AsyncStorage (7 jours), NetInfo → onlineManager + bandeau hors-ligne horodaté ; jeton en SecureStore ; tokens light/dark répliqués et testés ; client typé régénéré depuis docs/openapi.json (règle d'or). API : CORS_ORIGINS opt-in (vide par défaut) — Expo web/debug seulement, le web de prod reste derrière le proxy, les apps natives n'ont pas d'Origin. Vérifié 10/10 en Expo web piloté (connexion démo Ahmed → Ma journée scopée → hors-ligne servie du cache → retour) ; 6 tests jest-expo ; lint racine étendu (react-hooks) ; job CI mobile, deploy en dépend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
56
apps/mobile/app/(tabs)/_layout.tsx
Normal file
56
apps/mobile/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Text, type ColorValue } from 'react-native';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
/** La tabbar de la maquette R4. Les onglets à venir restent visibles
|
||||
* (périmètre annoncé, même patron que la sidebar web) mais mènent à un
|
||||
* écran « disponible en R4.x ». */
|
||||
|
||||
function Pic({ glyphe, couleur }: { glyphe: string; couleur: ColorValue }) {
|
||||
return <Text style={{ fontSize: 17, color: couleur, lineHeight: 20 }}>{glyphe}</Text>;
|
||||
}
|
||||
|
||||
export default function CoquilleTabs() {
|
||||
const t = useTokens();
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: t.primaire,
|
||||
tabBarInactiveTintColor: t.encre3,
|
||||
tabBarStyle: { backgroundColor: t.surface, borderTopColor: t.bordure },
|
||||
tabBarLabelStyle: { fontFamily: 'Manrope_700Bold', fontSize: 10 },
|
||||
sceneStyle: { backgroundColor: t.fond },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="journee"
|
||||
options={{
|
||||
title: 'Ma journée',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="☰" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="scanner"
|
||||
options={{
|
||||
title: 'Scanner',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="▣" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="preventif"
|
||||
options={{
|
||||
title: 'Préventif',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="✓" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="synchro"
|
||||
options={{
|
||||
title: 'Synchro',
|
||||
tabBarIcon: ({ color }) => <Pic glyphe="⇅" couleur={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
240
apps/mobile/app/(tabs)/journee.tsx
Normal file
240
apps/mobile/app/(tabs)/journee.tsx
Normal file
@@ -0,0 +1,240 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import {
|
||||
WORK_ORDER_PRIORITY_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_TYPE_LABELS,
|
||||
type WorkOrderPriority,
|
||||
type WorkOrderStatus,
|
||||
} from '@siop/shared';
|
||||
import { api, unwrap } from '@/api/client';
|
||||
import { useHorsLigne, useLogout, useMe } from '@/auth/session';
|
||||
import { triJournee } from '@/lib/journee';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Écran 1 de la maquette R4 : les OT du technicien, priorité puis
|
||||
* échéance, « personne bloquée » en tête. Hors-ligne : le cache persisté
|
||||
* sert la liste, le bandeau l'assume (D1). */
|
||||
|
||||
function useWorkOrders() {
|
||||
return useQuery({
|
||||
queryKey: ['work-orders'],
|
||||
queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
|
||||
});
|
||||
}
|
||||
|
||||
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],
|
||||
};
|
||||
const STRIE_PRIORITE: Record<WorkOrderPriority, (t: Tokens) => string> = {
|
||||
PERSON_TRAPPED: (t) => t.prioBloque,
|
||||
HIGH: (t) => t.prioHaute,
|
||||
MEDIUM: (t) => t.prioMoyenne,
|
||||
LOW: (t) => t.prioBasse,
|
||||
NONE: () => 'transparent',
|
||||
};
|
||||
|
||||
export default function PageJournee() {
|
||||
const t = useTokens();
|
||||
const horsLigne = useHorsLigne();
|
||||
const { data: me } = useMe();
|
||||
const { data: workOrders, refetch, isFetching, dataUpdatedAt } = useWorkOrders();
|
||||
const logout = useLogout();
|
||||
|
||||
const ots = triJournee(workOrders ?? []);
|
||||
const urgences = ots.filter((o) => o.priority === 'PERSON_TRAPPED');
|
||||
const jour = new Intl.DateTimeFormat('fr-FR', { weekday: 'long', day: 'numeric', month: 'short' })
|
||||
.format(new Date());
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ padding: 14, gap: 10, flex: 1 }}>
|
||||
{/* Entête app : marque + pastille synchro + compte */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<Text style={{ color: t.safran, fontSize: 15 }}>♦</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 17, color: t.encre }}>
|
||||
SIOP
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 10.5, color: t.encre3 }}>
|
||||
Technicien
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6, marginLeft: 'auto' }}>
|
||||
<Text
|
||||
accessibilityLabel="État de synchronisation"
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: horsLigne ? t.stAttente : t.stTermine,
|
||||
backgroundColor: horsLigne ? t.stAttenteFond : t.stTermineFond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{horsLigne ? 'Hors-ligne' : 'Synchro à jour'}
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Se déconnecter"
|
||||
onLongPress={() => {
|
||||
void logout().then(() => router.replace('/connexion'));
|
||||
}}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: t.stEncours,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 10 }}>
|
||||
{(me?.displayName ?? '·')
|
||||
.split(' ')
|
||||
.map((m) => m[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase()}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 19, color: t.encre }}>
|
||||
Ma journée
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
marginLeft: 'auto',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 11,
|
||||
color: t.encre3,
|
||||
}}
|
||||
>
|
||||
{jour}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{horsLigne ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.stAttenteFond,
|
||||
borderColor: t.stAttente,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.stAttente, fontFamily: 'Manrope_700Bold', fontSize: 12 }}>
|
||||
⚠ Hors-ligne — liste du{' '}
|
||||
{dataUpdatedAt
|
||||
? new Intl.DateTimeFormat('fr-FR', { hour: '2-digit', minute: '2-digit' }).format(dataUpdatedAt)
|
||||
: '…'}
|
||||
. Vos saisies partiront en file (R4.3).
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
{urgences.length ? (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.prioBloqueFond,
|
||||
borderRadius: 10,
|
||||
padding: 9,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: t.prioBloque }} />
|
||||
<Text style={{ color: t.prioBloque, fontFamily: 'Manrope_800ExtraBold', fontSize: 12 }}>
|
||||
{urgences.length === 1
|
||||
? `1 personne bloquée — ${urgences[0]!.assetReference} · ${urgences[0]!.siteName}`
|
||||
: `${urgences.length} personnes bloquées`}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<FlatList
|
||||
data={ots}
|
||||
keyExtractor={(o) => o.id}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isFetching} onRefresh={() => void refetch()} tintColor={t.primaire} />
|
||||
}
|
||||
contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
|
||||
ListEmptyComponent={
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_600SemiBold', padding: 16, textAlign: 'center' }}>
|
||||
Aucun OT en cours — tirez pour rafraîchir.
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: o }) => {
|
||||
const [enc, fond] = STYLE_STATUT[o.status](t);
|
||||
return (
|
||||
<View
|
||||
accessibilityLabel={`${o.reference} — ${o.title}`}
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
borderLeftWidth: 4,
|
||||
borderLeftColor: STRIE_PRIORITE[o.priority](t),
|
||||
padding: 11,
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
letterSpacing: 0.6,
|
||||
textTransform: 'uppercase',
|
||||
color: t.encre2,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{o.reference} · {WORK_ORDER_TYPE_LABELS[o.type]}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_700Bold',
|
||||
fontSize: 10.5,
|
||||
color: enc,
|
||||
backgroundColor: fond,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 999,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{WORK_ORDER_STATUS_LABELS[o.status]}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_700Bold', fontSize: 14, color: t.encre }}>
|
||||
{o.title}
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 12, color: t.encre2 }}>
|
||||
Asc. {o.assetReference} — {o.siteName}
|
||||
{o.dueDate
|
||||
? ` · échéance ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(o.dueDate))}`
|
||||
: ''}
|
||||
{o.priority !== 'NONE' && o.priority !== 'PERSON_TRAPPED'
|
||||
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
|
||||
: ''}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/preventif.tsx
Normal file
11
apps/mobile/app/(tabs)/preventif.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PagePreventif() {
|
||||
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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/scanner.tsx
Normal file
11
apps/mobile/app/(tabs)/scanner.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PageScanner() {
|
||||
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é."
|
||||
/>
|
||||
);
|
||||
}
|
||||
11
apps/mobile/app/(tabs)/synchro.tsx
Normal file
11
apps/mobile/app/(tabs)/synchro.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AVenir } from '@/composants/a-venir';
|
||||
|
||||
export default function PageSynchro() {
|
||||
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)."
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user