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:
pr-daaif
2026-07-17 03:12:34 +01:00
parent 2736a2f3a1
commit 5c8d5aac92
39 changed files with 9873 additions and 22 deletions

View File

@@ -118,6 +118,21 @@ jobs:
- run: pnpm --filter @siop/web test - run: pnpm --filter @siop/web test
- run: pnpm --filter @siop/web build - run: pnpm --filter @siop/web build
mobile:
name: mobile (typecheck + tests jest-expo)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v5
with:
node-version-file: .nvmrc
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter @siop/shared build
- run: pnpm --filter @siop/mobile typecheck
- run: pnpm --filter @siop/mobile test -- --ci
e2e: e2e:
name: e2e (parcours démo Playwright) name: e2e (parcours démo Playwright)
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -180,7 +195,7 @@ jobs:
deploy: deploy:
name: deploy (Dokploy — siop2.apps.enset.top) name: deploy (Dokploy — siop2.apps.enset.top)
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, contract, api, web, e2e] needs: [lint, contract, api, web, mobile, e2e]
runs-on: ubuntu-latest runs-on: ubuntu-latest
environment: production environment: production
steps: steps:

View File

@@ -55,5 +55,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndicsite via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright. - **R3.4 — corrections de recette + recherche globale** (17/07) : revue pixel (artefact) 8 écarts corrigés sur arbitrage du référent (filtre fournisseur + tri sous-seuil + entrée de stock en liste, fournisseur cliquable, sélecteur de période 3/6/12 sur les stats, rattachements syndicsite via `Location.partnerId` (migration `r3_recette_fixes`), filtre « Rattaché à » + glisser-déposer en bibliothèque, aperçus/ouverture des documents) ; **recherche globale ⌘K** (`GET /search`, 73 opérations, familles filtrées par la matrice, « voir autre » respecté). 74 tests API, 14/14 Playwright.
- 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne. - 🏁 **R3 CLOSE (17/07/2026, tag `release/r3`)** : recettée par le référent (revue pixel + 8 corrections + recherche globale, CI verte). Reste : redéploiement Dokploy (manuel, webhook CD absent) et vérification en ligne.
- **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file ni audio ni géoloc en R4). - **R4 — maquettes rédigées** (17/07) : `maquette-r4.html`, 7 écrans mobile technicien offline-first (Ma journée bi-état, fiche OT, clôture terrain avec garde, scan QR des étiquettes R1, fiche ascenseur, checklist en file, synchro & conflits à verrou optimiste) + 5 décisions à acter (offline-first en file, verrou optimiste tranché par l'humain, périmètre fermé technicien, scan local, photos en file ni audio ni géoloc en R4).
- 🔄 **Reprise ici** : validation des maquettes + 5 décisions par le référent R4.1 socle mobile (Expo). En parallèle : redéployer `release/r3` sur Dokploy et vérifier en ligne. - **R4 — maquettes + décisions D1-D5 VALIDÉES par le référent (17/07)** ; **R4.1 socle mobile** : `apps/mobile` (Expo SDK 57, TS strict), connexion + sélecteur démo ADR-002, tabbar (onglets futurs marqués), « Ma journée » triée priorité/échéance, cache TanStack persisté (lecture hors-ligne D1), jeton SecureStore, client typé du contrat, `CORS_ORIGINS` opt-in côté API (Expo web/debug seulement), 6 tests jest-expo + job CI `mobile`. Vérifié 10/10 en Expo web (connexion démo Ma journée hors-ligne/retour).
- 🔄 **Reprise ici** : R4.2 scan QR (expo-camera) + fiche ascenseur + fiche OT + checklist cochable hors-ligne ; puis R4.3 file d'écriture + verrou optimiste (D2). En parallèle : redéployer `release/r3` sur Dokploy et vérifier en ligne.
- Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5. - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0R5.

View File

@@ -18,3 +18,6 @@ MINIO_ENDPOINT=localhost
MINIO_PORT=9000 MINIO_PORT=9000
MINIO_ACCESS_KEY=siop MINIO_ACCESS_KEY=siop
MINIO_SECRET_KEY=siop-minio MINIO_SECRET_KEY=siop-minio
# R4 — origines navigateur autorisées (Expo web / debug mobile). Vide = pas de CORS.
CORS_ORIGINS=http://localhost:8081

View File

@@ -14,6 +14,10 @@ const EnvSchema = z.object({
MINIO_ACCESS_KEY: z.string().default('siop'), MINIO_ACCESS_KEY: z.string().default('siop'),
MINIO_SECRET_KEY: z.string().default('siop-minio'), MINIO_SECRET_KEY: z.string().default('siop-minio'),
MINIO_BUCKET: z.string().default('siop2'), MINIO_BUCKET: z.string().default('siop2'),
// R4 : origines navigateur autorisées en CORS (Expo web / debug mobile).
// Vide = aucun CORS (défaut sûr) — le web de prod passe par le proxy nginx
// même-origine, les apps natives n'envoient pas d'Origin.
CORS_ORIGINS: z.string().default(''),
}); });
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;

