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:
31
apps/mobile/src/api/client.ts
Normal file
31
apps/mobile/src/api/client.ts
Normal 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);
|
||||
}
|
||||
22
apps/mobile/src/api/jeton.ts
Normal file
22
apps/mobile/src/api/jeton.ts
Normal 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
4112
apps/mobile/src/api/schema.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
77
apps/mobile/src/auth/session.tsx
Normal file
77
apps/mobile/src/auth/session.tsx
Normal 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>;
|
||||
}
|
||||
40
apps/mobile/src/composants/a-venir.tsx
Normal file
40
apps/mobile/src/composants/a-venir.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
apps/mobile/src/lib/journee.test.ts
Normal file
44
apps/mobile/src/lib/journee.test.ts
Normal 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 d’abord, 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']);
|
||||
});
|
||||
});
|
||||
32
apps/mobile/src/lib/journee.ts
Normal file
32
apps/mobile/src/lib/journee.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
20
apps/mobile/src/theme/tokens.test.ts
Normal file
20
apps/mobile/src/theme/tokens.test.ts
Normal 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}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
80
apps/mobile/src/theme/tokens.ts
Normal file
80
apps/mobile/src/theme/tokens.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user