diff --git a/CLAUDE.md b/CLAUDE.md
index 3c2145a..473daa6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -56,5 +56,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS),
- đ **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 + 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.
+- â
**R4.2 â terrain en ligne** : scanner QR rĂ©el (expo-camera, `analyseScan` testĂ©e, rĂ©solution locale D4, QR Ă©trangers refusĂ©s), fiche ascenseur (D3, historique scopĂ©), fiche OT (machine Ă Ă©tats, coĂ»ts figĂ©s, garde par `closureBlockers` API), clĂŽture terrain (bilan 6 champs au pouce), prĂ©ventif â grille cochable (appui long = N/A, `aria-checked`). Ăcritures en ligne assumĂ©es (bandeaux) â la file est R4.3. VĂ©rifiĂ© 12/12 en Expo web (dont clĂŽture complĂšte d'un OT de test), 12 tests jest-expo.
+- đ **Reprise ici** : R4.3 â file d'Ă©criture persistĂ©e + verrou optimiste (D2) + Ă©cran Synchro & conflits + photos en file (D5) â recette « mode avion » sur tĂ©lĂ©phone (Expo Go). 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 R0âR5.
diff --git a/apps/mobile/app/(tabs)/journee.tsx b/apps/mobile/app/(tabs)/journee.tsx
index 6e7ebe8..884de68 100644
--- a/apps/mobile/app/(tabs)/journee.tsx
+++ b/apps/mobile/app/(tabs)/journee.tsx
@@ -177,8 +177,10 @@ export default function PageJournee() {
renderItem={({ item: o }) => {
const [enc, fond] = STYLE_STATUT[o.status](t);
return (
- router.push(`/ot/${o.id}`)}
style={{
backgroundColor: t.surface,
borderColor: t.bordure,
@@ -230,7 +232,7 @@ export default function PageJournee() {
? ` · ${WORK_ORDER_PRIORITY_LABELS[o.priority]}`
: ''}
-
+
);
}}
/>
diff --git a/apps/mobile/app/(tabs)/preventif.tsx b/apps/mobile/app/(tabs)/preventif.tsx
index 4514902..7567bae 100644
--- a/apps/mobile/app/(tabs)/preventif.tsx
+++ b/apps/mobile/app/(tabs)/preventif.tsx
@@ -1,11 +1,76 @@
-import { AVenir } from '@/composants/a-venir';
+import { router } from 'expo-router';
+import { FlatList, Pressable, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useWorkOrders } from '@/api/exploitation';
+import { ChipStatut } from '@/composants/ui';
+import { useTokens } from '@/theme/tokens';
+/** Onglet PrĂ©ventif : mes grilles du moment â chaque tuile mĂšne Ă la
+ * checklist cochable (écran 6). Les OT viennent déjà scopés par l'API. */
export default function PagePreventif() {
+ const t = useTokens();
+ const { data: workOrders } = useWorkOrders();
+
+ const grilles = (workOrders ?? [])
+ .filter((w) => w.type === 'PREVENTIVE' && w.status !== 'DONE' && w.status !== 'CANCELLED')
+ .sort((a, b) => (a.dueDate ?? '9999').localeCompare(b.dueDate ?? '9999'));
+
return (
-
+
+
+
+ Préventif
+
+ w.id}
+ contentContainerStyle={{ gap: 8, paddingBottom: 12 }}
+ ListEmptyComponent={
+
+ Aucune grille préventive en cours pour vous.
+
+ }
+ renderItem={({ item: w }) => (
+ router.push(`/ot/${w.id}/grille`)}
+ style={{
+ backgroundColor: t.surface,
+ borderColor: t.bordure,
+ borderWidth: 1,
+ borderRadius: 12,
+ padding: 12,
+ gap: 2,
+ }}
+ >
+
+
+ {w.reference}
+
+
+
+
+ {w.title}
+
+
+ Asc. {w.assetReference} â {w.siteName}
+ {w.dueDate
+ ? ` · pour le ${new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' }).format(new Date(w.dueDate))}`
+ : ''}
+
+
+ )}
+ />
+
+
);
}
diff --git a/apps/mobile/app/(tabs)/scanner.tsx b/apps/mobile/app/(tabs)/scanner.tsx
index 6437174..aafd997 100644
--- a/apps/mobile/app/(tabs)/scanner.tsx
+++ b/apps/mobile/app/(tabs)/scanner.tsx
@@ -1,11 +1,158 @@
-import { AVenir } from '@/composants/a-venir';
+import { CameraView, useCameraPermissions } from 'expo-camera';
+import { router } from 'expo-router';
+import { useRef, useState } from 'react';
+import { Platform, Text, TextInput, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useAssets } from '@/api/exploitation';
+import { useHorsLigne } from '@/auth/session';
+import { BoutonTel } from '@/composants/ui';
+import { analyseScan } from '@/lib/scan';
+import { useTokens } from '@/theme/tokens';
+/** Ăcran 4 de la maquette R4 (D4) : le QR de l'Ă©tiquette A6 contient l'URL
+ * portail `âŠ/q/REF` â la rĂ©solution se fait D'ABORD dans le parc dĂ©jĂ en
+ * cache (donc en sous-sol aussi). Repli : saisie de la référence. */
export default function PageScanner() {
+ const t = useTokens();
+ const horsLigne = useHorsLigne();
+ const [permission, demanderPermission] = useCameraPermissions();
+ const { data: assets, refetch } = useAssets();
+ const [manuel, setManuel] = useState('');
+ const [erreur, setErreur] = useState(null);
+ const dernierScan = useRef(0);
+
+ const resoudre = async (brut: string) => {
+ const reference = analyseScan(brut);
+ if (!reference) {
+ setErreur('Ce code nâest pas une Ă©tiquette SIOP.');
+ return;
+ }
+ // 1 · le parc en cache (fonctionne hors-ligne)
+ let appareil = (assets ?? []).find((a) => a.reference.toUpperCase() === reference);
+ // 2 · sinon, un rafraĂźchissement si le rĂ©seau est lĂ
+ if (!appareil && !horsLigne) {
+ const frais = await refetch();
+ appareil = (frais.data ?? []).find((a) => a.reference.toUpperCase() === reference);
+ }
+ if (!appareil) {
+ setErreur(
+ horsLigne
+ ? `« ${reference} » n'est pas dans le parc synchronisĂ© â rĂ©essayez au retour du rĂ©seau.`
+ : `Aucun appareil « ${reference} » dans le parc.`,
+ );
+ return;
+ }
+ setErreur(null);
+ setManuel('');
+ router.push(`/ascenseur/${appareil.id}`);
+ };
+
+ const surScan = ({ data }: { data: string }) => {
+ const maintenant = Date.now();
+ if (maintenant - dernierScan.current < 1500) return; // anti-rafale
+ dernierScan.current = maintenant;
+ void resoudre(data);
+ };
+
+ const cameraUtilisable = Platform.OS !== 'web' && permission?.granted;
+
return (
-
+
+
+
+ Scanner
+
+ {cameraUtilisable ? (
+
+
+
+ Visez le QR de lâĂ©tiquette de cabine
+
+
+ ) : (
+
+
+
+ {Platform.OS === 'web'
+ ? 'CamĂ©ra indisponible sur web â saisissez la rĂ©fĂ©rence ci-dessous.'
+ : 'Lâappareil photo sert uniquement Ă lire les Ă©tiquettes du parc.'}
+
+ {Platform.OS !== 'web' && !permission?.granted ? (
+ void demanderPermission()} />
+ ) : null}
+
+ )}
+
+
+ {
+ setManuel(v);
+ setErreur(null);
+ }}
+ onSubmitEditing={() => void resoudre(manuel)}
+ style={{
+ flex: 1,
+ backgroundColor: t.surface,
+ borderColor: t.bordureForte,
+ borderWidth: 1.5,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 10,
+ color: t.encre,
+ fontFamily: 'Manrope_600SemiBold',
+ }}
+ />
+ void resoudre(manuel)} />
+
+ {erreur ? (
+
+ {erreur}
+
+ ) : null}
+
+
);
}
diff --git a/apps/mobile/app/ascenseur/[id].tsx b/apps/mobile/app/ascenseur/[id].tsx
new file mode 100644
index 0000000..bfcbf67
--- /dev/null
+++ b/apps/mobile/app/ascenseur/[id].tsx
@@ -0,0 +1,129 @@
+import { router, useLocalSearchParams } from 'expo-router';
+import { Pressable, ScrollView, Text } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ASSET_STATUS_LABELS } from '@siop/shared';
+import { useAsset, useWorkOrders } from '@/api/exploitation';
+import { Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
+import { triJournee } from '@/lib/journee';
+import { useTokens } from '@/theme/tokens';
+
+/** Ăcran 5 de la maquette R4 : tout ce qu'il faut DEVANT la machine.
+ * Consultation seule (D3) â l'historique montre ce que le rĂŽle a le droit
+ * de voir (invariant « voir autre », mĂȘme rĂšgle que partout). */
+export default function PageAscenseur() {
+ const t = useTokens();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { data: appareil } = useAsset(id);
+ const { data: workOrders } = useWorkOrders();
+
+ if (!appareil) return null;
+
+ const interventions = (workOrders ?? [])
+ .filter((w) => w.assetId === appareil.id)
+ .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
+ const enCours = triJournee(interventions);
+ const statut = ASSET_STATUS_LABELS[appareil.status];
+
+ return (
+
+
+
+ {statut}
+
+ }
+ />
+
+ {appareil.brand}
+ {appareil.model ? ` ${appareil.model}` : ''} â {appareil.siteName}
+
+
+
+
+
+
+
+ {appareil.serialNumber ? (
+
+ ) : null}
+
+
+
+ {appareil.components.length ? (
+ appareil.components.map((c) => (
+
+ ))
+ ) : (
+
+ Aucun organe déclaré.
+
+ )}
+
+
+
+ {interventions.slice(0, 6).map((w) => (
+ router.push(`/ot/${w.id}`)}
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 8, paddingVertical: 3 }}
+ >
+
+ {w.reference}
+
+
+ {w.title}
+
+
+
+ ))}
+ {!interventions.length ? (
+
+ Aucune intervention visible pour votre rĂŽle.
+
+ ) : null}
+
+
+ {enCours.length ? (
+ router.push(`/ot/${enCours[0]!.id}`)}
+ style={{ backgroundColor: t.primaire, borderRadius: 10, padding: 12, alignItems: 'center' }}
+ >
+
+ Ouvrir lâOT en cours ({enCours[0]!.reference})
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/app/ot/[id]/cloture.tsx b/apps/mobile/app/ot/[id]/cloture.tsx
new file mode 100644
index 0000000..cd80541
--- /dev/null
+++ b/apps/mobile/app/ot/[id]/cloture.tsx
@@ -0,0 +1,144 @@
+import { router, useLocalSearchParams } from 'expo-router';
+import { useState } from 'react';
+import { Alert, ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ BILAN_FIELD_LABELS,
+ BILAN_FIELDS,
+ REQUIRED_BILAN_FIELDS,
+ type BilanField,
+ type ReportUpsert,
+} from '@siop/shared';
+import { useBilan, useReferenceValues, useTransition, useWorkOrder } from '@/api/exploitation';
+import { useHorsLigne } from '@/auth/session';
+import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui';
+import { useTokens } from '@/theme/tokens';
+
+/** Ăcran 3 de la maquette R4 : le bilan codĂ© R2 au pouce â 3 champs requis,
+ * garde visible et bloquante (les MESSAGES viennent de l'API : une seule
+ * source de vérité, comme le web). ClÎture en ligne en R4.2. */
+
+const CHAMP_VERS_ID: Record = {
+ DOOR_STATE: 'doorStateId',
+ CABIN_POSITION: 'cabinPositionId',
+ ANOMALY: 'anomalyId',
+ EXTERNAL_CAUSE: 'externalCauseId',
+ ACTION_TAKEN: 'actionTakenId',
+ COMPONENT_CONCERNED: 'componentConcernedId',
+};
+const CHAMP_VERS_VALEUR = {
+ DOOR_STATE: 'doorState',
+ CABIN_POSITION: 'cabinPosition',
+ ANOMALY: 'anomaly',
+ EXTERNAL_CAUSE: 'externalCause',
+ ACTION_TAKEN: 'actionTaken',
+ COMPONENT_CONCERNED: 'componentConcerned',
+} as const;
+
+export default function PageCloture() {
+ const t = useTokens();
+ const horsLigne = useHorsLigne();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { data: ot } = useWorkOrder(id);
+ const { data: valeurs } = useReferenceValues();
+ const bilan = useBilan(id);
+ const transition = useTransition(id);
+ const [choix, setChoix] = useState>>({});
+
+ if (!ot) return null;
+
+ const optionsDe = (champ: BilanField) =>
+ (valeurs ?? []).filter((v: { field: string; isActive: boolean }) => v.field === champ && v.isActive);
+
+ /** Valeur affichée : le choix local s'il existe, sinon le bilan serveur. */
+ const valeurDe = (champ: BilanField): { id: string; label: string } | null => {
+ if (champ in choix) {
+ const vid = choix[champ];
+ if (!vid) return null;
+ const v = (valeurs ?? []).find((x) => x.id === vid);
+ return v ? { id: v.id, label: v.label } : null;
+ }
+ return ot.report?.[CHAMP_VERS_VALEUR[champ]] ?? null;
+ };
+
+ const enregistrer = (apres?: () => void) => {
+ const corps: ReportUpsert = {};
+ for (const champ of BILAN_FIELDS) {
+ if (champ in choix) corps[CHAMP_VERS_ID[champ]] = choix[champ] ?? null;
+ }
+ bilan.mutate(corps, {
+ onSuccess: apres,
+ onError: (e) => Alert.alert('Bilan refusé', e.message),
+ });
+ };
+
+ const cloturer = () =>
+ enregistrer(() =>
+ transition.mutate(
+ { to: 'DONE' },
+ {
+ onSuccess: () => router.replace(`/ot/${id}`),
+ onError: (e) => Alert.alert('ClÎture bloquée', e.message),
+ },
+ ),
+ );
+
+ const manquants = REQUIRED_BILAN_FIELDS.filter((c) => !valeurDe(c));
+
+ return (
+
+
+
+
+
+ {[0, 2, 4].map((rang) => (
+
+ {[BILAN_FIELDS[rang]!, BILAN_FIELDS[rang + 1]!].map((champ) => (
+ setChoix((c) => ({ ...c, [champ]: vid }))}
+ />
+ ))}
+
+ ))}
+
+ {manquants.length ? (
+
+ â {manquants.map((c) => `« ${BILAN_FIELD_LABELS[c]} »`).join(', ')}{' '}
+ {manquants.length > 1 ? 'manquent' : 'manque'} â la clĂŽture restera bloquĂ©e (mĂȘme garde
+ que le web).
+
+ ) : null}
+
+
+ enregistrer()}
+ />
+ 0 || bilan.isPending || transition.isPending}
+ surAppui={cloturer}
+ />
+ {horsLigne ? (
+
+ Hors-ligne : la clĂŽture en file arrive en R4.3 â pour lâinstant, revenez au rĂ©seau.
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/mobile/app/ot/[id]/grille.tsx b/apps/mobile/app/ot/[id]/grille.tsx
new file mode 100644
index 0000000..667b04b
--- /dev/null
+++ b/apps/mobile/app/ot/[id]/grille.tsx
@@ -0,0 +1,125 @@
+import { useLocalSearchParams } from 'expo-router';
+import { Alert, Pressable, ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { useCocheChecklist, useWorkOrder } from '@/api/exploitation';
+import { useHorsLigne } from '@/auth/session';
+import { EnteteFiche } from '@/composants/ui';
+import { prochainEtat, progression } from '@/lib/checklist';
+import { useTokens } from '@/theme/tokens';
+
+/** Ăcran 6 de la maquette R4 : la grille au pouce. Appui simple = coche,
+ * appui long = non-applicable. En R4.2 chaque coche part immĂ©diatement Ă
+ * l'API (hors-ligne : lecture seule â la file individuelle arrive en R4.3). */
+export default function PageGrille() {
+ const t = useTokens();
+ const horsLigne = useHorsLigne();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { data: ot } = useWorkOrder(id);
+ const coche = useCocheChecklist(id);
+
+ if (!ot) return null;
+ const { faits, total, pct } = progression(ot.checklist);
+
+ const basculer = (itemId: string, etat: Parameters[0], versNA = false) => {
+ if (horsLigne) return;
+ coche.mutate(
+ { itemId, state: versNA ? (etat === 'NA' ? 'PENDING' : 'NA') : prochainEtat(etat) },
+ { onError: (e) => Alert.alert('Coche refusée', e.message) },
+ );
+ };
+
+ return (
+
+
+
+ {faits}/{total}
+
+ }
+ />
+
+
+
+ {horsLigne ? (
+
+
+ â Hors-ligne â les coches en file arrivent en R4.3 ; pour lâinstant la grille est en
+ lecture.
+
+
+ ) : null}
+ {ot.checklist.map((item) => (
+ basculer(item.id, item.state)}
+ onLongPress={() => basculer(item.id, item.state, true)}
+ style={{
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ backgroundColor: t.surface,
+ borderColor: t.bordure,
+ borderWidth: 1,
+ borderRadius: 10,
+ paddingHorizontal: 12,
+ paddingVertical: 11,
+ }}
+ >
+
+ {item.state === 'DONE' ? (
+ â
+ ) : item.state === 'NA' ? (
+ NA
+ ) : null}
+
+
+ {item.label}
+
+ {item.doneBy ? (
+
+ {item.doneBy.displayName.split(' ')[0]}
+
+ ) : null}
+
+ ))}
+
+ Appui simple : cocher / décocher · appui long : non-applicable.
+
+
+
+ );
+}
diff --git a/apps/mobile/app/ot/[id]/index.tsx b/apps/mobile/app/ot/[id]/index.tsx
new file mode 100644
index 0000000..c39e9bc
--- /dev/null
+++ b/apps/mobile/app/ot/[id]/index.tsx
@@ -0,0 +1,177 @@
+import { router, useLocalSearchParams } from 'expo-router';
+import { Alert, ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import {
+ WORK_ORDER_PRIORITY_LABELS,
+ WORK_ORDER_TYPE_LABELS,
+} from '@siop/shared';
+import { useTransition, useWorkOrder } from '@/api/exploitation';
+import { useHorsLigne } from '@/auth/session';
+import { BoutonTel, Carte, ChipStatut, EnteteFiche, LigneInfo } from '@/composants/ui';
+import { progression } from '@/lib/checklist';
+import { useTokens } from '@/theme/tokens';
+
+const fmt = new Intl.NumberFormat('fr-FR');
+
+/** Ăcran 2 de la maquette R4 : la mĂȘme machine Ă Ă©tats que le web â UN
+ * bouton principal selon l'état, la garde de clÎture visible. En R4.2 les
+ * Ă©critures restent en ligne (la file arrive en R4.3 â assumĂ© Ă l'Ă©cran). */
+export default function PageFicheOT() {
+ const t = useTokens();
+ const horsLigne = useHorsLigne();
+ const { id } = useLocalSearchParams<{ id: string }>();
+ const { data: ot } = useWorkOrder(id);
+ const transition = useTransition(id);
+
+ if (!ot) return null;
+
+ const grille = progression(ot.checklist);
+ const peutDemarrer = ot.allowedTransitions.includes('IN_PROGRESS');
+ const peutCloturer = ot.allowedTransitions.includes('DONE');
+ const peutSuspendre = ot.allowedTransitions.includes('ON_HOLD');
+
+ const demarrer = () =>
+ transition.mutate(
+ { to: 'IN_PROGRESS' },
+ { onError: (e) => Alert.alert('Transition refusée', e.message) },
+ );
+
+ return (
+
+
+ } />
+
+ {ot.title}
+
+ {horsLigne ? (
+
+
+ â Hors-ligne â lecture seule pour lâinstant : les saisies hors rĂ©seau arrivent en R4.3 (file).
+
+
+ ) : null}
+
+
+
+
+
+ {WORK_ORDER_PRIORITY_LABELS[ot.priority]}
+
+ }
+ />
+
+ {ot.request ? (
+
+ ) : null}
+
+
+
+ {ot.costs.parts.map((p) => (
+
+ ))}
+ {ot.costs.labor.map((l) => (
+
+ ))}
+ {!ot.costs.parts.length && !ot.costs.labor.length ? (
+
+ Aucune consommation ni temps saisi. (Saisies piĂšces/temps : depuis le web en R4.2.)
+
+ ) : null}
+
+
+ {ot.checklist.length ? (
+
+ router.push(`/ot/${ot.id}/grille`)}
+ />
+
+ ) : null}
+
+ {ot.closureBlockers.length && (peutCloturer || ot.status === 'IN_PROGRESS') ? (
+
+ â {ot.closureBlockers.join(' · ')}
+
+ ) : null}
+
+ {peutDemarrer ? (
+
+ ) : null}
+ {peutCloturer ? (
+ router.push(`/ot/${ot.id}/cloture`)}
+ />
+ ) : null}
+ {peutSuspendre ? (
+
+ transition.mutate(
+ { to: 'ON_HOLD' },
+ { onError: (e) => Alert.alert('Transition refusée', e.message) },
+ )
+ }
+ />
+ ) : null}
+
+
+ {ot.events.slice(0, 5).map((e) => (
+
+
+ {e.kind}
+ {e.message ? ` â ${e.message}` : ''}
+
+
+ {new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeStyle: 'short' }).format(
+ new Date(e.createdAt),
+ )}
+ {e.by ? ` · ${e.by.displayName}` : ''}
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/mobile/package.json b/apps/mobile/package.json
index 7f9fa65..e7530de 100644
--- a/apps/mobile/package.json
+++ b/apps/mobile/package.json
@@ -11,6 +11,7 @@
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-persist-client": "^5.101.2",
"expo": "~57.0.6",
+ "expo-camera": "~57.0.3",
"expo-constants": "~57.0.5",
"expo-font": "~57.0.1",
"expo-linking": "~57.0.3",
diff --git a/apps/mobile/src/api/exploitation.ts b/apps/mobile/src/api/exploitation.ts
new file mode 100644
index 0000000..5b7afc2
--- /dev/null
+++ b/apps/mobile/src/api/exploitation.ts
@@ -0,0 +1,91 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import type { ChecklistState, ReportUpsert, WorkOrderStatus } from '@siop/shared';
+import { api, unwrap } from './client';
+
+/** Hooks R4.2 â mĂȘmes opĂ©rations que le web (contrat unique). Les Ă©critures
+ * restent EN LIGNE dans cette release ; la mise en file arrive en R4.3 (D1). */
+
+export function useWorkOrders() {
+ return useQuery({
+ queryKey: ['work-orders'],
+ queryFn: async () => (await unwrap(await api.GET('/work-orders'))).workOrders,
+ });
+}
+
+export function useWorkOrder(id: string) {
+ return useQuery({
+ queryKey: ['work-orders', id],
+ queryFn: async () =>
+ unwrap(await api.GET('/work-orders/{id}', { params: { path: { id } } })),
+ });
+}
+
+export function useAssets() {
+ return useQuery({
+ queryKey: ['assets'],
+ queryFn: async () => (await unwrap(await api.GET('/assets'))).assets,
+ });
+}
+
+export function useAsset(id: string) {
+ return useQuery({
+ queryKey: ['assets', id],
+ queryFn: async () => unwrap(await api.GET('/assets/{id}', { params: { path: { id } } })),
+ });
+}
+
+export function useReferenceValues() {
+ return useQuery({
+ queryKey: ['reference-values'],
+ staleTime: 3600_000, // référentiels administrables : stables en journée
+ queryFn: async () => (await unwrap(await api.GET('/reference-values'))).referenceValues,
+ });
+}
+
+function useInvalideOT(id: string) {
+ const queryClient = useQueryClient();
+ return () =>
+ Promise.all([
+ queryClient.invalidateQueries({ queryKey: ['work-orders', id] }),
+ queryClient.invalidateQueries({ queryKey: ['work-orders'] }),
+ ]);
+}
+
+export function useTransition(id: string) {
+ const invalide = useInvalideOT(id);
+ return useMutation({
+ mutationFn: async (input: { to: WorkOrderStatus; comment?: string }) =>
+ unwrap(
+ await api.POST('/work-orders/{id}/transition', {
+ params: { path: { id } },
+ body: input,
+ }),
+ ),
+ onSuccess: invalide,
+ });
+}
+
+export function useCocheChecklist(otId: string) {
+ const invalide = useInvalideOT(otId);
+ return useMutation({
+ mutationFn: async (input: { itemId: string; state: ChecklistState }) =>
+ unwrap(
+ await api.PATCH('/work-orders/{id}/checklist/{itemId}', {
+ params: { path: { id: otId, itemId: input.itemId } },
+ body: { state: input.state },
+ }),
+ ),
+ onSuccess: invalide,
+ });
+}
+
+export function useBilan(otId: string) {
+ const invalide = useInvalideOT(otId);
+ return useMutation({
+ mutationFn: async (body: ReportUpsert) =>
+ unwrap(
+ await api.PUT('/work-orders/{id}/report', { params: { path: { id: otId } }, body }),
+ ),
+ onSuccess: invalide,
+ });
+}
diff --git a/apps/mobile/src/composants/ui.tsx b/apps/mobile/src/composants/ui.tsx
new file mode 100644
index 0000000..038d1ea
--- /dev/null
+++ b/apps/mobile/src/composants/ui.tsx
@@ -0,0 +1,265 @@
+import { router } from 'expo-router';
+import { useState, type ReactNode } from 'react';
+import { Modal, Pressable, ScrollView, Text, View } from 'react-native';
+import { WORK_ORDER_STATUS_LABELS, type WorkOrderStatus } from '@siop/shared';
+import { useTokens, type Tokens } from '@/theme/tokens';
+
+/** Briques d'Ă©cran de la maquette R4 â cartes, chips, boutons, sĂ©lecteur. */
+
+export function Carte({ titre, children }: { titre?: string; children: ReactNode }) {
+ const t = useTokens();
+ return (
+
+ {titre ? (
+
+ {titre}
+
+ ) : null}
+ {children}
+
+ );
+}
+
+export function LigneInfo({ nom, valeur }: { nom: string; valeur: ReactNode }) {
+ const t = useTokens();
+ return (
+
+
+ {nom}
+
+ {typeof valeur === 'string' ? (
+
+ {valeur}
+
+ ) : (
+ valeur
+ )}
+
+ );
+}
+
+const STYLE_STATUT: Record [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],
+};
+
+export function ChipStatut({ statut }: { statut: WorkOrderStatus }) {
+ const t = useTokens();
+ const [encre, fond] = STYLE_STATUT[statut](t);
+ return (
+
+ {WORK_ORDER_STATUS_LABELS[statut]}
+
+ );
+}
+
+export function BoutonTel({
+ libelle,
+ variante = 'prim',
+ desactive,
+ surAppui,
+}: {
+ libelle: string;
+ variante?: 'prim' | 'vert' | 'contour' | 'gris';
+ desactive?: boolean;
+ surAppui: () => void;
+}) {
+ const t = useTokens();
+ const fonds = { prim: t.primaire, vert: t.succes, contour: t.surface, gris: t.bordureForte };
+ return (
+
+
+ {libelle}
+
+
+ );
+}
+
+export function EnteteFiche({ titre, apres }: { titre: string; apres?: ReactNode }) {
+ const t = useTokens();
+ return (
+
+ (router.canGoBack() ? router.back() : router.replace('/(tabs)/journee'))}
+ hitSlop={10}
+ >
+ âč
+
+
+ {titre}
+
+ {apres}
+
+ );
+}
+
+/** Sélecteur au pouce (RN n'a pas de