View File

@@ -9,6 +9,8 @@ async function bootstrap() {
assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau assertDemoModeAllowed(); // ADR-002 — double verrou avant toute écoute réseau
const app = await NestFactory.create(AppModule.forRoot()); const app = await NestFactory.create(AppModule.forRoot());
const origines = env.CORS_ORIGINS.split(',').map((o) => o.trim()).filter(Boolean);
if (origines.length) app.enableCors({ origin: origines });
app.enableShutdownHooks(); app.enableShutdownHooks();
await app.listen(env.PORT); await app.listen(env.PORT);
new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`); new Logger('Bootstrap').log(`API SIOP V2 démarrée sur :${env.PORT}`);

View File

@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"expo@claude-plugins-official": true
}
}

41
apps/mobile/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
# generated native folders
/ios
/android

3
apps/mobile/AGENTS.md Normal file
View File

@@ -0,0 +1,3 @@
# Expo HAS CHANGED
Read the exact versioned docs at https://docs.expo.dev/versions/v57.0.0/ before writing any code.

33
apps/mobile/README.md Normal file
View File

@@ -0,0 +1,33 @@
# @siop/mobile — l'app du technicien (R4)
Expo (SDK 57), offline-first, périmètre fermé : **Ma journée, scan, préventif, synchro**
la gestion reste web, le demandeur reste sur le portail QR public. Maquettes validées :
`docs/02-design/maquettes/maquette-r4.html` (+ décisions D1-D5 au journal du 17/07/2026).
## Lancer
```bash
# API locale d'abord (apps/api : pnpm dev), puis :
pnpm --filter @siop/mobile start # QR Expo Go (téléphone sur le MÊME réseau)
EXPO_PUBLIC_API_URL=http://192.168.x.y:3000 pnpm --filter @siop/mobile start
```
- `EXPO_PUBLIC_API_URL` : URL de l'API vue **depuis le téléphone** (IP LAN, pas localhost).
Défaut : `http://localhost:3000` (suffisant pour `expo start --web`).
- Vérification navigateur : `expo start --web` + `CORS_ORIGINS=http://localhost:8081`
côté API (les apps natives n'envoient pas d'Origin — CORS ne concerne que le web).
## Ce que porte R4.1 (socle)
- Connexion e-mail/mot de passe + **sélecteur démo** (ADR-002 : n'existe que si l'API l'expose).
- Coquille tabbar (onglets à venir marqués R4.2/R4.3) + **Ma journée** : OT triés
priorité puis échéance (`src/lib/journee.ts`, testé), urgence en tête, pastille de synchro.
- **Lecture hors-ligne (D1)** : cache TanStack persisté dans AsyncStorage (7 jours),
NetInfo pilote `onlineManager` + bandeau. L'écriture en file arrive en R4.3.
- Client typé généré depuis `docs/openapi.json` (`pnpm generate:client`) — règle d'or ADR-001.
- Jeton dans SecureStore (trousseau) ; AsyncStorage en repli web de dev.
## Tests
`pnpm --filter @siop/mobile test` (jest-expo — logique pure : tri, tokens) ;
`typecheck` et le lint racine s'appliquent (job CI `mobile`).

31
apps/mobile/app.json Normal file
View File

@@ -0,0 +1,31 @@
{
"expo": {
"name": "SIOP",
"slug": "siop2-mobile",
"version": "0.1.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"predictiveBackGestureEnabled": false
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
"expo-font",
"expo-secure-store"
],
"scheme": "siop"
}
}

View 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>
);
}

