From a22ea60f839941c819be05daa83799d46e11410d Mon Sep 17 00:00:00 2001 From: pr-daaif Date: Fri, 17 Jul 2026 03:47:04 +0100 Subject: [PATCH] =?UTF-8?q?feat(r4.2):=20terrain=20mobile=20=E2=80=94=20sc?= =?UTF-8?q?an=20QR,=20fiches=20ascenseur/OT,=20cl=C3=B4ture,=20grille?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanner expo-camera (QR seulement) : analyseScan testée — URL portail …/q/REF des étiquettes A6 ou référence tapée, QR étrangers refusés ; résolution D4 dans le parc en cache d'abord (sous-sol inclus), message honnête sinon. Fiche ascenseur en consultation (D3, historique scopé « voir autre »). Fiche OT : un bouton principal selon la machine à états, coûts figés, garde alimentée par closureBlockers (source unique API). Clôture terrain : bilan codé 6 champs au pouce (sélecteur plein écran), garde visible, clôture en ligne. Préventif : mes grilles → checklist cochable, appui long = N/A, aria-checked (leçon RN web : accessibilityState.checked ne produit pas aria-checked). Écritures en ligne assumées en R4.2 (bandeaux hors-ligne explicites) — la file persistée et le verrou optimiste sont R4.3 (D1/D2). Vérifié 12/12 en Expo web piloté : scan → fiche → OT-0341 (505 MAD, garde), coche/décoche restituée, OT de test Démarrer → bilan → Terminé, zéro erreur console, données purgées. 12 tests jest-expo. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 3 +- apps/mobile/app/(tabs)/journee.tsx | 6 +- apps/mobile/app/(tabs)/preventif.tsx | 77 +++++++- apps/mobile/app/(tabs)/scanner.tsx | 159 +++++++++++++++- apps/mobile/app/ascenseur/[id].tsx | 129 +++++++++++++ apps/mobile/app/ot/[id]/cloture.tsx | 144 ++++++++++++++ apps/mobile/app/ot/[id]/grille.tsx | 125 ++++++++++++ apps/mobile/app/ot/[id]/index.tsx | 177 +++++++++++++++++ apps/mobile/package.json | 1 + apps/mobile/src/api/exploitation.ts | 91 +++++++++ apps/mobile/src/composants/ui.tsx | 265 ++++++++++++++++++++++++++ apps/mobile/src/lib/checklist.test.ts | 25 +++ apps/mobile/src/lib/checklist.ts | 19 ++ apps/mobile/src/lib/scan.test.ts | 21 ++ apps/mobile/src/lib/scan.ts | 16 ++ docs/journal/journal.md | 18 ++ pnpm-lock.yaml | 63 ++++++ pnpm-workspace.yaml | 2 + 18 files changed, 1326 insertions(+), 15 deletions(-) create mode 100644 apps/mobile/app/ascenseur/[id].tsx create mode 100644 apps/mobile/app/ot/[id]/cloture.tsx create mode 100644 apps/mobile/app/ot/[id]/grille.tsx create mode 100644 apps/mobile/app/ot/[id]/index.tsx create mode 100644 apps/mobile/src/api/exploitation.ts create mode 100644 apps/mobile/src/composants/ui.tsx create mode 100644 apps/mobile/src/lib/checklist.test.ts create mode 100644 apps/mobile/src/lib/checklist.ts create mode 100644 apps/mobile/src/lib/scan.test.ts create mode 100644 apps/mobile/src/lib/scan.ts 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 `), garde visible, clôture en ligne. **Préventif** : mes grilles → checklist cochable, appui long = N/A, coche individuelle à l'API. +- En R4.2 les écritures restent **en ligne** (bandeaux explicites hors-ligne) — la file générale et le verrou optimiste sont le cœur de R4.3, comme prévu. +- Vérifié en Expo web piloté : **12/12** — scan A1 → fiche → OT-0341 (505 MAD, garde), référence inconnue, coche/décoche (état restitué), et un OT créé par l'API : Démarrer → clôture bloquée bilan incomplet → 3 champs remplis → garde éteinte → **Terminé** ; zéro erreur console ; données de test purgées. 12 tests jest-expo (scan, progression, tri, tokens). + +**Décisions** + +- Leçon accessibilité RN web : `accessibilityState.checked` ne produit PAS `aria-checked` — utiliser la prop `aria-checked` (mieux pour les lecteurs d'écran, et testable). +- La caméra ne se recette pas sur web : scan réel à valider dans Expo Go (le repli manuel couvre le parcours en attendant). + +**Prochaine étape** : R4.3 — file d'écriture persistée rejouée dans l'ordre, verrou optimiste tranché par l'humain (D2), écran Synchro & conflits, photos en file (D5) → recette « mode avion » sur téléphone. Redéploiement Dokploy de `release/r3` toujours en attente. + +--- + ## 2026-07-17 — Pr. Daaif (+ Claude) — R4.1 : socle mobile Expo (app du technicien) **Actions** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa092f1..3f13ce5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: expo: specifier: ~57.0.6 version: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-camera: + specifier: ~57.0.3 + version: 57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) expo-constants: specifier: ~57.0.5 version: 57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2)) @@ -2556,6 +2559,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/emscripten@1.41.5': + resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -3073,6 +3079,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + barcode-detector@3.2.1: + resolution: {integrity: sha512-zLL7AbT9uNJBUzYpKg9v5tUA4yQGReExSi60q0g660Mj0wjUhKmN0GrUF3qELo8ITW9THzyJXdZQkXLMnsieiw==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} @@ -3801,6 +3810,17 @@ packages: react: '*' react-native: '*' + expo-camera@57.0.3: + resolution: {integrity: sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + react-native-web: '*' + peerDependenciesMeta: + react-native-web: + optional: true + expo-constants@57.0.5: resolution: {integrity: sha512-HVxPZc1uBdqrlcmNvdyO3L107vt/gsCRNGvXrYXWjZqmz1XOvGeUCR7S3MG4wUj4cQw4/WMCp+fcoDIhynJ80A==} peerDependencies: @@ -6031,6 +6051,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@2.6.1: resolution: {integrity: sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==} @@ -6246,6 +6270,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -6683,6 +6711,11 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zxing-wasm@3.1.1: + resolution: {integrity: sha512-g0sPJBIubO6zLcJh1jftLPIN6xziaqLsvLgtpGKwDrEhyXXqla3E3yjFrznlr78UHIOMzbJPi0HDWKs/KgaB7A==} + peerDependencies: + '@types/emscripten': '>=1.39.6' + snapshots: '@adobe/css-tools@4.5.0': {} @@ -9361,6 +9394,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/emscripten@1.41.5': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -10027,6 +10062,12 @@ snapshots: balanced-match@4.0.4: {} + barcode-detector@3.2.1(@types/emscripten@1.41.5): + dependencies: + zxing-wasm: 3.1.1(@types/emscripten@1.41.5) + transitivePeerDependencies: + - '@types/emscripten' + base64-js@1.5.1: {} baseline-browser-mapping@2.10.43: {} @@ -10789,6 +10830,17 @@ snapshots: - supports-color - typescript + expo-camera@57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + barcode-detector: 3.2.1(@types/emscripten@1.41.5) + expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2) + optionalDependencies: + react-native-web: 0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + transitivePeerDependencies: + - '@types/emscripten' + expo-constants@57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2)): dependencies: '@expo/env': 2.4.2 @@ -13499,6 +13551,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@2.6.1: {} tailwindcss@4.3.2: {} @@ -13663,6 +13717,10 @@ snapshots: type-fest@4.41.0: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -14074,3 +14132,8 @@ snapshots: zod@3.25.76: {} zod@4.4.3: {} + + zxing-wasm@3.1.1(@types/emscripten@1.41.5): + dependencies: + '@types/emscripten': 1.41.5 + type-fest: 5.8.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 110d507..8efad4a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -11,3 +11,5 @@ allowBuilds: unrs-resolver: true '@scarf/scarf': false # télémétrie — bloquée '@prisma/client': true +minimumReleaseAgeExclude: + - expo-camera@57.0.3