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