View 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>
);
}

View 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."
/>
);
}

View 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é."
/>
);
}

View 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)."
/>
);
}

View File

@@ -0,0 +1,77 @@
import {
Manrope_400Regular,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
useFonts,
} from '@expo-google-fonts/manrope';
import NetInfo from '@react-native-community/netinfo';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister';
import { onlineManager, QueryClient } from '@tanstack/react-query';
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { useEffect, useState } from 'react';
import { HorsLigneProvider } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Racine de l'app (D1 validée) : le cache TanStack est PERSISTÉ dans
* AsyncStorage — les OT lus restent lisibles hors-ligne, y compris après
* redémarrage. NetInfo pilote onlineManager (pas de retry dans le vide)
* et le drapeau hors-ligne des écrans. */
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
gcTime: 7 * 24 * 3600_000, // une semaine de lecture hors-ligne
retry: 1,
},
},
});
const persister = createAsyncStoragePersister({
storage: AsyncStorage,
key: 'siop.cache',
});
export default function RacineApp() {
const t = useTokens();
const [horsLigne, setHorsLigne] = useState(false);
const [polices] = useFonts({
Manrope_400Regular,
Manrope_600SemiBold,
Manrope_700Bold,
Manrope_800ExtraBold,
});
useEffect(
() =>
NetInfo.addEventListener((etat) => {
const enLigne = !!etat.isConnected;
onlineManager.setOnline(enLigne);
setHorsLigne(!enLigne);
}),
[],
);
if (!polices) return null;
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{ persister, maxAge: 7 * 24 * 3600_000 }}
>
<HorsLigneProvider valeur={horsLigne}>
<StatusBar style="auto" />
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: t.fond },
}}
/>
</HorsLigneProvider>
</PersistQueryClientProvider>
);
}

View File

@@ -0,0 +1,150 @@
import { router } from 'expo-router';
import { useState } from 'react';
import {
KeyboardAvoidingView,
Platform,
Pressable,
ScrollView,
Text,
TextInput,
View,
} from 'react-native';
import { useDemoAccounts, useDemoLogin, useLogin } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Connexion mobile — mêmes règles que le web : formulaire e-mail/mot de
* passe, et le sélecteur démo (< 3 s pour changer de rôle, ADR-002)
* UNIQUEMENT si l'API l'expose. */
export default function PageConnexion() {
const t = useTokens();
const [email, setEmail] = useState('');
const [motDePasse, setMotDePasse] = useState('');
const { data: comptes } = useDemoAccounts();
const login = useLogin();
const demo = useDemoLogin();
const entrer = () => router.replace('/(tabs)/journee');
return (
<KeyboardAvoidingView
style={{ flex: 1, backgroundColor: t.fond }}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<ScrollView contentContainerStyle={{ padding: 24, paddingTop: 80, gap: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'baseline', gap: 8 }}>
<Text style={{ color: t.safran, fontSize: 22 }}></Text>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 26, color: t.encre }}>
SIOP
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', fontSize: 13, color: t.encre3 }}>
Technicien
</Text>
</View>
<Text style={{ fontFamily: 'Manrope_400Regular', color: t.encre2, marginBottom: 8 }}>
Vos ordres de travail, sur le terrain même sans réseau.
</Text>
<View style={{ gap: 10 }}>
<TextInput
accessibilityLabel="E-mail"
placeholder="E-mail"
placeholderTextColor={t.encre3}
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
style={{
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
padding: 13,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<TextInput
accessibilityLabel="Mot de passe"
placeholder="Mot de passe"
placeholderTextColor={t.encre3}
secureTextEntry
value={motDePasse}
onChangeText={setMotDePasse}
style={{
backgroundColor: t.surface,
borderColor: t.bordureForte,
borderWidth: 1.5,
borderRadius: 10,
padding: 13,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
}}
/>
<Pressable
accessibilityRole="button"
disabled={!email || !motDePasse || login.isPending}
onPress={() =>
login.mutate({ email, password: motDePasse }, { onSuccess: entrer })
}
style={{
backgroundColor: !email || !motDePasse ? t.bordureForte : t.primaire,
borderRadius: 10,
padding: 14,
alignItems: 'center',
}}
>
<Text style={{ color: '#fff', fontFamily: 'Manrope_700Bold', fontSize: 15 }}>
{login.isPending ? 'Connexion…' : 'Se connecter'}
</Text>
</Pressable>
{login.isError ? (
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}>
{login.error.message}
</Text>
) : null}
</View>
{comptes?.length ? (
<View style={{ marginTop: 18, gap: 8 }}>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 11,
letterSpacing: 1,
textTransform: 'uppercase',
color: t.encre3,
}}
>
Mode démo connexion 1 clic
</Text>
{comptes.map((c) => (
<Pressable
key={c.id}
accessibilityRole="button"
disabled={demo.isPending}
onPress={() => demo.mutate(c.id, { onSuccess: entrer })}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
borderWidth: 1,
borderRadius: 10,
padding: 12,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Text style={{ fontFamily: 'Manrope_700Bold', color: t.encre, fontSize: 14 }}>
{c.displayName}
</Text>
<Text style={{ fontFamily: 'Manrope_600SemiBold', color: t.encre3, fontSize: 12 }}>
{c.roleName}
</Text>
</Pressable>
))}
</View>
) : null}
</ScrollView>
</KeyboardAvoidingView>
);
}

20
apps/mobile/app/index.tsx Normal file
View File

@@ -0,0 +1,20 @@
import { Redirect } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';
import { useMe } from '@/auth/session';
import { useTokens } from '@/theme/tokens';
/** Aiguillage : session valide → Ma journée ; sinon → connexion.
* (Hors-ligne avec cache persisté, /users/me sort du cache : on entre.) */
export default function Aiguillage() {
const t = useTokens();
const { data: me, isLoading } = useMe();
if (isLoading) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: t.fond }}>
<ActivityIndicator color={t.primaire} />
</View>
);
}
return me ? <Redirect href="/(tabs)/journee" /> : <Redirect href="/connexion" />;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
apps/mobile/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,10 @@
/** jest-expo (convention CLAUDE.md) — R4.1 teste la logique pure (tri de
* Ma journée, tokens bi-thème) ; les parcours complets passeront par la
* recette sur appareil. */
module.exports = {
preset: 'jest-expo',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testMatch: ['**/*.test.ts'],
};

48
apps/mobile/package.json Normal file
View File

@@ -0,0 +1,48 @@
{
"name": "@siop/mobile",
"version": "0.1.0",
"main": "expo-router/entry",
"dependencies": {
"@expo-google-fonts/manrope": "^0.4.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-native-community/netinfo": "12.0.1",
"@siop/shared": "workspace:*",
"@tanstack/query-async-storage-persister": "^5.101.2",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2",
"expo": "~57.0.6",
"expo-constants": "~57.0.5",
"expo-font": "~57.0.1",
"expo-linking": "~57.0.3",
"expo-router": "~57.0.6",
"expo-secure-store": "~57.0.1",
"expo-status-bar": "~57.0.1",
"openapi-fetch": "^0.13.8",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-web": "^0.21.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/react": "~19.2.2",
"jest": "^29.7.0",
"jest-expo": "^57.0.2",
"openapi-typescript": "^7.13.0",
"react-test-renderer": "^19.2.7",
"typescript": "~6.0.3"
},
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "jest",
"generate:client": "openapi-typescript ../../docs/openapi.json -o src/api/schema.d.ts",
"lint": "eslint app src"
},
"private": true
}

View File

@@ -0,0 +1,31 @@
import createClient from 'openapi-fetch';
import type { paths } from './schema';
import { lireJeton } from './jeton';
/** Client typé — généré depuis docs/openapi.json (règle d'or ADR-001 :
* spec committée, clients régénérés dans le même commit).
* EXPO_PUBLIC_API_URL pointe l'API (IP LAN pour Expo Go sur téléphone). */
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'http://localhost:3000';
export const api = createClient<paths>({ baseUrl: API_URL });
api.use({
async onRequest({ request }) {
const jeton = await lireJeton();
if (jeton) request.headers.set('Authorization', `Bearer ${jeton}`);
return request;
},
});
export async function unwrap<T>(res: {
data?: T;
error?: unknown;
response: Response;
}): Promise<T> {
if (res.data !== undefined) return res.data;
const message =
res.error && typeof res.error === 'object' && 'message' in res.error
? String((res.error as { message: unknown }).message)
: `API injoignable ou refus (${res.response.status})`;
throw new Error(message);
}

View File

@@ -0,0 +1,22 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as SecureStore from 'expo-secure-store';
import { Platform } from 'react-native';
/** Le jeton vit dans le trousseau du téléphone (SecureStore) ; sur web
* (vérifications de dev seulement), repli AsyncStorage. */
const CLE = 'siop.jeton';
const natif = Platform.OS !== 'web';
export async function lireJeton(): Promise<string | null> {
return natif ? SecureStore.getItemAsync(CLE) : AsyncStorage.getItem(CLE);
}
export async function ecrireJeton(jeton: string): Promise<void> {
if (natif) await SecureStore.setItemAsync(CLE, jeton);
else await AsyncStorage.setItem(CLE, jeton);
}
export async function effacerJeton(): Promise<void> {
if (natif) await SecureStore.deleteItemAsync(CLE);
else await AsyncStorage.removeItem(CLE);
}

4112
apps/mobile/src/api/schema.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,77 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, type ReactNode } from 'react';
import { api, unwrap } from '@/api/client';
import { ecrireJeton, effacerJeton } from '@/api/jeton';
/** Session mobile — mêmes règles que le web : le sélecteur démo n'existe
* que si l'API répond (ADR-002 : 404 sinon, une seule source de vérité). */
export function useMe() {
return useQuery({
queryKey: ['me'],
retry: false,
queryFn: async () => unwrap(await api.GET('/users/me')),
});
}
export function useDemoAccounts() {
return useQuery({
queryKey: ['demo-accounts'],
retry: false,
queryFn: async () => {
const res = await api.GET('/auth/demo-accounts');
if (res.response.status === 404) return null; // démo désactivée
return (await unwrap(res)).accounts;
},
});
}
function useApresConnexion() {
const queryClient = useQueryClient();
return async (accessToken: string) => {
await ecrireJeton(accessToken);
await queryClient.invalidateQueries();
};
}
export function useLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (input: { email: string; password: string }) => {
const res = await unwrap(await api.POST('/auth/login', { body: input }));
await apres(res.accessToken);
},
});
}
export function useDemoLogin() {
const apres = useApresConnexion();
return useMutation({
mutationFn: async (userId: string) => {
const res = await unwrap(await api.POST('/auth/demo-login', { body: { userId } }));
await apres(res.accessToken);
},
});
}
export function useLogout() {
const queryClient = useQueryClient();
return async () => {
await effacerJeton();
queryClient.clear();
};
}
/** Drapeau hors-ligne partagé (pastille topbar + bandeaux d'écrans). */
export const HorsLigneContexte = createContext(false);
export const useHorsLigne = () => useContext(HorsLigneContexte);
export function HorsLigneProvider({
valeur,
children,
}: {
valeur: boolean;
children: ReactNode;
}) {
return <HorsLigneContexte.Provider value={valeur}>{children}</HorsLigneContexte.Provider>;
}

View File

@@ -0,0 +1,40 @@
import { Text, View } from 'react-native';
import { useTokens } from '@/theme/tokens';
/** Écran futur du périmètre R4 annoncé — même patron que la sidebar web :
* visible, honnête sur sa release, jamais un cul-de-sac silencieux. */
export function AVenir({ titre, release, detail }: { titre: string; release: string; detail: string }) {
const t = useTokens();
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 8 }}>
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 18, color: t.encre }}>
{titre}
</Text>
<Text
style={{
fontFamily: 'Manrope_700Bold',
fontSize: 11,
color: t.safran,
backgroundColor: t.safranDoux,
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 999,
overflow: 'hidden',
}}
>
Disponible en {release}
</Text>
<Text
style={{
fontFamily: 'Manrope_400Regular',
fontSize: 13,
color: t.encre2,
textAlign: 'center',
maxWidth: 280,
}}
>
{detail}
</Text>
</View>
);
}

View File

@@ -0,0 +1,44 @@
import { triJournee, type OtJournee } from './journee';
const ot = (sur: Partial<OtJournee>): OtJournee => ({
priority: 'NONE',
status: 'OPEN',
dueDate: null,
createdAt: '2026-07-01T08:00:00.000Z',
...sur,
});
describe('triJournee (écran 1 de la maquette R4)', () => {
it('met « personne bloquée » en tête, puis la priorité décroissante', () => {
const tri = triJournee([
ot({ priority: 'LOW' }),
ot({ priority: 'PERSON_TRAPPED' }),
ot({ priority: 'HIGH' }),
ot({ priority: 'MEDIUM' }),
]);
expect(tri.map((o) => o.priority)).toEqual(['PERSON_TRAPPED', 'HIGH', 'MEDIUM', 'LOW']);
});
it('à priorité égale : échéance la plus proche dabord, sans échéance en dernier', () => {
const tri = triJournee([
ot({ priority: 'HIGH', dueDate: null, createdAt: '2026-07-01T08:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-20T00:00:00.000Z' }),
ot({ priority: 'HIGH', dueDate: '2026-07-18T00:00:00.000Z' }),
]);
expect(tri.map((o) => o.dueDate)).toEqual([
'2026-07-18T00:00:00.000Z',
'2026-07-20T00:00:00.000Z',
null,
]);
});
it('écarte les OT terminés et annulés — la journée ne montre que là-faire', () => {
const tri = triJournee([
ot({ status: 'DONE' }),
ot({ status: 'IN_PROGRESS' }),
ot({ status: 'CANCELLED' }),
ot({ status: 'ON_HOLD' }),
]);
expect(tri.map((o) => o.status)).toEqual(['IN_PROGRESS', 'ON_HOLD']);
});
});

View File

@@ -0,0 +1,32 @@
import type { WorkOrderPriority, WorkOrderStatus } from '@siop/shared';
/** Tri de « Ma journée » (maquette R4, écran 1) : priorité décroissante
* (personne bloquée en tête) puis échéance la plus proche ; les OT clos
* ou annulés n'apparaissent pas. Fonction pure — testée. */
const POIDS: Record<WorkOrderPriority, number> = {
PERSON_TRAPPED: 4,
HIGH: 3,
MEDIUM: 2,
LOW: 1,
NONE: 0,
};
export interface OtJournee {
priority: WorkOrderPriority;
status: WorkOrderStatus;
dueDate: string | null;
createdAt: string;
}
export function triJournee<T extends OtJournee>(ots: readonly T[]): T[] {
return ots
.filter((o) => o.status !== 'DONE' && o.status !== 'CANCELLED')
.sort(
(a, b) =>
POIDS[b.priority] - POIDS[a.priority] ||
(a.dueDate ? Date.parse(a.dueDate) : Infinity) -
(b.dueDate ? Date.parse(b.dueDate) : Infinity) ||
Date.parse(a.createdAt) - Date.parse(b.createdAt),
);
}

View File

@@ -0,0 +1,20 @@
import { CLAIR, SOMBRE } from './tokens';
describe('tokens SIOP portés en RN', () => {
it('le sombre couvre exactement les clés du clair (pas de token orphelin)', () => {
expect(Object.keys(SOMBRE).sort()).toEqual(Object.keys(CLAIR).sort());
});
it('réplique les valeurs pivots de docs/02-design/tokens.css', () => {
expect(CLAIR.primaire).toBe('#1f4fb8');
expect(CLAIR.safran).toBe('#dd8a0b');
expect(SOMBRE.fond).toBe('#0f1622');
expect(SOMBRE.primaire).toBe('#6e92e8');
});
it('chaque valeur est une couleur hex ou transparent', () => {
for (const jeu of [CLAIR, SOMBRE]) {
for (const v of Object.values(jeu)) expect(v).toMatch(/^#[0-9a-f]{6}$/);
}
});
});

View File

@@ -0,0 +1,80 @@
import { useColorScheme } from 'react-native';
/** Tokens SIOP portés en RN — répliques de docs/02-design/tokens.css
* (mêmes valeurs que le web et les maquettes R4, bi-thème). */
export const CLAIR = {
primaire: '#1f4fb8',
primaireDoux: '#eaf0fb',
safran: '#dd8a0b',
safranDoux: '#fdf3e3',
fond: '#f5f7fa',
surface: '#ffffff',
surface2: '#eef2f7',
bordure: '#dce3ec',
bordureForte: '#b9c4d4',
encre: '#1b2534',
encre2: '#55647a',
encre3: '#8494ab',
stOuvert: '#3d6fe0',
stOuvertFond: '#e9effc',
stEncours: '#6d5bd8',
stEncoursFond: '#efecfa',
stAttente: '#b96f07',
stAttenteFond: '#fbf1df',
stTermine: '#178a50',
stTermineFond: '#e6f5ec',
stAnnule: '#68788f',
stAnnuleFond: '#edf0f4',
prioBloque: '#d92626',
prioBloqueFond: '#fdeaea',
prioHaute: '#d92626',
prioMoyenne: '#b96f07',
prioBasse: '#178a50',
prioAucune: '#8494ab',
succes: '#178a50',
alerte: '#b96f07',
danger: '#d92626',
navFond: '#16233b',
} as const;
export const SOMBRE: Tokens = {
primaire: '#6e92e8',
primaireDoux: '#1d2c4c',
safran: '#e89a1f',
safranDoux: '#33270f',
fond: '#0f1622',
surface: '#182234',
surface2: '#1e2a40',
bordure: '#2b3850',
bordureForte: '#3d4d6b',
encre: '#e8edf5',
encre2: '#a7b4c8',
encre3: '#6d7d96',
stOuvert: '#7da2ee',
stOuvertFond: '#1c2a47',
stEncours: '#a394ec',
stEncoursFond: '#262040',
stAttente: '#e0a33c',
stAttenteFond: '#322510',
stTermine: '#4bc084',
stTermineFond: '#12301f',
stAnnule: '#93a3ba',
stAnnuleFond: '#222d3f',
prioBloque: '#f26d6d',
prioBloqueFond: '#3a1414',
prioHaute: '#f26d6d',
prioMoyenne: '#e0a33c',
prioBasse: '#4bc084',
prioAucune: '#6d7d96',
succes: '#4bc084',
alerte: '#e0a33c',
danger: '#f26d6d',
navFond: '#0c1524',
};
export type Tokens = { [K in keyof typeof CLAIR]: string };
export function useTokens(): Tokens {
return useColorScheme() === 'dark' ? SOMBRE : CLAIR;
}

10
apps/mobile/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"types": ["jest"],
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -4,6 +4,24 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
--- ---
## 2026-07-17 — Pr. Daaif (+ Claude) — R4.1 : socle mobile Expo (app du technicien)
**Actions**
- **`apps/mobile` posé** (Expo SDK 57, TypeScript strict, pnpm workspace, `@siop/shared` consommé) : connexion e-mail/mdp + **sélecteur démo ADR-002** (n'existe que si l'API l'expose), coquille **tabbar** de la maquette (Scanner/Préventif/Synchro visibles mais marqués R4.2/R4.3 — même patron que la sidebar web), **Ma journée** : OT triés priorité puis échéance (fonction pure testée), urgence « personne bloquée » en tête, strie de priorité, pastille de synchro permanente.
- **D1 en actes (lecture)** : cache TanStack **persisté** dans AsyncStorage (7 jours), NetInfo → `onlineManager` + bandeau hors-ligne horodaté. Jeton en SecureStore. Client typé régénéré depuis `docs/openapi.json` (règle d'or). Thème : tokens light/dark répliqués (testés), Manrope embarquée.
- **API : `CORS_ORIGINS`** (env, vide par défaut = pas de CORS) — nécessaire à Expo **web**/debug seulement ; le web de prod reste derrière le proxy nginx, les apps natives n'ont pas d'Origin.
- Vérifié en navigateur réel (Expo web + Playwright) : **10/10** — connexion démo Ahmed → Ma journée (ses OT seulement : le scoping « voir autre » joue), bascule hors-ligne (bandeau + liste servie du cache) et retour, onglets à venir honnêtes ; zéro erreur console. 6 tests jest-expo, lint racine étendu (react-hooks sur mobile), **job CI `mobile`** ajouté (deploy dépend de lui).
**Décisions**
- La vraie recette hors-ligne (mode avion, redémarrage, file d'écriture) se fera **sur téléphone** — Expo web ne prouve que le chemin de code NetInfo→UI (dispatch `navigator.connection.change` observé).
- Leçon Playwright : `setOffline` ne déclenche pas les événements réseau du navigateur — NetInfo web écoute `navigator.connection` sur Chromium.
**Prochaine étape** : R4.2 — scan QR (expo-camera) + fiche ascenseur + fiche OT + checklist cochable ; puis R4.3 file d'écriture + verrou optimiste (D2). Redéploiement Dokploy de `release/r3` toujours en attente.
---
## 2026-07-17 — Pr. Daaif (+ Claude) — R4 ouverte : maquettes mobile (design d'abord) ## 2026-07-17 — Pr. Daaif (+ Claude) — R4 ouverte : maquettes mobile (design d'abord)
**Actions** **Actions**
@@ -13,9 +31,9 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook**
**Décisions** **Décisions**
- Aucune actée — les 5 décisions ci-dessus **attendent la validation du référent avec les écrans**. Aucune ligne de code Expo avant (principe n°1). - **→ Levé le 17/07/2026 : maquettes R4 et les 5 décisions (D1-D5) VALIDÉES par le référent.** Lancement R4.1 (socle mobile Expo).
**Prochaine étape** : validation référent (maquettes + décisions) → R4.1 socle mobile (Expo, coquille + connexion démo + lecture hors-ligne) ; en parallèle, redéploiement Dokploy de `release/r3` toujours à faire. **Prochaine étape** : R4.1 socle Expo : coquille tabbar, connexion + sélecteur démo (ADR-002), client typé du contrat, « Ma journée » en lecture avec cache hors-ligne. En parallèle, redéploiement Dokploy de `release/r3` toujours à faire.
--- ---

View File

@@ -46,7 +46,7 @@ export default tseslint.config(
rules: { 'no-restricted-imports': 'off' }, rules: { 'no-restricted-imports': 'off' },
}, },
{ {
files: ['apps/web/**/*.{ts,tsx}'], files: ['apps/web/**/*.{ts,tsx}', 'apps/mobile/**/*.{ts,tsx}'],
plugins: { 'react-hooks': reactHooks }, plugins: { 'react-hooks': reactHooks },
rules: { rules: {
'react-hooks/rules-of-hooks': 'error', 'react-hooks/rules-of-hooks': 'error',

4627
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff